Files
astro/internal/svgmap/land_globe.go
T
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00

191 lines
6.0 KiB
Go

package svgmap
import (
"fmt"
"strconv"
"strings"
"sync"
"b612.me/astro/internal/geodata"
)
var (
landRingsOnce sync.Once
landRingsCache [][]geodata.GeoPoint
)
// landRings 把烘焙的等经纬陆地路径还原成经纬度环。
// 路径编码在 5760x2880 的整数像素网格上,即 1/16° 量化;在球面图上不足 0.2 像素。
// 正射图的视点随事件变化,无法预烘焙,只能运行时投影。
func landRings() [][]geodata.GeoPoint {
landRingsOnce.Do(func() {
landRingsCache = decodeLandPath(equirectangularLandPath, worldLandWidth, worldLandHeight)
})
return landRingsCache
}
// scanLandNumber 读取一个整数坐标;SVG 允许负号紧接前一个数字,因此不能按空白切分。
func scanLandNumber(text string, position int) (int, int, bool) {
// 坐标对之间有空白,调用方可能在命令字母之后直接进入,所以这里要自己跳过分隔符。
for position < len(text) && (text[position] == ' ' || text[position] == ',') {
position++
}
start := position
if position < len(text) && (text[position] == '-' || text[position] == '+') {
position++
}
digits := position
for position < len(text) && text[position] >= '0' && text[position] <= '9' {
position++
}
if position == digits {
return 0, start, false
}
value, err := strconv.Atoi(text[start:position])
if err != nil {
return 0, start, false
}
return value, position, true
}
// decodeLandPath 解析内嵌底图的紧凑路径,只用到 M/l/h/v/z 五种命令。
func decodeLandPath(text string, width, height int) [][]geodata.GeoPoint {
rings := make([][]geodata.GeoPoint, 0, 2048)
var ring []geodata.GeoPoint
x, y := 0, 0
position := 0
var command byte
point := func(px, py int) geodata.GeoPoint {
return geodata.GeoPoint{
Longitude: float64(px)/float64(width)*360 - 180,
Latitude: 90 - float64(py)/float64(height)*180,
}
}
flush := func() {
if len(ring) >= 3 {
rings = append(rings, ring)
}
ring = nil
}
for position < len(text) {
for position < len(text) && (text[position] == ' ' || text[position] == ',') {
position++
}
if position >= len(text) {
break
}
if character := text[position]; (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') {
command = character
position++
if command == 'z' || command == 'Z' {
flush()
command = 0
}
continue
}
value, next, ok := scanLandNumber(text, position)
if !ok {
break
}
position = next
switch command {
case 'M':
second, afterSecond, secondOK := scanLandNumber(text, position)
if !secondOK {
return rings
}
position = afterSecond
flush()
x, y = value, second
ring = append(ring, point(x, y))
// M 之后的裸坐标对按规范是隐式相对直线段。
command = 'l'
case 'l':
second, afterSecond, secondOK := scanLandNumber(text, position)
if !secondOK {
return rings
}
position = afterSecond
x += value
y += second
ring = append(ring, point(x, y))
case 'h':
x += value
ring = append(ring, point(x, y))
case 'v':
y += value
ring = append(ring, point(x, y))
}
}
flush()
return rings
}
// writeOrthographicPath 先按视界裁剪再投影一段地理折线,写成 SVG 路径命令。
func (frame Frame) writeOrthographicPath(builder *strings.Builder, view geodata.ClipView, points []geodata.GeoPoint, close bool) {
for _, segment := range geodata.PolylineSegments(points, view) {
frame.writeOrthographicRing(builder, segment, close)
}
}
// writeOrthographicRing 投影一个已经裁剪好的环或折线。
// 这里不能再调 PolylineSegments:视界上的点深度恰为 0,会被判成不可见而把环拆开,
// 每个碎片再用 Z 直线闭合,就会在圆盘上留下横穿的弦。
func (frame Frame) writeOrthographicRing(builder *strings.Builder, points []geodata.GeoPoint, close bool) {
written := 0
for _, item := range points {
x, y, visible := frame.Project(item.Longitude, item.Latitude)
if !visible {
continue
}
command := "L"
if written == 0 {
command = "M"
}
// 球面图上 0.1 像素已远小于海岸线本身的量化误差。
fmt.Fprintf(builder, "%s%.1f %.1f", command, x, y)
written++
}
if written >= 2 && close {
builder.WriteString("Z")
}
}
// writeOrthographicLand 在正射球面图上运行时投影并裁剪陆地。
func (frame Frame) writeOrthographicLand(builder *strings.Builder, clipID string) {
view := frame.Clip()
var path strings.Builder
for _, ring := range landRings() {
for _, fragment := range geodata.PolygonFragments(ring, view) {
frame.writeOrthographicRing(&path, fragment, true)
}
}
fmt.Fprintf(builder, `<g class="land-layer" clip-path="url(#%s)" fill="#d8d9d2" stroke="#a6aaa4" stroke-width="0.75" stroke-linejoin="round"><path class="land" d="%s" fill-rule="evenodd" vector-effect="non-scaling-stroke"/></g>`,
clipID, path.String())
}
// writeOrthographicGraticule 绘制球面经纬网;经线是完整大圆,纬线是等纬圈,都按视界裁剪。
func (frame Frame) writeOrthographicGraticule(builder *strings.Builder, clipID string) {
view := frame.Clip()
fmt.Fprintf(builder, `<g class="graticule" clip-path="url(#%s)" fill="none" stroke="#b8c4c3" stroke-width="0.65">`, clipID)
for longitude := -180.0; longitude < 180; longitude += 30 {
points := make([]geodata.GeoPoint, 0, 91)
for latitude := -90.0; latitude <= 90; latitude += 2 {
points = append(points, geodata.GeoPoint{Longitude: longitude, Latitude: latitude})
}
builder.WriteString(`<path d="`)
frame.writeOrthographicPath(builder, view, points, false)
builder.WriteString(`"/>`)
}
for latitude := -60.0; latitude <= 60; latitude += 30 {
points := make([]geodata.GeoPoint, 0, 181)
for longitude := -180.0; longitude <= 180; longitude += 2 {
points = append(points, geodata.GeoPoint{Longitude: longitude, Latitude: latitude})
}
builder.WriteString(`<path d="`)
frame.writeOrthographicPath(builder, view, points, false)
builder.WriteString(`"/>`)
}
builder.WriteString(`</g>`)
}