package svg import ( "fmt" "html" "math" "strings" "time" "b612.me/astro/basic" eclipsecore "b612.me/astro/eclipse" "b612.me/astro/internal/svgmap" ) const ( solarEclipseMapDefaultWidth = 960 solarEclipseMapDefaultHeight = 640 ) // SolarEclipseMapSVGOptions 控制无国界全球日食地图。 // SolarEclipseMapSVGOptions controls a border-free global solar-eclipse map. type SolarEclipseMapSVGOptions 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 one from the event geometry; unsupported values make the renderer return false. Projection EclipseMapProjection // 空文本字段使用本地化的自动标签。 // Empty text fields use localized automatic labels. Title string MapTitle string EventsTitle string FooterNote string // PartialStep 是半影足迹请求的时间步长;非正值使用两分钟,正值小于一秒时使用一秒。长事件可能增大实际步长,以保持时间序列不超过 30000 个采样点。 // PartialStep is the requested partial-footprint time step. Values <= 0 use two minutes; positive values below one second use one second. Long events may use a larger effective step to keep the time series within 30000 samples. PartialStep time.Duration // BoundaryPoints 是每个瞬时偏食足迹的角向采样数;非正值使用 180,正值限制在 12..1440。 // BoundaryPoints is the angular sample count for each instantaneous partial footprint. Values <= 0 use 180; positive values are clamped to 12..1440. BoundaryPoints int // PenumbralOutlineStep 控制半影边界轮廓采样;零值使用 60 分钟,负值禁用,正值小于一分钟时使用一分钟。 // PenumbralOutlineStep controls sampled penumbral boundary outlines. Zero uses 60 minutes, negative values disable them, and positive values below one minute use one minute. PenumbralOutlineStep time.Duration // CentralShadowStep 控制本影/反本影轮廓采样;零值使用 10 分钟,负值禁用,正值小于一分钟时使用一分钟。 // CentralShadowStep controls sampled umbral/antumbral outlines. Zero uses 10 minutes, negative values disable them, and positive values below one minute use one minute. CentralShadowStep time.Duration // CentralStep 是中心路径请求的时间步长;非正值使用两分钟,正值小于一秒时使用一秒。长事件可能增大实际步长,以保持基础路径不超过 30000 个采样点。 // CentralStep is the requested central-path time step. Values <= 0 use two minutes; positive values below one second use one second. Long events may use a larger effective step to keep the base path within 30000 samples. CentralStep time.Duration // TargetSpacingKM 是中心线地面间距上限,单位为千米;非正值使用 150 km,非有限值禁用加密。 // TargetSpacingKM is the requested maximum center-line ground spacing in kilometers. Values <= 0 use 150 km; non-finite values disable refinement. TargetSpacingKM float64 // TimeLabelStep 控制中心线上的 HH:MM 标签;零值使用 30 分钟,负值禁用标签,正值小于一分钟时使用一分钟。 // TimeLabelStep controls HH:MM labels along the central line. Zero uses 30 minutes, negative values disable labels, and positive values below one minute use one minute. TimeLabelStep time.Duration } type solarEclipseMapCalculators struct { partial func(time.Time, eclipsecore.SolarEclipsePartialFootprintOptions) (eclipsecore.SolarEclipsePartialFootprintsInfo, bool) central func(time.Time, eclipsecore.SolarEclipsePathOptions) (eclipsecore.SolarEclipsePath, bool) local func(time.Time, float64, float64, float64) (eclipsecore.LocalSolarEclipseInfo, bool) } // SolarEclipseMapSVG 使用 NASA bulletin Split-K 绘制完整偏食可见范围,并在存在时绘制全食或环食中心线。 // SolarEclipseMapSVG renders the full partial-visibility sweep and, when present, the total or annular central path using NASA bulletin Split-K. func SolarEclipseMapSVG(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) { return SolarEclipseMapSVGNASABulletinSplitK(date, options) } // SolarEclipseMapSVGNASABulletinSplitK 使用 NASA bulletin Split-K 绘制地图。 // SolarEclipseMapSVGNASABulletinSplitK renders a NASA bulletin Split-K map. func SolarEclipseMapSVGNASABulletinSplitK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) { return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{ partial: eclipsecore.SolarEclipsePartialFootprintsNASABulletinSplitK, central: eclipsecore.SolarEclipseCentralPathNASABulletinSplitK, local: eclipsecore.GeometricLocalSolarEclipseOnDateNASABulletinSplitK, }) } // SolarEclipseMapSVGIAUSingleK 使用 IAU Single-K 模型绘制地图。 // SolarEclipseMapSVGIAUSingleK renders an IAU Single-K map. func SolarEclipseMapSVGIAUSingleK(date time.Time, options SolarEclipseMapSVGOptions) (string, bool) { return solarEclipseMapSVG(date, options, solarEclipseMapCalculators{ partial: eclipsecore.SolarEclipsePartialFootprintsIAUSingleK, central: eclipsecore.SolarEclipseCentralPathIAUSingleK, local: eclipsecore.GeometricLocalSolarEclipseOnDateIAUSingleK, }) } func solarEclipseMapSVG( date time.Time, options SolarEclipseMapSVGOptions, calculators solarEclipseMapCalculators, ) (string, bool) { if !validEclipseMapProjection(options.Projection) { return "", false } options = normalizeSolarEclipseMapSVGOptions(date, options) partial, ok := calculators.partial(date, eclipsecore.SolarEclipsePartialFootprintOptions{ Step: options.PartialStep, BoundaryPoints: options.BoundaryPoints, CentralShadowStep: options.CentralShadowStep, }) if !ok { return "", false } central, hasCentral := calculators.central(date, eclipsecore.SolarEclipsePathOptions{ Step: options.CentralStep, TargetSpacingKM: options.TargetSpacingKM, }) local, hasLocal := calculators.local( partial.Eclipse.GreatestEclipse, partial.Eclipse.GreatestLongitude, partial.Eclipse.GreatestLatitude, 0, ) projection := resolveSolarEclipseMapProjection(partial, central, hasCentral, options.Projection) return renderSolarEclipseMapSVG(partial, central, hasCentral, local, hasLocal, options, projection), true } func normalizeSolarEclipseMapSVGOptions(date time.Time, options SolarEclipseMapSVGOptions) SolarEclipseMapSVGOptions { if options.Width < 640 { options.Width = solarEclipseMapDefaultWidth } if options.Height < 420 { options.Height = solarEclipseMapDefaultHeight } if strings.EqualFold(options.Language, "en") { options.Language = "en" } else { options.Language = "zh" } if options.Location == nil { options.Location = date.Location() } if options.PartialStep <= 0 { options.PartialStep = 2 * time.Minute } if options.BoundaryPoints <= 0 { options.BoundaryPoints = 180 } if options.PenumbralOutlineStep < 0 { options.PenumbralOutlineStep = 0 } else if options.PenumbralOutlineStep == 0 { options.PenumbralOutlineStep = time.Hour } else if options.PenumbralOutlineStep < time.Minute { options.PenumbralOutlineStep = time.Minute } if options.CentralShadowStep < 0 { options.CentralShadowStep = 0 } else if options.CentralShadowStep == 0 { options.CentralShadowStep = 10 * time.Minute } else if options.CentralShadowStep < time.Minute { options.CentralShadowStep = time.Minute } if options.CentralStep <= 0 { options.CentralStep = 2 * time.Minute } if options.TargetSpacingKM <= 0 { options.TargetSpacingKM = 150 } if options.TimeLabelStep < 0 { options.TimeLabelStep = 0 } else if options.TimeLabelStep == 0 { options.TimeLabelStep = 30 * time.Minute } else if options.TimeLabelStep < time.Minute { options.TimeLabelStep = time.Minute } return options } func resolveSolarEclipseMapProjection( partial eclipsecore.SolarEclipsePartialFootprintsInfo, central eclipsecore.SolarEclipsePath, hasCentral bool, requested EclipseMapProjection, ) svgmap.Projection { if requested != EclipseMapProjectionAuto { return internalEclipseMapProjection(requested) } focus := partial.Eclipse.GreatestLatitude minimum, maximum := focus, focus for _, footprint := range partial.Footprints { for _, boundary := range footprint.Boundaries { for _, point := range boundary { minimum = math.Min(minimum, point.Latitude) maximum = math.Max(maximum, point.Latitude) } } } if hasCentral { for _, series := range [][]eclipsecore.SolarEclipsePathPoint{central.CenterLine, central.NorthernLimit, central.SouthernLimit} { for _, point := range series { minimum = math.Min(minimum, point.Latitude) maximum = math.Max(maximum, point.Latitude) } } } resolved := svgmap.ResolveProjection("", focus, minimum, maximum) if resolved == svgmap.ProjectionEquirectangular && focus >= 65 { return svgmap.ProjectionNorthPolar } if resolved == svgmap.ProjectionEquirectangular && focus <= -65 { return svgmap.ProjectionSouthPolar } return resolved } func renderSolarEclipseMapSVG( partial eclipsecore.SolarEclipsePartialFootprintsInfo, central eclipsecore.SolarEclipsePath, hasCentral bool, local eclipsecore.LocalSolarEclipseInfo, hasLocal bool, options SolarEclipseMapSVGOptions, projection svgmap.Projection, ) string { layout := solarEclipseMapLayoutFor(options, projection) frame := layout.frame title := solarEclipseMapTitle(partial.Eclipse, options) partialPath := solarEclipsePartialSweepPath(partial, frame) var builder strings.Builder fmt.Fprintf(&builder, ``, options.Width, options.Height, options.Width, options.Height, html.EscapeString(title)) builder.WriteString(``) builder.WriteString(frame.ClipDefinition("solar-map-clip")) builder.WriteString(``) builder.WriteString(``) fmt.Fprintf(&builder, ``, options.Width-44, options.Height-36) fmt.Fprintf(&builder, `%s`, float64(options.Width)/2, html.EscapeString(title)) writeSolarEclipseMapSummary(&builder, partial.Eclipse, local, hasLocal, options) writeSolarEclipseMapSectionTitle(&builder, layout, options, hasCentral) frame.WriteOcean(&builder) frame.WriteGraticule(&builder, "solar-map-clip") frame.WriteLand(&builder, "solar-map-clip") fmt.Fprintf(&builder, ``, partialPath) writeSolarEclipseTerminator(&builder, partial.Eclipse, frame) writeSolarEclipsePenumbralOutlines(&builder, partial, frame, options) if hasCentral { writeSolarEclipseCentralPath(&builder, central, frame, partial.Eclipse.Type) } writeSolarEclipseCentralShadowOutlines(&builder, partial.CentralShadowFootprints, frame) if hasCentral { writeSolarEclipseTimeMarkers(&builder, central, frame, options) writeSolarEclipseAxisMarkers(&builder, central, frame, options) } writeSolarEclipseContactMarkers(&builder, partial, frame) writeSolarEclipseGreatestMarker(&builder, partial.Eclipse, frame, options.Language) writeSolarEclipseSubsolarMarker(&builder, partial.Eclipse, frame, options.Language) frame.WriteFrame(&builder) writeSolarEclipseMapLegend(&builder, layout, partial.Eclipse, hasCentral, options) writeSolarEclipseGlobalEventsPanel(&builder, partial, central, hasCentral, layout, options) writeSolarEclipseMapFooter(&builder, frame, options, projection) builder.WriteString(``) return builder.String() } func solarEclipsePartialSweepPath(info eclipsecore.SolarEclipsePartialFootprintsInfo, frame svgmap.Frame) string { var builder strings.Builder for _, footprint := range info.Footprints { segments := make([][]svgmap.GeoPoint, 0, len(footprint.Boundaries)) for _, source := range footprint.Boundaries { segment := make([]svgmap.GeoPoint, len(source)) for index, point := range source { segment[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} } segments = append(segments, segment) } boundary := svgmap.JoinPolylineSegments(segments) if len(boundary) < 3 { continue } if len(boundary) > 1 && svgmap.SameGeoPoint(boundary[0], boundary[len(boundary)-1]) { boundary = boundary[:len(boundary)-1] } polygon := solarEclipsePartialFootprintPolygon(boundary, footprint.Time, footprint.Closed, frame.Projection) for _, fragment := range svgmap.PolygonFragments(polygon, frame.Projection) { appendEclipseMapPolygonPathConsistent(&builder, frame, fragment) } } return builder.String() } func solarEclipsePartialFootprintPolygon( boundary []svgmap.GeoPoint, value time.Time, closed bool, projection svgmap.Projection, ) []svgmap.GeoPoint { polygon := append([]svgmap.GeoPoint(nil), boundary...) if len(boundary) < 2 { return polygon } if !closed { terminator := svgmap.SphericalCircle(solarEclipseSubsolarPoint(value), 90, 360) polygon = append(polygon, svgmap.ShortestCircleArc(terminator, boundary[len(boundary)-1], boundary[0])...) } if interior, ok := solarEclipseSphericalBoundaryCentroid(polygon); ok && projection == svgmap.ProjectionEquirectangular { polygon = solarEclipseAppendEquirectangularPoleRim(polygon, interior) } return polygon } func solarEclipseSphericalBoundaryCentroid(points []svgmap.GeoPoint) (svgmap.GeoPoint, bool) { var sum solarEclipseMapVector for _, point := range points { value := solarEclipseMapUnitVector(point) sum[0] += value[0] sum[1] += value[1] sum[2] += value[2] } length := math.Sqrt(solarEclipseMapDot(sum, sum)) if length < 1e-12 { return svgmap.GeoPoint{}, false } for index := range sum { sum[index] /= length } return svgmap.GeoPoint{ Longitude: math.Atan2(sum[1], sum[0]) * 180 / math.Pi, Latitude: math.Asin(math.Max(-1, math.Min(1, sum[2]))) * 180 / math.Pi, }, true } func solarEclipseAppendEquirectangularPoleRim( polygon []svgmap.GeoPoint, interior svgmap.GeoPoint, ) []svgmap.GeoPoint { interiorAngle := solarEclipseSphericalPolygonAngle(polygon, interior) if math.Abs(interiorAngle) < math.Pi { return polygon } poleLatitude := 0.0 for _, candidate := range []float64{90, -90} { angle := solarEclipseSphericalPolygonAngle(polygon, svgmap.GeoPoint{Latitude: candidate}) if math.Abs(angle) >= math.Pi && math.Signbit(angle) == math.Signbit(interiorAngle) { poleLatitude = candidate break } } if poleLatitude == 0 { return polygon } firstLongitude, lastLongitude := solarEclipseUnwrappedLongitudeEndpoints(polygon) delta := firstLongitude - lastLongitude if math.Abs(delta) < 180 { return polygon } steps := int(math.Ceil(math.Abs(delta) / 90)) result := append([]svgmap.GeoPoint(nil), polygon...) for step := 1; step <= steps; step++ { longitude := lastLongitude + delta*float64(step)/float64(steps) result = append(result, svgmap.GeoPoint{ Longitude: normalizeDegree180(longitude), Latitude: poleLatitude, }) } return result } func solarEclipseUnwrappedLongitudeEndpoints(points []svgmap.GeoPoint) (float64, float64) { first := points[0].Longitude previous := first for _, point := range points[1:] { longitude := point.Longitude for longitude-previous > 180 { longitude -= 360 } for longitude-previous < -180 { longitude += 360 } previous = longitude } return first, previous } type solarEclipseMapVector [3]float64 func solarEclipseSphericalPolygonAngle(points []svgmap.GeoPoint, target svgmap.GeoPoint) float64 { if len(points) < 3 { return 0 } reference := solarEclipseMapUnitVector(target) angle := 0.0 for index, point := range points { current, currentOK := solarEclipseMapTangentDirection(reference, solarEclipseMapUnitVector(point)) next, nextOK := solarEclipseMapTangentDirection(reference, solarEclipseMapUnitVector(points[(index+1)%len(points)])) if !currentOK || !nextOK { return math.Copysign(2*math.Pi, angle) } angle += math.Atan2( solarEclipseMapDot(reference, solarEclipseMapCross(current, next)), solarEclipseMapDot(current, next), ) } return angle } func solarEclipseMapUnitVector(point svgmap.GeoPoint) solarEclipseMapVector { longitude := point.Longitude * math.Pi / 180 latitude := point.Latitude * math.Pi / 180 return solarEclipseMapVector{ math.Cos(latitude) * math.Cos(longitude), math.Cos(latitude) * math.Sin(longitude), math.Sin(latitude), } } func solarEclipseMapTangentDirection( reference, value solarEclipseMapVector, ) (solarEclipseMapVector, bool) { projection := solarEclipseMapDot(reference, value) result := solarEclipseMapVector{ value[0] - projection*reference[0], value[1] - projection*reference[1], value[2] - projection*reference[2], } length := math.Sqrt(solarEclipseMapDot(result, result)) if length < 1e-12 { return solarEclipseMapVector{}, false } return solarEclipseMapVector{result[0] / length, result[1] / length, result[2] / length}, true } func solarEclipseMapDot(a, b solarEclipseMapVector) float64 { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2] } func solarEclipseMapCross(a, b solarEclipseMapVector) solarEclipseMapVector { return solarEclipseMapVector{ a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0], } } func solarEclipseSubsolarPoint(value time.Time) svgmap.GeoPoint { ttJDE := solarEclipseTimeToTTJDE(value) ra, dec := basic.HSunApparentRaDec(ttJDE) utJDE := basic.TD2UT(ttJDE, false) longitude := normalizeDegree180(ra - basic.ApparentSiderealTime(utJDE)*15) return svgmap.GeoPoint{Longitude: longitude, Latitude: dec} } func appendEclipseMapPolygonPathConsistent(builder *strings.Builder, frame svgmap.Frame, points []svgmap.GeoPoint) { if projectedPolygonArea(frame, points) < 0 { points = append([]svgmap.GeoPoint(nil), points...) reverseGeoPoints(points) } appendEclipseMapPolygonPath(builder, frame, points) } func projectedPolygonArea(frame svgmap.Frame, points []svgmap.GeoPoint) float64 { area := 0.0 for index, point := range points { next := points[(index+1)%len(points)] x1, y1, ok1 := frame.Project(point.Longitude, point.Latitude) x2, y2, ok2 := frame.Project(next.Longitude, next.Latitude) if ok1 && ok2 { area += x1*y2 - x2*y1 } } return area / 2 } func writeSolarEclipseCentralPath( builder *strings.Builder, path eclipsecore.SolarEclipsePath, frame svgmap.Frame, eclipseType eclipsecore.SolarEclipseType, ) { count := len(path.NorthernLimit) paired := count == len(path.SouthernLimit) && count >= 2 for index := 0; paired && index < count; index++ { paired = !path.NorthernLimit[index].Time.IsZero() && path.NorthernLimit[index].Time.Equal(path.SouthernLimit[index].Time) } if paired { polygon := make([]svgmap.GeoPoint, 0, 2*count) for _, point := range path.NorthernLimit[:count] { polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) } for index := count - 1; index >= 0; index-- { point := path.SouthernLimit[index] polygon = append(polygon, svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}) } color := solarEclipseCentralPathColor(eclipseType) fmt.Fprintf(builder, ``, color) for _, fragment := range svgmap.PolygonFragments(polygon, frame.Projection) { builder.WriteString(``) } builder.WriteString(``) } writeSolarPathLine(builder, frame, path.NorthernLimit, "northern-central-limit", "#7c2f28", 1.2, "") writeSolarPathLine(builder, frame, path.SouthernLimit, "southern-central-limit", "#7c2f28", 1.2, "") writeSolarPathLine(builder, frame, path.CenterLine, "solar-center-line", "#263f58", 1.8, "5 3") } func writeSolarPathLine( builder *strings.Builder, frame svgmap.Frame, points []eclipsecore.SolarEclipsePathPoint, className, color string, width float64, dash string, ) { geographic := make([]svgmap.GeoPoint, len(points)) for index, point := range points { geographic[index] = svgmap.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} } writeEclipseMapGeoLine(builder, frame, geographic, className, color, width, dash, "solar-map-clip") } func writeSolarEclipseGreatestMarker( builder *strings.Builder, info eclipsecore.SolarEclipseInfo, frame svgmap.Frame, language string, ) { x, y, ok := frame.Project(info.GreatestLongitude, info.GreatestLatitude) if !ok { return } label := "食甚" if language == "en" { label = "Greatest" } fmt.Fprintf(builder, `%s`, x, y, x, y-10, html.EscapeString(label)) } func solarEclipseMapTitle(info eclipsecore.SolarEclipseInfo, options SolarEclipseMapSVGOptions) string { if options.Title != "" { return options.Title } date := info.GreatestEclipse.In(options.Location).Format("2006-01-02") if options.Language == "en" { return fmt.Sprintf("%s %s Global Visibility", date, solarEclipseMapTypeName(info.Type, "en")) } return fmt.Sprintf("%s %s全球见食图", date, solarEclipseMapTypeName(info.Type, "zh")) } func writeSolarEclipseMapSummary( builder *strings.Builder, info eclipsecore.SolarEclipseInfo, local eclipsecore.LocalSolarEclipseInfo, hasLocal bool, options SolarEclipseMapSVGOptions, ) { start := info.PartialBeginOnEarth.In(options.Location) maximum := info.GreatestEclipse.In(options.Location) end := info.PartialEndOnEarth.In(options.Location) zone, _ := maximum.Zone() if zone == "" { zone = "UTC" } text := fmt.Sprintf("偏食始 %s | 食甚 %s | 偏食终 %s (%s) | 食分 %.3f | Gamma %.4f", start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone, info.Magnitude, info.Gamma) if options.Language == "en" { text = fmt.Sprintf("Partial begins %s | Greatest %s | Partial ends %s (%s) | magnitude %.3f | Gamma %.4f", start.Format("15:04:05"), maximum.Format("15:04:05"), end.Format("15:04:05"), zone, info.Magnitude, info.Gamma) } fmt.Fprintf(builder, `%s`, float64(options.Width)/2, html.EscapeString(text)) details := make([]string, 0, 4) if info.HasCentral { if options.Language == "en" { details = append(details, fmt.Sprintf("path width %.1f km", info.PathWidthKM)) } else { details = append(details, fmt.Sprintf("食带宽 %.1f km", info.PathWidthKM)) } } if info.HasSaros { if options.Language == "en" { details = append(details, fmt.Sprintf("Solar Saros %d, member %d/%d", info.Saros.Series, info.Saros.Member, info.Saros.Count)) } else { details = append(details, fmt.Sprintf("太阳沙罗 %d,第 %d/%d 个成员", info.Saros.Series, info.Saros.Member, info.Saros.Count)) } } if hasLocal { if options.Language == "en" { details = append(details, fmt.Sprintf("Sun alt %.1f° az %.1f°", local.SunAltitude, local.SunAzimuth)) } else { details = append(details, fmt.Sprintf("食甚点太阳高度 %.1f° 方位 %.1f°", local.SunAltitude, local.SunAzimuth)) } if local.HasCentral && !local.CentralStart.IsZero() && !local.CentralEnd.IsZero() { duration := local.CentralEnd.Sub(local.CentralStart) if duration > 0 { if options.Language == "en" { details = append(details, "central duration "+formatSolarEclipseMapDuration(duration)) } else { details = append(details, "中心食持续 "+formatSolarEclipseMapDuration(duration)) } } } } if len(details) > 0 { fmt.Fprintf(builder, `%s`, float64(options.Width)/2, html.EscapeString(strings.Join(details, " | "))) } } func formatSolarEclipseMapDuration(value time.Duration) string { seconds := int(math.Round(value.Seconds())) if seconds < 0 { seconds = -seconds } return fmt.Sprintf("%02d:%02d", seconds/60, seconds%60) } func writeSolarEclipseMapLegend( builder *strings.Builder, layout solarEclipseMapLayout, info eclipsecore.SolarEclipseInfo, hasCentral bool, options SolarEclipseMapSVGOptions, ) { type legendItem struct { label string kind string color string dash string } items := []legendItem{{label: "偏食可见区", kind: "fill", color: "#dfb84d"}} if hasCentral { items = append(items, legendItem{label: solarEclipseCentralPathLabel(info.Type, options.Language), kind: "fill", color: solarEclipseCentralPathColor(info.Type)}, legendItem{label: "中心线", kind: "line", color: "#263f58", dash: "5 3"}, ) } if options.PenumbralOutlineStep > 0 { items = append(items, legendItem{ label: solarEclipseOutlineLegendLabel("penumbra", info.Type, options.PenumbralOutlineStep, options.Language), kind: "line", color: "#b07a18", dash: "3 3", }) } items = append(items, legendItem{label: "食甚晨昏圈", kind: "line", color: "#6f7778", dash: "4 3"}) if hasCentral && options.CentralShadowStep > 0 { items = append(items, legendItem{ label: solarEclipseOutlineLegendLabel("central", info.Type, options.CentralShadowStep, options.Language), kind: "line", color: "#7b5a42", }) } contactLabel := "P/U 影锥接触" if !hasCentral { contactLabel = "P 半影接触" } if options.Language == "en" { items[0].label = "Partial-eclipse visibility" if hasCentral { items[2].label = "Center line" } for index := range items { if items[index].label == "食甚晨昏圈" { items[index].label = "Terminator at greatest" } } contactLabel = "P/U shadow contacts" if !hasCentral { contactLabel = "P penumbral contacts" } } items = append(items, legendItem{label: contactLabel, kind: "contact", color: "#a52d70"}) columns := len(items) if columns > 4 { columns = 4 } legendWidth := layout.panelX + layout.panelWidth - layout.frame.X itemWidth := legendWidth / float64(columns) baseY := layout.frame.Y + layout.frame.Height + 29 builder.WriteString(``) for index, item := range items { row, column := index/columns, index%columns x := layout.frame.X + float64(column)*itemWidth y := baseY + float64(row)*22 switch item.kind { case "line": fmt.Fprintf(builder, ``, x, y-4, x+20, y-4, item.color, item.dash) case "contact": fmt.Fprintf(builder, ``, x+8, y-4, item.color) default: fmt.Fprintf(builder, ``, x, y-8, item.color) } fmt.Fprintf(builder, `%s`, x+25, y, html.EscapeString(item.label)) } builder.WriteString(``) } func solarEclipseOutlineLegendLabel(kind string, eclipseType eclipsecore.SolarEclipseType, step time.Duration, language string) string { interval := solarEclipseMapStepLabel(step, language) if kind == "penumbra" { if language == "en" { return "Penumbral outlines (" + interval + ")" } return "半影时刻线(" + interval + ")" } name := "本影轮廓" if eclipseType == eclipsecore.SolarEclipseAnnular { name = "反本影轮廓" } else if eclipseType == eclipsecore.SolarEclipseHybrid { name = "本影/反本影轮廓" } if language == "en" { name = "Umbral outlines" if eclipseType == eclipsecore.SolarEclipseAnnular { name = "Antumbral outlines" } else if eclipseType == eclipsecore.SolarEclipseHybrid { name = "Umbral/antumbral outlines" } return name + " (" + interval + ")" } return name + "(" + interval + ")" } func solarEclipseMapStepLabel(step time.Duration, language string) string { if step%time.Minute != 0 { return step.String() } minutes := int(step / time.Minute) if language == "en" { return fmt.Sprintf("%d min", minutes) } return fmt.Sprintf("%d 分钟", minutes) } func writeSolarEclipseMapFooter( builder *strings.Builder, frame svgmap.Frame, options SolarEclipseMapSVGOptions, projection svgmap.Projection, ) { text := options.FooterNote if text == "" { if options.Language == "en" { text = eclipseMapProjectionLabel(projection, "en") + "; sampled penumbral sweep and central path; Natural Earth 1:50m physical land, no administrative boundaries." } else { text = eclipseMapProjectionLabel(projection, "zh") + ";偏食区为半影足迹时间扫掠,叠加中心食带;Natural Earth 1:50m 物理陆地底图,不含行政边界。" } } fmt.Fprintf(builder, `%s`, frame.X, float64(options.Height)-38, html.EscapeString(text)) } func solarEclipseMapTypeName(value eclipsecore.SolarEclipseType, language string) string { if language == "en" { switch value { case eclipsecore.SolarEclipseTotal: return "Total Solar Eclipse" case eclipsecore.SolarEclipseAnnular: return "Annular Solar Eclipse" case eclipsecore.SolarEclipseHybrid: return "Hybrid Solar Eclipse" default: return "Partial Solar Eclipse" } } switch value { case eclipsecore.SolarEclipseTotal: return "日全食" case eclipsecore.SolarEclipseAnnular: return "日环食" case eclipsecore.SolarEclipseHybrid: return "全环食" default: return "日偏食" } } func solarEclipseCentralPathLabel(value eclipsecore.SolarEclipseType, language string) string { if language == "en" { switch value { case eclipsecore.SolarEclipseAnnular: return "Annular path" case eclipsecore.SolarEclipseHybrid: return "Hybrid central path" default: return "Path of totality" } } switch value { case eclipsecore.SolarEclipseAnnular: return "环食带" case eclipsecore.SolarEclipseHybrid: return "全环食中心带" default: return "全食带" } } func solarEclipseCentralPathColor(value eclipsecore.SolarEclipseType) string { if value == eclipsecore.SolarEclipseAnnular { return "#a94f3f" } if value == eclipsecore.SolarEclipseHybrid { return "#76506f" } return "#38516d" } func reverseGeoPoints(points []svgmap.GeoPoint) { for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 { points[left], points[right] = points[right], points[left] } }