Files
astro/moon/svg/occultation_model.go
b612 9ee2163cc7 feat: 新增月掩与日月食地理绘图并提升观测计算精度
- 新增月掩恒星和行星:支持搜索、掩甚点、全球掩带及固定地点轨迹计算
- 支持恒星星表坐标转换、有限盘面行星接触事件和月掩 SVG 输出
- 新增日月食及月掩全球投影图、时间标记和 GeoJSON 地理数据接口
- 扩展日食中心线、南北界及偏食足迹采样,支持极区投影
- 修正站心时角、月出月落、月球视半径、折射和恒星自行计算
- 优化内外行星事件搜索、边界选择、极端输入处理和计算稳定性
2026-08-06 12:00:56 +08:00

671 lines
21 KiB
Go
Raw Permalink 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/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
}
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)
}
}
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)
}
return nil
}
func validateStarOccultationPathPoint(name string, point moon.OccultationPathPoint) error {
if point.Time.IsZero() {
return fmt.Errorf("%w: %s time is required", ErrInvalidStarOccultationPath, name)
}
if !starOccultationFinite(point.Longitude) || point.Longitude < -180 || point.Longitude > 180 {
return fmt.Errorf("%w: %s longitude must be in [-180, 180]", ErrInvalidStarOccultationPath, name)
}
if !starOccultationFinite(point.Latitude) || point.Latitude < -90 || point.Latitude > 90 {
return fmt.Errorf("%w: %s latitude must be in [-90, 90]", ErrInvalidStarOccultationPath, name)
}
if !starOccultationFinite(point.MoonAltitude) || point.MoonAltitude < -90 || point.MoonAltitude > 90 {
return fmt.Errorf("%w: %s Moon altitude must be in [-90, 90]", ErrInvalidStarOccultationPath, name)
}
if !starOccultationFinite(point.WidthKM) || point.WidthKM < 0 {
return fmt.Errorf("%w: %s width must be finite and non-negative", ErrInvalidStarOccultationPath, name)
}
return nil
}
func starOccultationSVGLayoutFor(
options StarOccultationSVGOptions,
headerBottom float64,
projection svgmap.Projection,
) 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 := 82.0
if projection != svgmap.ProjectionEquirectangular {
footerSpace = 116
}
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)
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: height - 54,
projection: projection,
}
}
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,
}
}
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)
}
}
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,
projection svgmap.Projection,
) [][]moon.OccultationPathPoint {
if 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, projection)
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 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.ProjectionEquirectangular)
}
func starOccultationBandFragments(
northern, southern []moon.OccultationPathPoint,
projection svgmap.Projection,
) [][]starOccultationGeoPoint {
count := len(northern)
if len(southern) < count {
count = len(southern)
}
if count < 2 {
return nil
}
polygon := make([]starOccultationGeoPoint, 0, 2*count)
for _, point := range northern[:count] {
polygon = append(polygon, starOccultationGeoPoint{point.Longitude, point.Latitude})
}
for index := count - 1; index >= 0; 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}
}
fragments := svgmap.PolygonFragments(geographic, projection)
segments := make([][]starOccultationGeoPoint, 0, len(fragments))
for _, fragment := range fragments {
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 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) []string {
lines := make([]string, 0, 4)
for _, item := range []struct {
text string
fontSize float64
}{
{starOccultationSVGSummaryText(path, options), 14},
{starOccultationSVGGreatestText(path, options), 13},
} {
lines = append(lines, starOccultationWrapText(item.text, 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)
if options.Language == starOccultationSVGLanguageEnglish {
return fmt.Sprintf("Greatest point %s | path width %.1f km | Moon altitude %s", coordinates, path.Greatest.WidthKM, altitude)
}
return fmt.Sprintf("掩甚点 %s | 掩带宽 %.1f km | 月球高度 %s", coordinates, path.Greatest.WidthKM, 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("%sNatural 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"
default:
return "Equirectangular projection"
}
}
switch projection {
case MapProjectionNorthPolar:
return "北极方位等距投影"
case MapProjectionSouthPolar:
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
}
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)
}