feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
eclipsecore "b612.me/astro/eclipse"
|
||||
)
|
||||
|
||||
// 月食全球可见图的分区契约:四类区域对全球网格穷尽且互斥,"不可见"只覆盖 P1、食甚、P4 三刻都在
|
||||
// 地平下的地点,"全程可见"恰好等于 P1、食甚、P4 三刻都在地平上的地点。
|
||||
// Partition contract: the four regions tile the globe exhaustively and exclusively.
|
||||
|
||||
const (
|
||||
lunarPartitionCategoryEntire = "entire"
|
||||
lunarPartitionCategoryMoonset = "moonset"
|
||||
lunarPartitionCategoryMoonrise = "moonrise"
|
||||
lunarPartitionCategoryUnavailable = "unavailable"
|
||||
lunarPartitionCategoryPenumbra = "penumbra-only"
|
||||
)
|
||||
|
||||
type lunarPartitionLayer struct {
|
||||
category string
|
||||
shape string
|
||||
masks []string
|
||||
clips []string
|
||||
}
|
||||
|
||||
type lunarPartition struct {
|
||||
frame svgFrameBox
|
||||
shapes map[string][][][2]float64
|
||||
masks map[string][]string
|
||||
clips map[string][]string
|
||||
layers []lunarPartitionLayer
|
||||
}
|
||||
|
||||
func lunarMapPartitionOf(t *testing.T, doc string) lunarPartition {
|
||||
t.Helper()
|
||||
partition := lunarPartition{
|
||||
frame: lunarPenumbralTestFrame(t, doc),
|
||||
shapes: map[string][][][2]float64{},
|
||||
masks: map[string][]string{},
|
||||
clips: map[string][]string{},
|
||||
}
|
||||
type frame struct {
|
||||
masks []string
|
||||
clips []string
|
||||
}
|
||||
var stack []frame
|
||||
inRegions := false
|
||||
var container, containerID string
|
||||
decoder := xml.NewDecoder(strings.NewReader(doc))
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
class := lunarPartitionAttr(typed, "class")
|
||||
reference := strings.TrimPrefix(lunarPartitionAttr(typed, "href"), "#")
|
||||
mask := lunarPartitionURLID(lunarPartitionAttr(typed, "mask"))
|
||||
clip := lunarPartitionURLID(lunarPartitionAttr(typed, "clip-path"))
|
||||
switch typed.Name.Local {
|
||||
case "path":
|
||||
if id := lunarPartitionAttr(typed, "id"); id != "" {
|
||||
partition.shapes[id] = lunarPenumbralTestRings(t, lunarPartitionAttr(typed, "d"))
|
||||
}
|
||||
case "mask", "clipPath":
|
||||
container, containerID = typed.Name.Local, lunarPartitionAttr(typed, "id")
|
||||
case "use":
|
||||
switch container {
|
||||
case "mask":
|
||||
if reference != "" {
|
||||
partition.masks[containerID] = append(partition.masks[containerID], reference)
|
||||
}
|
||||
case "clipPath":
|
||||
if reference != "" {
|
||||
partition.clips[containerID] = append(partition.clips[containerID], reference)
|
||||
}
|
||||
}
|
||||
case "rect", "circle":
|
||||
if container == "clipPath" && containerID != "" && partition.clips[containerID] == nil {
|
||||
partition.clips[containerID] = []string{}
|
||||
}
|
||||
case "g":
|
||||
if strings.Contains(class, "lunar-visibility-regions") {
|
||||
inRegions = true
|
||||
}
|
||||
inherited := frame{}
|
||||
if len(stack) > 0 {
|
||||
inherited = stack[len(stack)-1]
|
||||
}
|
||||
if mask != "" {
|
||||
inherited.masks = append(append([]string(nil), inherited.masks...), mask)
|
||||
}
|
||||
if clip != "" {
|
||||
inherited.clips = append(append([]string(nil), inherited.clips...), clip)
|
||||
}
|
||||
stack = append(stack, inherited)
|
||||
}
|
||||
if inRegions && (typed.Name.Local == "use" || typed.Name.Local == "rect" || typed.Name.Local == "circle") {
|
||||
current := frame{}
|
||||
if len(stack) > 0 {
|
||||
current = stack[len(stack)-1]
|
||||
}
|
||||
if mask != "" {
|
||||
current.masks = append(append([]string(nil), current.masks...), mask)
|
||||
}
|
||||
if clip != "" {
|
||||
current.clips = append(append([]string(nil), current.clips...), clip)
|
||||
}
|
||||
partition.layers = append(partition.layers, lunarPartitionLayer{
|
||||
category: lunarPartitionCategory(class, typed.Name.Local),
|
||||
shape: reference,
|
||||
masks: current.masks,
|
||||
clips: current.clips,
|
||||
})
|
||||
}
|
||||
case xml.EndElement:
|
||||
if typed.Name.Local == "mask" || typed.Name.Local == "clipPath" {
|
||||
container, containerID = "", ""
|
||||
}
|
||||
if typed.Name.Local == "g" {
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
if len(stack) == 0 {
|
||||
inRegions = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(partition.layers) == 0 {
|
||||
t.Fatal("visibility region layers not found")
|
||||
}
|
||||
return partition
|
||||
}
|
||||
|
||||
func lunarPartitionAttr(element xml.StartElement, name string) string {
|
||||
for _, attribute := range element.Attr {
|
||||
if attribute.Name.Local == name {
|
||||
return attribute.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func lunarPartitionURLID(value string) string {
|
||||
value = strings.TrimSuffix(strings.TrimPrefix(value, "url(#"), ")")
|
||||
return value
|
||||
}
|
||||
|
||||
func lunarPartitionCategory(class, element string) string {
|
||||
switch {
|
||||
case strings.Contains(class, "entire-eclipse-region"):
|
||||
return lunarPartitionCategoryEntire
|
||||
case strings.Contains(class, "penumbra-only-region"):
|
||||
return lunarPartitionCategoryPenumbra
|
||||
case strings.Contains(class, "moonset-region"):
|
||||
return lunarPartitionCategoryMoonset
|
||||
case strings.Contains(class, "moonrise-region"):
|
||||
return lunarPartitionCategoryMoonrise
|
||||
case strings.Contains(class, "eclipse-unavailable-region"):
|
||||
return lunarPartitionCategoryUnavailable
|
||||
}
|
||||
return element + ":" + class
|
||||
}
|
||||
|
||||
// covers 逐点复算一层是否落在该点:形状、外层 clip-path(多形状取并)与嵌套 mask 都要通过,
|
||||
// mask 里的黑色形状按挖空处理。
|
||||
func (partition lunarPartition) covers(layer lunarPartitionLayer, x, y float64) bool {
|
||||
if layer.shape != "" && !partition.inside(layer.shape, x, y) {
|
||||
return false
|
||||
}
|
||||
// clipPath 里的多个形状取并集:落在其中任意一个之内就算通过。
|
||||
for _, clip := range layer.clips {
|
||||
shapes := partition.clips[clip]
|
||||
if len(shapes) == 0 {
|
||||
continue
|
||||
}
|
||||
inside := false
|
||||
for _, shape := range shapes {
|
||||
if partition.inside(shape, x, y) {
|
||||
inside = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inside {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, maskID := range layer.masks {
|
||||
for _, excluded := range partition.masks[maskID] {
|
||||
if partition.inside(excluded, x, y) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (partition lunarPartition) inside(shapeID string, x, y float64) bool {
|
||||
return lunarPenumbralTestInside(partition.shapes[shapeID], x, y)
|
||||
}
|
||||
|
||||
// painted 返回覆盖该点的最上层区域(SVG 后画的盖住先画的)与覆盖层数。
|
||||
func (partition lunarPartition) painted(x, y float64) (string, int) {
|
||||
category, count := "", 0
|
||||
for _, layer := range partition.layers {
|
||||
if partition.covers(layer, x, y) {
|
||||
category, count = layer.category, count+1
|
||||
}
|
||||
}
|
||||
return category, count
|
||||
}
|
||||
|
||||
// partitionGrid 遍历 1°×1° 全球网格(64800 点,格心取 ±0.5°)。
|
||||
func (partition lunarPartition) grid(visit func(longitude, latitude float64, x, y float64, category string, covered int)) {
|
||||
for longitude := -179.5; longitude < 180; longitude += 1 {
|
||||
for latitude := -89.5; latitude < 90; latitude += 1 {
|
||||
x, y, projectable := partition.frame.Project(longitude, latitude)
|
||||
if !projectable {
|
||||
continue
|
||||
}
|
||||
category, count := partition.painted(x, y)
|
||||
visit(longitude, latitude, x, y, category, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lunarPartitionAltitude(at time.Time, longitude, latitude float64) float64 {
|
||||
return basic.HMoonHeight(basic.Date2JD(at.UTC()), longitude, latitude, 0)
|
||||
}
|
||||
|
||||
func TestLunarEclipseMapSVGVisibilityRegionsTileTheGlobe(t *testing.T) {
|
||||
for _, date := range []time.Time{
|
||||
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
|
||||
time.Date(2016, 8, 18, 0, 0, 0, 0, time.UTC),
|
||||
} {
|
||||
// 只检查四类底图的分区:半影带另有覆盖性用例。
|
||||
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
|
||||
Width: 960, Height: 640, Language: "zh", Location: time.UTC, DisablePenumbralPhase: true})
|
||||
if !ok {
|
||||
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
|
||||
}
|
||||
partition := lunarMapPartitionOf(t, doc)
|
||||
counts := map[string]int{}
|
||||
uncovered, covered, multiple := 0, 64800, 0
|
||||
var firstUncovered string
|
||||
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
|
||||
counts[category]++
|
||||
if category == "" {
|
||||
uncovered++
|
||||
if firstUncovered == "" {
|
||||
firstUncovered = "(" + lunarPartitionFormat(longitude) + "," + lunarPartitionFormat(latitude) + ")"
|
||||
}
|
||||
return
|
||||
}
|
||||
if layers > 1 {
|
||||
multiple++
|
||||
}
|
||||
})
|
||||
t.Logf("%s %s: %v", date.Format("2006-01-02"), countsString(counts), "uncovered="+lunarPartitionFormat(float64(uncovered)))
|
||||
if uncovered != 0 {
|
||||
t.Errorf("%s: %d of %d grid points carry no region colour, first at %s",
|
||||
date.Format("2006-01-02"), uncovered, covered, firstUncovered)
|
||||
}
|
||||
if multiple != 0 {
|
||||
t.Errorf("%s: %d grid points are painted by more than one region layer", date.Format("2006-01-02"), multiple)
|
||||
}
|
||||
for _, category := range []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset,
|
||||
lunarPartitionCategoryMoonrise, lunarPartitionCategoryUnavailable} {
|
||||
if counts[category] == 0 {
|
||||
t.Errorf("%s: region %s covers no grid point", date.Format("2006-01-02"), category)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打开半影阶段后,"仅见半影"带与带食月出/带食月落必须互斥:两种半透明底色叠在一起会混出图例里
|
||||
// 没有的颜色,所以底带要被同源的 U1/U4 形状裁掉;同时裁切不能切出没有着色的缝。
|
||||
func TestLunarEclipseMapSVGPenumbralPhaseKeepsCoverage(t *testing.T) {
|
||||
date := time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC)
|
||||
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
|
||||
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
|
||||
if !ok {
|
||||
t.Fatal("missing penumbral-phase map")
|
||||
}
|
||||
partition := lunarMapPartitionOf(t, doc)
|
||||
uncovered, penumbraOnly, doubleTinted := 0, 0, 0
|
||||
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
|
||||
if category == "" {
|
||||
uncovered++
|
||||
return
|
||||
}
|
||||
if category != lunarPartitionCategoryPenumbra {
|
||||
return
|
||||
}
|
||||
penumbraOnly++
|
||||
for _, layer := range partition.layers {
|
||||
if layer.category == lunarPartitionCategoryPenumbra || !partition.covers(layer, x, y) {
|
||||
continue
|
||||
}
|
||||
if layer.category == lunarPartitionCategoryMoonset || layer.category == lunarPartitionCategoryMoonrise {
|
||||
doubleTinted++
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
if uncovered != 0 {
|
||||
t.Errorf("penumbral-phase map leaves %d grid points without a region colour", uncovered)
|
||||
}
|
||||
if penumbraOnly == 0 || doubleTinted != 0 {
|
||||
t.Errorf("penumbra-only bands cover %d points, %d of them still tinted by a moonrise/moonset band", penumbraOnly, doubleTinted)
|
||||
}
|
||||
}
|
||||
|
||||
// 与 NASA/EclipseWise 世界图同口径:月球在食甚时刻已在地平上的地点,无论月出月落落在食内哪一段,
|
||||
// 都属于可见区,绝不能画成"不可见";反之不可见区必须三刻都在地平下。
|
||||
func TestLunarEclipseMapSVGNotVisibleFollowsHorizon(t *testing.T) {
|
||||
for _, date := range []time.Time{
|
||||
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
|
||||
} {
|
||||
info, ok := eclipsecore.LunarEclipseOnDate(date)
|
||||
if !ok {
|
||||
t.Fatalf("%s: missing eclipse", date.Format("2006-01-02"))
|
||||
}
|
||||
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
|
||||
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
|
||||
if !ok {
|
||||
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
|
||||
}
|
||||
partition := lunarMapPartitionOf(t, doc)
|
||||
// 0.02° 容差内的地点落在边界上,任何一类都不算错。
|
||||
wronglyInvisible, upAtMaximumInvisible, wronglyVisible := 0, 0, 0
|
||||
var sample string
|
||||
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
|
||||
start := lunarPartitionAltitude(info.PenumbralStart, longitude, latitude)
|
||||
maximum := lunarPartitionAltitude(info.Maximum, longitude, latitude)
|
||||
end := lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude)
|
||||
if math.Abs(start) < 0.02 || math.Abs(maximum) < 0.02 || math.Abs(end) < 0.02 {
|
||||
return
|
||||
}
|
||||
invisible := category == lunarPartitionCategoryUnavailable
|
||||
wantInvisible := start < 0 && maximum < 0 && end < 0
|
||||
if invisible && maximum > 0 {
|
||||
upAtMaximumInvisible++
|
||||
if sample == "" {
|
||||
sample = "(" + lunarPartitionFormat(longitude) + "," + lunarPartitionFormat(latitude) + ")"
|
||||
}
|
||||
}
|
||||
if invisible != wantInvisible {
|
||||
wronglyInvisible++
|
||||
}
|
||||
if !invisible && start > 0 && maximum > 0 && end > 0 && category != lunarPartitionCategoryEntire {
|
||||
wronglyVisible++
|
||||
}
|
||||
})
|
||||
t.Logf("%s: 食甚在地平上却画成不可见 %d 点%s;与三刻地平判据不符 %d 点;全程在地平上却不在全程可见区 %d 点",
|
||||
date.Format("2006-01-02"), upAtMaximumInvisible, sample, wronglyInvisible, wronglyVisible)
|
||||
if upAtMaximumInvisible != 0 {
|
||||
t.Errorf("%s: %d grid points whose Moon is above the horizon at greatest eclipse %s are painted as not visible",
|
||||
date.Format("2006-01-02"), upAtMaximumInvisible, sample)
|
||||
}
|
||||
if wronglyInvisible != 0 || wronglyVisible != 0 {
|
||||
t.Errorf("%s: visibility regions disagree with the horizon at P1/greatest/P4 for %d + %d grid points",
|
||||
date.Format("2006-01-02"), wronglyInvisible, wronglyVisible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "全程可见"必须同时满足 P1、食甚、P4 三刻月亮都在地平上:两端可见推不出中途可见,
|
||||
// 高纬下中天会让月亮在食甚前后落到地平下,这类点归入带食月落。
|
||||
// Visible throughout requires the Moon above the horizon at P1, greatest and P4 alike:
|
||||
// visible at both contacts does not imply visible in between.
|
||||
func TestLunarEclipseMapSVGEntireRegionRequiresGreatestVisibility(t *testing.T) {
|
||||
for _, date := range []time.Time{
|
||||
time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2029, 1, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
time.Date(2026, 3, 3, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2011, 6, 15, 12, 0, 0, 0, time.UTC),
|
||||
} {
|
||||
doc, ok := LunarEclipseMapSVG(date, LunarEclipseMapSVGOptions{
|
||||
Width: 960, Height: 640, Language: "zh", Location: time.UTC})
|
||||
if !ok {
|
||||
t.Fatalf("%s: missing map", date.Format("2006-01-02"))
|
||||
}
|
||||
info, ok := eclipsecore.LunarEclipseOnDate(date)
|
||||
if !ok {
|
||||
t.Fatalf("%s: missing eclipse", date.Format("2006-01-02"))
|
||||
}
|
||||
partition := lunarMapPartitionOf(t, doc)
|
||||
holes, holeArea, missing, mislabelled, partialBand := 0, 0.0, 0, 0, 0
|
||||
partition.grid(func(longitude, latitude, x, y float64, category string, layers int) {
|
||||
inP1 := partition.inside("lunar-visible-p1-shape", x, y)
|
||||
inP4 := partition.inside("lunar-visible-p4-shape", x, y)
|
||||
inMaximum := partition.inside("lunar-visible-maximum-shape", x, y)
|
||||
cell := math.Cos(latitude * math.Pi / 180)
|
||||
switch {
|
||||
case inP1 && inP4 && inMaximum && category != lunarPartitionCategoryEntire:
|
||||
holes++
|
||||
holeArea += cell
|
||||
case !(inP1 && inP4 && inMaximum) && category == lunarPartitionCategoryEntire:
|
||||
missing++
|
||||
case inP1 && inP4 && !inMaximum && category == lunarPartitionCategoryMoonset:
|
||||
partialBand++
|
||||
}
|
||||
// 几何复核:三刻中任一刻月亮在地平下(留 0.05° 余量)就不得涂成全程可见。
|
||||
if category == lunarPartitionCategoryEntire {
|
||||
if lunarPartitionAltitude(info.PenumbralStart, longitude, latitude) < -0.05 ||
|
||||
lunarPartitionAltitude(info.Maximum, longitude, latitude) < -0.05 ||
|
||||
lunarPartitionAltitude(info.PenumbralEnd, longitude, latitude) < -0.05 {
|
||||
mislabelled++
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Logf("%s: p1∩p4∩max 内未着全程可见色 %d 点(%.1f 平方度),全程可见区越界 %d 点,中间不可见带 %d 点,误标 %d 点",
|
||||
date.Format("2006-01-02"), holes, holeArea, missing, partialBand, mislabelled)
|
||||
if holes != 0 || missing != 0 || mislabelled != 0 {
|
||||
t.Errorf("%s: entire-eclipse region is not exactly p1∩p4∩max (holes=%d, extra=%d, mislabelled=%d)",
|
||||
date.Format("2006-01-02"), holes, missing, mislabelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lunarPartitionFormat(value float64) string {
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func countsString(counts map[string]int) string {
|
||||
keys := []string{lunarPartitionCategoryEntire, lunarPartitionCategoryMoonset,
|
||||
lunarPartitionCategoryMoonrise, lunarPartitionCategoryUnavailable, "", lunarPartitionCategoryPenumbra}
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if counts[key] == 0 {
|
||||
continue
|
||||
}
|
||||
name := key
|
||||
if name == "" {
|
||||
name = "uncovered"
|
||||
}
|
||||
parts = append(parts, name+"="+lunarPartitionFormat(float64(counts[key])))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
Reference in New Issue
Block a user