Files

1478 lines
48 KiB
Go
Raw Permalink Normal View History

// 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
}