Files
astro/moon/svg/occultation_model.go
T
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00

888 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package svg
import (
"errors"
"fmt"
"math"
"strings"
"time"
"unicode"
"unicode/utf8"
"b612.me/astro/internal/occultationgeo"
"b612.me/astro/internal/svgchart"
"b612.me/astro/internal/svgmap"
"b612.me/astro/moon"
)
// ErrInvalidStarOccultationPath 表示传入 SVG 渲染器的掩带数据无效。
// ErrInvalidStarOccultationPath reports malformed path data passed to the SVG renderer.
var ErrInvalidStarOccultationPath = errors.New("invalid stellar occultation path")
type starOccultationSVGLayout struct {
width float64
height float64
margin float64
mapX float64
mapY float64
mapWidth float64
mapHeight float64
panelX float64
panelY float64
panelWidth float64
footerY float64
projection svgmap.Projection
// center 是正射球面的视点;其他投影忽略它。
center svgmap.GeoPoint
}
type starOccultationGeoPoint struct {
longitude float64
latitude float64
}
type starOccultationEventRow struct {
name string
point moon.OccultationPathPoint
}
func validateStarOccultationPath(path moon.StarOccultationPath) error {
if !path.Complete {
return fmt.Errorf("%w: global path is incomplete", ErrInvalidStarOccultationPath)
}
if err := validateStarOccultationPathPoint("start", path.Start); err != nil {
return err
}
if err := validateStarOccultationPathPoint("greatest", path.Greatest); err != nil {
return err
}
if err := validateStarOccultationPathPoint("end", path.End); err != nil {
return err
}
if path.Greatest.Time.Before(path.Start.Time) || path.End.Time.Before(path.Greatest.Time) {
return fmt.Errorf("%w: event times must be ordered start, greatest, end", ErrInvalidStarOccultationPath)
}
if err := (moon.OccultationPathOptions{Step: path.Step, TargetSpacingKM: path.TargetSpacingKM}).Validate(); err != nil {
return fmt.Errorf("%w: invalid path sampling metadata: %v", ErrInvalidStarOccultationPath, err)
}
series := []struct {
name string
points []moon.OccultationPathPoint
}{
{"center line", path.CenterLine},
{"northern limit", path.NorthernLimit},
{"southern limit", path.SouthernLimit},
}
for _, current := range series {
name, points := current.name, current.points
for index, point := range points {
if err := validateStarOccultationPathPoint(fmt.Sprintf("%s[%d]", name, index), point); err != nil {
return err
}
if index > 0 && !point.Time.After(points[index-1].Time) {
return fmt.Errorf("%w: %s times must be strictly increasing", ErrInvalidStarOccultationPath, name)
}
}
}
if len(path.NorthernLimit) != len(path.SouthernLimit) {
return fmt.Errorf("%w: northern and southern limits must have the same sample count", ErrInvalidStarOccultationPath)
}
if len(path.NorthernLimit) < 2 {
return fmt.Errorf("%w: global limits must contain start and end", ErrInvalidStarOccultationPath)
}
for index := range path.NorthernLimit {
if !path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time) {
return fmt.Errorf("%w: northern and southern limit sample %d times must match", ErrInvalidStarOccultationPath, index)
}
}
if err := occultationgeo.ValidateRiseSetCurves(path.RiseSetCurves, path.Start.Time, path.End.Time); err != nil {
return fmt.Errorf("%w: invalid rise/set curves: %v", ErrInvalidStarOccultationPath, err)
}
if err := validateOccultationContours(
ErrInvalidStarOccultationPath, "band contours", path.BandContours,
path.Start.Time, path.End.Time,
); err != nil {
return err
}
if err := validateOccultationContours(
ErrInvalidStarOccultationPath, "visibility contours", path.VisibilityContours,
path.Start.Time, path.End.Time,
); err != nil {
return err
}
if err := validateOccultationGreatestTimeContours(ErrInvalidStarOccultationPath, path.GreatestTimeContours); err != nil {
return err
}
last := len(path.NorthernLimit) - 1
if !path.NorthernLimit[0].Time.Equal(path.Start.Time) || !path.SouthernLimit[0].Time.Equal(path.Start.Time) ||
!path.NorthernLimit[last].Time.Equal(path.End.Time) || !path.SouthernLimit[last].Time.Equal(path.End.Time) {
return fmt.Errorf("%w: global limits must span start through end", ErrInvalidStarOccultationPath)
}
if err := validateOccultationFootprints(
ErrInvalidStarOccultationPath, "stellar", path.Footprints, path.Start.Time, path.End.Time,
); err != nil {
return err
}
return validateOccultationFootprints(
ErrInvalidStarOccultationPath, "stellar compact band", path.BandFootprints, path.Start.Time, path.End.Time,
)
}
func validateStarOccultationPathPoint(name string, point moon.OccultationPathPoint) error {
return validateOccultationPathPoint(ErrInvalidStarOccultationPath, name, point)
}
func validateOccultationPathPoint(base error, name string, point moon.OccultationPathPoint) error {
if point.Time.IsZero() {
return fmt.Errorf("%w: %s time is required", base, name)
}
if !starOccultationFinite(point.Longitude) || point.Longitude < -180 || point.Longitude > 180 {
return fmt.Errorf("%w: %s longitude must be in [-180, 180]", base, name)
}
if !starOccultationFinite(point.Latitude) || point.Latitude < -90 || point.Latitude > 90 {
return fmt.Errorf("%w: %s latitude must be in [-90, 90]", base, name)
}
if !starOccultationFinite(point.MoonAltitude) || point.MoonAltitude < -90 || point.MoonAltitude > 90 {
return fmt.Errorf("%w: %s Moon altitude must be in [-90, 90]", base, name)
}
if !starOccultationFinite(point.WidthKM) || point.WidthKM < 0 {
return fmt.Errorf("%w: %s width must be finite and non-negative", base, name)
}
return nil
}
func validateOccultationContours(
base error,
name string,
contours [][]moon.OccultationPathPoint,
start, end time.Time,
) error {
for contourIndex, contour := range contours {
if len(contour) < 2 {
return fmt.Errorf("%w: %s[%d] must contain at least two points", base, name, contourIndex)
}
for pointIndex, point := range contour {
if err := validateOccultationPathPoint(
base, fmt.Sprintf("%s[%d][%d]", name, contourIndex, pointIndex), point,
); err != nil {
return err
}
if point.Time.Before(start) || point.Time.After(end) {
return fmt.Errorf("%w: %s point is outside event time", base, name)
}
if pointIndex > 0 && !point.Time.After(contour[pointIndex-1].Time) {
return fmt.Errorf("%w: %s[%d] times must be strictly increasing", base, name, contourIndex)
}
}
}
return nil
}
// validateOccultationGreatestTimeContours 校验等时线:支路至少两点、点时刻必须等于该支路的
// 时刻取值(1 ms 容差覆盖 TT 往返),其余字段与普通路径点同口径。
func validateOccultationGreatestTimeContours(base error, contours []moon.OccultationGreatestTimeContour) error {
for contourIndex, contour := range contours {
if !starOccultationFinite(contour.JDE) || contour.JDE == 0 {
return fmt.Errorf("%w: greatest-time contour %d JDE is required", base, contourIndex)
}
if contour.Time.IsZero() {
return fmt.Errorf("%w: greatest-time contour %d time is required", base, contourIndex)
}
for segmentIndex, segment := range contour.Segments {
if len(segment) < 2 {
return fmt.Errorf("%w: greatest-time contour %d segment %d must contain at least two points", base, contourIndex, segmentIndex)
}
name := fmt.Sprintf("greatest-time contour %d segment %d", contourIndex, segmentIndex)
for pointIndex, point := range segment {
if err := validateOccultationPathPoint(base, fmt.Sprintf("%s point %d", name, pointIndex), point); err != nil {
return err
}
if delta := point.Time.Sub(contour.Time); delta > time.Millisecond || delta < -time.Millisecond {
return fmt.Errorf("%w: %s point %d time does not match the contour instant", base, name, pointIndex)
}
}
}
}
return nil
}
func starOccultationSVGLayoutFor(
options StarOccultationSVGOptions,
headerBottom float64,
projection svgmap.Projection,
center svgmap.GeoPoint,
) starOccultationSVGLayout {
width := float64(options.Width)
height := float64(options.Height)
margin := math.Max(22, math.Min(42, width*0.04))
gap := math.Max(16, math.Min(24, width*0.025))
panelWidth := math.Max(148, math.Min(238, width*0.23))
mapWidth := width - 2*margin - gap - panelWidth
if mapWidth < 220 {
panelWidth = math.Max(126, width*0.21)
mapWidth = width - 2*margin - gap - panelWidth
}
contentTop := headerBottom + 28
// 底部预留必须同时容纳图例行、页脚正文与它们之间的空隙,否则小画布上图例与页脚会同高叠印。
footerSpace := 104.0
if projection != svgmap.ProjectionEquirectangular {
footerSpace = 128
}
availableHeight := math.Max(110, height-contentTop-footerSpace)
mapHeight := math.Min(mapWidth/2, availableHeight)
if projection != svgmap.ProjectionEquirectangular {
mapHeight = math.Min(mapWidth, availableHeight)
mapWidth = mapHeight
}
if mapHeight < 110 {
mapHeight = 110
mapWidth = math.Min(mapWidth, 2*mapHeight)
}
mapY := contentTop + math.Max(0, (availableHeight-mapHeight)/2)
footerY := height - 54
// 图例最多两行(occultationLegendRowHeight),页脚必须落在第二行下方。
if legendY := mapY + mapHeight + 30; footerY < legendY+2*occultationLegendRowHeight {
footerY = legendY + 2*occultationLegendRowHeight
}
return starOccultationSVGLayout{
width: width,
height: height,
margin: margin,
mapX: margin,
mapY: mapY,
mapWidth: mapWidth,
mapHeight: mapHeight,
panelX: margin + mapWidth + gap,
panelY: mapY,
panelWidth: panelWidth,
footerY: footerY,
projection: projection,
center: center,
}
}
func (layout starOccultationSVGLayout) project(longitude, latitude float64) (float64, float64) {
x, y, _ := layout.mapFrame().Project(longitude, latitude)
return x, y
}
func (layout starOccultationSVGLayout) mapFrame() svgmap.Frame {
return svgmap.Frame{
X: layout.mapX,
Y: layout.mapY,
Width: layout.mapWidth,
Height: layout.mapHeight,
Projection: layout.projection,
// 正射球面必须给出视点,否则会退回 (0°,0°) 而完全不对准事件。
CenterLongitude: layout.center.Longitude,
CenterLatitude: layout.center.Latitude,
}
}
func resolveStarOccultationMapProjection(path moon.StarOccultationPath, requested MapProjection) svgmap.Projection {
minimumLatitude := path.Greatest.Latitude
maximumLatitude := path.Greatest.Latitude
for _, series := range [][]moon.OccultationPathPoint{path.CenterLine, path.NorthernLimit, path.SouthernLimit} {
for _, point := range series {
minimumLatitude = math.Min(minimumLatitude, point.Latitude)
maximumLatitude = math.Max(maximumLatitude, point.Latitude)
}
}
for _, footprints := range [][]moon.OccultationFootprint{path.Footprints, path.BandFootprints} {
for _, footprint := range footprints {
for _, polygon := range footprint.Polygons {
for _, point := range polygon {
minimumLatitude = math.Min(minimumLatitude, point.Latitude)
maximumLatitude = math.Max(maximumLatitude, point.Latitude)
}
}
}
}
return svgmap.ResolveProjection(internalMapProjection(requested), path.Greatest.Latitude, minimumLatitude, maximumLatitude)
}
func starOccultationPathSegments(points []moon.OccultationPathPoint) [][]moon.OccultationPathPoint {
if len(points) == 0 {
return nil
}
segments := make([][]moon.OccultationPathPoint, 0, 2)
current := []moon.OccultationPathPoint{points[0]}
for index := 1; index < len(points); index++ {
if math.Abs(points[index].Longitude-points[index-1].Longitude) <= 180 {
current = append(current, points[index])
continue
}
boundary, fraction, ok := starOccultationAntimeridianCrossing(points[index-1], points[index])
if !ok {
current = append(current, points[index])
continue
}
crossing := starOccultationInterpolatePathPoint(points[index-1], points[index], fraction, boundary)
current = append(current, crossing)
if len(current) >= 2 {
segments = append(segments, current)
}
wrapped := crossing
wrapped.Longitude = -boundary
current = []moon.OccultationPathPoint{wrapped, points[index]}
}
if len(current) >= 2 {
segments = append(segments, current)
}
return segments
}
func starOccultationPathSegmentsForProjection(
points []moon.OccultationPathPoint,
view svgmap.ClipView,
) [][]moon.OccultationPathPoint {
if view.Projection == svgmap.ProjectionEquirectangular {
return starOccultationPathSegments(points)
}
geographic := make([]svgmap.GeoPoint, len(points))
for index, point := range points {
geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
clipped := svgmap.PolylineSegments(geographic, view)
result := make([][]moon.OccultationPathPoint, 0, len(clipped))
for _, segment := range clipped {
converted := make([]moon.OccultationPathPoint, len(segment))
for index, point := range segment {
converted[index] = moon.OccultationPathPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
result = append(result, converted)
}
return result
}
func starOccultationBoundarySegmentsForProjection(
points []moon.OccultationPathPoint,
view svgmap.ClipView,
) [][]moon.OccultationPathPoint {
ranges := occultationgeo.ContinuousBoundaryRanges(points)
result := make([][]moon.OccultationPathPoint, 0, len(ranges))
for _, sampleRange := range ranges {
current := points[sampleRange.Start:sampleRange.End]
if len(current) == 1 {
current = []moon.OccultationPathPoint{current[0], current[0]}
}
if view.Projection == svgmap.ProjectionEquirectangular {
result = append(result, starOccultationPathSegments(current)...)
continue
}
geographic := make([]svgmap.GeoPoint, len(current))
for index, point := range current {
geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
for _, segment := range svgmap.PolylineSegments(geographic, view) {
converted := make([]moon.OccultationPathPoint, len(segment))
for index, point := range segment {
converted[index] = moon.OccultationPathPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
result = append(result, converted)
}
}
return result
}
func starOccultationAntimeridianCrossing(a, b moon.OccultationPathPoint) (float64, float64, bool) {
if math.Abs(b.Longitude-a.Longitude) <= 180 {
return 0, 0, false
}
boundary := 180.0
adjustedB := b.Longitude
if a.Longitude < 0 {
boundary = -180
adjustedB -= 360
} else {
adjustedB += 360
}
denominator := adjustedB - a.Longitude
if math.Abs(denominator) < 1e-12 {
return 0, 0, false
}
fraction := (boundary - a.Longitude) / denominator
if fraction <= 0 || fraction >= 1 {
return 0, 0, false
}
return boundary, fraction, true
}
func starOccultationInterpolatePathPoint(a, b moon.OccultationPathPoint, fraction, longitude float64) moon.OccultationPathPoint {
if fraction < 0 {
fraction = 0
}
if fraction > 1 {
fraction = 1
}
duration := b.Time.Sub(a.Time)
return moon.OccultationPathPoint{
Time: a.Time.Add(time.Duration(float64(duration) * fraction)),
Longitude: longitude,
Latitude: a.Latitude + (b.Latitude-a.Latitude)*fraction,
MoonAltitude: a.MoonAltitude + (b.MoonAltitude-a.MoonAltitude)*fraction,
WidthKM: a.WidthKM + (b.WidthKM-a.WidthKM)*fraction,
}
}
func starOccultationBandSegments(
northern, southern []moon.OccultationPathPoint,
) [][]starOccultationGeoPoint {
return starOccultationBandFragments(northern, southern,
svgmap.ClipView{Projection: svgmap.ProjectionEquirectangular})
}
func starOccultationBandFragments(
northern, southern []moon.OccultationPathPoint,
view svgmap.ClipView,
) [][]starOccultationGeoPoint {
count := len(northern)
if len(southern) < count {
count = len(southern)
}
if count < 2 {
return nil
}
segments := make([][]starOccultationGeoPoint, 0, 2)
for _, sampleRange := range occultationgeo.ContinuousPairedBoundaryRanges(northern[:count], southern[:count]) {
if sampleRange.End-sampleRange.Start < 2 {
continue
}
polygon := make([]starOccultationGeoPoint, 0, 2*(sampleRange.End-sampleRange.Start))
for _, point := range northern[sampleRange.Start:sampleRange.End] {
polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude})
}
for index := sampleRange.End - 1; index >= sampleRange.Start; index-- {
point := southern[index]
polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude})
}
geographic := make([]svgmap.GeoPoint, len(polygon))
for index, point := range polygon {
geographic[index] = svgmap.GeoPoint{Longitude: point.longitude, Latitude: point.latitude}
}
for _, fragment := range svgmap.PolygonFragments(geographic, view) {
converted := make([]starOccultationGeoPoint, len(fragment))
for index, point := range fragment {
converted[index] = starOccultationGeoPoint{longitude: point.Longitude, latitude: point.Latitude}
}
if math.Abs(starOccultationPolygonArea(converted)) > 1e-9 {
segments = append(segments, converted)
}
}
}
return segments
}
func starOccultationBandEndpointSections(
northern, southern []moon.OccultationPathPoint,
view svgmap.ClipView,
) [][]starOccultationGeoPoint {
count := len(northern)
if len(southern) < count {
count = len(southern)
}
if count == 0 {
return nil
}
sections := make([][]starOccultationGeoPoint, 0, 2)
for _, sampleRange := range occultationgeo.ContinuousPairedBoundaryRanges(northern[:count], southern[:count]) {
if sampleRange.End-sampleRange.Start != 1 {
continue
}
index := sampleRange.Start
line := []svgmap.GeoPoint{
{Longitude: northern[index].Longitude, Latitude: northern[index].Latitude},
{Longitude: southern[index].Longitude, Latitude: southern[index].Latitude},
}
for _, segment := range svgmap.PolylineSegments(line, view) {
converted := make([]starOccultationGeoPoint, len(segment))
for pointIndex, point := range segment {
converted[pointIndex] = starOccultationGeoPoint{longitude: point.Longitude, latitude: point.Latitude}
}
sections = append(sections, converted)
}
}
return sections
}
func legacyStarOccultationBandSegments(polygon []starOccultationGeoPoint) [][]starOccultationGeoPoint {
polygon = starOccultationUnwrapPolygon(polygon)
minimumLongitude, maximumLongitude := polygon[0].longitude, polygon[0].longitude
for _, point := range polygon[1:] {
minimumLongitude = math.Min(minimumLongitude, point.longitude)
maximumLongitude = math.Max(maximumLongitude, point.longitude)
}
firstWorld := int(math.Floor((minimumLongitude + 180) / 360))
lastWorld := int(math.Floor((maximumLongitude + 180) / 360))
segments := make([][]starOccultationGeoPoint, 0, lastWorld-firstWorld+1)
for world := firstWorld; world <= lastWorld; world++ {
left := -180.0 + 360*float64(world)
right := 180.0 + 360*float64(world)
clipped := starOccultationClipPolygonLongitude(polygon, left, true)
clipped = starOccultationClipPolygonLongitude(clipped, right, false)
if len(clipped) < 3 {
continue
}
for index := range clipped {
clipped[index].longitude -= 360 * float64(world)
}
if math.Abs(starOccultationPolygonArea(clipped)) > 1e-9 {
segments = append(segments, clipped)
}
}
return segments
}
func starOccultationUnwrapPolygon(points []starOccultationGeoPoint) []starOccultationGeoPoint {
if len(points) < 2 {
return points
}
unwrapped := make([]starOccultationGeoPoint, len(points))
unwrapped[0] = points[0]
for index := 1; index < len(points); index++ {
point := points[index]
previous := unwrapped[index-1].longitude
for point.longitude-previous > 180 {
point.longitude -= 360
}
for point.longitude-previous < -180 {
point.longitude += 360
}
unwrapped[index] = point
}
return unwrapped
}
func starOccultationClipPolygonLongitude(
points []starOccultationGeoPoint,
boundary float64,
keepGreater bool,
) []starOccultationGeoPoint {
if len(points) == 0 {
return nil
}
inside := func(point starOccultationGeoPoint) bool {
if keepGreater {
return point.longitude >= boundary
}
return point.longitude <= boundary
}
intersect := func(a, b starOccultationGeoPoint) starOccultationGeoPoint {
fraction := (boundary - a.longitude) / (b.longitude - a.longitude)
return starOccultationGeoPoint{
longitude: boundary,
latitude: a.latitude + (b.latitude-a.latitude)*fraction,
}
}
clipped := make([]starOccultationGeoPoint, 0, len(points)+2)
previous := points[len(points)-1]
previousInside := inside(previous)
for _, current := range points {
currentInside := inside(current)
if currentInside != previousInside {
clipped = append(clipped, intersect(previous, current))
}
if currentInside {
clipped = append(clipped, current)
}
previous = current
previousInside = currentInside
}
return clipped
}
func starOccultationPolygonArea(points []starOccultationGeoPoint) float64 {
area := 0.0
for index, point := range points {
next := points[(index+1)%len(points)]
area += point.longitude*next.latitude - next.longitude*point.latitude
}
return area / 2
}
func starOccultationSVGHeaderLines(
path moon.StarOccultationPath,
options StarOccultationSVGOptions,
flags occultationSVGTextFlags,
) []string {
lines := make([]string, 0, 4)
for _, item := range []struct {
text string
fontSize float64
custom bool
}{
{starOccultationSVGSummaryText(path, options), 14, flags.summaryText},
{starOccultationSVGGreatestText(path, options), 13, flags.greatestText},
} {
lines = append(lines, occultationWrappedTextLines(item.text, item.custom, float64(options.Width)-80, item.fontSize)...)
}
return lines
}
func starOccultationSVGTitle(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
if options.Title != "" {
return options.Title
}
target := path.TargetID
if target == "" {
if options.Language == starOccultationSVGLanguageEnglish {
target = "star"
} else {
target = "恒星"
}
}
date := path.Greatest.Time.In(options.Location).Format("2006-01-02")
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("%s Lunar Occultation of %s", date, target)
}
return fmt.Sprintf("%s 月掩%s全球掩带", date, target)
}
func starOccultationSVGSummaryText(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
if options.SummaryText != "" {
return options.SummaryText
}
start := path.Start.Time.In(options.Location)
greatest := path.Greatest.Time.In(options.Location)
end := path.End.Time.In(options.Location)
zone := starOccultationLocationLabel(greatest, options.Location)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Start %s | Greatest %s | End %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
return fmt.Sprintf("掩始 %s | 掩甚 %s | 掩终 %s (%s)",
starOccultationFormatEventTime(start, true),
starOccultationFormatEventTime(greatest, !starOccultationSameDate(start, greatest)),
starOccultationFormatEventTime(end, !starOccultationSameDate(start, end)), zone)
}
func starOccultationSVGGreatestText(path moon.StarOccultationPath, options StarOccultationSVGOptions) string {
if options.GreatestText != "" {
return options.GreatestText
}
coordinates := starOccultationFormatCoordinates(path.Greatest.Longitude, path.Greatest.Latitude)
altitude := starOccultationFormatSignedDegree(path.Greatest.MoonAltitude)
// 带宽与行星图、详细版同口径:取南北限的地面间距。Greatest.WidthKM 是另一种构造,两者不可互换。
partialWidth := path.GreatestLimitSeparationKM
if partialWidth <= 0 {
partialWidth = path.Greatest.WidthKM
}
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Greatest point %s | path width %.1f km | Moon altitude %s", coordinates, partialWidth, altitude)
}
return fmt.Sprintf("掩甚点 %s | 掩带宽 %.1f km | 月球高度 %s", coordinates, partialWidth, altitude)
}
func starOccultationSVGMapTitle(options StarOccultationSVGOptions) string {
if options.MapTitle != "" {
return options.MapTitle
}
if options.Language == starOccultationSVGLanguageEnglish {
return "Global center line and occultation limits"
}
return "全球中心线与掩带边界"
}
func starOccultationSVGContactsTitle(options StarOccultationSVGOptions) string {
if options.ContactsTitle != "" {
return options.ContactsTitle
}
if options.Language == starOccultationSVGLanguageEnglish {
return "Global events"
}
return "全球事件"
}
func starOccultationSVGFooter(options StarOccultationSVGOptions) string {
if options.FooterNote != "" {
return options.FooterNote
}
projection := starOccultationProjectionLabel(options.Projection, options.Language)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("%s with Natural Earth 1:50m physical land and no administrative boundaries. Limits use the outer lunar limb on the Earth ellipsoid.", projection)
}
return fmt.Sprintf("%s;Natural Earth 1:50m 物理陆地底图,不含行政边界;掩带边界为地球椭球上的月球外缘投影。", projection)
}
func starOccultationProjectionLabel(projection MapProjection, language string) string {
if language == starOccultationSVGLanguageEnglish {
switch projection {
case MapProjectionNorthPolar:
return "North-polar azimuthal equidistant projection"
case MapProjectionSouthPolar:
return "South-polar azimuthal equidistant projection"
case MapProjectionOrthographic:
return "Orthographic globe projection"
default:
return "Equirectangular projection"
}
}
switch projection {
case MapProjectionNorthPolar:
return "北极方位等距投影"
case MapProjectionSouthPolar:
return "南极方位等距投影"
case MapProjectionOrthographic:
return "正射球面投影"
default:
return "等经纬投影"
}
}
func starOccultationSVGEventRows(path moon.StarOccultationPath, language string) []starOccultationEventRow {
names := []string{"掩始", "掩甚", "掩终"}
if language == starOccultationSVGLanguageEnglish {
names = []string{"Start", "Greatest", "End"}
}
return []starOccultationEventRow{
{name: names[0], point: path.Start},
{name: names[1], point: path.Greatest},
{name: names[2], point: path.End},
}
}
func starOccultationFormatEventTime(value time.Time, withDate bool) string {
layout := "15:04:05.0"
if withDate {
layout = "2006-01-02 15:04:05.0"
}
return value.Format(layout)
}
func starOccultationFormatCoordinates(longitude, latitude float64) string {
longitudeSuffix := "E"
if longitude < 0 {
longitudeSuffix = "W"
}
latitudeSuffix := "N"
if latitude < 0 {
latitudeSuffix = "S"
}
return fmt.Sprintf("%.4f°%s, %.4f°%s", math.Abs(longitude), longitudeSuffix, math.Abs(latitude), latitudeSuffix)
}
func starOccultationFormatSignedDegree(value float64) string {
return fmt.Sprintf("%+.1f°", value)
}
func starOccultationLocationLabel(value time.Time, location *time.Location) string {
if location == time.UTC {
return "UTC"
}
name, offset := value.Zone()
if name != "" && name != "Local" {
return name
}
hours := float64(offset) / 3600
return fmt.Sprintf("UTC%+.1f", hours)
}
func starOccultationSameDate(first, second time.Time) bool {
y1, m1, d1 := first.Date()
y2, m2, d2 := second.Date()
return y1 == y2 && m1 == m2 && d1 == d2
}
// occultationTitleText 截断调用方给出的单行标题:自动文案不动,避免改变既有产物。
func occultationTitleText(value string, custom bool, maxWidth, fontSize float64) string {
// 标题是单行固定槽位,放不下就截断;默认标题本来就放得下时结果不变。
return svgchart.EllipsizeText(value, maxWidth, fontSize)
}
// occultationWrappedTextLines 折行一段文本:调用方自定义文本按保守字宽折行,自动生成文本沿用既有口径,改口径会移动断行位置。
func occultationWrappedTextLines(value string, custom bool, maxWidth, fontSize float64) []string {
if custom {
return svgchart.WrapText(value, maxWidth, fontSize)
}
return starOccultationWrapText(value, maxWidth, fontSize)
}
// starOccultationSVGHeaderLineLimit 返回页眉行数上限:地图下限 110、上方空隙 28 与页脚预留之外的余高才轮到页眉。
func starOccultationSVGHeaderLineLimit(options StarOccultationSVGOptions, projection svgmap.Projection) int {
footerSpace := 104.0
if projection != svgmap.ProjectionEquirectangular {
footerSpace = 128
}
limit := int(math.Floor((float64(options.Height) - 210 - footerSpace) / 19))
if limit < 1 {
return 1
}
return limit
}
func starOccultationWrapText(value string, maxWidth, fontSize float64) []string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
if starOccultationTextWidth(value, fontSize) <= maxWidth {
return []string{value}
}
runes := []rune(value)
lines := make([]string, 0, 2)
for len(runes) > 0 {
width := 0.0
end := 0
lastSpace := -1
for end < len(runes) {
nextWidth := width + starOccultationRuneWidth(runes[end], fontSize)
if nextWidth > maxWidth && end > 0 {
break
}
width = nextWidth
if unicode.IsSpace(runes[end]) {
lastSpace = end
}
end++
}
if end < len(runes) && lastSpace > 0 {
end = lastSpace
}
if end == 0 {
end = 1
}
line := strings.TrimSpace(string(runes[:end]))
if line != "" {
lines = append(lines, line)
}
runes = runes[end:]
for len(runes) > 0 && unicode.IsSpace(runes[0]) {
runes = runes[1:]
}
}
return lines
}
func starOccultationTitleFontSize(title string, width float64) int {
for size := 26; size >= 16; size-- {
if starOccultationTextWidth(title, float64(size)) <= width-80 {
return size
}
}
return 16
}
func starOccultationTextWidth(value string, fontSize float64) float64 {
width := 0.0
for _, current := range value {
width += starOccultationRuneWidth(current, fontSize)
}
return width
}
func starOccultationRuneWidth(value rune, fontSize float64) float64 {
if unicode.Is(unicode.Han, value) || value > utf8.RuneSelf {
return fontSize
}
if unicode.IsSpace(value) {
return fontSize * 0.34
}
return fontSize * 0.58
}
func starOccultationFinite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}