package svg import ( "fmt" "html" "math" "strings" "time" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" "b612.me/astro/internal/svgmap" ) const ( lunarEclipseMapDefaultWidth = 960 lunarEclipseMapDefaultHeight = 640 ) // LunarEclipseMapSVGOptions 控制无国界全球可见性地图。 // LunarEclipseMapSVGOptions controls a border-free global visibility map. type LunarEclipseMapSVGOptions struct { // Width 和 Height 是 SVG 画布尺寸;宽度小于 640 或高度小于 420 时使用 960x640 默认值。 // Width and Height are SVG canvas dimensions in user units. Width values below 640 and height values below 420 use the 960x640 defaults. Width int Height int // Language 为 "en"(不区分大小写)时使用英文,否则使用中文。 // Language uses English for "en" (case-insensitive) and Chinese otherwise. Language string // Location 控制显示的事件时刻;nil 使用 date.Location()。 // Location controls displayed event times. Nil uses date.Location(). Location *time.Location // Projection 选择地图投影;零值使用等经纬投影,不支持的值使渲染器返回 false。 // Projection selects the map projection. The zero value selects the equirectangular projection; unsupported values make the renderer return false. Projection EclipseMapProjection // 空文本字段使用本地化的自动标签。 // Empty text fields use localized automatic labels. Title string FooterNote string } // LunarEclipseMapSVG 使用默认月食模型绘制全球 P1-P4 可见区域。 // LunarEclipseMapSVG renders the global P1-P4 visibility regions using the default lunar-eclipse model. func LunarEclipseMapSVG(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) { return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDate) } // LunarEclipseMapSVGDanjon 使用 Danjon 模型绘制全球可见区域。 // LunarEclipseMapSVGDanjon renders the global visibility regions with Danjon's model. func LunarEclipseMapSVGDanjon(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) { return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDateDanjon) } // LunarEclipseMapSVGChauvenet 使用 Chauvenet 模型绘制全球可见区域。 // LunarEclipseMapSVGChauvenet renders the global visibility regions with Chauvenet's model. func LunarEclipseMapSVGChauvenet(date time.Time, options LunarEclipseMapSVGOptions) (string, bool) { return lunarEclipseMapSVG(date, options, eclipsecore.LunarEclipseOnDateChauvenet) } func lunarEclipseMapSVG( date time.Time, options LunarEclipseMapSVGOptions, calculator func(time.Time) (eclipsecore.LunarEclipseInfo, bool), ) (string, bool) { if !validEclipseMapProjection(options.Projection) { return "", false } info, ok := calculator(date) if !ok || info.PenumbralStart.IsZero() || info.PenumbralEnd.IsZero() { return "", false } options = normalizeLunarEclipseMapSVGOptions(date, options) projection := internalEclipseMapProjection(options.Projection) if projection == "" { projection = svgmap.ProjectionEquirectangular } return renderLunarEclipseMapSVG(info, options, projection), true } func normalizeLunarEclipseMapSVGOptions(date time.Time, options LunarEclipseMapSVGOptions) LunarEclipseMapSVGOptions { if options.Width < 640 { options.Width = lunarEclipseMapDefaultWidth } if options.Height < 420 { options.Height = lunarEclipseMapDefaultHeight } if strings.EqualFold(options.Language, "en") { options.Language = "en" } else { options.Language = "zh" } if options.Location == nil { options.Location = date.Location() } return options } func renderLunarEclipseMapSVG( info eclipsecore.LunarEclipseInfo, options LunarEclipseMapSVGOptions, projection svgmap.Projection, ) string { frame := eclipseMapFrame(options.Width, options.Height, projection, 142, 92) startPath, startBoundary := lunarEclipseVisibilityPath(info.PenumbralStart, frame) endPath, endBoundary := lunarEclipseVisibilityPath(info.PenumbralEnd, frame) title := options.Title if title == "" { date := info.Maximum.In(options.Location).Format("2006-01-02") if options.Language == "en" { title = fmt.Sprintf("%s %s Global Visibility", date, lunarEclipseSVGTypeName(info.Type, "en")) } else { title = fmt.Sprintf("%s %s全球可见图", date, lunarEclipseSVGTypeName(info.Type, "zh")) } } var builder strings.Builder fmt.Fprintf(&builder, ``, options.Width, options.Height, options.Width, options.Height, html.EscapeString(title)) builder.WriteString(``) builder.WriteString(frame.ClipDefinition("lunar-map-clip")) fmt.Fprintf(&builder, ``, startPath) fmt.Fprintf(&builder, ``, endPath) builder.WriteString(``) writeLunarEclipseVisibilityMasks(&builder, frame) builder.WriteString(``) builder.WriteString(``) fmt.Fprintf(&builder, ``, options.Width-44, options.Height-36) fmt.Fprintf(&builder, `%s`, float64(options.Width)/2, html.EscapeString(title)) writeLunarEclipseMapSummary(&builder, info, options) frame.WriteOcean(&builder) frame.WriteGraticule(&builder, "lunar-map-clip") frame.WriteLand(&builder, "lunar-map-clip") fmt.Fprintf(&builder, ``) writeLunarEclipseUnavailableRegion(&builder, frame) builder.WriteString(``) builder.WriteString(``) builder.WriteString(``) builder.WriteString(``) writeEclipseMapGeoLine(&builder, frame, startBoundary, "p1-horizon", "#a56c16", 1.2, "4 3", "lunar-map-clip") writeEclipseMapGeoLine(&builder, frame, endBoundary, "p4-horizon", "#197a82", 1.2, "4 3", "lunar-map-clip") frame.WriteFrame(&builder) writeLunarEclipseMapLegend(&builder, frame, options.Language) writeLunarEclipseMapFooter(&builder, frame, options, projection) builder.WriteString(``) return builder.String() } func writeLunarEclipseVisibilityMasks(builder *strings.Builder, frame svgmap.Frame) { writeLunarEclipseVisibilityMask(builder, "lunar-not-visible-mask", frame, "lunar-visible-p1-shape", "lunar-visible-p4-shape") writeLunarEclipseVisibilityMask(builder, "lunar-not-p4-mask", frame, "lunar-visible-p4-shape") writeLunarEclipseVisibilityMask(builder, "lunar-not-p1-mask", frame, "lunar-visible-p1-shape") } func writeLunarEclipseVisibilityMask( builder *strings.Builder, id string, frame svgmap.Frame, excludedShapeIDs ...string, ) { fmt.Fprintf(builder, ``, id, frame.X, frame.Y, frame.Width, frame.Height) fmt.Fprintf(builder, ``, frame.X, frame.Y, frame.Width, frame.Height) for _, shapeID := range excludedShapeIDs { fmt.Fprintf(builder, ``, shapeID) } builder.WriteString(``) } func eclipseMapFrame(width, height int, projection svgmap.Projection, top, bottom float64) svgmap.Frame { availableWidth := float64(width) - 90 availableHeight := float64(height) - top - bottom mapWidth := math.Min(availableWidth, availableHeight*2) mapHeight := mapWidth / 2 if projection != svgmap.ProjectionEquirectangular { mapWidth = math.Min(availableWidth, availableHeight) mapHeight = mapWidth } return svgmap.Frame{ X: (float64(width) - mapWidth) / 2, Y: top, Width: mapWidth, Height: mapHeight, Projection: projection, } } func lunarEclipseVisibilityPath(value time.Time, frame svgmap.Frame) (string, []svgmap.GeoPoint) { center := lunarEclipseSubpoint(value) boundary := svgmap.SphericalCircle(center, 90, 360) path := lunarEclipseVisibilityPathForCenter(center, frame) closedBoundary := append(append([]svgmap.GeoPoint(nil), boundary...), boundary[0]) return path, closedBoundary } func lunarEclipseVisibilityPathForCenter(center svgmap.GeoPoint, frame svgmap.Frame) string { var builder strings.Builder for _, polygon := range svgmap.VisibleHemispherePolygons(center, frame.Projection, 360) { appendEclipseMapPolygonPath(&builder, frame, polygon) } return builder.String() } func lunarEclipseVisibilityPolygons( center svgmap.GeoPoint, projection svgmap.Projection, samples int, ) [][]svgmap.GeoPoint { return svgmap.VisibleHemispherePolygons(center, projection, samples) } func lunarEclipseSubpoint(value time.Time) svgmap.GeoPoint { ttJDE := timeToTTJDE(value) ra, dec := basic.HMoonTrueRaDec(ttJDE) utJDE := basic.TD2UT(ttJDE, false) longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(utJDE)*15) return svgmap.GeoPoint{Longitude: longitude, Latitude: dec} } func appendEclipseMapPolygonPath(builder *strings.Builder, frame svgmap.Frame, points []svgmap.GeoPoint) { if len(points) < 3 { return } for index, point := range points { x, y, ok := frame.Project(point.Longitude, point.Latitude) if !ok { continue } command := "L" if index == 0 { command = "M" } fmt.Fprintf(builder, `%s %.3f %.3f `, command, x, y) } builder.WriteString(`Z `) } func writeEclipseMapGeoLine( builder *strings.Builder, frame svgmap.Frame, points []svgmap.GeoPoint, className, color string, strokeWidth float64, dash, clipID string, ) { for _, segment := range svgmap.PolylineSegments(points, frame.Projection) { if len(segment) < 2 { continue } fmt.Fprintf(builder, ``, clipID, color, strokeWidth, dash) } } func writeLunarEclipseUnavailableRegion(builder *strings.Builder, frame svgmap.Frame) { if frame.IsPolar() { fmt.Fprintf(builder, ``, frame.X+frame.Width/2, frame.Y+frame.Height/2, frame.Width/2) return } fmt.Fprintf(builder, ``, frame.X, frame.Y, frame.Width, frame.Height) } func writeLunarEclipseMapSummary(builder *strings.Builder, info eclipsecore.LunarEclipseInfo, options LunarEclipseMapSVGOptions) { start := info.PenumbralStart.In(options.Location) maximum := info.Maximum.In(options.Location) end := info.PenumbralEnd.In(options.Location) zone, _ := maximum.Zone() if zone == "" { zone = "UTC" } text := fmt.Sprintf("P1 %s | 食甚 %s | P4 %s (%s) | 半影食分 %.3f | 本影食分 %.3f", start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone, info.PenumbralMagnitude, info.UmbralMagnitude) if options.Language == "en" { text = fmt.Sprintf("P1 %s | Greatest %s | P4 %s (%s) | penumbral magnitude %.3f | umbral magnitude %.3f", start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone, info.PenumbralMagnitude, info.UmbralMagnitude) } fmt.Fprintf(builder, `%s`, float64(options.Width)/2, html.EscapeString(text)) } func writeLunarEclipseMapLegend(builder *strings.Builder, frame svgmap.Frame, language string) { labels := []string{"全程可见", "带食月出", "带食月落", "不可见"} colors := []string{"#5e846d", "#4e9da0", "#e2aa4b", "#747b7d"} if language == "en" { labels = []string{"Entire eclipse", "Moonrise during eclipse", "Moonset during eclipse", "Not visible"} } y := frame.Y + frame.Height + 31 itemWidth := frame.Width / 4 for index, label := range labels { x := frame.X + float64(index)*itemWidth fmt.Fprintf(builder, ``, x, y-8, colors[index]) fmt.Fprintf(builder, `%s`, x+24, y, html.EscapeString(label)) } } func writeLunarEclipseMapFooter( builder *strings.Builder, frame svgmap.Frame, options LunarEclipseMapSVGOptions, projection svgmap.Projection, ) { text := options.FooterNote if text == "" { if options.Language == "en" { text = eclipseMapProjectionLabel(projection, "en") + "; P1/P4 Moon-visible hemispheres; Natural Earth 1:50m physical land, no administrative boundaries." } else { text = eclipseMapProjectionLabel(projection, "zh") + ";按 P1/P4 月球可见半球分区;Natural Earth 1:50m 物理陆地底图,不含行政边界。" } } fmt.Fprintf(builder, `%s`, frame.X, float64(options.Height)-38, html.EscapeString(text)) } func normalizeDegree180(value float64) float64 { value = math.Mod(value+180, 360) if value < 0 { value += 360 } return value - 180 }