package svg import ( "fmt" "html" "math" "strings" "time" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" ) const ( lunarEclipseSVGDefaultWidth = 960 lunarEclipseSVGDefaultHeight = 620 lunarEclipseSVGDefaultZone = 8 * 60 * 60 lunarEclipseSVGLanguageChinese = "zh" lunarEclipseSVGLanguageEnglish = "en" ) // LunarEclipseSVGOptions 控制月食穿影 SVG 输出。 // LunarEclipseSVGOptions controls lunar eclipse shadow-path SVG output. type LunarEclipseSVGOptions struct { // Width / Height 是 SVG 画布尺寸;<=0 时使用默认尺寸。 // Width/Height are SVG canvas size; values <= 0 use defaults. Width int Height int // Step 是月心路径采样步长;<=0 时使用 5 分钟。 // Step is the Moon-center path sampling step; values <= 0 use five minutes. Step time.Duration // Title 是图题;为空时自动生成。 // Title is the chart title; empty values use an automatic title. Title string // SummaryText 是标题下第一行摘要;为空时自动生成。 // SummaryText is the first summary line below the title; empty values use an automatic summary. SummaryText string // MaximumText 是标题下第二行食甚说明;为空时自动生成。 // MaximumText is the second line below the title for maximum-eclipse details; empty values use automatic text. MaximumText string // CoordinatesText 是标题下第三行坐标说明;为空时自动生成。 // CoordinatesText is the third line below the title for coordinates; empty values use automatic text. CoordinatesText string // DurationText 是标题下第四行历时说明;为空时自动生成。 // DurationText is the fourth line below the title for durations; empty values use automatic text. DurationText string // MetaText 是标题下第五行补充说明;为空时自动生成沙罗信息。 // MetaText is the fifth line below the title; empty values use automatic Saros text. MetaText string // ContactsTitle 是接触时刻区标题;为空时自动生成。 // ContactsTitle is the contacts-panel title; empty values use an automatic title. ContactsTitle string // DirectionText 是底部方向说明;为空时自动生成。 // DirectionText is the footer direction note; empty values use an automatic note. DirectionText string // FooterNote 是底部补充说明;为空时自动生成。 // FooterNote is the footer explanatory note; empty values use an automatic note. FooterNote string // Language 是标签语言;"en" 使用英文,其他值或空值使用中文。 // Language controls label language; "en" uses English, other values or empty values use Chinese. Language string // Location 是图中显示时刻的时区;nil 时使用 UTC+8。 // Location is the display timezone for chart times; nil uses UTC+8. Location *time.Location } type lunarEclipseSVGCalculator func(float64, basic.LunarEclipseDiagramOptions) basic.LunarEclipseDiagramResult type lunarEclipseSVGFinder func(time.Time) LunarEclipseInfo type lunarEclipseSVGLayout struct { width float64 height float64 cx float64 cy float64 scale float64 diagramLeft float64 diagramRight float64 panelX float64 panelY float64 } type lunarEclipseSVGPoint struct { basic.LunarEclipseDiagramPoint X float64 Y float64 } type lunarEclipseSVGMaximumCoordinates struct { RA float64 Dec float64 EclipticLongitude float64 EclipticLatitude float64 ConstellationCode string ConstellationName string } // LunarEclipseSVG 生成月食穿影图 SVG,默认使用 Danjon 影半径模型。 // LunarEclipseSVG generates an SVG lunar eclipse shadow-path chart, using the Danjon shadow model by default. func LunarEclipseSVG(date time.Time, options LunarEclipseSVGOptions) (string, bool) { return LunarEclipseSVGDanjon(date, options) } // LunarEclipseSVGDanjon 生成月食穿影图 SVG,使用 Danjon 影半径模型。 // LunarEclipseSVGDanjon generates an SVG lunar eclipse shadow-path chart with the Danjon shadow model. func LunarEclipseSVGDanjon(date time.Time, options LunarEclipseSVGOptions) (string, bool) { return lunarEclipseSVG(date, options, basic.LunarEclipseDiagramDanjon, eclipsecore.ClosestLunarEclipseDanjon) } // LunarEclipseSVGChauvenet 生成月食穿影图 SVG,使用 Chauvenet 影半径模型。 // LunarEclipseSVGChauvenet generates an SVG lunar eclipse shadow-path chart with the Chauvenet shadow model. func LunarEclipseSVGChauvenet(date time.Time, options LunarEclipseSVGOptions) (string, bool) { return lunarEclipseSVG(date, options, basic.LunarEclipseDiagramChauvenet, eclipsecore.ClosestLunarEclipseChauvenet) } func lunarEclipseSVG( date time.Time, options LunarEclipseSVGOptions, calculator lunarEclipseSVGCalculator, finder lunarEclipseSVGFinder, ) (string, bool) { options = normalizeLunarEclipseSVGOptions(options) diagram := calculator(timeToTTJDE(date), basic.LunarEclipseDiagramOptions{ StepDays: durationToDays(options.Step), }) if diagram.Eclipse.Type == basic.LunarEclipseNone || len(diagram.Points) == 0 { return "", false } info := lunarEclipseInfoFromBasic(diagram.Eclipse, options.Location) if finder != nil { coreInfo := finder(info.Maximum) info.HasSaros = coreInfo.HasSaros info.Saros = coreInfo.Saros } return renderLunarEclipseSVG(info, diagram, options), true } func normalizeLunarEclipseSVGOptions(options LunarEclipseSVGOptions) LunarEclipseSVGOptions { if options.Width <= 0 { options.Width = lunarEclipseSVGDefaultWidth } if options.Height <= 0 { options.Height = lunarEclipseSVGDefaultHeight } if options.Location == nil { options.Location = time.FixedZone("UTC+8", lunarEclipseSVGDefaultZone) } if strings.EqualFold(options.Language, lunarEclipseSVGLanguageEnglish) { options.Language = lunarEclipseSVGLanguageEnglish } else { options.Language = lunarEclipseSVGLanguageChinese } return options } func renderLunarEclipseSVG( info LunarEclipseInfo, diagram basic.LunarEclipseDiagramResult, options LunarEclipseSVGOptions, ) string { headerTexts := lunarEclipseSVGHeaderTexts(info, options) layout := lunarEclipseSVGLayoutFor(diagram, options, lunarEclipseSVGHeaderBottom(headerTexts)) points := lunarEclipseSVGPoints(diagram.Points) mapX := func(x float64) float64 { return layout.cx - x*layout.scale } mapY := func(y float64) float64 { return layout.cy - y*layout.scale } title := lunarEclipseSVGTitleText(info, options) var b strings.Builder fmt.Fprintf(&b, ``, options.Width, options.Height, options.Width, options.Height) b.WriteString(``) b.WriteString(lunarEclipseSVGMoonSymbol) b.WriteString(``) b.WriteString(``) fmt.Fprintf(&b, ``, layout.width-44, layout.height-36) fmt.Fprintf(&b, `%s`, layout.width/2, html.EscapeString(title)) fmt.Fprintf(&b, ``, layout.width/2-78, layout.width/2+78) writeLunarEclipseSummary(&b, headerTexts, options) fmt.Fprintf(&b, ``, layout.cx, layout.cy, diagram.PenumbraRadius*layout.scale) fmt.Fprintf(&b, ``, layout.cx, layout.cy, diagram.UmbraRadius*layout.scale) writeLunarEclipseShadowLabels(&b, layout, diagram, options.Language) writeLunarEclipseAxes(&b, layout.cx, layout.cy, diagram.PenumbraRadius*layout.scale, options.Language) writeLunarEclipseEclipticLine(&b, layout, diagram, mapX, mapY, options.Language) if len(points) > 0 { b.WriteString(``) } eventPoints := lunarEclipseSVGEventPoints(points) for _, point := range eventPoints { if point.Label == "Greatest" { continue } writeLunarEclipseMoon(&b, point, diagram, mapX(point.X), mapY(point.Y), layout.scale, false) } for _, point := range eventPoints { if point.Label == "Greatest" { writeLunarEclipseMoon(&b, point, diagram, mapX(point.X), mapY(point.Y), layout.scale, true) break } } for _, point := range eventPoints { if point.Label == "" { continue } x := mapX(point.X) y := mapY(point.Y) writeLunarEclipseEventLabel(&b, point.Label, x, y, layout.cx, diagram.MoonRadius*layout.scale, options.Language) } writeLunarEclipseContacts(&b, info, options, layout.panelX, layout.panelY) writeLunarEclipseFooter(&b, info, options, layout) b.WriteString(``) return b.String() } func lunarEclipseSVGLayoutFor( diagram basic.LunarEclipseDiagramResult, options LunarEclipseSVGOptions, headerBottom float64, ) lunarEclipseSVGLayout { width := float64(options.Width) height := float64(options.Height) margin := math.Max(32, math.Min(48, width*0.05)) panelWidth := math.Max(210, math.Min(260, width*0.24)) diagramLeft := margin diagramRight := width - panelWidth - 34 if diagramRight-diagramLeft < width*0.48 { diagramRight = width - margin } topReserved := math.Max(166, headerBottom+24) bottomReserved := 82.0 extent := diagram.PenumbraRadius + diagram.MoonRadius + 0.72 scale := math.Min((diagramRight-diagramLeft)/(2*extent), (height-topReserved-bottomReserved)/(2*extent)) if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) { scale = 1 } cx := (diagramLeft + diagramRight) / 2 cy := topReserved + (height-topReserved-bottomReserved)/2 + 12 panelX := diagramRight + 22 if panelX+panelWidth > width-margin/2 { panelX = width - panelWidth - margin/2 } if panelX < diagramRight { panelX = diagramRight } return lunarEclipseSVGLayout{ width: width, height: height, cx: cx, cy: cy, scale: scale, diagramLeft: diagramLeft, diagramRight: diagramRight, panelX: panelX, panelY: math.Max(148, cy-88), } } func lunarEclipseSVGTitle(info LunarEclipseInfo, language string) string { return fmt.Sprintf("%s %s", info.Maximum.Format("2006-01-02"), lunarEclipseSVGTypeName(info.Type, language)) } func lunarEclipseSVGTitleText(info LunarEclipseInfo, options LunarEclipseSVGOptions) string { if options.Title != "" { return options.Title } return lunarEclipseSVGTitle(info, options.Language) } func lunarEclipseSVGHeaderTexts(info LunarEclipseInfo, options LunarEclipseSVGOptions) []string { lines := []string{ lunarEclipseSVGSummaryText(info, options), lunarEclipseSVGMaximumTextValue(info, options), lunarEclipseSVGCoordinatesTextValue(info, options), lunarEclipseSVGDurationTextValue(info, options), lunarEclipseSVGMetaTextValue(info, options), } filtered := make([]string, 0, len(lines)) for _, line := range lines { if line != "" { filtered = append(filtered, line) } } return filtered } func lunarEclipseSVGSummaryText(info LunarEclipseInfo, options LunarEclipseSVGOptions) string { if options.SummaryText != "" { return options.SummaryText } return lunarEclipseSVGSummary(info, options.Language) } func lunarEclipseSVGMaximumTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string { if options.MaximumText != "" { return options.MaximumText } return lunarEclipseSVGMaximumText(info, options) } func lunarEclipseSVGCoordinatesTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string { if options.CoordinatesText != "" { return options.CoordinatesText } coordinates := lunarEclipseSVGMaximumCoordinatesFor(info, options.Language) return lunarEclipseSVGMaximumCoordinatesText(coordinates, options.Language) } func lunarEclipseSVGDurationTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string { if options.DurationText != "" { return options.DurationText } return lunarEclipseSVGDurationSummary(info, options.Language) } func lunarEclipseSVGMetaTextValue(info LunarEclipseInfo, options LunarEclipseSVGOptions) string { if options.MetaText != "" { return options.MetaText } return lunarEclipseSVGMetaText(info, options.Language) } func lunarEclipseSVGContactsTitleText(options LunarEclipseSVGOptions) string { if options.ContactsTitle != "" { return options.ContactsTitle } if options.Language == lunarEclipseSVGLanguageEnglish { return "Contacts" } return "接触时刻" } func lunarEclipseSVGDirectionTextValue(options LunarEclipseSVGOptions) string { if options.DirectionText != "" { return options.DirectionText } return lunarEclipseSVGDirectionText(options.Language) } func lunarEclipseSVGFooterNoteText(options LunarEclipseSVGOptions) string { if options.FooterNote != "" { return options.FooterNote } note := "图中月面大小和影半径均按实际相对角半径缩放。" if options.Language == lunarEclipseSVGLanguageEnglish { note = "Moon disks and shadow radii are drawn to the same relative angular-radius scale." } return note } func lunarEclipseSVGHeaderLineY(index int) float64 { return 84 + float64(index)*22 } func lunarEclipseSVGHeaderBottom(lines []string) float64 { if len(lines) == 0 { return 72 } return lunarEclipseSVGHeaderLineY(len(lines)-1) + 14 } func lunarEclipseSVGSummary(info LunarEclipseInfo, language string) string { if language == lunarEclipseSVGLanguageEnglish { return fmt.Sprintf("type=%s penumbral=%.4f umbral=%.4f", lunarEclipseSVGTypeName(info.Type, language), info.PenumbralMagnitude, info.UmbralMagnitude) } return fmt.Sprintf("食型=%s 半影食分=%.4f 本影食分=%.4f", lunarEclipseSVGTypeName(info.Type, language), info.PenumbralMagnitude, info.UmbralMagnitude) } func writeLunarEclipseSummary(b *strings.Builder, lines []string, options LunarEclipseSVGOptions) { for index, line := range lines { fontSize := 13 fill := "#333" if index == 0 { fontSize = 14 fill = "#222" } fmt.Fprintf(b, `%s`, float64(options.Width)/2, lunarEclipseSVGHeaderLineY(index), fill, fontSize, html.EscapeString(line)) } } func lunarEclipseSVGDirectionText(language string) string { if language == lunarEclipseSVGLanguageEnglish { return "North is up and east is left; the ecliptic is projected near greatest eclipse." } return "上北下南,左东右西;黄道按食甚附近天球投影绘制。" } func lunarEclipseSVGMaximumText(info LunarEclipseInfo, options LunarEclipseSVGOptions) string { maximum := info.Maximum.In(options.Location).Format("2006-01-02 15:04:05 MST") if options.Language == lunarEclipseSVGLanguageEnglish { return "Maximum: " + maximum } return "食甚:" + maximum } func lunarEclipseSVGMaximumCoordinatesFor( info LunarEclipseInfo, language string, ) lunarEclipseSVGMaximumCoordinates { jde := timeToTTJDE(info.Maximum) ra, dec := basic.HMoonTrueRaDec(jde) lon := basic.HMoonApparentLo(jde) lat := basic.HMoonTrueBo(jde) code := basic.ConstellationCode(ra, dec, jde) name := basic.ConstellationNameByCodeZH(code) if language == lunarEclipseSVGLanguageEnglish { name = basic.ConstellationNameByCodeEN(code) } return lunarEclipseSVGMaximumCoordinates{ RA: ra, Dec: dec, EclipticLongitude: lon, EclipticLatitude: lat, ConstellationCode: code, ConstellationName: name, } } func lunarEclipseSVGMaximumCoordinatesText( coordinates lunarEclipseSVGMaximumCoordinates, language string, ) string { if language == lunarEclipseSVGLanguageEnglish { return fmt.Sprintf("Moon: RA %s Dec %s ecl.lon %.4f deg ecl.lat %.4f deg %s", lunarEclipseSVGFormatRA(coordinates.RA), lunarEclipseSVGFormatSignedAngle(coordinates.Dec), coordinates.EclipticLongitude, coordinates.EclipticLatitude, coordinates.ConstellationName, ) } return fmt.Sprintf("月球:赤经 %s 赤纬 %s 黄经 %.4f° 黄纬 %.4f° %s", lunarEclipseSVGFormatRA(coordinates.RA), lunarEclipseSVGFormatSignedAngle(coordinates.Dec), coordinates.EclipticLongitude, coordinates.EclipticLatitude, coordinates.ConstellationName, ) } func lunarEclipseSVGFormatRA(degree float64) string { totalSeconds := int(math.Round(normalizeDegree360(degree) / 15 * 3600)) totalSeconds %= 24 * 3600 hours := totalSeconds / 3600 minutes := totalSeconds % 3600 / 60 seconds := totalSeconds % 60 return fmt.Sprintf("%02dh%02dm%02ds", hours, minutes, seconds) } func lunarEclipseSVGFormatSignedAngle(degree float64) string { sign := "+" if degree < 0 { sign = "-" degree = -degree } totalSeconds := int(math.Round(degree * 3600)) degrees := totalSeconds / 3600 minutes := totalSeconds % 3600 / 60 seconds := totalSeconds % 60 return fmt.Sprintf("%s%02d°%02d′%02d″", sign, degrees, minutes, seconds) } func lunarEclipseSVGDurationSummary(info LunarEclipseInfo, language string) string { penumbral := lunarEclipseSVGFormatDuration(info.PenumbralEnd.Sub(info.PenumbralStart)) if language == lunarEclipseSVGLanguageEnglish { parts := []string{"Penumbral duration " + penumbral} if info.HasPartial { parts = append(parts, "Umbral duration "+lunarEclipseSVGFormatDuration(info.PartialEnd.Sub(info.PartialStart))) } if info.HasTotal { parts = append(parts, "Total duration "+lunarEclipseSVGFormatDuration(info.TotalEnd.Sub(info.TotalStart))) } return strings.Join(parts, " ") } parts := []string{"半影历时 " + penumbral} if info.HasPartial { parts = append(parts, "本影历时 "+lunarEclipseSVGFormatDuration(info.PartialEnd.Sub(info.PartialStart))) } if info.HasTotal { parts = append(parts, "全食历时 "+lunarEclipseSVGFormatDuration(info.TotalEnd.Sub(info.TotalStart))) } return strings.Join(parts, " ") } func lunarEclipseSVGMetaText(info LunarEclipseInfo, language string) string { if !info.HasSaros { return "" } if language == lunarEclipseSVGLanguageEnglish { return fmt.Sprintf("Lunar Saros %d %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count) } return fmt.Sprintf("沙罗 %d 第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count) } func lunarEclipseSVGFormatDuration(duration time.Duration) string { if duration < 0 { duration = -duration } totalSeconds := int(duration.Round(time.Second).Seconds()) hours := totalSeconds / 3600 minutes := totalSeconds % 3600 / 60 seconds := totalSeconds % 60 return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) } func lunarEclipseSVGTypeName(eclipseType LunarEclipseType, language string) string { if language == lunarEclipseSVGLanguageEnglish { switch eclipseType { case LunarEclipsePenumbral: return "Penumbral Lunar Eclipse" case LunarEclipsePartial: return "Partial Lunar Eclipse" case LunarEclipseTotal: return "Total Lunar Eclipse" default: return "No Lunar Eclipse" } } switch eclipseType { case LunarEclipsePenumbral: return "半影月食" case LunarEclipsePartial: return "月偏食" case LunarEclipseTotal: return "月全食" default: return "无月食" } } func writeLunarEclipseAxes(b *strings.Builder, cx, cy, radius float64, language string) { north, east, west, south := "北", "东", "西", "南" if language == lunarEclipseSVGLanguageEnglish { north, east, west, south = "N", "E", "W", "S" } fmt.Fprintf(b, `%s`, cx, cy-radius-18, html.EscapeString(north)) fmt.Fprintf(b, `%s`, cx-radius-22, cy+4, html.EscapeString(east)) fmt.Fprintf(b, `%s`, cx+radius+22, cy+4, html.EscapeString(west)) fmt.Fprintf(b, `%s`, cx, cy+radius+28, html.EscapeString(south)) } func lunarEclipseSVGPoints(points []basic.LunarEclipseDiagramPoint) []lunarEclipseSVGPoint { result := make([]lunarEclipseSVGPoint, 0, len(points)) for _, point := range points { distance := math.Hypot(point.X, point.Y) positionAngleRad := lunarEclipseMoonCenterPositionAngle(point.JDE) * math.Pi / 180 result = append(result, lunarEclipseSVGPoint{ LunarEclipseDiagramPoint: point, X: distance * math.Sin(positionAngleRad), Y: distance * math.Cos(positionAngleRad), }) } return result } func writeLunarEclipseEclipticLine( b *strings.Builder, layout lunarEclipseSVGLayout, diagram basic.LunarEclipseDiagramResult, mapX, mapY func(float64) float64, language string, ) { unitX, unitY, ok := lunarEclipseSVGEclipticDirection(diagram.Eclipse.Maximum) if !ok { return } extent := (diagram.PenumbraRadius + diagram.MoonRadius) * 1.42 screenStartX := mapX(-unitX * extent) screenStartY := mapY(-unitY * extent) screenEndX := mapX(unitX * extent) screenEndY := mapY(unitY * extent) if math.IsNaN(screenStartX) || math.IsNaN(screenStartY) || math.IsNaN(screenEndX) || math.IsNaN(screenEndY) { return } fmt.Fprintf(b, ``, screenStartX, screenStartY, screenEndX, screenEndY) labelX := screenStartX labelY := screenStartY anchor := "end" if screenEndX < screenStartX { labelX = screenEndX labelY = screenEndY } labelX -= 8 if labelX < layout.diagramLeft+18 { labelX += 16 anchor = "start" } fmt.Fprintf(b, `%s`, labelX, labelY+4, anchor, html.EscapeString(lunarEclipseSVGLabelEcliptic(language))) } func lunarEclipseSVGEclipticDirection(ttJDE float64) (float64, float64, bool) { originRA, originDec := lunarEclipseShadowCenterRaDec(ttJDE) centerLongitude := normalizeDegree360(basic.HSunApparentLo(ttJDE) + 180) ra1, dec1 := basic.LoBoToRaDec(ttJDE, centerLongitude-1, 0) ra2, dec2 := basic.LoBoToRaDec(ttJDE, centerLongitude+1, 0) x1, y1 := lunarEclipseSVGTangentOffset(originRA, originDec, ra1, dec1) x2, y2 := lunarEclipseSVGTangentOffset(originRA, originDec, ra2, dec2) dx := x2 - x1 dy := y2 - y1 length := math.Hypot(dx, dy) if length == 0 || math.IsNaN(length) || math.IsInf(length, 0) { return 0, 0, false } return dx / length, dy / length, true } func lunarEclipseSVGTangentOffset(originRA, originDec, targetRA, targetDec float64) (float64, float64) { separation := lunarEclipseSVGAngularSeparation(originRA, originDec, targetRA, targetDec) positionAngleRad := lunarEclipsePositionAngle(originRA, originDec, targetRA, targetDec) * math.Pi / 180 return separation * math.Sin(positionAngleRad), separation * math.Cos(positionAngleRad) } func lunarEclipseSVGAngularSeparation(ra1, dec1, ra2, dec2 float64) float64 { ra1Rad := ra1 * math.Pi / 180 dec1Rad := dec1 * math.Pi / 180 ra2Rad := ra2 * math.Pi / 180 dec2Rad := dec2 * math.Pi / 180 cosDistance := math.Sin(dec1Rad)*math.Sin(dec2Rad) + math.Cos(dec1Rad)*math.Cos(dec2Rad)*math.Cos(ra2Rad-ra1Rad) if cosDistance > 1 { cosDistance = 1 } if cosDistance < -1 { cosDistance = -1 } return math.Acos(cosDistance) * 180 / math.Pi } func lunarEclipseSVGLabelEcliptic(language string) string { if language == lunarEclipseSVGLanguageEnglish { return "Ecliptic" } return "黄道" } func writeLunarEclipseShadowLabels( b *strings.Builder, layout lunarEclipseSVGLayout, diagram basic.LunarEclipseDiagramResult, language string, ) { penumbraLabel, umbraLabel := "地球半影", "地球本影" if language == lunarEclipseSVGLanguageEnglish { penumbraLabel, umbraLabel = "Earth's Penumbra", "Earth's Umbra" } fmt.Fprintf(b, `%s`, layout.cx, layout.cy-diagram.PenumbraRadius*layout.scale+28, html.EscapeString(penumbraLabel)) fmt.Fprintf(b, `%s`, layout.cx, layout.cy-diagram.UmbraRadius*layout.scale+22, html.EscapeString(umbraLabel)) } func lunarEclipseSVGEventPoints(points []lunarEclipseSVGPoint) []lunarEclipseSVGPoint { events := make([]lunarEclipseSVGPoint, 0, 7) for _, point := range points { for _, label := range lunarEclipseSVGPointLabels(point) { event := point event.Label = label event.Labels = []string{label} events = append(events, event) } } return events } func lunarEclipseSVGPointLabels(point lunarEclipseSVGPoint) []string { if len(point.Labels) > 0 { return point.Labels } if point.Label == "" { return nil } return []string{point.Label} } func writeLunarEclipseMoon( b *strings.Builder, point lunarEclipseSVGPoint, diagram basic.LunarEclipseDiagramResult, x, y, scale float64, greatest bool, ) { radius := diagram.MoonRadius * scale opacity := 0.64 if greatest { opacity = 0.9 } fmt.Fprintf(b, ``) fmt.Fprintf(b, ``, x-radius, y-radius, radius*2, radius*2, opacity) if lunarEclipseSVGDeepUmbra(point.LunarEclipseDiagramPoint, diagram) { tintOpacity := 0.46 if greatest { tintOpacity = 0.58 } fmt.Fprintf(b, ``, x, y, radius, tintOpacity) } fmt.Fprintf(b, ``, x, y, radius) b.WriteString(``) } func lunarEclipseSVGDeepUmbra(point basic.LunarEclipseDiagramPoint, diagram basic.LunarEclipseDiagramResult) bool { return math.Hypot(point.X, point.Y) < diagram.UmbraRadius+diagram.MoonRadius*0.75 } func writeLunarEclipseEventLabel( b *strings.Builder, label string, x, y, cx, moonRadius float64, language string, ) { text := lunarEclipseSVGEventName(label, language) dx := moonRadius*0.72 + 6 anchor := "start" if x < cx { dx = -dx anchor = "end" } dy := -moonRadius*0.35 - 4 if label == "Greatest" { dy = moonRadius + 15 anchor = "middle" dx = 0 } fmt.Fprintf(b, `%s`, x+dx, y+dy, anchor, html.EscapeString(text)) } func lunarEclipseSVGEventName(label, language string) string { if language == lunarEclipseSVGLanguageEnglish { switch label { case "Greatest": return "Greatest" default: return label } } switch label { case "P1": return "P1 半影始" case "U1": return "U1 初亏" case "U2": return "U2 食既" case "Greatest": return "食甚" case "U3": return "U3 生光" case "U4": return "U4 复圆" case "P4": return "P4 半影终" default: return label } } type lunarEclipseSVGContact struct { label string name string time time.Time angle float64 hasAngle bool } func writeLunarEclipseContacts( b *strings.Builder, info LunarEclipseInfo, options LunarEclipseSVGOptions, x, y float64, ) { contacts := lunarEclipseSVGContacts(info, options.Language) if len(contacts) == 0 { return } title := lunarEclipseSVGContactsTitleText(options) fmt.Fprintf(b, `%s (%s)`, x, y, html.EscapeString(title), html.EscapeString(options.Location.String())) for index, contact := range contacts { line := fmt.Sprintf("%s %s %s", contact.label, contact.name, contact.time.In(options.Location).Format("15:04:05")) if contact.hasAngle { if options.Language == lunarEclipseSVGLanguageEnglish { line = fmt.Sprintf("%s PA %.1f°", line, contact.angle) } else { line = fmt.Sprintf("%s 方位 %.1f°", line, contact.angle) } } fmt.Fprintf(b, `%s`, x, y+float64(index+1)*18, html.EscapeString(line)) } } func writeLunarEclipseFooter( b *strings.Builder, info LunarEclipseInfo, options LunarEclipseSVGOptions, layout lunarEclipseSVGLayout, ) { _ = info fmt.Fprintf(b, `%s`, 40.0, layout.height-54, html.EscapeString(lunarEclipseSVGDirectionTextValue(options))) note := lunarEclipseSVGFooterNoteText(options) fmt.Fprintf(b, `%s`, 40.0, layout.height-34, html.EscapeString(note)) } func lunarEclipseSVGContacts(info LunarEclipseInfo, language string) []lunarEclipseSVGContact { angles := lunarEclipseContactAngleMap(info.ContactPoints) contacts := []lunarEclipseSVGContact{ lunarEclipseSVGContactFor("P1", lunarEclipseSVGContactName("P1", language), info.PenumbralStart, angles), } if info.HasPartial { contacts = append(contacts, lunarEclipseSVGContactFor("U1", lunarEclipseSVGContactName("U1", language), info.PartialStart, angles)) } if info.HasTotal { contacts = append(contacts, lunarEclipseSVGContactFor("U2", lunarEclipseSVGContactName("U2", language), info.TotalStart, angles)) } contacts = append(contacts, lunarEclipseSVGContact{label: "GE", name: lunarEclipseSVGContactName("Greatest", language), time: info.Maximum}) if info.HasTotal { contacts = append(contacts, lunarEclipseSVGContactFor("U3", lunarEclipseSVGContactName("U3", language), info.TotalEnd, angles)) } if info.HasPartial { contacts = append(contacts, lunarEclipseSVGContactFor("U4", lunarEclipseSVGContactName("U4", language), info.PartialEnd, angles)) } contacts = append(contacts, lunarEclipseSVGContactFor("P4", lunarEclipseSVGContactName("P4", language), info.PenumbralEnd, angles)) return contacts } func lunarEclipseSVGContactFor( label, name string, time time.Time, angles map[string]float64, ) lunarEclipseSVGContact { angle, ok := angles[label] return lunarEclipseSVGContact{ label: label, name: name, time: time, angle: angle, hasAngle: ok, } } func lunarEclipseContactAngleMap(points []LunarEclipseContactPoint) map[string]float64 { angles := make(map[string]float64, len(points)) for _, point := range points { angles[point.Label] = point.ContactPositionAngle } return angles } func lunarEclipseSVGContactName(label, language string) string { if language == lunarEclipseSVGLanguageEnglish { switch label { case "P1": return "Penumbral begins" case "U1": return "Partial begins" case "U2": return "Total begins" case "Greatest": return "Greatest" case "U3": return "Total ends" case "U4": return "Partial ends" case "P4": return "Penumbral ends" default: return label } } switch label { case "P1": return "半影始" case "U1": return "初亏" case "U2": return "食既" case "Greatest": return "食甚" case "U3": return "生光" case "U4": return "复圆" case "P4": return "半影终" default: return label } } func durationToDays(duration time.Duration) float64 { if duration <= 0 { return 0 } return duration.Hours() / 24 }