package svg import ( "fmt" "html" "math" "strings" "time" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" ) const ( localSolarEclipseSVGDefaultWidth = 920 localSolarEclipseSVGDefaultHeight = 720 localSolarEclipseSVGDefaultZone = 8 * 60 * 60 localSolarEclipseSVGLanguageChinese = "zh" localSolarEclipseSVGLanguageEnglish = "en" ) // LocalSolarEclipseSVGOptions 控制站心日食视圆 SVG 输出。 // LocalSolarEclipseSVGOptions controls local solar eclipse disk SVG output. type LocalSolarEclipseSVGOptions 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 // GreatestText 是标题下第二行食甚说明;为空时自动生成。 // GreatestText is the second line below the title for greatest-eclipse details; empty values use automatic text. GreatestText string // MetaText 是标题下第三行补充说明;为空时自动生成沙罗/中心食历时等信息。 // MetaText is the third line below the title; empty values use automatic Saros and central-duration text. MetaText string // OverviewTitle 是上方总览区标题;为空时自动生成。 // OverviewTitle is the overview-panel title; empty values use an automatic title. OverviewTitle string // PhasePanelsTitle 是下方阶段视圆区标题;为空时自动生成。 // PhasePanelsTitle is the phase-panels title; empty values use an automatic title. PhasePanelsTitle 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 localSolarEclipseSVGCalculator func(float64, float64, float64, float64, basic.LocalSolarEclipseDiagramOptions) basic.LocalSolarEclipseDiagramResult type localSolarEclipseSVGFinder func(time.Time, float64, float64, float64) LocalSolarEclipseInfo // LocalSolarEclipseSVG 生成给定地点的站心日食日月视圆 SVG,默认使用 NASA bulletin Split-K 模型。 // LocalSolarEclipseSVG generates an SVG local solar eclipse Sun-Moon disk chart, using NASA bulletin Split-K by default. func LocalSolarEclipseSVG( date time.Time, lon, lat, height float64, options LocalSolarEclipseSVGOptions, ) (string, bool) { return LocalSolarEclipseSVGNASABulletinSplitK(date, lon, lat, height, options) } // LocalSolarEclipseSVGNASABulletinSplitK 生成站心日食 SVG,使用 NASA bulletin Split-K 模型。 // LocalSolarEclipseSVGNASABulletinSplitK generates a local solar eclipse SVG with the NASA bulletin Split-K model. func LocalSolarEclipseSVGNASABulletinSplitK( date time.Time, lon, lat, height float64, options LocalSolarEclipseSVGOptions, ) (string, bool) { return localSolarEclipseSVG(date, lon, lat, height, options, basic.LocalSolarEclipseDiagramNASABulletinSplitK, eclipsecore.ClosestLocalSolarEclipseNASABulletinSplitK) } // LocalSolarEclipseSVGIAUSingleK 生成站心日食 SVG,使用 IAU Single-K 模型。 // LocalSolarEclipseSVGIAUSingleK generates a local solar eclipse SVG with the IAU Single-K model. func LocalSolarEclipseSVGIAUSingleK( date time.Time, lon, lat, height float64, options LocalSolarEclipseSVGOptions, ) (string, bool) { return localSolarEclipseSVG(date, lon, lat, height, options, basic.LocalSolarEclipseDiagramIAUSingleK, eclipsecore.ClosestLocalSolarEclipseIAUSingleK) } func localSolarEclipseSVG( date time.Time, lon, lat, height float64, options LocalSolarEclipseSVGOptions, calculator localSolarEclipseSVGCalculator, finder localSolarEclipseSVGFinder, ) (string, bool) { options = normalizeLocalSolarEclipseSVGOptions(options) diagram := calculator( solarEclipseTimeToTTJDE(date), lon, lat, height, basic.LocalSolarEclipseDiagramOptions{StepDays: solarEclipseDurationToDays(options.Step)}, ) if diagram.Eclipse.Type == basic.SolarEclipseNone || len(diagram.Frames) == 0 { return "", false } info := localSolarEclipseInfoFromDiagram(diagram, lon, lat, height, options.Location) if finder != nil { coreInfo := finder(info.GreatestEclipse, lon, lat, height) info.HasSaros = coreInfo.HasSaros info.Saros = coreInfo.Saros } return renderLocalSolarEclipseSVG(info, diagram, options), true } func normalizeLocalSolarEclipseSVGOptions(options LocalSolarEclipseSVGOptions) LocalSolarEclipseSVGOptions { if options.Width <= 0 { options.Width = localSolarEclipseSVGDefaultWidth } if options.Height <= 0 { options.Height = localSolarEclipseSVGDefaultHeight } if options.Location == nil { options.Location = time.FixedZone("UTC+8", localSolarEclipseSVGDefaultZone) } if strings.EqualFold(options.Language, localSolarEclipseSVGLanguageEnglish) { options.Language = localSolarEclipseSVGLanguageEnglish } else { options.Language = localSolarEclipseSVGLanguageChinese } return options } func renderLocalSolarEclipseSVG( info LocalSolarEclipseInfo, diagram basic.LocalSolarEclipseDiagramResult, options LocalSolarEclipseSVGOptions, ) string { headerTexts := localSolarEclipseSVGHeaderTexts(info, options) headerBottom := localSolarEclipseSVGHeaderBottom(headerTexts) width := float64(options.Width) height := float64(options.Height) margin := math.Max(30, math.Min(46, width*0.05)) panelWidth := math.Max(230, math.Min(280, width*0.28)) diagramLeft := margin diagramRight := width - panelWidth - margin - 24 if diagramRight-diagramLeft < width*0.48 { diagramRight = width - margin } footerHeight := 72.0 stageHeight := math.Max(160, math.Min(210, height*0.27)) stageTop := height - stageHeight - footerHeight if stageTop < headerBottom+160 { stageTop = headerBottom + 160 } cx := (diagramLeft + diagramRight) / 2 cy := headerBottom + (stageTop-headerBottom)/2 + 4 extent := localSolarEclipseDiagramExtent(diagram) scale := math.Min((diagramRight-diagramLeft)/(2*extent), (stageTop-headerBottom-18)/(2*extent)) if scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) { scale = 1 } panelX := diagramRight + 24 if panelX+panelWidth > width-margin/2 { panelX = width - panelWidth - margin/2 } mapX := func(x float64) float64 { return cx - x*scale } mapY := func(y float64) float64 { return cy - y*scale } title := localSolarEclipseSVGTitleText(info, options) var b strings.Builder fmt.Fprintf(&b, ``, options.Width, options.Height, options.Width, options.Height) b.WriteString(``) b.WriteString(``) b.WriteString(``) b.WriteString(``) fmt.Fprintf(&b, ``, width-44, height-36) fmt.Fprintf(&b, `%s`, width/2, html.EscapeString(title)) fmt.Fprintf(&b, ``, width/2-78, width/2+78) for index, line := range headerTexts { fontSize := 13 fill := "#333" if index == 0 { fontSize = 14 fill = "#222" } fmt.Fprintf(&b, `%s`, width/2, localSolarEclipseSVGHeaderLineY(index), fill, fontSize, html.EscapeString(line)) } eventFrames := localSolarEclipseSVGEventFrames(diagram.Frames) fmt.Fprintf(&b, `%s`, diagramLeft, headerBottom+10, html.EscapeString(localSolarEclipseSVGOverviewTitleText(options))) fmt.Fprintf(&b, ``, cx, cy, scale) fmt.Fprintf(&b, ``, cx, cy, scale+2) writeLocalSolarEclipseAxes(&b, cx, cy, scale, options.Language) writeLocalSolarEclipseEclipticLine(&b, diagram, mapX, mapY, extent, options.Language) if len(diagram.Frames) > 0 { b.WriteString(``) } for _, frame := range eventFrames { x := mapX(frame.MoonX) y := mapY(frame.MoonY) if localSolarEclipseSVGDrawOverviewMoon(frame.Label) { writeLocalSolarEclipseMoonOutline(&b, frame, x, y, scale) continue } writeLocalSolarEclipseEventPoint(&b, frame.Label, x, y, "#24518a") } writeLocalSolarEclipseContactMarkers(&b, info, cx, cy, scale, options.Language) for _, frame := range eventFrames { if frame.Label == "" { continue } x := mapX(frame.MoonX) y := mapY(frame.MoonY) writeLocalSolarEclipseEventLabel(&b, info.Type, frame.Label, x, y, cx, frame.MoonRadius*scale, options.Language) } writeLocalSolarEclipseStagePanels(&b, info, eventFrames, options, margin, stageTop, width-2*margin, stageHeight) fmt.Fprintf(&b, `%s`, 40.0, height-54, html.EscapeString(localSolarEclipseSVGDirectionTextValue(options))) note := localSolarEclipseSVGFooterNoteText(options) fmt.Fprintf(&b, `%s`, 40.0, height-34, html.EscapeString(note)) writeLocalSolarEclipseContacts(&b, info, options, panelX, math.Max(154, cy-92)) b.WriteString(``) return b.String() } func localSolarEclipseSVGTitleText(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) string { if options.Title != "" { return options.Title } return localSolarEclipseSVGTitle(info, options.Language) } func localSolarEclipseSVGHeaderTexts(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) []string { lines := []string{ localSolarEclipseSVGSummaryText(info, options), localSolarEclipseSVGGreatestTextValue(info, options), localSolarEclipseSVGMetaTextValue(info, options), } filtered := make([]string, 0, len(lines)) for _, line := range lines { if line != "" { filtered = append(filtered, line) } } return filtered } func localSolarEclipseSVGSummaryText(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) string { if options.SummaryText != "" { return options.SummaryText } return localSolarEclipseSVGSummary(info, options.Language) } func localSolarEclipseSVGGreatestTextValue(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) string { if options.GreatestText != "" { return options.GreatestText } return localSolarEclipseSVGGreatestText(info, options) } func localSolarEclipseSVGMetaTextValue(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) string { if options.MetaText != "" { return options.MetaText } return localSolarEclipseSVGMetaText(info, options.Language) } func localSolarEclipseSVGOverviewTitleText(options LocalSolarEclipseSVGOptions) string { if options.OverviewTitle != "" { return options.OverviewTitle } return localSolarEclipseSVGOverviewTitle(options.Language) } func localSolarEclipseSVGPhasePanelsTitleText(options LocalSolarEclipseSVGOptions) string { if options.PhasePanelsTitle != "" { return options.PhasePanelsTitle } if options.Language == localSolarEclipseSVGLanguageEnglish { return "Phase disk panels" } return "阶段视圆图" } func localSolarEclipseSVGContactsTitleText(options LocalSolarEclipseSVGOptions) string { if options.ContactsTitle != "" { return options.ContactsTitle } if options.Language == localSolarEclipseSVGLanguageEnglish { return "Contacts" } return "接触时刻" } func localSolarEclipseSVGDirectionTextValue(options LocalSolarEclipseSVGOptions) string { if options.DirectionText != "" { return options.DirectionText } return localSolarEclipseSVGDirectionText(options.Language) } func localSolarEclipseSVGFooterNoteText(options LocalSolarEclipseSVGOptions) string { if options.FooterNote != "" { return options.FooterNote } if options.Language == localSolarEclipseSVGLanguageEnglish { return "Overview omits C2/C3 Moon outlines; lower panels show each phase separately. Contact PAs are measured from celestial north toward east." } return "上方为全局路径,C2/C3 只标点位;下方为各阶段独立视圆图。接触点位置角从天球北点起向东量。" } func localSolarEclipseSVGHeaderLineY(index int) float64 { return 86 + float64(index)*23 } func localSolarEclipseSVGHeaderBottom(lines []string) float64 { if len(lines) == 0 { return 72 } return localSolarEclipseSVGHeaderLineY(len(lines)-1) + 14 } func localSolarEclipseSVGTitle(info LocalSolarEclipseInfo, language string) string { if language == localSolarEclipseSVGLanguageEnglish { return fmt.Sprintf("%s Local Solar Eclipse", info.GreatestEclipse.Format("2006-01-02")) } return fmt.Sprintf("%s 站心%s", info.GreatestEclipse.Format("2006-01-02"), localSolarEclipseSVGTypeName(info.Type, language)) } func localSolarEclipseSVGSummary(info LocalSolarEclipseInfo, language string) string { if language == localSolarEclipseSVGLanguageEnglish { return fmt.Sprintf("lon=%.4f lat=%.4f type=%s magnitude=%.4f obscuration=%.4f", info.Longitude, info.Latitude, localSolarEclipseSVGTypeName(info.Type, language), info.Magnitude, info.Obscuration) } return fmt.Sprintf("经度=%.4f 纬度=%.4f 食型=%s 食分=%.4f 掩食比=%.4f", info.Longitude, info.Latitude, localSolarEclipseSVGTypeName(info.Type, language), info.Magnitude, info.Obscuration) } func localSolarEclipseSVGMetaText(info LocalSolarEclipseInfo, language string) string { parts := make([]string, 0, 2) if info.HasSaros { if language == localSolarEclipseSVGLanguageEnglish { parts = append(parts, fmt.Sprintf("Solar Saros %d %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count)) } else { parts = append(parts, fmt.Sprintf("沙罗 %d 第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count)) } } if duration := localSolarEclipseSVGCentralDurationText(info, language); duration != "" { parts = append(parts, duration) } return strings.Join(parts, " ") } func localSolarEclipseSVGDirectionText(language string) string { if language == localSolarEclipseSVGLanguageEnglish { return "Sun is fixed at center; Moon path uses the local tangent plane. East is left, north is up." } return "太阳固定在中心;月球路径使用站心切平面。图上左东右西,向上为北。" } func localSolarEclipseSVGGreatestText(info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions) string { greatest := info.GreatestEclipse.In(options.Location).Format("2006-01-02 15:04:05 MST") constellation := localSolarEclipseSVGConstellationName(info, options.Language) if options.Language == localSolarEclipseSVGLanguageEnglish { return fmt.Sprintf("Greatest: %s Sun altitude %.2f deg Sun in %s", greatest, info.SunAltitude, constellation) } return fmt.Sprintf("食甚:%s 太阳高度 %.2f 度 太阳位于%s", greatest, info.SunAltitude, constellation) } func localSolarEclipseSVGCentralDurationText(info LocalSolarEclipseInfo, language string) string { if !info.HasCentral || info.CentralStart.IsZero() || info.CentralEnd.IsZero() { return "" } duration := lunarEclipseSVGFormatDuration(info.CentralEnd.Sub(info.CentralStart)) if language == localSolarEclipseSVGLanguageEnglish { switch info.Type { case SolarEclipseTotal: return "Totality " + duration case SolarEclipseAnnular: return "Annularity " + duration default: return "Central phase " + duration } } switch info.Type { case SolarEclipseTotal: return "全食历时 " + duration case SolarEclipseAnnular: return "环食历时 " + duration default: return "中心食历时 " + duration } } func localSolarEclipseSVGOverviewTitle(language string) string { if language == localSolarEclipseSVGLanguageEnglish { return "Overview path" } return "全局路径" } func localSolarEclipseSVGOverviewEventName(label, language string) string { if label == "Greatest" { if language == localSolarEclipseSVGLanguageEnglish { return "GE" } return "食甚" } return label } func localSolarEclipseSVGTypeName(eclipseType SolarEclipseType, language string) string { if language == localSolarEclipseSVGLanguageEnglish { switch eclipseType { case SolarEclipsePartial: return "partial" case SolarEclipseAnnular: return "annular" case SolarEclipseTotal: return "total" case SolarEclipseHybrid: return "hybrid" default: return "none" } } switch eclipseType { case SolarEclipsePartial: return "日偏食" case SolarEclipseAnnular: return "日环食" case SolarEclipseTotal: return "日全食" case SolarEclipseHybrid: return "全环食" default: return "无日食" } } func localSolarEclipseSVGEventFrames(frames []basic.LocalSolarEclipseDiagramFrame) []basic.LocalSolarEclipseDiagramFrame { events := make([]basic.LocalSolarEclipseDiagramFrame, 0, 5) for _, frame := range frames { for _, label := range localSolarEclipseSVGFrameLabels(frame) { event := frame event.Label = label event.Labels = []string{label} events = append(events, event) } } return events } func localSolarEclipseSVGFrameLabels(frame basic.LocalSolarEclipseDiagramFrame) []string { if len(frame.Labels) > 0 { return frame.Labels } if frame.Label == "" { return nil } return []string{frame.Label} } func writeLocalSolarEclipseAxes(b *strings.Builder, cx, cy, radius float64, language string) { north, east, west, south := "北", "东", "西", "南" if language == localSolarEclipseSVGLanguageEnglish { north, east, west, south = "N", "E", "W", "S" } fmt.Fprintf(b, `%s`, cx, cy-radius-17, html.EscapeString(north)) fmt.Fprintf(b, `%s`, cx-radius-20, cy+4, html.EscapeString(east)) fmt.Fprintf(b, `%s`, cx+radius+20, cy+4, html.EscapeString(west)) fmt.Fprintf(b, `%s`, cx, cy+radius+27, html.EscapeString(south)) } func writeLocalSolarEclipseMoon( b *strings.Builder, frame basic.LocalSolarEclipseDiagramFrame, x, y, scale float64, ) { radius := frame.MoonRadius * scale fmt.Fprintf(b, ``, x, y, radius) } func writeLocalSolarEclipseMoonOutline( b *strings.Builder, frame basic.LocalSolarEclipseDiagramFrame, x, y, scale float64, ) { radius := frame.MoonRadius * scale stroke := "#24518a" if frame.Label == "Greatest" { stroke = "#111111" } fmt.Fprintf(b, ``, html.EscapeString(frame.Label), x, y, radius, stroke) writeLocalSolarEclipseEventPoint(b, frame.Label, x, y, stroke) } func localSolarEclipseSVGDrawOverviewMoon(label string) bool { return label != "C2" && label != "C3" } func writeLocalSolarEclipseEventPoint(b *strings.Builder, label string, x, y float64, stroke string) { fmt.Fprintf(b, ``, html.EscapeString(label), x, y, stroke) } func writeLocalSolarEclipseContactMarkers( b *strings.Builder, info LocalSolarEclipseInfo, cx, cy, radius float64, language string, ) { for _, point := range info.ContactPoints { angle := point.ContactPositionAngle * math.Pi / 180 unitX := math.Sin(angle) unitY := math.Cos(angle) x := cx - radius*unitX y := cy - radius*unitY labelDistance := radius + localSolarEclipseSVGContactLabelDistance(radius) labelDistance += localSolarEclipseSVGContactLabelExtraDistance(unitX, unitY) labelX := cx - labelDistance*unitX labelY := cy - labelDistance*unitY + 4 sideShift := localSolarEclipseSVGContactLabelSideShift(unitX, unitY) labelX += unitY * sideShift labelY += unitX * sideShift anchor := "middle" if unitX > 0.28 { anchor = "end" } else if unitX < -0.28 { anchor = "start" } fmt.Fprintf(b, ``, x, y, labelX, labelY-4) fmt.Fprintf(b, ``, x, y) fmt.Fprintf(b, `%s`, labelX, labelY, anchor, html.EscapeString(localSolarEclipseSVGContactLabel(point, language))) } } func localSolarEclipseSVGContactLabel(point LocalSolarEclipseContactPoint, language string) string { if language == localSolarEclipseSVGLanguageEnglish { return fmt.Sprintf("%s %.0f°", point.Label, point.ContactPositionAngle) } return fmt.Sprintf("%s %.0f°", point.Label, point.ContactPositionAngle) } func writeLocalSolarEclipseEclipticLine( b *strings.Builder, diagram basic.LocalSolarEclipseDiagramResult, mapX, mapY func(float64) float64, extent float64, language string, ) { unitX, unitY, ok := localSolarEclipseSVGEclipticDirection(diagram.Eclipse.GreatestEclipse) if !ok { return } lineExtent := extent * 0.92 startX := mapX(-unitX * lineExtent) startY := mapY(-unitY * lineExtent) endX := mapX(unitX * lineExtent) endY := mapY(unitY * lineExtent) if math.IsNaN(startX) || math.IsNaN(startY) || math.IsNaN(endX) || math.IsNaN(endY) { return } fmt.Fprintf(b, ``, startX, startY, endX, endY) labelX := startX labelY := startY anchor := "end" if endX < startX { labelX = endX labelY = endY } if labelX < endX { anchor = "start" } fmt.Fprintf(b, `%s`, labelX, labelY-6, anchor, html.EscapeString(localSolarEclipseSVGLabelEcliptic(language))) } func localSolarEclipseSVGEclipticDirection(ttJDE float64) (float64, float64, bool) { originRA, originDec := basic.HSunApparentRaDec(ttJDE) centerLongitude := normalizeSolarEclipseDegree360(basic.HSunApparentLo(ttJDE)) ra1, dec1 := basic.LoBoToRaDec(ttJDE, centerLongitude-1, 0) ra2, dec2 := basic.LoBoToRaDec(ttJDE, centerLongitude+1, 0) x1, y1 := localSolarEclipseSVGTangentOffset(originRA, originDec, ra1, dec1) x2, y2 := localSolarEclipseSVGTangentOffset(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 localSolarEclipseSVGTangentOffset(originRA, originDec, targetRA, targetDec float64) (float64, float64) { separation := localSolarEclipseSVGAngularSeparation(originRA, originDec, targetRA, targetDec) positionAngleRad := localSolarEclipseSVGPositionAngle(originRA, originDec, targetRA, targetDec) * math.Pi / 180 return separation * math.Sin(positionAngleRad), separation * math.Cos(positionAngleRad) } func localSolarEclipseSVGAngularSeparation(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 localSolarEclipseSVGPositionAngle(fromRA, fromDec, toRA, toDec float64) float64 { dRA := (toRA - fromRA) * math.Pi / 180 fromDecRad := fromDec * math.Pi / 180 toDecRad := toDec * math.Pi / 180 angle := math.Atan2( math.Sin(dRA), math.Cos(fromDecRad)*math.Tan(toDecRad)-math.Sin(fromDecRad)*math.Cos(dRA), ) * 180 / math.Pi return normalizeSolarEclipseDegree360(angle) } func localSolarEclipseSVGLabelEcliptic(language string) string { if language == localSolarEclipseSVGLanguageEnglish { return "Ecliptic" } return "黄道" } func writeLocalSolarEclipseEventLabel( b *strings.Builder, _ SolarEclipseType, label string, x, y, cx, moonRadius float64, language string, ) { text := localSolarEclipseSVGOverviewEventName(label, language) dx, dy, anchor := localSolarEclipseSVGEventLabelLayout(label, x, cx, moonRadius) lineX := x + dx*0.8 lineY := y + dy*0.8 - 2 fmt.Fprintf(b, ``, x, y, lineX, lineY) fmt.Fprintf(b, `%s`, x+dx, y+dy, anchor, html.EscapeString(text)) } func localSolarEclipseSVGEventLabelLayout(label string, x, cx, moonRadius float64) (float64, float64, string) { dx := moonRadius + 18 anchor := "start" if x > cx { dx = -dx anchor = "end" } dy := -moonRadius*0.62 - 10 switch label { case "C1": dx = -(moonRadius*1.12 + 26) dy = -(moonRadius*0.84 + 16) anchor = "end" case "C2": dx = -(moonRadius*0.96 + 18) dy = -(moonRadius*0.56 + 14) anchor = "end" case "C3": dx = moonRadius*1.04 + 24 dy = moonRadius*0.42 + 16 anchor = "start" case "C4": dx = moonRadius*0.94 + 20 dy = moonRadius*0.58 + 18 anchor = "start" case "Greatest": dx = -(moonRadius*0.42 + 16) dy = moonRadius + 24 anchor = "end" } return dx, dy, anchor } func localSolarEclipseSVGConstellationName(info LocalSolarEclipseInfo, language string) string { jde := solarEclipseTimeToTTJDE(info.GreatestEclipse) ra, dec := basic.HSunApparentRaDec(jde) code := basic.ConstellationCode(ra, dec, jde) if language == localSolarEclipseSVGLanguageEnglish { return basic.ConstellationNameByCodeEN(code) } return basic.ConstellationNameByCodeZH(code) } func localSolarEclipseSVGContactLabelDistance(radius float64) float64 { return math.Max(40, math.Min(58, radius*0.86)) } func localSolarEclipseSVGContactLabelExtraDistance(unitX, unitY float64) float64 { extra := 8.0 if unitY > 0.72 { extra += 10 } if math.Abs(unitX) < 0.18 { extra += 8 } if math.Abs(unitX) > 0.82 { extra += 4 } return extra } func localSolarEclipseSVGContactLabelSideShift(unitX, unitY float64) float64 { if unitY > 0.72 { return 14 } if unitX > 0.82 { return 18 } if unitX < -0.82 { return 12 } if unitY < -0.72 { return 6 } return 0 } func localSolarEclipseSVGEventName(label, language string, eclipseType SolarEclipseType) string { if language == localSolarEclipseSVGLanguageEnglish { switch label { case "C2": switch eclipseType { case SolarEclipseTotal: return "C2 Total begins" case SolarEclipseAnnular: return "C2 Annularity begins" default: return "C2 Central begins" } case "C3": switch eclipseType { case SolarEclipseTotal: return "C3 Total ends" case SolarEclipseAnnular: return "C3 Annularity ends" default: return "C3 Central ends" } } return label } switch label { case "C1": return "C1 初亏" case "C2": switch eclipseType { case SolarEclipseTotal: return "C2 食既" case SolarEclipseAnnular: return "C2 环食始" default: return "C2 中心食始" } case "Greatest": return "食甚" case "C3": switch eclipseType { case SolarEclipseTotal: return "C3 生光" case SolarEclipseAnnular: return "C3 环食终" default: return "C3 中心食终" } case "C4": return "C4 复圆" default: return label } } func writeLocalSolarEclipseStagePanels( b *strings.Builder, info LocalSolarEclipseInfo, frames []basic.LocalSolarEclipseDiagramFrame, options LocalSolarEclipseSVGOptions, x, y, width, height float64, ) { if len(frames) == 0 { return } title := localSolarEclipseSVGPhasePanelsTitleText(options) fmt.Fprintf(b, `%s`, x, y+14, html.EscapeString(title)) gap := 10.0 panelCount := float64(len(frames)) panelWidth := (width - gap*(panelCount-1)) / panelCount if panelWidth < 74 { gap = 6 panelWidth = (width - gap*(panelCount-1)) / panelCount } panelTop := y + 24 panelHeight := height - 30 panelScale := localSolarEclipseStageScaleForFrames(frames, panelWidth, panelHeight) contactPoints := localSolarEclipseContactPointMap(info.ContactPoints) for index, frame := range frames { panelX := x + float64(index)*(panelWidth+gap) writeLocalSolarEclipseStagePanel(b, info, frame, options, contactPoints, panelX, panelTop, panelWidth, panelHeight, panelScale) } } func writeLocalSolarEclipseStagePanel( b *strings.Builder, info LocalSolarEclipseInfo, frame basic.LocalSolarEclipseDiagramFrame, options LocalSolarEclipseSVGOptions, contactPoints map[string]LocalSolarEclipseContactPoint, x, y, width, height, scale float64, ) { fmt.Fprintf(b, ``, x, y, width, height) label := localSolarEclipseSVGEventName(frame.Label, options.Language, info.Type) fmt.Fprintf(b, `%s`, x+width/2, y+18, html.EscapeString(label)) centerX := x + width/2 centerY := y + 30 + (height-62)/2 moonX := centerX - frame.MoonX*scale moonY := centerY - frame.MoonY*scale fmt.Fprintf(b, ``, centerX, centerY, scale) fmt.Fprintf(b, ``, centerX, centerY, scale+1.2) writeLocalSolarEclipseMoon(b, frame, moonX, moonY, scale) if point, ok := contactPoints[frame.Label]; ok { writeLocalSolarEclipseStageContactMarker(b, point, centerX, centerY, scale) } if eventTime, ok := localSolarEclipseSVGEventTime(info, frame.Label); ok { fmt.Fprintf(b, `%s`, x+width/2, y+height-12, html.EscapeString(eventTime.In(options.Location).Format("15:04:05"))) } } func localSolarEclipseStageScaleForFrames(frames []basic.LocalSolarEclipseDiagramFrame, width, height float64) float64 { availableX := width/2 - 12 availableY := (height - 62) / 2 if availableX < 12 { availableX = 12 } if availableY < 12 { availableY = 12 } extentX := 1.15 extentY := 1.15 for _, frame := range frames { candidateX := math.Abs(frame.MoonX) + frame.MoonRadius + 0.14 candidateY := math.Abs(frame.MoonY) + frame.MoonRadius + 0.14 if candidateX > extentX { extentX = candidateX } if candidateY > extentY { extentY = candidateY } } scale := math.Min(48, math.Min(availableX/extentX, availableY/extentY)) if scale < 13 { scale = 13 } return scale } func writeLocalSolarEclipseStageContactMarker( b *strings.Builder, point LocalSolarEclipseContactPoint, cx, cy, radius float64, ) { angle := point.ContactPositionAngle * math.Pi / 180 x := cx - radius*math.Sin(angle) y := cy - radius*math.Cos(angle) fmt.Fprintf(b, ``, x, y) } func localSolarEclipseSVGEventTime(info LocalSolarEclipseInfo, label string) (time.Time, bool) { switch label { case "C1": return info.PartialStart, !info.PartialStart.IsZero() case "C2": return info.CentralStart, !info.CentralStart.IsZero() case "Greatest": return info.GreatestEclipse, !info.GreatestEclipse.IsZero() case "C3": return info.CentralEnd, !info.CentralEnd.IsZero() case "C4": return info.PartialEnd, !info.PartialEnd.IsZero() default: return time.Time{}, false } } type localSolarEclipseSVGContact struct { label string name string time time.Time angle float64 hasAngle bool } func writeLocalSolarEclipseContacts( b *strings.Builder, info LocalSolarEclipseInfo, options LocalSolarEclipseSVGOptions, x, y float64, ) { contacts := localSolarEclipseSVGContacts(info, options.Language) if len(contacts) == 0 { return } title := localSolarEclipseSVGContactsTitleText(options) boxWidth := float64(options.Width) - x - 34 if boxWidth < 210 { boxWidth = 210 } if boxWidth > 260 { boxWidth = 260 } boxHeight := 27 + float64(len(contacts))*18 fmt.Fprintf(b, ``, x-12, y-20, boxWidth, boxHeight) 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 == localSolarEclipseSVGLanguageEnglish { 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)*17.5, html.EscapeString(line)) } } func localSolarEclipseSVGContacts(info LocalSolarEclipseInfo, language string) []localSolarEclipseSVGContact { angles := localSolarEclipseContactAngleMap(info.ContactPoints) contacts := []localSolarEclipseSVGContact{ localSolarEclipseSVGContactFor("C1", localSolarEclipseSVGContactName("C1", language, info.Type), info.PartialStart, angles), } if info.HasCentral { contacts = append(contacts, localSolarEclipseSVGContactFor("C2", localSolarEclipseSVGContactName("C2", language, info.Type), info.CentralStart, angles)) } contacts = append(contacts, localSolarEclipseSVGContact{label: "GE", name: localSolarEclipseSVGContactName("Greatest", language, info.Type), time: info.GreatestEclipse}) if info.HasCentral { contacts = append(contacts, localSolarEclipseSVGContactFor("C3", localSolarEclipseSVGContactName("C3", language, info.Type), info.CentralEnd, angles)) } contacts = append(contacts, localSolarEclipseSVGContactFor("C4", localSolarEclipseSVGContactName("C4", language, info.Type), info.PartialEnd, angles)) return contacts } func localSolarEclipseSVGContactFor( label, name string, time time.Time, angles map[string]float64, ) localSolarEclipseSVGContact { angle, ok := angles[label] return localSolarEclipseSVGContact{ label: label, name: name, time: time, angle: angle, hasAngle: ok, } } func localSolarEclipseContactAngleMap(points []LocalSolarEclipseContactPoint) map[string]float64 { angles := make(map[string]float64, len(points)) for _, point := range points { angles[point.Label] = point.ContactPositionAngle } return angles } func localSolarEclipseContactPointMap(points []LocalSolarEclipseContactPoint) map[string]LocalSolarEclipseContactPoint { contacts := make(map[string]LocalSolarEclipseContactPoint, len(points)) for _, point := range points { contacts[point.Label] = point } return contacts } func localSolarEclipseSVGContactName(label, language string, eclipseType SolarEclipseType) string { if language == localSolarEclipseSVGLanguageEnglish { switch label { case "C1": return "First contact" case "C2": switch eclipseType { case SolarEclipseTotal: return "Total begins" case SolarEclipseAnnular: return "Annularity begins" default: return "Central begins" } case "Greatest": return "Greatest" case "C3": switch eclipseType { case SolarEclipseTotal: return "Total ends" case SolarEclipseAnnular: return "Annularity ends" default: return "Central ends" } case "C4": return "Last contact" default: return label } } switch label { case "C1": return "初亏" case "C2": switch eclipseType { case SolarEclipseTotal: return "食既" case SolarEclipseAnnular: return "环食始" default: return "中心食始" } case "Greatest": return "食甚" case "C3": switch eclipseType { case SolarEclipseTotal: return "生光" case SolarEclipseAnnular: return "环食终" default: return "中心食终" } case "C4": return "复圆" default: return label } } func localSolarEclipseDiagramExtent(diagram basic.LocalSolarEclipseDiagramResult) float64 { extent := 1.45 for _, frame := range diagram.Frames { candidate := math.Hypot(frame.MoonX, frame.MoonY) + frame.MoonRadius + 0.28 if candidate > extent { extent = candidate } } return extent } func solarEclipseDurationToDays(duration time.Duration) float64 { if duration <= 0 { return 0 } return duration.Hours() / 24 }