feat: 完善时标与天象几何计算并扩展输出接口

- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
This commit is contained in:
2026-09-23 18:55:12 +08:00
parent 1f31a9b5b5
commit 16c62a97d5
503 changed files with 33290 additions and 9471 deletions
+13 -10
View File
@@ -163,11 +163,13 @@ func (frame Frame) WriteGraticule(builder *strings.Builder, clipID string) {
}
fmt.Fprintf(builder, `<g class="graticule" clip-path="url(#%s)" fill="none" stroke="#b8c4c3" stroke-width="0.65">`, clipID)
if !frame.IsPolar() {
first, last := -150.0, 150.0
// 居中视图循环到窗口另一端会同一条接缝经线,只画一次。
first, count := -150.0, 11
if frame.CenterLongitude != 0 {
first, last = frame.CenterLongitude-180, frame.CenterLongitude+180
first, count = frame.CenterLongitude-180, 12
}
for longitude := first; longitude <= last; longitude += 30 {
for index := 0; index < count; index++ {
longitude := first + 30*float64(index)
x, _, _ := frame.Project(longitude, 0)
if frame.CenterLongitude != 0 && (x < frame.X-0.5 || x > frame.X+frame.Width+0.5) {
continue
@@ -269,17 +271,18 @@ func (frame Frame) hemisphere() float64 {
return 1
}
// equirectangularLongitudeOffset 返回经度相对居中经线的偏移,换算成 0…360 的剂量。
// 居中经线落在画面正中,其对面的经线落在左右任一边界上。
// equirectangularLongitudeOffset 返回经度相对画面左边缘的偏移,换算成 0…360 的剂量。
// 居中经线落在画面正中,其对面的经线同时是左右边缘:已经在窗口内的经度按原值返回,
// 让接缝两侧的点各自贴住自己那一侧的边缘,窗口外的经度再按 360 折回。
func equirectangularLongitudeOffset(longitude, center float64) float64 {
offset := math.Mod(longitude-center, 360)
offset := longitude - (center - 180)
if offset >= 0 && offset <= 360 {
return offset
}
offset = math.Mod(offset, 360)
if offset < 0 {
offset += 360
}
offset += 180
if offset >= 360 {
offset -= 360
}
return offset
}
+92
View File
@@ -0,0 +1,92 @@
package svgmap
import (
"math"
"testing"
)
// 居中经线对面的接缝同时是画面左右边缘:贴右边缘的分段必须落在窗口右端,
// 折回左端会让闭合边横穿整幅图。
func TestEquirectangularSeamKeepsBothWindowEdges(t *testing.T) {
frame := Frame{X: 52, Y: 168, Width: 610.8, Height: 305.4,
Projection: ProjectionEquirectangular, CenterLongitude: 176.271}
left := frame.CenterLongitude - 180
right := frame.CenterLongitude + 180
if x, _, _ := frame.Project(left, 0); math.Abs(x-frame.X) > 1e-9 {
t.Fatalf("seam at the left edge maps to x=%.6f, want %.6f", x, frame.X)
}
if x, _, _ := frame.Project(right, 0); math.Abs(x-(frame.X+frame.Width)) > 1e-9 {
t.Fatalf("the same seam in the adjacent world maps to x=%.6f, want %.6f", x, frame.X+frame.Width)
}
if x, _, _ := frame.Project(right-0.5, 0); x >= frame.X+frame.Width {
t.Fatalf("a point just west of the seam maps to x=%.6f, want inside the frame", x)
}
ring := make([]GeoPoint, 0, 40)
for longitude := -10.0; longitude <= 5; longitude += 5 {
ring = append(ring, GeoPoint{Longitude: longitude, Latitude: 0})
}
for latitude := 5.0; latitude <= 20; latitude += 5 {
ring = append(ring, GeoPoint{Longitude: 5, Latitude: latitude})
}
for longitude := 0.0; longitude >= -10; longitude -= 5 {
ring = append(ring, GeoPoint{Longitude: longitude, Latitude: 20})
}
for latitude := 15.0; latitude >= 5; latitude -= 5 {
ring = append(ring, GeoPoint{Longitude: -10, Latitude: latitude})
}
fragments := PolygonFragments(ring, frame.Clip())
if len(fragments) != 2 {
t.Fatalf("straddling ring fragments = %d, want 2", len(fragments))
}
for index, fragment := range fragments {
minX, maxX := math.Inf(1), math.Inf(-1)
for _, point := range fragment {
x, _, ok := frame.Project(point.Longitude, point.Latitude)
if !ok {
t.Fatalf("fragment %d dropped a point", index)
}
minX, maxX = math.Min(minX, x), math.Max(maxX, x)
}
if maxX-minX > frame.Width/2 {
t.Fatalf("fragment %d spans %.3f px, want one side of the seam only", index, maxX-minX)
}
}
}
// 绕极点一圈的环靠地图上边缘闭合,闭合边两端必须分贴左右边缘。
func TestEquirectangularPoleClosureUsesMapEdges(t *testing.T) {
frame := Frame{X: 52, Y: 168, Width: 610.8, Height: 305.4,
Projection: ProjectionEquirectangular, CenterLongitude: 176.271}
ring := make([]GeoPoint, 0, 36)
for longitude := -180.0; longitude < 180; longitude += 10 {
ring = append(ring, GeoPoint{Longitude: longitude, Latitude: 70})
}
fragments := PolygonFragments(ring, frame.Clip())
if len(fragments) != 1 {
t.Fatalf("cap ring fragments = %d, want 1", len(fragments))
}
left, right, top := false, false, false
for _, point := range fragments[0] {
if math.Abs(point.Latitude) < 89.999 {
continue
}
x, y, _ := frame.Project(point.Longitude, point.Latitude)
if math.Abs(y-frame.Y) > 1e-6 {
t.Fatalf("pole closure at y=%.3f, want the map top edge %.3f", y, frame.Y)
}
switch {
case math.Abs(x-frame.X) < 1e-6:
left = true
case math.Abs(x-(frame.X+frame.Width)) < 1e-6:
right = true
}
top = true
}
if !top || !left || !right {
t.Fatalf("pole closure left=%v right=%v top=%v, want the cap closed along both edges", left, right, top)
}
if fragments := PolygonFragments(ring, ClipView{Projection: ProjectionEquirectangular}); len(fragments) != 1 {
t.Fatalf("uncentred cap fragments = %d, want 1", len(fragments))
}
}