2026-09-23 18:55:12 +08:00
// Package kml 把 RFC 7946 GeoJSON 转成 KML 2.2,供 Google Earth 等客户端交互查看;除本库根包的
// 时标门面外只依赖标准库。输入可以是本库 geojson 包的输出,也可以是任何第三方 GeoJSON 文件。
// Package kml converts RFC 7946 GeoJSON into KML 2.2 for interactive clients such as Google Earth,
// using only the standard library apart from the root package's time-scale facade. The input may come
// from this library's geojson package or from any third-party GeoJSON file.
//
// 分层与配色都按要素的 role 属性派发:role 作 Folder,样式取下面的默认调色板,可用 Options.Styles 覆盖。
// 默认只有高亮中心带与掩星带填充,其余面只描轮廓,跨半球且互相重叠的重图层可用 Options.SkipRoles 整层丢弃。
// 时间:带 times 的 MultiLineString 拆成逐段 Placemark 并各带 TimeStamp,逐点的 time 属性同理;
// 集合带 time_scale=UT1 时先把标签折回 UTC 再写 <when>( KML 时间轴必须是 UTC),原口径记进 ExtendedData。
2026-10-15 23:54:15 +08:00
// 输入宽进:越界经度按 360° 回绕,真正跨反经线的面在 ±180 处切开;本库 geojson 的输出侧则严格拒绝越界经度。
2026-09-23 18:55:12 +08:00
// Layers and colors are dispatched by each feature's role property, and times become KML TimeStamps.
package kml
import (
"encoding/json"
"encoding/xml"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"b612.me/astro"
)
const namespace = "http://www.opengis.net/kml/2.2"
// 默认调色板,KML 的 aabbggrr 十六进制(与 CSS 的 rrggbb 顺序不同):日食中心带红、中心线黑、
// 食甚等时线绿、食分线与掩星全掩/半掩带黄、日出日落阶段线与偏食区边界橙,其余中性灰。
const (
// ColorRed 不透明红,日食中心带 / opaque red for the solar central band.
ColorRed = "ff0000ff"
// ColorBlack 不透明黑,中心线 / opaque black for the center line.
ColorBlack = "ff000000"
// ColorGreen 不透明绿,日食食甚等时线 / opaque green for solar greatest-time isochrones.
ColorGreen = "ff008000"
// ColorYellow 不透明黄,食分线与掩星全掩/半掩带 / opaque yellow for magnitude contours and occultation bands.
ColorYellow = "ff00ffff"
// ColorDarkBlue 深蓝,导出常量保留但默认调色板已不用它 / kept exported, unused by the default palette.
ColorDarkBlue = "ff8b0000"
// ColorOrange 不透明橙,日出日落阶段线与偏食区边界 / opaque orange for rise/set phase lines and the partial-visibility edge.
ColorOrange = "ff008cff"
// ColorGray 灰,未指定家族的中性图层 / gray for unclassified layers.
ColorGray = "ff808080"
redFill = "590000ff"
yellowFill = "4d00ffff"
grayFill = "33808080"
// 仅见半影的两条带用与 SVG 同一族色相:月出偏蓝、月落偏紫红。
penumbraMoonriseColor = "ffcc8874"
penumbraMoonsetColor = "ffb57fab"
penumbraMoonriseFill = "4dcc8874"
penumbraMoonsetFill = "4db57fab"
2026-10-15 23:54:15 +08:00
// 凌日分区与等时线沿用凌日 SVG 的配色。
transitEntireColor = "ff6d845e"
transitEntireFill = "576d845e"
transitSunriseColor = "ffa09d4e"
transitSunriseFill = "57a09d4e"
transitSunsetColor = "ff4baae2"
transitSunsetFill = "574baae2"
transitC1Color = "ff2f39a3"
transitC2Color = "ff2a70c0"
transitC3Color = "ffa04c7a"
transitC4Color = "ffa35e2f"
2026-09-23 18:55:12 +08:00
)
const (
eventSolarEclipse = "solar-eclipse"
eventLunarEclipse = "lunar-eclipse"
eventLunarOccultation = "lunar-occultation"
2026-10-15 23:54:15 +08:00
eventPlanetTransit = "planet-transit"
2026-09-23 18:55:12 +08:00
)
// Style 是一个 KML 样式;零值字段使用内置调色板。颜色为 KML 的 aabbggrr 十六进制(ff0000ff 是不透明红)。
// Style describes one KML style; zero fields fall back to the built-in palette. Colors use KML's
// aabbggrr hex order (ff0000ff is opaque red).
type Style struct {
// LineColor 是线色;空值用调色板 / line color, empty for the palette default.
LineColor string
// FillColor 是面填充色;空值用调色板(只有中心带、掩星带这类高亮面才有填充),点与线忽略该字段。
// FillColor is the polygon fill; empty uses the palette, where only highlight bands carry a fill.
FillColor string
// NoFill 为 true 时强制不填充,压过调色板与 FillColor / forces no fill, overriding the palette and FillColor.
NoFill bool
// LineWidth 是线宽(像素);<=0 用调色板默认 / line width in pixels.
LineWidth float64
}
// Options 控制输出 / Options controls the output.
type Options struct {
// Name 是 Document 名;空值按事件类型与集合里最早的时刻推导。
// Name is the Document name; empty derives it from the event type and the earliest instant.
Name string
// Language 选择图层名语言:"zh"(默认)或 "en";未收录的角色保留原始 role id。
// Language selects layer names: "zh" (default) or "en"; unknown roles keep their raw id.
Language string
// Styles 覆盖默认样式,键为 role,或更具体的 "event/role"(后者优先)。
// Styles overrides the palette, keyed by role or by the more specific "event/role".
Styles map [ string ] Style
// NoLookAt 为 true 时不输出 Document/LookAt / omits the Document LookAt.
NoLookAt bool
// SkipRoles 列出要整层丢弃的 role。日食的 partial-band 与 partial-footprint 覆盖大半个地球,
// 光栅化很重且互相重叠,移动端客户端容易卡顿,按需丢弃即可 / roles to drop entirely.
SkipRoles [] string
// FillContext 为 true 时中性面图层也填充;默认只填高亮中心带与掩星带,避免大面积半透明面拖慢客户端。
// FillContext also fills neutral context polygons; by default only highlight bands are filled.
FillContext bool
// NoTimes 为 true 时不输出任何时间元素,得到一份静态叠加;客户端的时间轴停在事件窗口之外时会
// 隐藏全部带时间的要素(只剩没有时间戳的面),静态查看时用它绕开该行为。
// NoTimes omits every time primitive, producing a static overlay; clients whose time slider sits
// outside the event window hide all timed features, and this option sidesteps that behaviour.
NoTimes bool
}
type document struct {
Name string `xml:"name,omitempty"`
Description string `xml:"description,omitempty"`
LookAt * lookAt `xml:"LookAt,omitempty"`
TimeSpan * timeSpan `xml:"TimeSpan,omitempty"`
Styles [] kmlStyle `xml:"Style"`
ExtendedData * extendedData `xml:"ExtendedData,omitempty"`
Folders [] folder `xml:"Folder"`
}
type folder struct {
Name string `xml:"name,omitempty"`
Placemarks [] placemark `xml:"Placemark"`
}
type placemark struct {
Name string `xml:"name,omitempty"`
TimeStamp * timeStamp `xml:"TimeStamp,omitempty"`
StyleURL string `xml:"styleUrl,omitempty"`
ExtendedData * extendedData `xml:"ExtendedData,omitempty"`
geometry
}
type timeStamp struct {
When string `xml:"when"`
}
type timeSpan struct {
Begin string `xml:"begin"`
End string `xml:"end"`
}
// 字段顺序就是 KML 2.2 的 LookAt sequence( heading、tilt、range),顺序错了严格客户端与 XSD 校验会拒绝整个 LookAt。
type lookAt struct {
Longitude float64 `xml:"longitude"`
Latitude float64 `xml:"latitude"`
Heading float64 `xml:"heading"`
Tilt float64 `xml:"tilt"`
Range float64 `xml:"range"`
}
// 字段顺序就是 KML 2.2 的 Style sequence( IconStyle、LabelStyle、LineStyle、PolyStyle),
// 顺序错了严格客户端会整段丢弃样式。
type kmlStyle struct {
ID string `xml:"id,attr"`
IconStyle * iconStyle `xml:"IconStyle,omitempty"`
LineStyle * lineStyle `xml:"LineStyle,omitempty"`
PolyStyle * polyStyle `xml:"PolyStyle,omitempty"`
}
type lineStyle struct {
Color string `xml:"color"`
Width float64 `xml:"width"`
}
type polyStyle struct {
Color string `xml:"color"`
Fill int `xml:"fill"`
Outline int `xml:"outline"`
}
type iconStyle struct {
Color string `xml:"color"`
Scale float64 `xml:"scale"`
}
type dataEntry struct {
Name string `xml:"name,attr"`
Value string `xml:"value"`
}
type extendedData struct {
Data [] dataEntry `xml:"Data"`
}
type point struct {
AltitudeMode string `xml:"altitudeMode"`
Coordinates string `xml:"coordinates"`
}
type lineString struct {
Tessellate int `xml:"tessellate"`
AltitudeMode string `xml:"altitudeMode"`
Coordinates string `xml:"coordinates"`
}
type linearRing struct {
Coordinates string `xml:"coordinates"`
}
type boundary struct {
Ring linearRing `xml:"LinearRing"`
}
type polygon struct {
Tessellate int `xml:"tessellate"`
AltitudeMode string `xml:"altitudeMode"`
OuterBoundaryIs * boundary `xml:"outerBoundaryIs,omitempty"`
InnerBoundaries [] boundary `xml:"innerBoundaryIs,omitempty"`
}
type geometry struct {
Point * point `xml:"Point,omitempty"`
LineString * lineString `xml:"LineString,omitempty"`
Polygon * polygon `xml:"Polygon,omitempty"`
MultiGeometry * multiGeometry `xml:"MultiGeometry,omitempty"`
}
type multiGeometry struct {
Points [] point `xml:"Point,omitempty"`
Lines [] lineString `xml:"LineString,omitempty"`
Polygons [] polygon `xml:"Polygon,omitempty"`
}
// empty 报告几何在去重与接缝切除之后是否还有可画的部分。
func ( g geometry ) empty () bool {
switch {
case g . Point != nil , g . LineString != nil , g . Polygon != nil :
return false
case g . MultiGeometry != nil :
multi := g . MultiGeometry
return len ( multi . Points ) == 0 && len ( multi . Lines ) == 0 && len ( multi . Polygons ) == 0
}
return true
}
// rawCollection 是解析 RFC 7946 的最小结构;coordinates 按几何类型分别解码。
type rawCollection struct {
Type string `json:"type"`
TimeScale string `json:"time_scale"`
Features [] rawFeature `json:"features"`
}
type rawFeature struct {
Type string `json:"type"`
Properties map [ string ] interface {} `json:"properties"`
Geometry rawGeometry `json:"geometry"`
}
type rawGeometry struct {
Type string `json:"type"`
Coordinates json . RawMessage `json:"coordinates"`
Geometries [] rawGeometry `json:"geometries"`
}
// element 是规范化的几何片段:三种类型互斥,when 是该片段的时间戳(可空)。
type element struct {
kind string
point [] float64
line [][] float64
rings [][][] float64
when string
}
type kmlRoot struct {
XMLName xml . Name `xml:"kml"`
Xmlns string `xml:"xmlns,attr"`
Document document `xml:"Document"`
}
// FromGeoJSON 把一份 RFC 7946 FeatureCollection 转成 KML 2.2 字节 / converts one RFC 7946 FeatureCollection into KML 2.2 bytes.
func FromGeoJSON ( data [] byte , options Options ) ([] byte , error ) {
var collection rawCollection
if err := json . Unmarshal ( data , & collection ); err != nil {
return nil , fmt . Errorf ( "kml: decode GeoJSON: %w" , err )
}
if collection . Type != "FeatureCollection" {
return nil , fmt . Errorf ( "kml: GeoJSON type %q is not a FeatureCollection" , collection . Type )
}
built , err := buildDocument ( & collection , options )
if err != nil {
return nil , err
}
body , err := xml . Marshal ( kmlRoot { Xmlns : namespace , Document : * built })
if err != nil {
return nil , fmt . Errorf ( "kml: encode: %w" , err )
}
return append ([] byte ( xml . Header ), body ... ), nil
}
type preparedFeature struct {
event string
role string
props map [ string ] interface {}
groups [][] element
polygon bool
timed bool
earliest time . Time
latest time . Time
}
func buildDocument ( collection * rawCollection , options Options ) ( * document , error ) {
language := options . Language
if language == "" {
language = "zh"
}
features := make ([] preparedFeature , 0 , len ( collection . Features ))
for index := range collection . Features {
prepared , err := prepareFeature ( collection . Features [ index ], collection . TimeScale )
if err != nil {
return nil , fmt . Errorf ( "kml: feature %d: %w" , index , err )
}
features = append ( features , * prepared )
}
skipRoles := map [ string ] bool {}
for _ , role := range options . SkipRoles {
skipRoles [ role ] = true
}
kept := make ([] preparedFeature , 0 , len ( features ))
for _ , feature := range features {
if ! skipRoles [ feature . role ] {
kept = append ( kept , feature )
}
}
if len ( collection . Features ) > 0 && len ( kept ) == 0 {
return nil , fmt . Errorf ( "kml: every feature was dropped by SkipRoles" )
}
documentEntries := commonProperties ( kept )
if collection . TimeScale != "" {
documentEntries [ "time_scale" ] = collection . TimeScale
}
// 样式与图层都用 (event, role) 结构体做键:两者都可能含 '-',拼成字符串后
// (foo-bar, baz) 与 (foo, bar-baz) 会撞成同一个键,把两个图层和样式并成一个。
styles := map [ styleRef ] bool {}
polygonal := map [ styleRef ] bool {}
folderKeys := map [ styleRef ][] placemark {}
folderOrder := [] styleRef {}
eventCount := map [ string ] bool {}
// 样式 id 必须同时合法且唯一:role 来自任意输入,不同键也可能清洗成同一个 id。
styleIDs := map [ styleRef ] string {}
usedStyleIDs := map [ string ] bool {}
styleIDFor := func ( ref styleRef ) string {
if id , ok := styleIDs [ ref ]; ok {
return id
}
base := sanitizeStyleID ( ref . id ())
id := base
for suffix := 2 ; usedStyleIDs [ id ]; suffix ++ {
id = base + "-" + strconv . Itoa ( suffix )
}
usedStyleIDs [ id ] = true
styleIDs [ ref ] = id
return id
}
var lookAtGroups [] roleGeometry
var earliest , latest time . Time
drawn := 0
for _ , feature := range kept {
ref := styleRef { event : feature . event , role : feature . role }
featureStyle := resolveStyle ( feature . event , feature . role , feature . polygon , options )
placemarks , err := placemarksFor ( feature , styleIDFor ( ref ), documentEntries , options . NoTimes , featureStyle . FillColor != "" )
if err != nil {
return nil , err
}
if len ( placemarks ) == 0 {
continue
}
drawn ++
eventCount [ feature . event ] = true
styles [ ref ] = true
if feature . polygon {
polygonal [ ref ] = true
}
if ! feature . earliest . IsZero () && ( earliest . IsZero () || feature . earliest . Before ( earliest )) {
earliest = feature . earliest
}
if ! feature . latest . IsZero () && ( latest . IsZero () || feature . latest . After ( latest )) {
latest = feature . latest
}
if _ , ok := folderKeys [ ref ]; ! ok {
folderOrder = append ( folderOrder , ref )
}
folderKeys [ ref ] = append ( folderKeys [ ref ], placemarks ... )
lookAtGroups = append ( lookAtGroups , roleGeometry { role : feature . role , groups : feature . groups })
}
sort . Slice ( folderOrder , func ( left , right int ) bool { return styleLess ( folderOrder [ left ], folderOrder [ right ]) })
if drawn == 0 && len ( collection . Features ) > 0 {
return nil , fmt . Errorf ( "kml: no feature produced drawable geometry" )
}
built := & document {
Folders : make ([] folder , 0 , len ( folderOrder )),
}
for _ , ref := range folderOrder {
name := localizedRole ( ref . event , ref . role , language )
if len ( eventCount ) > 1 && ref . event != "" {
name = localizedEvent ( ref . event , language ) + " · " + name
}
built . Folders = append ( built . Folders , folder { Name : name , Placemarks : folderKeys [ ref ]})
}
refs := make ([] styleRef , 0 , len ( styles ))
for ref := range styles {
refs = append ( refs , ref )
}
sort . Slice ( refs , func ( left , right int ) bool { return styleLess ( refs [ left ], refs [ right ]) })
built . Styles = make ([] kmlStyle , 0 , len ( refs ))
for _ , ref := range refs {
resolved := resolveStyle ( ref . event , ref . role , polygonal [ ref ], options )
style := kmlStyle {
ID : styleIDFor ( ref ),
IconStyle : & iconStyle { Color : resolved . LineColor , Scale : 0.8 },
LineStyle : & lineStyle { Color : resolved . LineColor , Width : resolved . LineWidth },
}
if resolved . FillColor != "" {
// 面的描边交给同一 Placemark 里的折线:折线能在反经线接缝处断开,而面必须闭合,
// 闭合边会在换日线上留下一条染色直线。
style . PolyStyle = & polyStyle { Color : resolved . FillColor , Fill : 1 , Outline : 0 }
} else if polygonal [ ref ] {
// 面要素必须显式写 fill=0:缺 PolyStyle 时客户端会套用自己的默认面样式(Google Earth 是白填充),
// 把整个可见域盖成一片白,底下的中心带与等值线全部看不见。
style . PolyStyle = & polyStyle { Color : resolved . LineColor , Fill : 0 , Outline : 1 }
}
built . Styles = append ( built . Styles , style )
}
if len ( documentEntries ) > 0 {
built . ExtendedData = & extendedData { Data : sortedDataEntries ( documentEntries )}
}
built . Name = documentName ( options , kept , earliest )
2026-10-15 23:54:15 +08:00
built . Description = descriptionText ( options , language , collection . TimeScale , eventCount )
2026-09-23 18:55:12 +08:00
if ! options . NoLookAt {
built . LookAt = lookAtFor ( lookAtElements ( lookAtGroups ))
}
if ! options . NoTimes && ! earliest . IsZero () && ! latest . IsZero () {
// Document 级 TimeSpan 声明整个集合的时间窗口,免得客户端的时间轴默认停在窗口之外把带时间的要素全藏起来。
built . TimeSpan = & timeSpan {
Begin : earliest . UTC (). Format ( time . RFC3339Nano ),
End : latest . UTC (). Format ( time . RFC3339Nano ),
}
}
return built , nil
}
func prepareFeature ( feature rawFeature , timeScale string ) ( * preparedFeature , error ) {
props := feature . Properties
if props == nil {
props = map [ string ] interface {}{}
}
event , _ := props [ "event" ].( string )
role , _ := props [ "role" ].( string )
if role == "" {
role = "unclassified"
}
elements , err := decodeGeometry ( feature . Geometry )
if err != nil {
return nil , err
}
polygon := false
for _ , item := range elements {
if item . kind == "Polygon" {
polygon = true
}
}
prepared := & preparedFeature { event : event , role : role , props : props , polygon : polygon }
times , hasTimes , err := decodeTimes ( props [ "times" ])
if err != nil {
return nil , err
}
single , _ := props [ "time" ].( string )
switch {
case hasTimes :
lineOnly := true
for _ , item := range elements {
if item . kind != "LineString" {
lineOnly = false
}
}
if ! lineOnly || len ( times ) != len ( elements ) {
return nil , fmt . Errorf ( "times array (%d) does not align with %d line segments" , len ( times ), len ( elements ))
}
for index := range elements {
when , err := normalizeWhen ( times [ index ][ 0 ], timeScale )
if err != nil {
return nil , err
}
elements [ index ]. when = when
prepared . timed = true
prepared . earliest = minTime ( prepared . earliest , when )
prepared . latest = maxTime ( prepared . latest , when )
prepared . groups = append ( prepared . groups , [] element { elements [ index ]})
}
case single != "" :
when , err := normalizeWhen ( single , timeScale )
if err != nil {
return nil , err
}
// 一个标量 time 覆盖整组几何:它们进同一个 Placemark,时刻写在它的 TimeStamp 上。
for index := range elements {
elements [ index ]. when = when
}
prepared . timed = true
prepared . earliest = minTime ( prepared . earliest , when )
prepared . latest = maxTime ( prepared . latest , when )
prepared . groups = append ( prepared . groups , elements )
default :
prepared . groups = append ( prepared . groups , elements )
}
return prepared , nil
}
func placemarksFor ( feature preparedFeature , styleID string , documentEntries map [ string ] string , noTimes , filled bool ) ([] placemark , error ) {
entries := encodeProperties ( feature . props , true )
// times 是逐段时刻的嵌套数组:它随 TimeStamp 走,NoTimes 下也不该把坐标级数据抄一遍。
delete ( entries , "times" )
if feature . timed && ! noTimes {
// time 已经变成 TimeStamp,不再重复写进 ExtendedData。
delete ( entries , "time" )
}
placemarks := make ([] placemark , 0 , len ( feature . groups ))
for _ , group := range feature . groups {
value , err := geometryFor ( group , filled )
if err != nil {
return nil , err
}
if value . empty () {
continue
}
current := placemark { StyleURL : "#" + styleID , geometry : value }
if when := uniformWhen ( group ); when != "" {
if ! noTimes {
current . TimeStamp = & timeStamp { When : when }
current . Name = placemarkName ( feature . props , group [ 0 ]. when )
} else if label , ok := feature . props [ "label" ].( string ); ok && label != "" {
current . Name = label
}
}
if len ( entries ) > 0 {
local := map [ string ] string {}
for name , encoded := range entries {
if shared , ok := documentEntries [ name ]; ok && shared == encoded {
continue
}
local [ name ] = encoded
}
if len ( local ) > 0 {
current . ExtendedData = & extendedData { Data : sortedDataEntries ( local )}
}
}
placemarks = append ( placemarks , current )
}
return placemarks , nil
}
func placemarkName ( props map [ string ] interface {}, when string ) string {
if label , ok := props [ "label" ].( string ); ok && label != "" {
return label
}
return compactWhen ( when )
}
func compactWhen ( when string ) string {
return strings . Replace ( truncateToSecond ( when ), "T" , " " , 1 )
}
// truncateToSecond 去掉小数秒,仅用于 Placemark 名字;TimeStamp 仍写完整精度。
func truncateToSecond ( when string ) string {
dot := strings . IndexByte ( when , '.' )
if dot < 0 {
return when
}
end := len ( when )
for index := dot + 1 ; index < len ( when ); index ++ {
if when [ index ] == 'Z' || when [ index ] == '+' || when [ index ] == '-' {
end = index
break
}
}
return when [: dot ] + when [ end :]
}
func normalizeWhen ( when , timeScale string ) ( string , error ) {
parsed , err := time . Parse ( time . RFC3339Nano , when )
if err != nil {
return "" , fmt . Errorf ( "time %q is not RFC 3339: %w" , when , err )
}
if timeScale == "UT1" {
// GeoJSON 里的 UT1 标签带 Z 后缀但不等于 UTC,KML 的时间轴必须是 UTC,按同一 ΔT 模型折回。
parsed = astro . UTCFromUT1 ( parsed )
}
// KML 的 when 是 xsd:dateTime,允许小数秒;截断到整秒会把每个时刻整体提前最多 1 秒。
return parsed . UTC (). Format ( time . RFC3339Nano ), nil
}
func maxTime ( current time . Time , when string ) time . Time {
parsed , err := time . Parse ( time . RFC3339 , when )
if err != nil {
return current
}
if current . IsZero () || parsed . After ( current ) {
return parsed
}
return current
}
func minTime ( current time . Time , when string ) time . Time {
parsed , err := time . Parse ( time . RFC3339 , when )
if err != nil {
return current
}
if current . IsZero () || parsed . Before ( current ) {
return parsed
}
return current
}
func decodeTimes ( value interface {}) ([][] string , bool , error ) {
if value == nil {
return nil , false , nil
}
raw , ok := value .([] interface {})
if ! ok {
return nil , false , fmt . Errorf ( "times property has type %T" , value )
}
times := make ([][] string , 0 , len ( raw ))
for _ , segment := range raw {
list , ok := segment .([] interface {})
if ! ok {
return nil , false , fmt . Errorf ( "times segment has type %T" , segment )
}
entries := make ([] string , 0 , len ( list ))
for _ , entry := range list {
text , ok := entry .( string )
if ! ok {
return nil , false , fmt . Errorf ( "times entry has type %T" , entry )
}
entries = append ( entries , text )
}
if len ( entries ) == 0 {
return nil , false , fmt . Errorf ( "times segment is empty" )
}
times = append ( times , entries )
}
if len ( times ) == 0 {
return nil , false , fmt . Errorf ( "times array is empty" )
}
return times , true , nil
}
func decodeGeometry ( raw rawGeometry ) ([] element , error ) {
switch raw . Type {
case "Point" :
var position [] float64
if err := json . Unmarshal ( raw . Coordinates , & position ); err != nil {
return nil , fmt . Errorf ( "Point coordinates: %w" , err )
}
return [] element {{ kind : "Point" , point : position }}, nil
case "MultiPoint" :
var positions [][] float64
if err := json . Unmarshal ( raw . Coordinates , & positions ); err != nil {
return nil , fmt . Errorf ( "MultiPoint coordinates: %w" , err )
}
elements := make ([] element , 0 , len ( positions ))
for _ , position := range positions {
elements = append ( elements , element { kind : "Point" , point : position })
}
return elements , nil
case "LineString" :
var line [][] float64
if err := json . Unmarshal ( raw . Coordinates , & line ); err != nil {
return nil , fmt . Errorf ( "LineString coordinates: %w" , err )
}
return [] element {{ kind : "LineString" , line : line }}, nil
case "MultiLineString" :
var lines [][][] float64
if err := json . Unmarshal ( raw . Coordinates , & lines ); err != nil {
return nil , fmt . Errorf ( "MultiLineString coordinates: %w" , err )
}
elements := make ([] element , 0 , len ( lines ))
for _ , line := range lines {
elements = append ( elements , element { kind : "LineString" , line : line })
}
return elements , nil
case "Polygon" :
var rings [][][] float64
if err := json . Unmarshal ( raw . Coordinates , & rings ); err != nil {
return nil , fmt . Errorf ( "Polygon coordinates: %w" , err )
}
2026-10-15 23:54:15 +08:00
elements := [] element {}
for _ , piece := range splitAntimeridianPolygon ( rings ) {
elements = append ( elements , element { kind : "Polygon" , rings : piece })
}
return elements , nil
2026-09-23 18:55:12 +08:00
case "MultiPolygon" :
var polygons [][][][] float64
if err := json . Unmarshal ( raw . Coordinates , & polygons ); err != nil {
return nil , fmt . Errorf ( "MultiPolygon coordinates: %w" , err )
}
elements := make ([] element , 0 , len ( polygons ))
for _ , rings := range polygons {
2026-10-15 23:54:15 +08:00
for _ , piece := range splitAntimeridianPolygon ( rings ) {
elements = append ( elements , element { kind : "Polygon" , rings : piece })
}
2026-09-23 18:55:12 +08:00
}
return elements , nil
case "GeometryCollection" :
elements := [] element {}
for _ , child := range raw . Geometries {
decoded , err := decodeGeometry ( child )
if err != nil {
return nil , err
}
elements = append ( elements , decoded ... )
}
if len ( elements ) == 0 {
return nil , fmt . Errorf ( "GeometryCollection is empty" )
}
return elements , nil
case "" :
return nil , fmt . Errorf ( "geometry is missing" )
default :
return nil , fmt . Errorf ( "unsupported geometry type %q" , raw . Type )
}
}
func geometryFor ( elements [] element , filled bool ) ( geometry , error ) {
if len ( elements ) == 1 {
return singleGeometry ( elements [ 0 ], filled )
}
multi := & multiGeometry {}
for _ , item := range elements {
switch item . kind {
case "Point" :
text , err := pointText ( item . point )
if err != nil {
return geometry {}, err
}
multi . Points = append ( multi . Points , point { AltitudeMode : "clampToGround" , Coordinates : text })
case "LineString" :
parts , err := seamCutLine ( item . line )
if err != nil {
return geometry {}, err
}
for _ , part := range parts {
text , err := lineText ( part )
if err != nil {
return geometry {}, err
}
multi . Lines = append ( multi . Lines , lineString { Tessellate : 1 , AltitudeMode : "clampToGround" , Coordinates : text })
}
case "Polygon" :
outline , err := outlineGeometry ( item . rings )
if err != nil {
return geometry {}, err
}
switch {
case outline . LineString != nil :
multi . Lines = append ( multi . Lines , * outline . LineString )
case outline . MultiGeometry != nil :
multi . Lines = append ( multi . Lines , outline . MultiGeometry . Lines ... )
}
if ! filled {
continue
}
value , err := polygonFor ( item . rings )
if err != nil {
return geometry {}, err
}
multi . Polygons = append ( multi . Polygons , value )
}
}
return geometry { MultiGeometry : multi }, nil
}
func singleGeometry ( item element , filled bool ) ( geometry , error ) {
switch item . kind {
case "Point" :
text , err := pointText ( item . point )
if err != nil {
return geometry {}, err
}
return geometry { Point : & point { AltitudeMode : "clampToGround" , Coordinates : text }}, nil
case "LineString" :
parts , err := seamCutLine ( item . line )
if err != nil {
return geometry {}, err
}
lines := make ([] lineString , 0 , len ( parts ))
for _ , part := range parts {
text , err := lineText ( part )
if err != nil {
return geometry {}, err
}
lines = append ( lines , lineString { Tessellate : 1 , AltitudeMode : "clampToGround" , Coordinates : text })
}
switch len ( lines ) {
case 0 :
return geometry {}, nil
case 1 :
return geometry { LineString : & lines [ 0 ]}, nil
}
return geometry { MultiGeometry : & multiGeometry { Lines : lines }}, nil
case "Polygon" :
outline , err := outlineGeometry ( item . rings )
if err != nil {
return geometry {}, err
}
if ! filled {
return outline , nil
}
value , err := polygonFor ( item . rings )
if err != nil {
return geometry {}, err
}
multi := & multiGeometry { Polygons : [] polygon { value }}
switch {
case outline . LineString != nil :
multi . Lines = append ( multi . Lines , * outline . LineString )
case outline . MultiGeometry != nil :
multi . Lines = append ( multi . Lines , outline . MultiGeometry . Lines ... )
}
return geometry { MultiGeometry : multi }, nil
}
return geometry {}, fmt . Errorf ( "unsupported geometry element %q" , item . kind )
}
// outlineGeometry 把只描轮廓的面画成折线。反经线接缝(两端都在 ±180 的经线段)是拆环的产物、不是真实边界,
// 必须在它处断开,否则图上会多出一条沿换日线的直线。
func outlineGeometry ( rings [][][] float64 ) ( geometry , error ) {
lines := [] lineString {}
for _ , ring := range rings {
parts , err := outlineLines ( ring )
if err != nil {
return geometry {}, err
}
for _ , part := range parts {
text , err := lineText ( part )
if err != nil {
return geometry {}, err
}
lines = append ( lines , lineString { Tessellate : 1 , AltitudeMode : "clampToGround" , Coordinates : text })
}
}
switch len ( lines ) {
case 0 :
return geometry {}, nil
case 1 :
return geometry { LineString : & lines [ 0 ]}, nil
}
return geometry { MultiGeometry : & multiGeometry { Lines : lines }}, nil
}
func outlineLines ( ring [][] float64 ) ([][][] float64 , error ) {
normalized , err := normalizeRing ( ring , true )
if err != nil {
return nil , err
}
points := normalized
if len ( points ) > 1 && equalPosition ( points [ 0 ], points [ len ( points ) - 1 ]) {
points = points [: len ( points ) - 1 ]
}
// 从某条接缝边的后一个顶点起绕环:顺序扫描时"环尾回到环首"那一段真实边不会被检查到,
// 接缝在别处时它就被整段漏掉。旋转后每条真实边都在一圈之内,末尾那条正好是接缝本身。
start := - 1
for index := range points {
if isSeamSegment ( points [ index ], points [( index + 1 ) % len ( points )]) {
start = ( index + 1 ) % len ( points )
break
}
}
if start < 0 {
// 不碰接缝的环按闭合折线画,保持原来的形状。
closed := append ([][] float64 {}, points ... )
return [][][] float64 { append ( closed , append ([] float64 ( nil ), points [ 0 ] ... ))}, nil
}
ordered := append ( append ([][] float64 {}, points [ start :] ... ), points [: start ] ... )
parts := [][][] float64 {}
current := [][] float64 {}
for index := range ordered {
current = append ( current , ordered [ index ])
if index == len ( ordered ) - 1 {
break
}
if isSeamSegment ( ordered [ index ], ordered [ index + 1 ]) {
if len ( current ) > 1 {
parts = append ( parts , current )
}
current = nil
}
}
if len ( current ) > 1 {
parts = append ( parts , current )
}
// 整个环都落在 ±180 上时没有可画的真实边,交给调用方按"空几何"跳过。
return parts , nil
}
// seamCutLine 把折线在反经线接缝处断开:两端都落在 ±180 的段是拆环产物、不是真实边界,
// 描出来就是一条沿换日线的直线(本库 geojson 的 band-outline 正是这种闭合环线)。
// 不碰接缝的折线原样返回一段,保持既有输出。
func seamCutLine ( line [][] float64 ) ([][][] float64 , error ) {
points := make ([][] float64 , 0 , len ( line ))
for _ , position := range line {
normalized , err := normalizePosition ( position )
if err != nil {
return nil , err
}
if len ( points ) > 0 && equalPosition ( points [ len ( points ) - 1 ], normalized ) {
continue
}
points = append ( points , normalized )
}
if len ( points ) < 2 {
// 只剩一个位置的路径片段画不出线(本库 geojson 用两个相同坐标表示它),跳过而不是让整份转换失败。
return nil , nil
}
closed := equalPosition ( points [ 0 ], points [ len ( points ) - 1 ])
parts := [][][] float64 {}
current := [][] float64 {}
for index := range points {
current = append ( current , points [ index ])
if index == len ( points ) - 1 && ! closed {
break
}
if ! isSeamSegment ( points [ index ], points [( index + 1 ) % len ( points )]) {
continue
}
if len ( current ) > 1 {
parts = append ( parts , current )
}
current = nil
}
if len ( current ) > 1 {
parts = append ( parts , current )
}
if len ( parts ) == 0 {
// 整条线都落在 ±180 上:那是真实边界(例如极区被反经线裁出的边),不是拆环残留,
// 原样保留比让整次转换失败更合理。
return [][][] float64 { points }, nil
}
return parts , nil
}
func isSeamSegment ( from , to [] float64 ) bool {
const tolerance = 1e-9
return math . Abs ( math . Abs ( from [ 0 ]) - 180 ) < tolerance && math . Abs ( math . Abs ( to [ 0 ]) - 180 ) < tolerance
}
2026-10-15 23:54:15 +08:00
// splitAntimeridianPolygon 把真正跨过反经线(相邻顶点经度差超过 180°)的面在 ±180 处切成东西两块;
// 已按反经线拆好的面、包含极点的环和坐标非法的面原样返回,交给后续校验。
func splitAntimeridianPolygon ( rings [][][] float64 ) [][][][] float64 {
unchanged := [][][][] float64 { rings }
if len ( rings ) == 0 {
return unchanged
}
unwrapped := make ([][][] float64 , 0 , len ( rings ))
crosses := false
for _ , ring := range rings {
values , jumped , ok := unwrapRing ( ring )
if ! ok {
return unchanged
}
crosses = crosses || jumped
unwrapped = append ( unwrapped , values )
}
if ! crosses {
return unchanged
}
outerMin , outerMax := longitudeRange ( unwrapped [ 0 ])
cut := 180.0
if outerMax <= 180 {
if outerMin >= - 180 {
return unchanged
}
cut = - 180
}
center := ( outerMin + outerMax ) / 2
for index := 1 ; index < len ( unwrapped ); index ++ {
low , high := longitudeRange ( unwrapped [ index ])
shift := 360 * math . Round (( center - ( low + high ) / 2 ) / 360 )
for _ , position := range unwrapped [ index ] {
position [ 0 ] += shift
}
}
// 落在 [-180,180] 之外的一侧平移 360° 回到合法区间。
offsets := map [ bool ] float64 { true : 0 , false : - 360 }
if cut == - 180 {
offsets = map [ bool ] float64 { true : 360 , false : 0 }
}
pieces := [][][][] float64 {}
for _ , keepWest := range [] bool { true , false } {
offset := offsets [ keepWest ]
piece := [][][] float64 {}
for index , ring := range unwrapped {
clipped := clipRingAtLongitude ( ring , cut , keepWest , offset )
if len ( clipped ) < 4 {
if index == 0 {
piece = nil
break
}
continue
}
piece = append ( piece , clipped )
}
if len ( piece ) > 0 {
pieces = append ( pieces , piece )
}
}
if len ( pieces ) == 0 {
return unchanged
}
return pieces
}
// unwrapRing 把环的经度展开成连续序列;闭合后累计漂移不为零(环绕极点)时 ok=false。
func unwrapRing ( ring [][] float64 ) ( values [][] float64 , jumped , ok bool ) {
if len ( ring ) < 2 {
return nil , false , false
}
values = make ([][] float64 , 0 , len ( ring ))
for index , position := range ring {
if len ( position ) < 2 || math . IsNaN ( position [ 0 ]) || math . IsInf ( position [ 0 ], 0 ) ||
math . IsNaN ( position [ 1 ]) || math . IsInf ( position [ 1 ], 0 ) {
return nil , false , false
}
longitude := position [ 0 ]
if longitude > 180 || longitude < - 180 {
longitude = math . Mod ( longitude + 180 , 360 )
if longitude < 0 {
longitude += 360
}
longitude -= 180
}
if index > 0 {
previous := values [ index - 1 ][ 0 ]
for longitude - previous > 180 {
longitude -= 360
jumped = true
}
for previous - longitude > 180 {
longitude += 360
jumped = true
}
}
values = append ( values , [] float64 { longitude , position [ 1 ]})
}
// 闭合边展开后若回不到起点经度,说明环绕了极点,无法按经线切开。
first , last := values [ 0 ][ 0 ], values [ len ( values ) - 1 ][ 0 ]
closing := first
for closing - last > 180 {
closing -= 360
}
for last - closing > 180 {
closing += 360
}
if math . Abs ( closing - first ) > 1e-9 {
return nil , false , false
}
return values , jumped , true
}
func longitudeRange ( ring [][] float64 ) ( float64 , float64 ) {
low , high := math . Inf ( 1 ), math . Inf ( - 1 )
for _ , position := range ring {
low = math . Min ( low , position [ 0 ])
high = math . Max ( high , position [ 0 ])
}
return low , high
}
// clipRingAtLongitude 用经线 cut 截取环的西侧或东侧(半平面裁剪),结果平移 offset 并闭合。
func clipRingAtLongitude ( ring [][] float64 , cut float64 , keepWest bool , offset float64 ) [][] float64 {
inside := func ( position [] float64 ) bool {
if keepWest {
return position [ 0 ] <= cut
}
return position [ 0 ] >= cut
}
points := ring
if len ( points ) > 1 && equalPosition ( points [ 0 ], points [ len ( points ) - 1 ]) {
points = points [: len ( points ) - 1 ]
}
clipped := [][] float64 {}
for index , current := range points {
previous := points [( index + len ( points ) - 1 ) % len ( points )]
if inside ( current ) != inside ( previous ) {
fraction := ( cut - previous [ 0 ]) / ( current [ 0 ] - previous [ 0 ])
clipped = append ( clipped , [] float64 { cut + offset , previous [ 1 ] + ( current [ 1 ] - previous [ 1 ]) * fraction })
}
if inside ( current ) {
clipped = append ( clipped , [] float64 { current [ 0 ] + offset , current [ 1 ]})
}
}
if len ( clipped ) == 0 {
return nil
}
return append ( clipped , append ([] float64 ( nil ), clipped [ 0 ] ... ))
}
2026-09-23 18:55:12 +08:00
func polygonFor ( rings [][][] float64 ) ( polygon , error ) {
if len ( rings ) == 0 {
return polygon {}, fmt . Errorf ( "polygon has no rings" )
}
outer , err := normalizeRing ( rings [ 0 ], true )
if err != nil {
return polygon {}, err
}
value := polygon {
Tessellate : 1 ,
AltitudeMode : "clampToGround" ,
OuterBoundaryIs : & boundary { Ring : linearRing { Coordinates : textForPositions ( outer )}},
}
for _ , ring := range rings [ 1 :] {
hole , err := normalizeRing ( ring , false )
if err != nil {
return polygon {}, err
}
value . InnerBoundaries = append ( value . InnerBoundaries , boundary { Ring : linearRing { Coordinates : textForPositions ( hole )}})
}
return value , nil
}
// normalizeRing 归一化环:位置必须有限、经度回绕到 (−180,180]、纬度在 ±90 内;闭合,并按 KML 要求
// 把外环摆成逆时针、洞摆成顺时针。
func normalizeRing ( ring [][] float64 , outer bool ) ([][] float64 , error ) {
positions := make ([][] float64 , 0 , len ( ring ) + 1 )
for _ , position := range ring {
normalized , err := normalizePosition ( position )
if err != nil {
return nil , err
}
if len ( positions ) > 0 && equalPosition ( positions [ len ( positions ) - 1 ], normalized ) {
continue
}
positions = append ( positions , normalized )
}
if len ( positions ) > 1 && equalPosition ( positions [ 0 ], positions [ len ( positions ) - 1 ]) {
positions = positions [: len ( positions ) - 1 ]
}
if len ( positions ) < 3 {
return nil , fmt . Errorf ( "polygon ring requires at least three distinct positions" )
}
area := signedArea ( positions )
if math . Abs ( area ) < 1e-12 {
return nil , fmt . Errorf ( "polygon ring has zero area" )
}
if ( outer && area < 0 ) || (! outer && area > 0 ) {
for left , right := 0 , len ( positions ) - 1 ; left < right ; left , right = left + 1 , right - 1 {
positions [ left ], positions [ right ] = positions [ right ], positions [ left ]
}
}
return append ( positions , append ([] float64 ( nil ), positions [ 0 ] ... )), nil
}
func normalizePosition ( position [] float64 ) ([] float64 , error ) {
if len ( position ) < 2 {
return nil , fmt . Errorf ( "position requires longitude and latitude" )
}
longitude , latitude := position [ 0 ], position [ 1 ]
if math . IsNaN ( longitude ) || math . IsInf ( longitude , 0 ) || math . IsNaN ( latitude ) || math . IsInf ( latitude , 0 ) {
return nil , fmt . Errorf ( "position is not finite" )
}
if latitude < - 90 || latitude > 90 {
return nil , fmt . Errorf ( "latitude %.6f is outside ±90" , latitude )
}
// ±180 必须原样保留:把 +180 折成 −180,会让已经按反经线拆好的环在经度上多出 360° 的边,
// 客户端会把这种环填成一条环绕全球的假带。
if longitude > 180 || longitude < - 180 {
longitude = math . Mod ( longitude + 180 , 360 )
if longitude < 0 {
longitude += 360
}
longitude -= 180
}
return [] float64 { longitude , latitude }, nil
}
func equalPosition ( a , b [] float64 ) bool {
return len ( a ) >= 2 && len ( b ) >= 2 && a [ 0 ] == b [ 0 ] && a [ 1 ] == b [ 1 ]
}
func signedArea ( positions [][] float64 ) float64 {
area := 0.0
for index , position := range positions {
next := positions [( index + 1 ) % len ( positions )]
area += position [ 0 ] * next [ 1 ] - next [ 0 ] * position [ 1 ]
}
return area / 2
}
func pointText ( position [] float64 ) ( string , error ) {
normalized , err := normalizePosition ( position )
if err != nil {
return "" , err
}
return textForPositions ([][] float64 { normalized }), nil
}
func lineText ( line [][] float64 ) ( string , error ) {
if len ( line ) < 2 {
return "" , fmt . Errorf ( "line requires at least two positions" )
}
positions := make ([][] float64 , 0 , len ( line ))
for _ , position := range line {
normalized , err := normalizePosition ( position )
if err != nil {
return "" , err
}
positions = append ( positions , normalized )
}
return textForPositions ( positions ), nil
}
func textForPositions ( positions [][] float64 ) string {
var builder strings . Builder
for index , position := range positions {
if index > 0 {
builder . WriteByte ( ' ' )
}
builder . WriteString ( strconv . FormatFloat ( position [ 0 ], 'f' , - 1 , 64 ))
builder . WriteByte ( ',' )
builder . WriteString ( strconv . FormatFloat ( position [ 1 ], 'f' , - 1 , 64 ))
}
return builder . String ()
}
type roleGeometry struct {
role string
groups [][] element
}
// lookAtPriority 是取景优先的路径类角色:存在这些图层时只按它们取景,免得被跨半球的大区域拉成全球视角。
var lookAtPriority = map [ string ] bool {
"center-line" : true ,
"central-band" : true ,
"central-shadow" : true ,
"central-shadow-sweep" : true ,
"central-shadow-footprint" : true ,
"north-limit" : true ,
"south-limit" : true ,
"north-total-limit" : true ,
"south-total-limit" : true ,
"band-outline" : true ,
"total-band-outline" : true ,
"occultation-band" : true ,
"total-footprint" : true ,
"sublunar-track" : true ,
}
func lookAtElements ( groups [] roleGeometry ) [][] element {
preferred := [][] element {}
for _ , item := range groups {
if lookAtPriority [ item . role ] {
preferred = append ( preferred , item . groups ... )
}
}
if len ( preferred ) > 0 {
return preferred
}
all := [][] element {}
for _ , item := range groups {
all = append ( all , item . groups ... )
}
return all
}
func lookAtFor ( groups [][] element ) * lookAt {
latitudes := [] float64 {}
longitudes := [] float64 {}
for _ , group := range groups {
for _ , item := range group {
switch item . kind {
case "Point" :
if normalized , err := normalizePosition ( item . point ); err == nil {
latitudes = append ( latitudes , normalized [ 1 ])
longitudes = append ( longitudes , normalized [ 0 ])
}
case "LineString" :
for _ , position := range item . line {
if normalized , err := normalizePosition ( position ); err == nil {
latitudes = append ( latitudes , normalized [ 1 ])
longitudes = append ( longitudes , normalized [ 0 ])
}
}
case "Polygon" :
for _ , ring := range item . rings {
for _ , position := range ring {
if normalized , err := normalizePosition ( position ); err == nil {
latitudes = append ( latitudes , normalized [ 1 ])
longitudes = append ( longitudes , normalized [ 0 ])
}
}
}
}
}
}
if len ( latitudes ) == 0 {
return nil
}
sort . Float64s ( latitudes )
sort . Float64s ( longitudes )
latitude := ( latitudes [ 0 ] + latitudes [ len ( latitudes ) - 1 ]) / 2
// 经度用"最大间隙"法取跨反经线也正确的包络。
span := 0.0
gapStart := 0.0
for index := range longitudes {
next := longitudes [( index + 1 ) % len ( longitudes )] + 360 * float64 (( index + 1 ) / len ( longitudes ))
gap := next - longitudes [ index ]
if gap > span {
span = gap
gapStart = longitudes [( index + 1 ) % len ( longitudes )]
}
}
longitudeSpan := 360 - span
longitude := gapStart + longitudeSpan / 2
if longitude > 180 {
longitude -= 360
}
rangeKM := math . Max ( latitudes [ len ( latitudes ) - 1 ] - latitudes [ 0 ], longitudeSpan * math . Cos ( latitude * math . Pi / 180 )) * 111.32
// KML 的 LookAt/range 单位是米(不是千米),1.8 倍留边、下限 200 km。
return & lookAt { Longitude : longitude , Latitude : latitude , Range : math . Max ( rangeKM * 1.8 , 200 ) * 1000 }
}
func resolveStyle ( event , role string , polygonal bool , options Options ) Style {
resolved := paletteStyle ( event , role )
if polygonal && options . FillContext && resolved . FillColor == "" {
resolved . FillColor = grayFill
}
for _ , key := range [] string { event + "/" + role , role } {
custom , ok := options . Styles [ key ]
if ! ok {
continue
}
if custom . LineColor != "" {
resolved . LineColor = custom . LineColor
}
if custom . FillColor != "" {
resolved . FillColor = custom . FillColor
}
if custom . LineWidth > 0 {
resolved . LineWidth = custom . LineWidth
}
if custom . NoFill {
resolved . FillColor = ""
}
break
}
return resolved
}
func paletteStyle ( event , role string ) Style {
switch role {
case "center-line" :
return Style { LineColor : ColorBlack , LineWidth : 3 }
case "central-band" , "total-footprint" , "central-shadow-footprint" , "central-shadow-sweep" , "central-shadow" :
if event == eventSolarEclipse {
return Style { LineColor : ColorRed , FillColor : redFill , LineWidth : 1.5 }
}
case "greatest-time-line" :
if event == eventSolarEclipse {
return Style { LineColor : ColorGreen , LineWidth : 2 }
}
case "magnitude-line" , "magnitude-one-envelope" :
return Style { LineColor : ColorYellow , LineWidth : 2 }
case "total-band" , "partial-band" , "total-band-outline" , "occultation-band" :
if event == eventLunarOccultation {
return Style { LineColor : ColorYellow , FillColor : yellowFill , LineWidth : 2 }
}
if role == "partial-band" {
// 日食的 partial-band 是地方最大食分等于零的可见域边界;它有一大段与日出日落线重合,
// 同色才不会看起来"一半橙一半黄"。
return Style { LineColor : ColorOrange , LineWidth : 2 }
}
case "visibility-boundary" , "p1-horizon" , "p4-horizon" ,
"visible-at-p1" , "visible-at-p4" ,
"visible-during-eclipse" , "visible-throughout-eclipse" :
return Style { LineColor : ColorOrange , LineWidth : 2 }
2026-10-15 23:54:15 +08:00
case "c1-horizon" , "c4-horizon" , "visible-at-c1" , "visible-at-c4" :
return Style { LineColor : ColorOrange , LineWidth : 2 }
case "transit-entire" :
return Style { LineColor : transitEntireColor , FillColor : transitEntireFill , LineWidth : 1.5 }
case "transit-at-sunrise" :
return Style { LineColor : transitSunriseColor , FillColor : transitSunriseFill , LineWidth : 1.5 }
case "transit-at-sunset" :
return Style { LineColor : transitSunsetColor , FillColor : transitSunsetFill , LineWidth : 1.5 }
case "c1-isochrone" :
return Style { LineColor : transitC1Color , LineWidth : 1 }
case "c2-isochrone" :
return Style { LineColor : transitC2Color , LineWidth : 1 }
case "c3-isochrone" :
return Style { LineColor : transitC3Color , LineWidth : 1 }
case "c4-isochrone" :
return Style { LineColor : transitC4Color , LineWidth : 1 }
2026-09-23 18:55:12 +08:00
case "penumbra-moonrise" :
return Style { LineColor : penumbraMoonriseColor , FillColor : penumbraMoonriseFill , LineWidth : 2 }
case "penumbra-moonset" :
return Style { LineColor : penumbraMoonsetColor , FillColor : penumbraMoonsetFill , LineWidth : 2 }
}
return Style { LineColor : ColorGray , LineWidth : 1 }
}
// styleRef 是样式与图层的身份:event 与 role 都可能含 '-',拼成字符串再当键会让
// (foo-bar, baz) 与 (foo, bar-baz) 撞成同一个图层和样式。
type styleRef struct {
event string
role string
}
// id 返回样式 id 的基础串,合法性与唯一性分别由 sanitizeStyleID 与调用方处理。
func ( ref styleRef ) id () string {
if ref . event == "" {
return "style-" + ref . role
}
return "style-" + ref . event + "-" + ref . role
}
// styleLess 按 id 字典序排序;id 相同时再比 event 与 role,保证碰撞时输出仍逐字节稳定。
func styleLess ( left , right styleRef ) bool {
if left . id () != right . id () {
return left . id () < right . id ()
}
if left . event != right . event {
return left . event < right . event
}
return left . role < right . role
}
// commonProperties 取所有要素共有的属性(名字与编码后的值都相同)——只有它们真正属于集合层,
// 首个要素独有的属性(瞬时足迹的 closure、interp_signature 之类)不能冒充集合属性。
func commonProperties ( features [] preparedFeature ) map [ string ] string {
if len ( features ) == 0 {
return map [ string ] string {}
}
common := encodeProperties ( features [ 0 ]. props , true )
for _ , feature := range features [ 1 :] {
entries := encodeProperties ( feature . props , true )
for name , value := range common {
if other , ok := entries [ name ]; ! ok || other != value {
delete ( common , name )
}
}
}
// 角色与时间只对单个要素有意义,留在要素层。
delete ( common , "role" )
delete ( common , "time" )
delete ( common , "times" )
return common
}
// sanitizeStyleID 把逻辑键转成合法的 XML ID(NCName):非法字符换成 '-',首字符不能是数字、'.' 或 '-'。
// Google Earth 容忍非法 id,但 KML 2.2 的 id 是 xsd:ID,严格客户端与 XSD 校验会直接拒绝。
func sanitizeStyleID ( key string ) string {
var builder strings . Builder
builder . Grow ( len ( key ) + 2 )
for _ , symbol := range key {
switch {
case symbol >= 'a' && symbol <= 'z' , symbol >= 'A' && symbol <= 'Z' ,
symbol >= '0' && symbol <= '9' , symbol == '_' , symbol == '-' , symbol == '.' :
builder . WriteRune ( symbol )
default :
builder . WriteByte ( '-' )
}
}
id := builder . String ()
if id == "" {
return "style"
}
first := id [ 0 ]
if !( first >= 'a' && first <= 'z' || first >= 'A' && first <= 'Z' || first == '_' ) {
id = "style-" + id
}
return id
}
// uniformWhen 返回整组几何共用的时刻;时刻不一致的组(times 数组那种)返回空串。
func uniformWhen ( group [] element ) string {
if len ( group ) == 0 || group [ 0 ]. when == "" {
return ""
}
for _ , item := range group [ 1 :] {
if item . when != group [ 0 ]. when {
return ""
}
}
return group [ 0 ]. when
}
func encodeProperties ( props map [ string ] interface {}, includeNested bool ) map [ string ] string {
entries := map [ string ] string {}
for name , value := range props {
switch typed := value .( type ) {
case nil :
continue
case string :
entries [ name ] = typed
case bool :
entries [ name ] = strconv . FormatBool ( typed )
case float64 :
entries [ name ] = strconv . FormatFloat ( typed , 'g' , - 1 , 64 )
default :
if ! includeNested {
continue
}
encoded , err := json . Marshal ( typed )
if err != nil {
continue
}
entries [ name ] = string ( encoded )
}
}
return entries
}
func sortedDataEntries ( entries map [ string ] string ) [] dataEntry {
names := make ([] string , 0 , len ( entries ))
for name := range entries {
names = append ( names , name )
}
sort . Strings ( names )
data := make ([] dataEntry , 0 , len ( names ))
for _ , name := range names {
data = append ( data , dataEntry { Name : name , Value : entries [ name ]})
}
return data
}
func documentName ( options Options , features [] preparedFeature , earliest time . Time ) string {
if options . Name != "" {
return options . Name
}
// 事件类型只在整个集合一致时才代表整份文档;混了多种事件就退回通用名,
// 免得标题只写了首个要素的事件。
event := ""
for _ , feature := range features {
if feature . event == "" {
continue
}
if event == "" {
event = feature . event
continue
}
if feature . event != event {
event = ""
break
}
}
name := localizedEvent ( event , options . Language )
if ! earliest . IsZero () {
name = name + " " + earliest . UTC (). Format ( "2006-01-02" )
}
return name
}
2026-10-15 23:54:15 +08:00
func descriptionText ( options Options , language , timeScale string , events map [ string ] bool ) string {
transitOnly := len ( events ) == 1 && events [ eventPlanetTransit ]
transitMixed := events [ eventPlanetTransit ] && ! transitOnly
2026-09-23 18:55:12 +08:00
if language == "en" {
text := "Converted from GeoJSON. Layers are grouped by role; red: solar central band; black: center line; green: solar greatest-time isochrone; yellow: magnitude contours and occultation total/partial bands; orange: rise/set phase lines."
2026-10-15 23:54:15 +08:00
if transitOnly {
text = "Converted from GeoJSON. Layers are grouped by role; transit regions, C1/C4 apparent horizons, C1-C4 topocentric isochrones and greatest transit are kept in separate layers."
} else if transitMixed {
text = "Converted from GeoJSON. Layers are grouped by event and role."
}
2026-09-23 18:55:12 +08:00
if timeScale != "" {
text += " Times were written in UTC (original scale: " + timeScale + ")."
}
return text
}
text := "由 GeoJSON 转换而来。图层按 role 分组;红:日食中心带;黑:中心线;绿:日食食甚等时线;黄:食分线与掩星全掩/半掩带;橙:日出日落阶段线(初亏/食甚/复圆)。"
2026-10-15 23:54:15 +08:00
if transitOnly {
text = "由 GeoJSON 转换而来。图层按 role 分组;凌日可见分区、一触/四触视地平线、C1–C4 站心等时线与凌甚点分别成层。"
} else if transitMixed {
text = "由 GeoJSON 转换而来。图层按事件和 role 分组。"
}
2026-09-23 18:55:12 +08:00
if timeScale != "" {
text += "时间已按 UTC 写入(原口径:" + timeScale + ")。"
}
return text
}
var roleNames = map [ string ][ 2 ] string {
"central-band" : { "中心带" , "Central band" },
"central-shadow" : { "中心影" , "Central shadow" },
"central-shadow-footprint" : { "中心影轮廓" , "Central shadow footprint" },
"central-shadow-sweep" : { "中心影扫掠" , "Central shadow sweep" },
"central-shadow-boundary" : { "中心影边界" , "Central shadow boundary" },
"center-line" : { "中心线" , "Center line" },
"north-limit" : { "北限" , "Northern limit" },
"south-limit" : { "南限" , "Southern limit" },
"north-total-limit" : { "全带北限" , "Northern total limit" },
"south-total-limit" : { "全带南限" , "Southern total limit" },
"band-outline" : { "带边界" , "Band outline" },
"total-band-outline" : { "全带边界" , "Total band outline" },
"partial-footprint" : { "偏食足迹" , "Partial footprint" },
"total-footprint" : { "全食足迹" , "Total footprint" },
"occultation-band" : { "掩带" , "Occultation band" },
"occultation-footprint" : { "掩星足迹" , "Occultation footprint" },
"visible-footprint-sweep" : { "可见足迹扫掠" , "Visible footprint sweep" },
"greatest" : { "食甚" , "Greatest eclipse" },
"greatest-time-line" : { "食甚等时线" , "Greatest-time isochrone" },
"magnitude-line" : { "食分线" , "Magnitude contour" },
"magnitude-one-envelope" : { "食分 1 包络" , "Magnitude-1 envelope" },
"visibility-boundary" : { "日出日落阶段线" , "Rise/set phase lines" },
"p1-horizon" : { "P1 地平线" , "P1 horizon" },
"p4-horizon" : { "P4 地平线" , "P4 horizon" },
"visible-at-p1" : { "P1 可见点" , "Visible at P1" },
"visible-at-p4" : { "P4 可见点" , "Visible at P4" },
"visible-during-eclipse" : { "食内任意时刻可见区" , "Visible at some time during eclipse" },
"visible-throughout-eclipse" : { "食内全程可见区" , "Visible throughout eclipse" },
"penumbra-moonrise" : { "半影月出" , "Penumbra moonrise" },
"penumbra-moonset" : { "半影月落" , "Penumbra moonset" },
"horizon-connector" : { "地平连接线" , "Horizon connector" },
"target-horizon" : { "目标地平线" , "Target horizon" },
"time-marker" : { "时间标记" , "Time markers" },
"besselian-critical-envelope" : { "贝塞尔临界包络" , "Besselian critical envelope" },
"sublunar-track" : { "月下点轨迹" , "Sublunar track" },
2026-10-15 23:54:15 +08:00
"subsolar-track" : { "太阳直射点轨迹" , "Subsolar track" },
"c1-horizon" : { "一触视地平线" , "C1 apparent horizon" },
"c4-horizon" : { "四触视地平线" , "C4 apparent horizon" },
"visible-at-c1" : { "一触可见区" , "Visible at C1" },
"visible-at-c4" : { "四触可见区" , "Visible at C4" },
"transit-entire" : { "全程可见" , "Entire transit visible" },
"transit-at-sunrise" : { "日出时已在凌日" , "Transit in progress at sunrise" },
"transit-at-sunset" : { "日落时仍在凌日" , "Transit in progress at sunset" },
"c1-isochrone" : { "一触等时线" , "C1 isochrones" },
"c2-isochrone" : { "二触等时线" , "C2 isochrones" },
"c3-isochrone" : { "三触等时线" , "C3 isochrones" },
"c4-isochrone" : { "四触等时线" , "C4 isochrones" },
2026-09-23 18:55:12 +08:00
}
func localizedRole ( event , role , language string ) string {
2026-10-15 23:54:15 +08:00
if role == "greatest" && event == eventPlanetTransit {
if language == "en" {
return "Greatest transit"
}
return "凌甚"
}
2026-09-23 18:55:12 +08:00
if language == "en" {
if name , ok := roleNames [ role ]; ok {
return name [ 1 ]
}
return strings . ReplaceAll ( role , "-" , " " )
}
if name , ok := roleNames [ role ]; ok {
return name [ 0 ]
}
if role == "partial-band" && event == eventLunarOccultation {
return "半掩带"
}
if role == "total-band" && event == eventLunarOccultation {
return "全掩带"
}
if role == "partial-band" {
return "偏食带"
}
return role
}
func localizedEvent ( event , language string ) string {
switch event {
case eventSolarEclipse :
if language == "en" {
return "Solar eclipse"
}
return "日食"
case eventLunarEclipse :
if language == "en" {
return "Lunar eclipse"
}
return "月食"
case eventLunarOccultation :
if language == "en" {
return "Lunar occultation"
}
return "月掩"
2026-10-15 23:54:15 +08:00
case eventPlanetTransit :
if language == "en" {
return "Planetary transit"
}
return "凌日"
2026-09-23 18:55:12 +08:00
}
if event == "" {
if language == "en" {
return "GeoJSON"
}
return "GeoJSON"
}
return event
}