16c62a97d5
- 新增时标、ΔT 模型、质心时间与 UT1 支持 - 改进日月食、月掩、行星事件及路径边界计算 - 完善恒星三维自行与动态距离传播 - 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具 - 整理中英文手册、示例资源及回归测试
1478 lines
48 KiB
Go
1478 lines
48 KiB
Go
// 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。
|
||
// 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"
|
||
)
|
||
|
||
const (
|
||
eventSolarEclipse = "solar-eclipse"
|
||
eventLunarEclipse = "lunar-eclipse"
|
||
eventLunarOccultation = "lunar-occultation"
|
||
)
|
||
|
||
// 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)
|
||
built.Description = descriptionText(options, language, collection.TimeScale)
|
||
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)
|
||
}
|
||
return []element{{kind: "Polygon", rings: rings}}, nil
|
||
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 {
|
||
elements = append(elements, element{kind: "Polygon", rings: rings})
|
||
}
|
||
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
|
||
}
|
||
|
||
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}
|
||
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
|
||
}
|
||
|
||
func descriptionText(options Options, language, timeScale string) string {
|
||
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."
|
||
if timeScale != "" {
|
||
text += " Times were written in UTC (original scale: " + timeScale + ")."
|
||
}
|
||
return text
|
||
}
|
||
text := "由 GeoJSON 转换而来。图层按 role 分组;红:日食中心带;黑:中心线;绿:日食食甚等时线;黄:食分线与掩星全掩/半掩带;橙:日出日落阶段线(初亏/食甚/复圆)。"
|
||
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"},
|
||
}
|
||
|
||
func localizedRole(event, role, language string) string {
|
||
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 "月掩"
|
||
}
|
||
if event == "" {
|
||
if language == "en" {
|
||
return "GeoJSON"
|
||
}
|
||
return "GeoJSON"
|
||
}
|
||
return event
|
||
}
|