Files
astro/moon/svg/occultation_model.go
T

671 lines
21 KiB
Go
Raw Normal View History

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)
}