feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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>`)
|
||||
}
|
||||
Reference in New Issue
Block a user