package svg import ( "math" "strconv" "strings" "testing" "time" eclipsecore "b612.me/astro/eclipse" "b612.me/astro/internal/geodata" "b612.me/astro/internal/svgmap" ) func TestSolarEclipseMapSVGTotalIncludesPartialAndCentralRegions(t *testing.T) { diagram, ok := SolarEclipseMapSVG( time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC), SolarEclipseMapSVGOptions{Width: 900, Height: 620, Location: time.UTC, PartialStep: 10 * time.Minute}, ) if !ok { t.Fatal("expected total solar-eclipse map") } for _, want := range []string{ "日全食全球见食图", "偏食始", "偏食终", "偏食可见区", "全食带", "中心线", "全球见食范围与中心食带", "中心食始", "中心食终", "中心食带宽", "沙罗序列 139", "食甚点太阳高度", "中心食带宽", "图中时刻为", "中心食持续", "P2", "P3", "U1", "U4", `class="partial-eclipse-region"`, `class="central-eclipse-band"`, `class="solar-center-line"`, `class="northern-central-limit"`, `class="southern-central-limit"`, `class="solar-greatest-marker"`, `class="solar-time-marker"`, `>17:00`, `class="solar-rise-set-boundary solar-start-rise"`, `stroke="#d97706"`, `class="solar-shadow-contact solar-contact-p1"`, `class="solar-magnitude-contour"`, `class="solar-axis-contact"`, `class="solar-subsolar-marker"`, `font-size="7"`, `opacity="0.72">太阳直射点`, `class="land"`, "不含行政边界", } { if !strings.Contains(diagram, want) { t.Fatalf("total solar-eclipse map missing %q", want) } } // 瞬时半影/本影轮廓默认不画,否则会把地球盖住。 for _, unwanted := range []string{`class="solar-penumbral-outline"`, `class="solar-central-shadow-outline"`} { if strings.Contains(diagram, unwanted) { t.Fatalf("default map must not draw %s", unwanted) } } if err := validateEclipseMapXML(diagram); err != nil { t.Fatalf("total solar-eclipse map is not valid XML: %v", err) } } func TestSolarEclipsePartialBandPolygonsContainSampledFootprints(t *testing.T) { for _, test := range []struct { name string date time.Time step time.Duration boundaries int }{ {name: "2009 antimeridian", date: time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC), step: 2 * time.Minute, boundaries: 96}, {name: "2010 polar fold", date: time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC), step: 10 * time.Minute, boundaries: 24}, {name: "2014 noncentral", date: time.Date(2014, time.April, 29, 0, 0, 0, 0, time.UTC), step: 10 * time.Minute, boundaries: 24}, } { t.Run(test.name, func(t *testing.T) { partial, ok := eclipsecore.SolarEclipsePartialFootprints(test.date, eclipsecore.SolarEclipsePartialFootprintOptions{ Step: test.step, BoundaryPoints: test.boundaries, }) if !ok { t.Fatal("expected solar eclipse") } polygons, ok := solarEclipsePartialBandPolygons(partial) if !ok || len(polygons) == 0 { t.Fatal("expected authoritative partial-band polygons") } paths := make([][]geodata.GeoPoint, 0, len(partial.Footprints)*2) for _, footprint := range partial.Footprints { for _, boundary := range footprint.Boundaries { path := make([]geodata.GeoPoint, len(boundary)) for index, point := range boundary { path[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude} } paths = append(paths, path) } } if miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, paths, false); miss > 5 { t.Fatalf("sampled penumbral boundary protrudes %.2f km outside the authoritative band", miss) } }) } } func TestSolarEclipseMapSVGPartialOnlyUsesPolarProjection(t *testing.T) { diagram, ok := SolarEclipseMapSVG( time.Date(2025, 3, 29, 0, 0, 0, 0, time.UTC), SolarEclipseMapSVGOptions{PartialStep: 10 * time.Minute}, ) if !ok { t.Fatal("expected partial solar-eclipse map") } for _, want := range []string{"日偏食全球见食图", `class="partial-eclipse-region"`, `= len(path.NorthernLimit) || len(southern) >= len(path.SouthernLimit) { t.Fatal("external-contact samples were not trimmed from the rendered limits") } start := path.CenterLine[0].Time end := path.CenterLine[len(path.CenterLine)-1].Time for name, points := range map[string][]eclipsecore.SolarEclipsePathPoint{ "north": northern, "south": southern, } { if !points[0].Time.After(start) || !points[len(points)-1].Time.Before(end) { t.Fatalf("%s rendered limit extends outside the axis-contact interval", name) } } partial, ok := eclipsecore.SolarEclipsePartialFootprints( time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC), eclipsecore.SolarEclipsePartialFootprintOptions{Step: 10 * time.Minute, BoundaryPoints: 180}, ) if !ok { t.Fatal("expected 2010 annular partial footprint model") } polygons, ok := solarEclipseTwoLimitBandPolygons( path, northern, southern, partial.CentralBandFootprints, ) if !ok || len(polygons) != 1 || len(polygons[0]) <= len(northern)+len(southern) { t.Fatalf("physical endpoint sweep polygons=%d ok=%v, want one enriched central-band outline", len(polygons), ok) } minimumEndTurn := 180.0 for index := 1; index+1 < len(polygons[0]); index++ { point := polygons[0][index] if point.Longitude < 120 || point.Longitude > 123 || point.Latitude < 36 || point.Latitude > 39 { continue } previous, next := polygons[0][index-1], polygons[0][index+1] incoming := math.Atan2(point.Latitude-previous.Latitude, point.Longitude-previous.Longitude) outgoing := math.Atan2(next.Latitude-point.Latitude, next.Longitude-point.Longitude) minimumEndTurn = math.Min(minimumEndTurn, math.Remainder((outgoing-incoming)*180/math.Pi, 360)) } if minimumEndTurn < -30 { t.Fatalf("2010 eastern SVG central-band cap turns inward by %.1f degrees", minimumEndTurn) } diagram, ok := SolarEclipseMapSVG( time.Date(2010, time.January, 15, 0, 0, 0, 0, time.UTC), SolarEclipseMapSVGOptions{PartialStep: 10 * time.Minute}, ) if !ok { t.Fatal("expected 2010 annular SVG") } if strings.Contains(diagram, ``) if end < 0 { t.Fatal("central-shadow sweep group is not closed") } group := diagram[start : start+end] if got := strings.Count(group, ``) if end < 0 { t.Fatal("2043 critical-envelope group is not closed") } if got := strings.Count(diagram[start:start+end], ` 0 { polygons = append(polygons, current) } current = nil index++ case "L": index++ case "Z": if len(current) > 0 { polygons = append(polygons, current) current = nil } index++ default: if index+1 >= len(fields) { t.Fatalf("incomplete SVG coordinate at token %d", index) } x, err := strconv.ParseFloat(fields[index], 64) if err != nil { t.Fatalf("invalid SVG x coordinate %q: %v", fields[index], err) } y, err := strconv.ParseFloat(fields[index+1], 64) if err != nil { t.Fatalf("invalid SVG y coordinate %q: %v", fields[index+1], err) } current = append(current, [2]float64{x, y}) index += 2 } } if len(current) > 0 { polygons = append(polygons, current) } return polygons } func solarSVGPointInPolygon(x, y float64, polygon [][2]float64) bool { inside := false for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 { a, b := polygon[current], polygon[previous] if (a[1] > y) != (b[1] > y) && x < (b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0] { inside = !inside } } return inside }