Files
astro/kml/kml.go
T
b612 16c62a97d5 feat: 完善时标与天象几何计算并扩展输出接口
- 新增时标、ΔT 模型、质心时间与 UT1 支持
- 改进日月食、月掩、行星事件及路径边界计算
- 完善恒星三维自行与动态距离传播
- 扩展 SVG、GeoJSON、KML 输出与底层距离换算工具
- 整理中英文手册、示例资源及回归测试
2026-09-23 18:55:12 +08:00

1478 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}