@metweave/leaflet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/index.cjs +250 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +45 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +226 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 YaoJaro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @metweave/leaflet
|
|
2
|
+
|
|
3
|
+
metweave 的 Leaflet 适配器:报文卡片上图——marker、tooltip 与卡片弹窗一次调用全就位。
|
|
4
|
+
|
|
5
|
+
### 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @metweave/leaflet leaflet
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### 最小示例
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { addMetarLayer } from "@metweave/leaflet";
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
底图瓦片仍是宿主侧一行 `L.tileLayer` 配置——本包不绑定任何底图。其他地图库(MapLibre 等)的适配器在路线图上。文档与端到端示例见[主仓库](https://github.com/yaojaro/metweave)。
|
|
18
|
+
|
|
19
|
+
## 许可
|
|
20
|
+
|
|
21
|
+
[MIT](https://github.com/yaojaro/metweave/blob/main/LICENSE) © 2026 YaoJaro——授权仅覆盖本仓库的代码与文档;随包分发的报文样本不在其列,详见主仓库 README 的「许可」说明。
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
+
key = keys[i];
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
13
|
+
get: ((k) => from[k]).bind(null, key),
|
|
14
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
20
|
+
value: mod,
|
|
21
|
+
enumerable: true
|
|
22
|
+
}) : target, mod));
|
|
23
|
+
//#endregion
|
|
24
|
+
let leaflet = require("leaflet");
|
|
25
|
+
leaflet = __toESM(leaflet, 1);
|
|
26
|
+
let _metweave_core = require("@metweave/core");
|
|
27
|
+
let _metweave_render = require("@metweave/render");
|
|
28
|
+
//#region src/index.ts
|
|
29
|
+
/**
|
|
30
|
+
* @metweave/leaflet — Leaflet 适配器:报文卡片上图的桥接层。
|
|
31
|
+
* The Leaflet adapter: puts metweave report cards onto a Leaflet map.
|
|
32
|
+
*
|
|
33
|
+
* v0.1 唯一地图适配器;更多地图库适配(MapLibre 等)在路线图上。
|
|
34
|
+
* 兼容契约 = Leaflet overlay 能力:底图瓦片是宿主侧一行 L.tileLayer 配置,本包不做任何绑定,
|
|
35
|
+
* 也不推荐或代理任何底图服务——底图由使用者按自己的授权自行选择。
|
|
36
|
+
*/
|
|
37
|
+
/** addMetarLayer 的合法选项键(运行时校验用——拼错的选项键静默忽略违反本库不静默纪律) */
|
|
38
|
+
const ADD_METAR_LAYER_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
39
|
+
"locale",
|
|
40
|
+
"popup",
|
|
41
|
+
"card",
|
|
42
|
+
"conditionColors"
|
|
43
|
+
]);
|
|
44
|
+
/**
|
|
45
|
+
* tooltip 纯文本承载:Leaflet 对 string 内容走 innerHTML——宿主自传的 title 是任意串,
|
|
46
|
+
* 经由 textContent 装载(非 HTML 路径)杜绝标记注入;文案渲染不受影响。
|
|
47
|
+
*/
|
|
48
|
+
function textCarrier(text) {
|
|
49
|
+
const el = document.createElement("span");
|
|
50
|
+
el.textContent = text;
|
|
51
|
+
return el;
|
|
52
|
+
}
|
|
53
|
+
/** HTML 属性上下文转义(divIcon 的 html 字符串构造用——title/站名不可信面) */
|
|
54
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, (ch) => {
|
|
55
|
+
switch (ch) {
|
|
56
|
+
case "&": return "&";
|
|
57
|
+
case "<": return "<";
|
|
58
|
+
case ">": return ">";
|
|
59
|
+
case "\"": return """;
|
|
60
|
+
default: return "'";
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
/** 四档条件色(与卡片 mw-danger/mw-caution 同族色相:灰=不明、红=差、琥珀=注意、绿=好) */
|
|
64
|
+
const TIER_COLORS = {
|
|
65
|
+
unknown: "#8a94a0",
|
|
66
|
+
poor: "#d05656",
|
|
67
|
+
caution: "#e0a13c",
|
|
68
|
+
good: "#3aa657"
|
|
69
|
+
};
|
|
70
|
+
/** 档位可读名(aria-label 追加词——a11y 1.4.1:档位信息不只靠颜色传达) */
|
|
71
|
+
const TIER_WORDS = {
|
|
72
|
+
zh: {
|
|
73
|
+
unknown: "天气不明",
|
|
74
|
+
poor: "天气差",
|
|
75
|
+
caution: "天气注意",
|
|
76
|
+
good: "天气好"
|
|
77
|
+
},
|
|
78
|
+
en: {
|
|
79
|
+
unknown: "Weather unknown",
|
|
80
|
+
poor: "Weather poor",
|
|
81
|
+
caution: "Weather caution",
|
|
82
|
+
good: "Weather good"
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
/** 降水类现象(caution 判据用,与 WMO 4678 降水族对应——RA/SN/SG/PL/GS/IC/DZ/UP) */
|
|
86
|
+
const PRECIP_PHENOMENA = /* @__PURE__ */ new Set([
|
|
87
|
+
"RA",
|
|
88
|
+
"SN",
|
|
89
|
+
"SG",
|
|
90
|
+
"PL",
|
|
91
|
+
"GS",
|
|
92
|
+
"IC",
|
|
93
|
+
"DZ",
|
|
94
|
+
"UP"
|
|
95
|
+
]);
|
|
96
|
+
/** 风速折米/秒(阵风判据统一单位:kt ×0.514444、kmh ÷3.6、mps ×1) */
|
|
97
|
+
const toMps = (value, unit) => unit === "kt" ? value * .514444 : unit === "kmh" ? value / 3.6 : value;
|
|
98
|
+
/**
|
|
99
|
+
* 四档气象条件分级(判据本库自拟、初稿待审——显示层扫视启发式):
|
|
100
|
+
* 阈值由本库拟定,**不对应也不代表任何官方飞行天气分类;本库不提供飞行规则判定**(本期无此功能)。
|
|
101
|
+
* 仅供「一眼扫视哪些站值得注意」,不得作为任何运行判据:
|
|
102
|
+
* - unknown(灰)= NIL(台站无观测)或关键组全缺测(能见度与云均缺测且天气缺测/无——按可得要素无从判读)
|
|
103
|
+
* - poor(红)= 能见度 < 1500 m,或云底 < 1000 ft,或天气含 TS 族(任何雷暴,含 VC 邻近)
|
|
104
|
+
* 或现象含 GR/VA,或 + 强度显著降水,或阵风 ≥ 25 m/s,或云组含 CB/TCU,或跑道关闭
|
|
105
|
+
* - caution(琥珀)= 能见度 1500–4800 m,或云底 1000–3000 ft,或任何降水族(RA/SN 等),
|
|
106
|
+
* 或 FZ 族(结冰),或阵风 15–25 m/s
|
|
107
|
+
* - good(绿)= 其余(含 CAVOK)
|
|
108
|
+
* 缺测要素不参与限制(按可得要素判,见 conditionOf 内 unknown 判据的例外);阈值细则随口径审定后修订。
|
|
109
|
+
*/
|
|
110
|
+
function conditionOf(report) {
|
|
111
|
+
if (report.nil === true) return "unknown";
|
|
112
|
+
const v = (0, _metweave_core.toValues)(report);
|
|
113
|
+
const visMissing = report.visibility?.kind === "missing";
|
|
114
|
+
const elements = v.clouds?.elements ?? [];
|
|
115
|
+
const cloudsAllMissing = !report.cavok && elements.every((e) => e.heightFt.value === null && (e.kind === "vertical-visibility" || e.amount === null));
|
|
116
|
+
const weatherMissingOrNone = report.weather === void 0 || report.weather.kind === "missing" || report.weather.kind === "value" && report.weather.value.length === 0;
|
|
117
|
+
if (visMissing && cloudsAllMissing && weatherMissingOrNone) return "unknown";
|
|
118
|
+
const vis = v.visibility;
|
|
119
|
+
if (vis !== void 0) {
|
|
120
|
+
if ((vis.unit === "m" ? vis.value : vis.value * 1609.344) < 1500) return "poor";
|
|
121
|
+
}
|
|
122
|
+
const ceilings = elements.filter((e) => e.kind === "layer").filter((e) => e.amount === "BKN" || e.amount === "OVC").map((e) => e.heightFt.value ?? Number.POSITIVE_INFINITY);
|
|
123
|
+
for (const e of elements) if (e.kind === "vertical-visibility") ceilings.push(e.heightFt.value ?? Number.POSITIVE_INFINITY);
|
|
124
|
+
const ceiling = ceilings.length > 0 ? Math.min(...ceilings) : Number.POSITIVE_INFINITY;
|
|
125
|
+
if (ceiling < 1e3) return "poor";
|
|
126
|
+
for (const g of v.weather ?? []) {
|
|
127
|
+
const thunderstorm = g.descriptor === "TS";
|
|
128
|
+
const hailOrAsh = g.phenomena.includes("GR") || g.phenomena.includes("VA");
|
|
129
|
+
const heavyPrecip = g.intensity === "+" && (g.descriptor === "SH" || g.phenomena.some((p) => PRECIP_PHENOMENA.has(p)));
|
|
130
|
+
if (thunderstorm || hailOrAsh || heavyPrecip) return "poor";
|
|
131
|
+
}
|
|
132
|
+
const gust = v.wind?.gust;
|
|
133
|
+
if (gust !== void 0 && toMps(gust.value, gust.unit) >= 25) return "poor";
|
|
134
|
+
if (elements.some((e) => e.kind === "layer" && e.convective !== void 0)) return "poor";
|
|
135
|
+
if (v.runwayStates.some((st) => st.closed === true)) return "poor";
|
|
136
|
+
if (vis !== void 0) {
|
|
137
|
+
if ((vis.unit === "m" ? vis.value : vis.value * 1609.344) < 4800) return "caution";
|
|
138
|
+
}
|
|
139
|
+
if (ceiling < 3e3) return "caution";
|
|
140
|
+
for (const g of v.weather ?? []) {
|
|
141
|
+
const freezing = g.descriptor === "FZ";
|
|
142
|
+
const precip = g.phenomena.some((p) => PRECIP_PHENOMENA.has(p));
|
|
143
|
+
if (freezing || precip) return "caution";
|
|
144
|
+
}
|
|
145
|
+
if (gust !== void 0 && toMps(gust.value, gust.unit) >= 15) return "caution";
|
|
146
|
+
return "good";
|
|
147
|
+
}
|
|
148
|
+
/** 天气组显示码:span 在位取原码,缺席由 IR 重建(摘要行的要素原样口径) */
|
|
149
|
+
function weatherCode(report, g) {
|
|
150
|
+
return g.span === void 0 ? `${g.proximity ? "VC" : ""}${g.intensity ?? ""}${g.descriptor ?? ""}${g.phenomena.join("")}` : report.raw.slice(g.span.start, g.span.end);
|
|
151
|
+
}
|
|
152
|
+
/** 云层显示码:span 在位取原码,缺席由 IR 重建(缺测位还原为 ///) */
|
|
153
|
+
function cloudCode(report, layer) {
|
|
154
|
+
if (layer.span === void 0) {
|
|
155
|
+
if (layer.kind === "vertical-visibility") return `VV${layer.heightFt.value === null ? "///" : String(Math.round(layer.heightFt.value / 100)).padStart(3, "0")}`;
|
|
156
|
+
const height = layer.heightFt.value === null ? "///" : String(Math.round(layer.heightFt.value / 100)).padStart(3, "0");
|
|
157
|
+
return `${layer.amount ?? "///"}${height}${layer.convective ?? ""}`;
|
|
158
|
+
}
|
|
159
|
+
return report.raw.slice(layer.span.start, layer.span.end);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* tooltip 第二行要素摘要(扫视初筛):`2500m +TSRA BKN030CB` 式——
|
|
163
|
+
* 能见度 / 最显著天气(优先 TS/GR/+ 强度族)/ 最差云(对流云优先,否则最低 BKN/OVC,VV 兜底)。
|
|
164
|
+
* NIL 站显示「缺报(NIL)」、关键组全缺测站显示「数据缺测」(与 conditionOf 的 unknown 判据同款口径)。
|
|
165
|
+
*/
|
|
166
|
+
function summarizeReport(report, locale) {
|
|
167
|
+
if (report.nil === true) return locale === "en" ? "No report (NIL)" : "缺报(NIL)";
|
|
168
|
+
if (report.cavok) return "CAVOK";
|
|
169
|
+
const v = (0, _metweave_core.toValues)(report);
|
|
170
|
+
const elements = v.clouds?.elements ?? [];
|
|
171
|
+
const visMissing = report.visibility?.kind === "missing";
|
|
172
|
+
const cloudsAllMissing = !report.cavok && elements.every((e) => e.heightFt.value === null && (e.kind === "vertical-visibility" || e.amount === null));
|
|
173
|
+
const weatherMissingOrNone = report.weather === void 0 || report.weather.kind === "missing" || report.weather.kind === "value" && report.weather.value.length === 0;
|
|
174
|
+
if (visMissing && cloudsAllMissing && weatherMissingOrNone) return locale === "en" ? "Data missing" : "数据缺测";
|
|
175
|
+
const parts = [];
|
|
176
|
+
const vis = v.visibility;
|
|
177
|
+
if (vis !== void 0) parts.push(vis.unit === "m" ? vis.exact ? `${vis.value} m` : "≥10 km" : vis.beyond === "below" ? `<${vis.value} SM` : vis.beyond === "above" ? `>${vis.value} SM` : `${vis.value} SM`);
|
|
178
|
+
const weather = v.weather ?? [];
|
|
179
|
+
const significant = weather.find((g) => g.descriptor === "TS" || g.phenomena.includes("GR") || g.intensity === "+") ?? weather[0];
|
|
180
|
+
if (significant !== void 0) parts.push(weatherCode(report, significant));
|
|
181
|
+
const layers = v.clouds?.elements ?? [];
|
|
182
|
+
const convective = layers.find((e) => e.kind === "layer" && e.convective !== void 0);
|
|
183
|
+
const ceilingLayer = layers.filter((e) => e.kind === "layer").filter((e) => e.amount === "BKN" || e.amount === "OVC").reduce((lowest, e) => lowest === void 0 || (e.heightFt.value ?? Number.POSITIVE_INFINITY) < (lowest.heightFt.value ?? Number.POSITIVE_INFINITY) ? e : lowest, void 0);
|
|
184
|
+
const vertical = layers.find((e) => e.kind === "vertical-visibility");
|
|
185
|
+
const worstCloud = convective ?? ceilingLayer ?? vertical;
|
|
186
|
+
if (worstCloud !== void 0) parts.push(cloudCode(report, worstCloud));
|
|
187
|
+
else if (v.clouds?.clear !== void 0) parts.push(v.clouds.clear.code);
|
|
188
|
+
return parts.join(" ");
|
|
189
|
+
}
|
|
190
|
+
/** tooltip 内容节点:标题行 + 要素摘要行(两行皆纯文本装载;locale 决定摘要占位词语言) */
|
|
191
|
+
function tooltipContent(item, withSummary, locale) {
|
|
192
|
+
const container = document.createElement("span");
|
|
193
|
+
container.append(textCarrier(item.title ?? item.report.station));
|
|
194
|
+
if (withSummary) container.append(document.createElement("br"), textCarrier(summarizeReport(item.report, locale)));
|
|
195
|
+
return container;
|
|
196
|
+
}
|
|
197
|
+
/** Escape 关闭已开弹窗的监听只挂一次/地图(多图层叠加不重复绑定) */
|
|
198
|
+
const escapeBoundMaps = /* @__PURE__ */ new WeakSet();
|
|
199
|
+
/**
|
|
200
|
+
* Put a set of report stations onto a Leaflet map: markers + tooltips + card popups. Returns a removable layer group.
|
|
201
|
+
* 把一组报文站点挂上地图:标记 + tooltip + 卡片弹窗。返回可整体移除的图层组。
|
|
202
|
+
* @param map - A Leaflet map instance. Leaflet 地图实例。
|
|
203
|
+
* @param items - Stations to plot (report + position + title). 待上图的站点列表。
|
|
204
|
+
* @param options - See AddMetarLayerOptions. 见 AddMetarLayerOptions。
|
|
205
|
+
*/
|
|
206
|
+
function addMetarLayer(map, items, options = {}) {
|
|
207
|
+
for (const key of Object.keys(options)) if (!ADD_METAR_LAYER_OPTION_KEYS.has(key)) throw new Error(`addMetarLayer 收到未知选项 "${key}"——卡片级选项(locale/raw/className…)需包在 card 里传,可用项见 AddMetarLayerOptions`);
|
|
208
|
+
const locale = options.card?.locale ?? options.locale ?? "zh";
|
|
209
|
+
if (locale !== "zh" && locale !== "en") throw new Error(`addMetarLayer 的 locale 选项值 "${locale}" 不受支持(可用:"zh" | "en")`);
|
|
210
|
+
const group = leaflet.layerGroup();
|
|
211
|
+
for (const item of items) {
|
|
212
|
+
const name = item.title ?? item.report.station;
|
|
213
|
+
let marker;
|
|
214
|
+
if (options.conditionColors === true) {
|
|
215
|
+
const tier = conditionOf(item.report);
|
|
216
|
+
const label = `${name} · ${TIER_WORDS[locale][tier]}`;
|
|
217
|
+
marker = leaflet.marker(item.position, { icon: leaflet.divIcon({
|
|
218
|
+
className: "mw-cond-icon",
|
|
219
|
+
html: `<span role="img" aria-label="${escapeHtml(label)}" class="mw-dot mw-dot-${tier}" style="display:inline-block;width:12px;height:12px;border-radius:50%;background:${TIER_COLORS[tier]};border:2px solid #fff;box-shadow:0 0 2px rgba(0,0,0,.4)"></span>`,
|
|
220
|
+
iconSize: [14, 14],
|
|
221
|
+
iconAnchor: [7, 7]
|
|
222
|
+
}) });
|
|
223
|
+
} else marker = leaflet.marker(item.position, { alt: name });
|
|
224
|
+
marker.bindTooltip(tooltipContent(item, options.conditionColors === true, locale));
|
|
225
|
+
if (options.popup ?? true) {
|
|
226
|
+
const cardOpts = { ...options.card };
|
|
227
|
+
if (cardOpts.locale === void 0 && options.locale !== void 0) cardOpts.locale = options.locale;
|
|
228
|
+
const stationName = item.title !== void 0 && item.title.startsWith(`${item.report.station} `) ? item.title.slice(item.report.station.length + 1) : void 0;
|
|
229
|
+
if (cardOpts.stationTitle === void 0 && stationName !== void 0) cardOpts.stationTitle = stationName;
|
|
230
|
+
marker.bindPopup((0, _metweave_render.renderCard)(item.report, cardOpts), { maxWidth: 420 });
|
|
231
|
+
marker.on("popupopen", () => {
|
|
232
|
+
marker.closeTooltip();
|
|
233
|
+
(marker.getPopup()?.getElement()?.querySelector("a.leaflet-popup-close-button, button, [href]"))?.focus();
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
marker.addTo(group);
|
|
237
|
+
}
|
|
238
|
+
if (!escapeBoundMaps.has(map)) {
|
|
239
|
+
escapeBoundMaps.add(map);
|
|
240
|
+
map.getContainer().addEventListener("keydown", (event) => {
|
|
241
|
+
if (event.key === "Escape") map.closePopup();
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
group.addTo(map);
|
|
245
|
+
return group;
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
exports.addMetarLayer = addMetarLayer;
|
|
249
|
+
|
|
250
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["toValues","bad","L","renderCard"],"sources":["../src/index.ts"],"sourcesContent":["/**\n * @metweave/leaflet — Leaflet 适配器:报文卡片上图的桥接层。\n * The Leaflet adapter: puts metweave report cards onto a Leaflet map.\n *\n * v0.1 唯一地图适配器;更多地图库适配(MapLibre 等)在路线图上。\n * 兼容契约 = Leaflet overlay 能力:底图瓦片是宿主侧一行 L.tileLayer 配置,本包不做任何绑定,\n * 也不推荐或代理任何底图服务——底图由使用者按自己的授权自行选择。\n */\nimport * as L from \"leaflet\";\nimport type { CloudElement, MetarReport, WeatherGroup } from \"@metweave/core\";\nimport { toValues } from \"@metweave/core\";\nimport { renderCard } from \"@metweave/render\";\n\n/**\n * One station on the map: parsed report IR + WGS-84 position + optional tooltip title.\n * 地图上的一个站点:已解析报文 IR + WGS-84 坐标 + 可选悬浮标题。\n */\nexport interface MetarLayerItem {\n /** 已解析的报文 IR */\n report: MetarReport;\n /** 站点坐标(WGS-84 [lat, lon];用 GCJ-02 底图时的偏移由宿主决定是否校正) */\n position: [number, number];\n /** 站点提示(tooltip 文案,缺省用 IR 站名)。按纯文本处理——不解析 HTML,宿主自传任意串无注入面 */\n title?: string;\n}\n\n/**\n * Options for addMetarLayer: popup behavior, pass-through renderCard options, and the condition-color dot mode.\n * addMetarLayer 的选项:弹窗行为、透传 renderCard 的选项、条件色圆点模式。\n */\nexport interface AddMetarLayerOptions {\n /** 显示语言速记:等价 `card.locale`,同时决定 tooltip 摘要/档位词与弹窗卡片语言;\n * card.locale 显式传入时以其为准。未知选项运行时抛错(拼写错误不静默) */\n locale?: \"zh\" | \"en\";\n /** 点击站点时以弹窗展示报文卡片(缺省开启) */\n popup?: boolean;\n /** 传给 renderCard 的选项 */\n card?: Parameters<typeof renderCard>[1];\n /** marker 按四档气象条件着色(unknown/poor/caution/good 圆点替代默认图钉;缺省 false 保持现状)。\n * 判据为本库自拟的扫视启发式(初稿待审,见 conditionOf 注释)——不对应任何官方飞行天气分类,\n * 不得用作运行判据;\n * 开启时 tooltip 同时追加一行要素摘要(能见度/天气/最差云;NIL 与关键组全缺测站显示「缺报/数据缺测」),\n * 圆点 aria-label 追加档位词(zh「天气好/差…」/ en「Weather …」,语言随 card.locale) */\n conditionColors?: boolean;\n}\n\n/** addMetarLayer 的合法选项键(运行时校验用——拼错的选项键静默忽略违反本库不静默纪律) */\nconst ADD_METAR_LAYER_OPTION_KEYS: ReadonlySet<string> = new Set([\n \"locale\",\n \"popup\",\n \"card\",\n \"conditionColors\",\n]);\n\n/**\n * tooltip 纯文本承载:Leaflet 对 string 内容走 innerHTML——宿主自传的 title 是任意串,\n * 经由 textContent 装载(非 HTML 路径)杜绝标记注入;文案渲染不受影响。\n */\nfunction textCarrier(text: string): HTMLElement {\n const el = document.createElement(\"span\");\n el.textContent = text;\n return el;\n}\n\n/** HTML 属性上下文转义(divIcon 的 html 字符串构造用——title/站名不可信面) */\nconst escapeHtml = (text: string): string =>\n text.replace(/[&<>\"']/g, (ch) => {\n switch (ch) {\n case \"&\":\n return \"&\";\n case \"<\":\n return \"<\";\n case \">\":\n return \">\";\n case '\"':\n return \""\";\n default:\n return \"'\";\n }\n });\n\n/** 四档条件色(与卡片 mw-danger/mw-caution 同族色相:灰=不明、红=差、琥珀=注意、绿=好) */\nconst TIER_COLORS: Record<ConditionTier, string> = {\n unknown: \"#8a94a0\",\n poor: \"#d05656\",\n caution: \"#e0a13c\",\n good: \"#3aa657\",\n};\n\ntype ConditionTier = \"unknown\" | \"poor\" | \"caution\" | \"good\";\n\n/** 档位可读名(aria-label 追加词——a11y 1.4.1:档位信息不只靠颜色传达) */\nconst TIER_WORDS: Record<\"zh\" | \"en\", Record<ConditionTier, string>> = {\n zh: { unknown: \"天气不明\", poor: \"天气差\", caution: \"天气注意\", good: \"天气好\" },\n en: {\n unknown: \"Weather unknown\",\n poor: \"Weather poor\",\n caution: \"Weather caution\",\n good: \"Weather good\",\n },\n};\n\n/** 降水类现象(caution 判据用,与 WMO 4678 降水族对应——RA/SN/SG/PL/GS/IC/DZ/UP) */\nconst PRECIP_PHENOMENA: ReadonlySet<string> = new Set([\n \"RA\",\n \"SN\",\n \"SG\",\n \"PL\",\n \"GS\",\n \"IC\",\n \"DZ\",\n \"UP\",\n]);\n\n/** 风速折米/秒(阵风判据统一单位:kt ×0.514444、kmh ÷3.6、mps ×1) */\nconst toMps = (value: number, unit: \"kt\" | \"mps\" | \"kmh\"): number =>\n unit === \"kt\" ? value * 0.514444 : unit === \"kmh\" ? value / 3.6 : value;\n\n/**\n * 四档气象条件分级(判据本库自拟、初稿待审——显示层扫视启发式):\n * 阈值由本库拟定,**不对应也不代表任何官方飞行天气分类;本库不提供飞行规则判定**(本期无此功能)。\n * 仅供「一眼扫视哪些站值得注意」,不得作为任何运行判据:\n * - unknown(灰)= NIL(台站无观测)或关键组全缺测(能见度与云均缺测且天气缺测/无——按可得要素无从判读)\n * - poor(红)= 能见度 < 1500 m,或云底 < 1000 ft,或天气含 TS 族(任何雷暴,含 VC 邻近)\n * 或现象含 GR/VA,或 + 强度显著降水,或阵风 ≥ 25 m/s,或云组含 CB/TCU,或跑道关闭\n * - caution(琥珀)= 能见度 1500–4800 m,或云底 1000–3000 ft,或任何降水族(RA/SN 等),\n * 或 FZ 族(结冰),或阵风 15–25 m/s\n * - good(绿)= 其余(含 CAVOK)\n * 缺测要素不参与限制(按可得要素判,见 conditionOf 内 unknown 判据的例外);阈值细则随口径审定后修订。\n */\nfunction conditionOf(report: MetarReport): ConditionTier {\n if (report.nil === true) return \"unknown\";\n const v = toValues(report);\n // 关键组全缺测:能见度与云均缺测(云组每个体均为全缺测形态)且天气缺测/无——判读无从下手,灰而非绿\n const visMissing = report.visibility?.kind === \"missing\";\n const elements = v.clouds?.elements ?? [];\n const cloudsAllMissing =\n !report.cavok &&\n elements.every(\n (e) => e.heightFt.value === null && (e.kind === \"vertical-visibility\" || e.amount === null),\n );\n const weatherMissingOrNone =\n report.weather === undefined ||\n report.weather.kind === \"missing\" ||\n (report.weather.kind === \"value\" && report.weather.value.length === 0);\n if (visMissing && cloudsAllMissing && weatherMissingOrNone) return \"unknown\";\n\n // —— poor 判据(任一命中即红)\n const vis = v.visibility;\n if (vis !== undefined) {\n const visMeters = vis.unit === \"m\" ? vis.value : vis.value * 1609.344;\n if (visMeters < 1500) return \"poor\";\n }\n const ceilings = elements\n .filter((e): e is Extract<CloudElement, { kind: \"layer\" }> => e.kind === \"layer\")\n .filter((e) => e.amount === \"BKN\" || e.amount === \"OVC\")\n .map((e) => e.heightFt.value ?? Number.POSITIVE_INFINITY);\n for (const e of elements) {\n if (e.kind === \"vertical-visibility\")\n ceilings.push(e.heightFt.value ?? Number.POSITIVE_INFINITY);\n }\n const ceiling = ceilings.length > 0 ? Math.min(...ceilings) : Number.POSITIVE_INFINITY;\n if (ceiling < 1000) return \"poor\";\n for (const g of v.weather ?? []) {\n const thunderstorm = g.descriptor === \"TS\"; // TS 族:任何雷暴(含 VCTS 邻近雷暴)\n const hailOrAsh = g.phenomena.includes(\"GR\") || g.phenomena.includes(\"VA\");\n const heavyPrecip =\n g.intensity === \"+\" &&\n (g.descriptor === \"SH\" || g.phenomena.some((p) => PRECIP_PHENOMENA.has(p)));\n if (thunderstorm || hailOrAsh || heavyPrecip) return \"poor\";\n }\n const gust = v.wind?.gust;\n if (gust !== undefined && toMps(gust.value, gust.unit) >= 25) return \"poor\";\n const convective = elements.some(\n (e): e is Extract<CloudElement, { kind: \"layer\" }> =>\n e.kind === \"layer\" && e.convective !== undefined,\n );\n if (convective) return \"poor\";\n if (v.runwayStates.some((st) => st.closed === true)) return \"poor\";\n\n // —— caution 判据(任一命中即琥珀)\n if (vis !== undefined) {\n const visMeters = vis.unit === \"m\" ? vis.value : vis.value * 1609.344;\n if (visMeters < 4800) return \"caution\";\n }\n if (ceiling < 3000) return \"caution\";\n for (const g of v.weather ?? []) {\n const freezing = g.descriptor === \"FZ\";\n const precip = g.phenomena.some((p) => PRECIP_PHENOMENA.has(p));\n if (freezing || precip) return \"caution\";\n }\n if (gust !== undefined && toMps(gust.value, gust.unit) >= 15) return \"caution\";\n return \"good\";\n}\n\n/** 天气组显示码:span 在位取原码,缺席由 IR 重建(摘要行的要素原样口径) */\nfunction weatherCode(report: MetarReport, g: WeatherGroup): string {\n return g.span === undefined\n ? `${g.proximity ? \"VC\" : \"\"}${g.intensity ?? \"\"}${g.descriptor ?? \"\"}${g.phenomena.join(\"\")}`\n : report.raw.slice(g.span.start, g.span.end);\n}\n\n/** 云层显示码:span 在位取原码,缺席由 IR 重建(缺测位还原为 ///) */\nfunction cloudCode(report: MetarReport, layer: CloudElement): string {\n if (layer.span === undefined) {\n if (layer.kind === \"vertical-visibility\") {\n return `VV${layer.heightFt.value === null ? \"///\" : String(Math.round(layer.heightFt.value / 100)).padStart(3, \"0\")}`;\n }\n const height =\n layer.heightFt.value === null\n ? \"///\"\n : String(Math.round(layer.heightFt.value / 100)).padStart(3, \"0\");\n return `${layer.amount ?? \"///\"}${height}${layer.convective ?? \"\"}`;\n }\n return report.raw.slice(layer.span.start, layer.span.end);\n}\n\n/**\n * tooltip 第二行要素摘要(扫视初筛):`2500m +TSRA BKN030CB` 式——\n * 能见度 / 最显著天气(优先 TS/GR/+ 强度族)/ 最差云(对流云优先,否则最低 BKN/OVC,VV 兜底)。\n * NIL 站显示「缺报(NIL)」、关键组全缺测站显示「数据缺测」(与 conditionOf 的 unknown 判据同款口径)。\n */\nfunction summarizeReport(report: MetarReport, locale: \"zh\" | \"en\"): string {\n if (report.nil === true) return locale === \"en\" ? \"No report (NIL)\" : \"缺报(NIL)\";\n if (report.cavok) return \"CAVOK\";\n const v = toValues(report);\n // 关键组全缺测(能见度与云均缺测且天气缺测/无):要素摘要无从拼起,显示缺测占位而非空行\n const elements = v.clouds?.elements ?? [];\n const visMissing = report.visibility?.kind === \"missing\";\n const cloudsAllMissing =\n !report.cavok &&\n elements.every(\n (e) => e.heightFt.value === null && (e.kind === \"vertical-visibility\" || e.amount === null),\n );\n const weatherMissingOrNone =\n report.weather === undefined ||\n report.weather.kind === \"missing\" ||\n (report.weather.kind === \"value\" && report.weather.value.length === 0);\n if (visMissing && cloudsAllMissing && weatherMissingOrNone)\n return locale === \"en\" ? \"Data missing\" : \"数据缺测\";\n const parts: string[] = [];\n const vis = v.visibility;\n if (vis !== undefined) {\n parts.push(\n vis.unit === \"m\"\n ? vis.exact\n ? `${vis.value} m`\n : \"≥10 km\"\n : vis.beyond === \"below\"\n ? `<${vis.value} SM`\n : vis.beyond === \"above\"\n ? `>${vis.value} SM`\n : `${vis.value} SM`,\n );\n }\n const weather = v.weather ?? [];\n const significant =\n weather.find(\n (g) => g.descriptor === \"TS\" || g.phenomena.includes(\"GR\") || g.intensity === \"+\",\n ) ?? weather[0];\n if (significant !== undefined) parts.push(weatherCode(report, significant));\n const layers = v.clouds?.elements ?? [];\n const convective = layers.find(\n (e): e is Extract<CloudElement, { kind: \"layer\" }> =>\n e.kind === \"layer\" && e.convective !== undefined,\n );\n const ceilingLayer = layers\n .filter((e): e is Extract<CloudElement, { kind: \"layer\" }> => e.kind === \"layer\")\n .filter((e) => e.amount === \"BKN\" || e.amount === \"OVC\")\n .reduce<Extract<CloudElement, { kind: \"layer\" }> | undefined>(\n (lowest, e) =>\n lowest === undefined ||\n (e.heightFt.value ?? Number.POSITIVE_INFINITY) <\n (lowest.heightFt.value ?? Number.POSITIVE_INFINITY)\n ? e\n : lowest,\n undefined,\n );\n const vertical = layers.find((e) => e.kind === \"vertical-visibility\");\n const worstCloud = convective ?? ceilingLayer ?? vertical;\n if (worstCloud !== undefined) parts.push(cloudCode(report, worstCloud));\n else if (v.clouds?.clear !== undefined) parts.push(v.clouds.clear.code);\n return parts.join(\" \");\n}\n\n/** tooltip 内容节点:标题行 + 要素摘要行(两行皆纯文本装载;locale 决定摘要占位词语言) */\nfunction tooltipContent(\n item: MetarLayerItem,\n withSummary: boolean,\n locale: \"zh\" | \"en\",\n): HTMLElement {\n const container = document.createElement(\"span\");\n container.append(textCarrier(item.title ?? item.report.station));\n if (withSummary) {\n container.append(\n document.createElement(\"br\"),\n textCarrier(summarizeReport(item.report, locale)),\n );\n }\n return container;\n}\n\n/** Escape 关闭已开弹窗的监听只挂一次/地图(多图层叠加不重复绑定) */\nconst escapeBoundMaps = new WeakSet<L.Map>();\n\n/**\n * Put a set of report stations onto a Leaflet map: markers + tooltips + card popups. Returns a removable layer group.\n * 把一组报文站点挂上地图:标记 + tooltip + 卡片弹窗。返回可整体移除的图层组。\n * @param map - A Leaflet map instance. Leaflet 地图实例。\n * @param items - Stations to plot (report + position + title). 待上图的站点列表。\n * @param options - See AddMetarLayerOptions. 见 AddMetarLayerOptions。\n */\n\nexport function addMetarLayer(\n map: L.Map,\n items: readonly MetarLayerItem[],\n options: AddMetarLayerOptions = {},\n): L.LayerGroup {\n // 未知选项运行时抛错:拼写错误的选项被静默忽略 = 显示语言/行为悄悄不符预期(2026-09-15\n // 五角色评测实测:顶层 locale 此前被静默忽略,popup 整卡仍中文)。中文提示 = v0.1 message 语言契约。\n for (const key of Object.keys(options)) {\n if (!ADD_METAR_LAYER_OPTION_KEYS.has(key)) {\n throw new Error(\n `addMetarLayer 收到未知选项 \"${key}\"——卡片级选项(locale/raw/className…)需包在 card 里传,可用项见 AddMetarLayerOptions`,\n );\n }\n }\n // 语言解析单一出口:card.locale 显式传入 > 顶层 locale 速记 > zh;非法值清晰报错不裸崩\n const locale = options.card?.locale ?? options.locale ?? \"zh\";\n if (locale !== \"zh\" && locale !== \"en\") {\n // never 收窄后的宽化中转:模板表达式不接受 never 字面量类型\n const bad: string = locale;\n throw new Error(`addMetarLayer 的 locale 选项值 \"${bad}\" 不受支持(可用:\"zh\" | \"en\")`);\n }\n const group = L.layerGroup();\n for (const item of items) {\n const name = item.title ?? item.report.station;\n // alt 写入图标 img 的 alt 属性:marker 在读屏下 role=button,可访问名称 = 站名(WCAG 4.1.2)\n let marker: L.Marker;\n if (options.conditionColors === true) {\n // 圆点 divIcon 无 img——alt 失效,改以 role=img + aria-label 保住可访问名称(内容经 HTML 转义);\n // aria-label 追加档位词(a11y 1.4.1:档位不只靠颜色传达;语言随 card.locale,缺省中文)\n const tier = conditionOf(item.report);\n const label = `${name} · ${TIER_WORDS[locale][tier]}`;\n marker = L.marker(item.position, {\n icon: L.divIcon({\n className: \"mw-cond-icon\",\n html: `<span role=\"img\" aria-label=\"${escapeHtml(label)}\" class=\"mw-dot mw-dot-${tier}\" style=\"display:inline-block;width:12px;height:12px;border-radius:50%;background:${TIER_COLORS[tier]};border:2px solid #fff;box-shadow:0 0 2px rgba(0,0,0,.4)\"></span>`,\n iconSize: [14, 14],\n iconAnchor: [7, 7],\n }),\n });\n } else {\n marker = L.marker(item.position, { alt: name });\n }\n marker.bindTooltip(tooltipContent(item, options.conditionColors === true, locale));\n if (options.popup ?? true) {\n // 弹窗内容是 renderCard 的 DOM 元素(createElement/textContent 构建),本就走非 HTML 路径;\n // maxWidth 420 = 卡片设计宽(Leaflet 缺省 300 会压窄)\n // 元数据联表得到的站名随弹窗进卡片站名行(title = \"ICAO 站名\",剥掉 ICAO 前缀);调用方显式传入时以其为准\n const cardOpts: Parameters<typeof renderCard>[1] = { ...options.card };\n if (cardOpts.locale === undefined && options.locale !== undefined) {\n cardOpts.locale = options.locale;\n }\n const stationName =\n item.title !== undefined && item.title.startsWith(`${item.report.station} `)\n ? item.title.slice(item.report.station.length + 1)\n : undefined;\n if (cardOpts.stationTitle === undefined && stationName !== undefined) {\n cardOpts.stationTitle = stationName;\n }\n marker.bindPopup(renderCard(item.report, cardOpts), { maxWidth: 420 });\n marker.on(\"popupopen\", () => {\n // 触屏双浮层消除:弹窗打开即收起 tooltip\n marker.closeTooltip();\n // 焦点移入弹窗首个可聚焦元素(Leaflet 关闭按钮)——键盘与读屏可直达\n const focusTarget = marker\n .getPopup()\n ?.getElement()\n ?.querySelector<HTMLElement>(\"a.leaflet-popup-close-button, button, [href]\");\n focusTarget?.focus();\n });\n }\n marker.addTo(group);\n }\n if (!escapeBoundMaps.has(map)) {\n escapeBoundMaps.add(map);\n // 地图容器键盘路径:Escape 关闭已开弹窗(closePopup 对未开弹窗是空操作)\n map.getContainer().addEventListener(\"keydown\", (event) => {\n if (event.key === \"Escape\") map.closePopup();\n });\n }\n group.addTo(map);\n return group;\n}\n"],"mappings":"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"}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as L from "leaflet";
|
|
2
|
+
import { MetarReport } from "@metweave/core";
|
|
3
|
+
import { renderCard } from "@metweave/render";
|
|
4
|
+
//#region src/index.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* One station on the map: parsed report IR + WGS-84 position + optional tooltip title.
|
|
7
|
+
* 地图上的一个站点:已解析报文 IR + WGS-84 坐标 + 可选悬浮标题。
|
|
8
|
+
*/
|
|
9
|
+
export interface MetarLayerItem {
|
|
10
|
+
/** 已解析的报文 IR */
|
|
11
|
+
report: MetarReport;
|
|
12
|
+
/** 站点坐标(WGS-84 [lat, lon];用 GCJ-02 底图时的偏移由宿主决定是否校正) */
|
|
13
|
+
position: [number, number];
|
|
14
|
+
/** 站点提示(tooltip 文案,缺省用 IR 站名)。按纯文本处理——不解析 HTML,宿主自传任意串无注入面 */
|
|
15
|
+
title?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Options for addMetarLayer: popup behavior, pass-through renderCard options, and the condition-color dot mode.
|
|
19
|
+
* addMetarLayer 的选项:弹窗行为、透传 renderCard 的选项、条件色圆点模式。
|
|
20
|
+
*/
|
|
21
|
+
export interface AddMetarLayerOptions {
|
|
22
|
+
/** 显示语言速记:等价 `card.locale`,同时决定 tooltip 摘要/档位词与弹窗卡片语言;
|
|
23
|
+
* card.locale 显式传入时以其为准。未知选项运行时抛错(拼写错误不静默) */
|
|
24
|
+
locale?: "zh" | "en";
|
|
25
|
+
/** 点击站点时以弹窗展示报文卡片(缺省开启) */
|
|
26
|
+
popup?: boolean;
|
|
27
|
+
/** 传给 renderCard 的选项 */
|
|
28
|
+
card?: Parameters<typeof renderCard>[1];
|
|
29
|
+
/** marker 按四档气象条件着色(unknown/poor/caution/good 圆点替代默认图钉;缺省 false 保持现状)。
|
|
30
|
+
* 判据为本库自拟的扫视启发式(初稿待审,见 conditionOf 注释)——不对应任何官方飞行天气分类,
|
|
31
|
+
* 不得用作运行判据;
|
|
32
|
+
* 开启时 tooltip 同时追加一行要素摘要(能见度/天气/最差云;NIL 与关键组全缺测站显示「缺报/数据缺测」),
|
|
33
|
+
* 圆点 aria-label 追加档位词(zh「天气好/差…」/ en「Weather …」,语言随 card.locale) */
|
|
34
|
+
conditionColors?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Put a set of report stations onto a Leaflet map: markers + tooltips + card popups. Returns a removable layer group.
|
|
38
|
+
* 把一组报文站点挂上地图:标记 + tooltip + 卡片弹窗。返回可整体移除的图层组。
|
|
39
|
+
* @param map - A Leaflet map instance. Leaflet 地图实例。
|
|
40
|
+
* @param items - Stations to plot (report + position + title). 待上图的站点列表。
|
|
41
|
+
* @param options - See AddMetarLayerOptions. 见 AddMetarLayerOptions。
|
|
42
|
+
*/
|
|
43
|
+
export declare function addMetarLayer(map: L.Map, items: readonly MetarLayerItem[], options?: AddMetarLayerOptions): L.LayerGroup;
|
|
44
|
+
//#endregion
|
|
45
|
+
//# sourceMappingURL=index.d.cts.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as L from "leaflet";
|
|
2
|
+
import { MetarReport } from "@metweave/core";
|
|
3
|
+
import { renderCard } from "@metweave/render";
|
|
4
|
+
//#region src/index.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* One station on the map: parsed report IR + WGS-84 position + optional tooltip title.
|
|
7
|
+
* 地图上的一个站点:已解析报文 IR + WGS-84 坐标 + 可选悬浮标题。
|
|
8
|
+
*/
|
|
9
|
+
export interface MetarLayerItem {
|
|
10
|
+
/** 已解析的报文 IR */
|
|
11
|
+
report: MetarReport;
|
|
12
|
+
/** 站点坐标(WGS-84 [lat, lon];用 GCJ-02 底图时的偏移由宿主决定是否校正) */
|
|
13
|
+
position: [number, number];
|
|
14
|
+
/** 站点提示(tooltip 文案,缺省用 IR 站名)。按纯文本处理——不解析 HTML,宿主自传任意串无注入面 */
|
|
15
|
+
title?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Options for addMetarLayer: popup behavior, pass-through renderCard options, and the condition-color dot mode.
|
|
19
|
+
* addMetarLayer 的选项:弹窗行为、透传 renderCard 的选项、条件色圆点模式。
|
|
20
|
+
*/
|
|
21
|
+
export interface AddMetarLayerOptions {
|
|
22
|
+
/** 显示语言速记:等价 `card.locale`,同时决定 tooltip 摘要/档位词与弹窗卡片语言;
|
|
23
|
+
* card.locale 显式传入时以其为准。未知选项运行时抛错(拼写错误不静默) */
|
|
24
|
+
locale?: "zh" | "en";
|
|
25
|
+
/** 点击站点时以弹窗展示报文卡片(缺省开启) */
|
|
26
|
+
popup?: boolean;
|
|
27
|
+
/** 传给 renderCard 的选项 */
|
|
28
|
+
card?: Parameters<typeof renderCard>[1];
|
|
29
|
+
/** marker 按四档气象条件着色(unknown/poor/caution/good 圆点替代默认图钉;缺省 false 保持现状)。
|
|
30
|
+
* 判据为本库自拟的扫视启发式(初稿待审,见 conditionOf 注释)——不对应任何官方飞行天气分类,
|
|
31
|
+
* 不得用作运行判据;
|
|
32
|
+
* 开启时 tooltip 同时追加一行要素摘要(能见度/天气/最差云;NIL 与关键组全缺测站显示「缺报/数据缺测」),
|
|
33
|
+
* 圆点 aria-label 追加档位词(zh「天气好/差…」/ en「Weather …」,语言随 card.locale) */
|
|
34
|
+
conditionColors?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Put a set of report stations onto a Leaflet map: markers + tooltips + card popups. Returns a removable layer group.
|
|
38
|
+
* 把一组报文站点挂上地图:标记 + tooltip + 卡片弹窗。返回可整体移除的图层组。
|
|
39
|
+
* @param map - A Leaflet map instance. Leaflet 地图实例。
|
|
40
|
+
* @param items - Stations to plot (report + position + title). 待上图的站点列表。
|
|
41
|
+
* @param options - See AddMetarLayerOptions. 见 AddMetarLayerOptions。
|
|
42
|
+
*/
|
|
43
|
+
export declare function addMetarLayer(map: L.Map, items: readonly MetarLayerItem[], options?: AddMetarLayerOptions): L.LayerGroup;
|
|
44
|
+
//#endregion
|
|
45
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import * as L from "leaflet";
|
|
2
|
+
import { toValues } from "@metweave/core";
|
|
3
|
+
import { renderCard } from "@metweave/render";
|
|
4
|
+
//#region src/index.ts
|
|
5
|
+
/**
|
|
6
|
+
* @metweave/leaflet — Leaflet 适配器:报文卡片上图的桥接层。
|
|
7
|
+
* The Leaflet adapter: puts metweave report cards onto a Leaflet map.
|
|
8
|
+
*
|
|
9
|
+
* v0.1 唯一地图适配器;更多地图库适配(MapLibre 等)在路线图上。
|
|
10
|
+
* 兼容契约 = Leaflet overlay 能力:底图瓦片是宿主侧一行 L.tileLayer 配置,本包不做任何绑定,
|
|
11
|
+
* 也不推荐或代理任何底图服务——底图由使用者按自己的授权自行选择。
|
|
12
|
+
*/
|
|
13
|
+
/** addMetarLayer 的合法选项键(运行时校验用——拼错的选项键静默忽略违反本库不静默纪律) */
|
|
14
|
+
const ADD_METAR_LAYER_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
15
|
+
"locale",
|
|
16
|
+
"popup",
|
|
17
|
+
"card",
|
|
18
|
+
"conditionColors"
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* tooltip 纯文本承载:Leaflet 对 string 内容走 innerHTML——宿主自传的 title 是任意串,
|
|
22
|
+
* 经由 textContent 装载(非 HTML 路径)杜绝标记注入;文案渲染不受影响。
|
|
23
|
+
*/
|
|
24
|
+
function textCarrier(text) {
|
|
25
|
+
const el = document.createElement("span");
|
|
26
|
+
el.textContent = text;
|
|
27
|
+
return el;
|
|
28
|
+
}
|
|
29
|
+
/** HTML 属性上下文转义(divIcon 的 html 字符串构造用——title/站名不可信面) */
|
|
30
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g, (ch) => {
|
|
31
|
+
switch (ch) {
|
|
32
|
+
case "&": return "&";
|
|
33
|
+
case "<": return "<";
|
|
34
|
+
case ">": return ">";
|
|
35
|
+
case "\"": return """;
|
|
36
|
+
default: return "'";
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
/** 四档条件色(与卡片 mw-danger/mw-caution 同族色相:灰=不明、红=差、琥珀=注意、绿=好) */
|
|
40
|
+
const TIER_COLORS = {
|
|
41
|
+
unknown: "#8a94a0",
|
|
42
|
+
poor: "#d05656",
|
|
43
|
+
caution: "#e0a13c",
|
|
44
|
+
good: "#3aa657"
|
|
45
|
+
};
|
|
46
|
+
/** 档位可读名(aria-label 追加词——a11y 1.4.1:档位信息不只靠颜色传达) */
|
|
47
|
+
const TIER_WORDS = {
|
|
48
|
+
zh: {
|
|
49
|
+
unknown: "天气不明",
|
|
50
|
+
poor: "天气差",
|
|
51
|
+
caution: "天气注意",
|
|
52
|
+
good: "天气好"
|
|
53
|
+
},
|
|
54
|
+
en: {
|
|
55
|
+
unknown: "Weather unknown",
|
|
56
|
+
poor: "Weather poor",
|
|
57
|
+
caution: "Weather caution",
|
|
58
|
+
good: "Weather good"
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/** 降水类现象(caution 判据用,与 WMO 4678 降水族对应——RA/SN/SG/PL/GS/IC/DZ/UP) */
|
|
62
|
+
const PRECIP_PHENOMENA = /* @__PURE__ */ new Set([
|
|
63
|
+
"RA",
|
|
64
|
+
"SN",
|
|
65
|
+
"SG",
|
|
66
|
+
"PL",
|
|
67
|
+
"GS",
|
|
68
|
+
"IC",
|
|
69
|
+
"DZ",
|
|
70
|
+
"UP"
|
|
71
|
+
]);
|
|
72
|
+
/** 风速折米/秒(阵风判据统一单位:kt ×0.514444、kmh ÷3.6、mps ×1) */
|
|
73
|
+
const toMps = (value, unit) => unit === "kt" ? value * .514444 : unit === "kmh" ? value / 3.6 : value;
|
|
74
|
+
/**
|
|
75
|
+
* 四档气象条件分级(判据本库自拟、初稿待审——显示层扫视启发式):
|
|
76
|
+
* 阈值由本库拟定,**不对应也不代表任何官方飞行天气分类;本库不提供飞行规则判定**(本期无此功能)。
|
|
77
|
+
* 仅供「一眼扫视哪些站值得注意」,不得作为任何运行判据:
|
|
78
|
+
* - unknown(灰)= NIL(台站无观测)或关键组全缺测(能见度与云均缺测且天气缺测/无——按可得要素无从判读)
|
|
79
|
+
* - poor(红)= 能见度 < 1500 m,或云底 < 1000 ft,或天气含 TS 族(任何雷暴,含 VC 邻近)
|
|
80
|
+
* 或现象含 GR/VA,或 + 强度显著降水,或阵风 ≥ 25 m/s,或云组含 CB/TCU,或跑道关闭
|
|
81
|
+
* - caution(琥珀)= 能见度 1500–4800 m,或云底 1000–3000 ft,或任何降水族(RA/SN 等),
|
|
82
|
+
* 或 FZ 族(结冰),或阵风 15–25 m/s
|
|
83
|
+
* - good(绿)= 其余(含 CAVOK)
|
|
84
|
+
* 缺测要素不参与限制(按可得要素判,见 conditionOf 内 unknown 判据的例外);阈值细则随口径审定后修订。
|
|
85
|
+
*/
|
|
86
|
+
function conditionOf(report) {
|
|
87
|
+
if (report.nil === true) return "unknown";
|
|
88
|
+
const v = toValues(report);
|
|
89
|
+
const visMissing = report.visibility?.kind === "missing";
|
|
90
|
+
const elements = v.clouds?.elements ?? [];
|
|
91
|
+
const cloudsAllMissing = !report.cavok && elements.every((e) => e.heightFt.value === null && (e.kind === "vertical-visibility" || e.amount === null));
|
|
92
|
+
const weatherMissingOrNone = report.weather === void 0 || report.weather.kind === "missing" || report.weather.kind === "value" && report.weather.value.length === 0;
|
|
93
|
+
if (visMissing && cloudsAllMissing && weatherMissingOrNone) return "unknown";
|
|
94
|
+
const vis = v.visibility;
|
|
95
|
+
if (vis !== void 0) {
|
|
96
|
+
if ((vis.unit === "m" ? vis.value : vis.value * 1609.344) < 1500) return "poor";
|
|
97
|
+
}
|
|
98
|
+
const ceilings = elements.filter((e) => e.kind === "layer").filter((e) => e.amount === "BKN" || e.amount === "OVC").map((e) => e.heightFt.value ?? Number.POSITIVE_INFINITY);
|
|
99
|
+
for (const e of elements) if (e.kind === "vertical-visibility") ceilings.push(e.heightFt.value ?? Number.POSITIVE_INFINITY);
|
|
100
|
+
const ceiling = ceilings.length > 0 ? Math.min(...ceilings) : Number.POSITIVE_INFINITY;
|
|
101
|
+
if (ceiling < 1e3) return "poor";
|
|
102
|
+
for (const g of v.weather ?? []) {
|
|
103
|
+
const thunderstorm = g.descriptor === "TS";
|
|
104
|
+
const hailOrAsh = g.phenomena.includes("GR") || g.phenomena.includes("VA");
|
|
105
|
+
const heavyPrecip = g.intensity === "+" && (g.descriptor === "SH" || g.phenomena.some((p) => PRECIP_PHENOMENA.has(p)));
|
|
106
|
+
if (thunderstorm || hailOrAsh || heavyPrecip) return "poor";
|
|
107
|
+
}
|
|
108
|
+
const gust = v.wind?.gust;
|
|
109
|
+
if (gust !== void 0 && toMps(gust.value, gust.unit) >= 25) return "poor";
|
|
110
|
+
if (elements.some((e) => e.kind === "layer" && e.convective !== void 0)) return "poor";
|
|
111
|
+
if (v.runwayStates.some((st) => st.closed === true)) return "poor";
|
|
112
|
+
if (vis !== void 0) {
|
|
113
|
+
if ((vis.unit === "m" ? vis.value : vis.value * 1609.344) < 4800) return "caution";
|
|
114
|
+
}
|
|
115
|
+
if (ceiling < 3e3) return "caution";
|
|
116
|
+
for (const g of v.weather ?? []) {
|
|
117
|
+
const freezing = g.descriptor === "FZ";
|
|
118
|
+
const precip = g.phenomena.some((p) => PRECIP_PHENOMENA.has(p));
|
|
119
|
+
if (freezing || precip) return "caution";
|
|
120
|
+
}
|
|
121
|
+
if (gust !== void 0 && toMps(gust.value, gust.unit) >= 15) return "caution";
|
|
122
|
+
return "good";
|
|
123
|
+
}
|
|
124
|
+
/** 天气组显示码:span 在位取原码,缺席由 IR 重建(摘要行的要素原样口径) */
|
|
125
|
+
function weatherCode(report, g) {
|
|
126
|
+
return g.span === void 0 ? `${g.proximity ? "VC" : ""}${g.intensity ?? ""}${g.descriptor ?? ""}${g.phenomena.join("")}` : report.raw.slice(g.span.start, g.span.end);
|
|
127
|
+
}
|
|
128
|
+
/** 云层显示码:span 在位取原码,缺席由 IR 重建(缺测位还原为 ///) */
|
|
129
|
+
function cloudCode(report, layer) {
|
|
130
|
+
if (layer.span === void 0) {
|
|
131
|
+
if (layer.kind === "vertical-visibility") return `VV${layer.heightFt.value === null ? "///" : String(Math.round(layer.heightFt.value / 100)).padStart(3, "0")}`;
|
|
132
|
+
const height = layer.heightFt.value === null ? "///" : String(Math.round(layer.heightFt.value / 100)).padStart(3, "0");
|
|
133
|
+
return `${layer.amount ?? "///"}${height}${layer.convective ?? ""}`;
|
|
134
|
+
}
|
|
135
|
+
return report.raw.slice(layer.span.start, layer.span.end);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* tooltip 第二行要素摘要(扫视初筛):`2500m +TSRA BKN030CB` 式——
|
|
139
|
+
* 能见度 / 最显著天气(优先 TS/GR/+ 强度族)/ 最差云(对流云优先,否则最低 BKN/OVC,VV 兜底)。
|
|
140
|
+
* NIL 站显示「缺报(NIL)」、关键组全缺测站显示「数据缺测」(与 conditionOf 的 unknown 判据同款口径)。
|
|
141
|
+
*/
|
|
142
|
+
function summarizeReport(report, locale) {
|
|
143
|
+
if (report.nil === true) return locale === "en" ? "No report (NIL)" : "缺报(NIL)";
|
|
144
|
+
if (report.cavok) return "CAVOK";
|
|
145
|
+
const v = toValues(report);
|
|
146
|
+
const elements = v.clouds?.elements ?? [];
|
|
147
|
+
const visMissing = report.visibility?.kind === "missing";
|
|
148
|
+
const cloudsAllMissing = !report.cavok && elements.every((e) => e.heightFt.value === null && (e.kind === "vertical-visibility" || e.amount === null));
|
|
149
|
+
const weatherMissingOrNone = report.weather === void 0 || report.weather.kind === "missing" || report.weather.kind === "value" && report.weather.value.length === 0;
|
|
150
|
+
if (visMissing && cloudsAllMissing && weatherMissingOrNone) return locale === "en" ? "Data missing" : "数据缺测";
|
|
151
|
+
const parts = [];
|
|
152
|
+
const vis = v.visibility;
|
|
153
|
+
if (vis !== void 0) parts.push(vis.unit === "m" ? vis.exact ? `${vis.value} m` : "≥10 km" : vis.beyond === "below" ? `<${vis.value} SM` : vis.beyond === "above" ? `>${vis.value} SM` : `${vis.value} SM`);
|
|
154
|
+
const weather = v.weather ?? [];
|
|
155
|
+
const significant = weather.find((g) => g.descriptor === "TS" || g.phenomena.includes("GR") || g.intensity === "+") ?? weather[0];
|
|
156
|
+
if (significant !== void 0) parts.push(weatherCode(report, significant));
|
|
157
|
+
const layers = v.clouds?.elements ?? [];
|
|
158
|
+
const convective = layers.find((e) => e.kind === "layer" && e.convective !== void 0);
|
|
159
|
+
const ceilingLayer = layers.filter((e) => e.kind === "layer").filter((e) => e.amount === "BKN" || e.amount === "OVC").reduce((lowest, e) => lowest === void 0 || (e.heightFt.value ?? Number.POSITIVE_INFINITY) < (lowest.heightFt.value ?? Number.POSITIVE_INFINITY) ? e : lowest, void 0);
|
|
160
|
+
const vertical = layers.find((e) => e.kind === "vertical-visibility");
|
|
161
|
+
const worstCloud = convective ?? ceilingLayer ?? vertical;
|
|
162
|
+
if (worstCloud !== void 0) parts.push(cloudCode(report, worstCloud));
|
|
163
|
+
else if (v.clouds?.clear !== void 0) parts.push(v.clouds.clear.code);
|
|
164
|
+
return parts.join(" ");
|
|
165
|
+
}
|
|
166
|
+
/** tooltip 内容节点:标题行 + 要素摘要行(两行皆纯文本装载;locale 决定摘要占位词语言) */
|
|
167
|
+
function tooltipContent(item, withSummary, locale) {
|
|
168
|
+
const container = document.createElement("span");
|
|
169
|
+
container.append(textCarrier(item.title ?? item.report.station));
|
|
170
|
+
if (withSummary) container.append(document.createElement("br"), textCarrier(summarizeReport(item.report, locale)));
|
|
171
|
+
return container;
|
|
172
|
+
}
|
|
173
|
+
/** Escape 关闭已开弹窗的监听只挂一次/地图(多图层叠加不重复绑定) */
|
|
174
|
+
const escapeBoundMaps = /* @__PURE__ */ new WeakSet();
|
|
175
|
+
/**
|
|
176
|
+
* Put a set of report stations onto a Leaflet map: markers + tooltips + card popups. Returns a removable layer group.
|
|
177
|
+
* 把一组报文站点挂上地图:标记 + tooltip + 卡片弹窗。返回可整体移除的图层组。
|
|
178
|
+
* @param map - A Leaflet map instance. Leaflet 地图实例。
|
|
179
|
+
* @param items - Stations to plot (report + position + title). 待上图的站点列表。
|
|
180
|
+
* @param options - See AddMetarLayerOptions. 见 AddMetarLayerOptions。
|
|
181
|
+
*/
|
|
182
|
+
function addMetarLayer(map, items, options = {}) {
|
|
183
|
+
for (const key of Object.keys(options)) if (!ADD_METAR_LAYER_OPTION_KEYS.has(key)) throw new Error(`addMetarLayer 收到未知选项 "${key}"——卡片级选项(locale/raw/className…)需包在 card 里传,可用项见 AddMetarLayerOptions`);
|
|
184
|
+
const locale = options.card?.locale ?? options.locale ?? "zh";
|
|
185
|
+
if (locale !== "zh" && locale !== "en") throw new Error(`addMetarLayer 的 locale 选项值 "${locale}" 不受支持(可用:"zh" | "en")`);
|
|
186
|
+
const group = L.layerGroup();
|
|
187
|
+
for (const item of items) {
|
|
188
|
+
const name = item.title ?? item.report.station;
|
|
189
|
+
let marker;
|
|
190
|
+
if (options.conditionColors === true) {
|
|
191
|
+
const tier = conditionOf(item.report);
|
|
192
|
+
const label = `${name} · ${TIER_WORDS[locale][tier]}`;
|
|
193
|
+
marker = L.marker(item.position, { icon: L.divIcon({
|
|
194
|
+
className: "mw-cond-icon",
|
|
195
|
+
html: `<span role="img" aria-label="${escapeHtml(label)}" class="mw-dot mw-dot-${tier}" style="display:inline-block;width:12px;height:12px;border-radius:50%;background:${TIER_COLORS[tier]};border:2px solid #fff;box-shadow:0 0 2px rgba(0,0,0,.4)"></span>`,
|
|
196
|
+
iconSize: [14, 14],
|
|
197
|
+
iconAnchor: [7, 7]
|
|
198
|
+
}) });
|
|
199
|
+
} else marker = L.marker(item.position, { alt: name });
|
|
200
|
+
marker.bindTooltip(tooltipContent(item, options.conditionColors === true, locale));
|
|
201
|
+
if (options.popup ?? true) {
|
|
202
|
+
const cardOpts = { ...options.card };
|
|
203
|
+
if (cardOpts.locale === void 0 && options.locale !== void 0) cardOpts.locale = options.locale;
|
|
204
|
+
const stationName = item.title !== void 0 && item.title.startsWith(`${item.report.station} `) ? item.title.slice(item.report.station.length + 1) : void 0;
|
|
205
|
+
if (cardOpts.stationTitle === void 0 && stationName !== void 0) cardOpts.stationTitle = stationName;
|
|
206
|
+
marker.bindPopup(renderCard(item.report, cardOpts), { maxWidth: 420 });
|
|
207
|
+
marker.on("popupopen", () => {
|
|
208
|
+
marker.closeTooltip();
|
|
209
|
+
(marker.getPopup()?.getElement()?.querySelector("a.leaflet-popup-close-button, button, [href]"))?.focus();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
marker.addTo(group);
|
|
213
|
+
}
|
|
214
|
+
if (!escapeBoundMaps.has(map)) {
|
|
215
|
+
escapeBoundMaps.add(map);
|
|
216
|
+
map.getContainer().addEventListener("keydown", (event) => {
|
|
217
|
+
if (event.key === "Escape") map.closePopup();
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
group.addTo(map);
|
|
221
|
+
return group;
|
|
222
|
+
}
|
|
223
|
+
//#endregion
|
|
224
|
+
export { addMetarLayer };
|
|
225
|
+
|
|
226
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["bad"],"sources":["../src/index.ts"],"sourcesContent":["/**\n * @metweave/leaflet — Leaflet 适配器:报文卡片上图的桥接层。\n * The Leaflet adapter: puts metweave report cards onto a Leaflet map.\n *\n * v0.1 唯一地图适配器;更多地图库适配(MapLibre 等)在路线图上。\n * 兼容契约 = Leaflet overlay 能力:底图瓦片是宿主侧一行 L.tileLayer 配置,本包不做任何绑定,\n * 也不推荐或代理任何底图服务——底图由使用者按自己的授权自行选择。\n */\nimport * as L from \"leaflet\";\nimport type { CloudElement, MetarReport, WeatherGroup } from \"@metweave/core\";\nimport { toValues } from \"@metweave/core\";\nimport { renderCard } from \"@metweave/render\";\n\n/**\n * One station on the map: parsed report IR + WGS-84 position + optional tooltip title.\n * 地图上的一个站点:已解析报文 IR + WGS-84 坐标 + 可选悬浮标题。\n */\nexport interface MetarLayerItem {\n /** 已解析的报文 IR */\n report: MetarReport;\n /** 站点坐标(WGS-84 [lat, lon];用 GCJ-02 底图时的偏移由宿主决定是否校正) */\n position: [number, number];\n /** 站点提示(tooltip 文案,缺省用 IR 站名)。按纯文本处理——不解析 HTML,宿主自传任意串无注入面 */\n title?: string;\n}\n\n/**\n * Options for addMetarLayer: popup behavior, pass-through renderCard options, and the condition-color dot mode.\n * addMetarLayer 的选项:弹窗行为、透传 renderCard 的选项、条件色圆点模式。\n */\nexport interface AddMetarLayerOptions {\n /** 显示语言速记:等价 `card.locale`,同时决定 tooltip 摘要/档位词与弹窗卡片语言;\n * card.locale 显式传入时以其为准。未知选项运行时抛错(拼写错误不静默) */\n locale?: \"zh\" | \"en\";\n /** 点击站点时以弹窗展示报文卡片(缺省开启) */\n popup?: boolean;\n /** 传给 renderCard 的选项 */\n card?: Parameters<typeof renderCard>[1];\n /** marker 按四档气象条件着色(unknown/poor/caution/good 圆点替代默认图钉;缺省 false 保持现状)。\n * 判据为本库自拟的扫视启发式(初稿待审,见 conditionOf 注释)——不对应任何官方飞行天气分类,\n * 不得用作运行判据;\n * 开启时 tooltip 同时追加一行要素摘要(能见度/天气/最差云;NIL 与关键组全缺测站显示「缺报/数据缺测」),\n * 圆点 aria-label 追加档位词(zh「天气好/差…」/ en「Weather …」,语言随 card.locale) */\n conditionColors?: boolean;\n}\n\n/** addMetarLayer 的合法选项键(运行时校验用——拼错的选项键静默忽略违反本库不静默纪律) */\nconst ADD_METAR_LAYER_OPTION_KEYS: ReadonlySet<string> = new Set([\n \"locale\",\n \"popup\",\n \"card\",\n \"conditionColors\",\n]);\n\n/**\n * tooltip 纯文本承载:Leaflet 对 string 内容走 innerHTML——宿主自传的 title 是任意串,\n * 经由 textContent 装载(非 HTML 路径)杜绝标记注入;文案渲染不受影响。\n */\nfunction textCarrier(text: string): HTMLElement {\n const el = document.createElement(\"span\");\n el.textContent = text;\n return el;\n}\n\n/** HTML 属性上下文转义(divIcon 的 html 字符串构造用——title/站名不可信面) */\nconst escapeHtml = (text: string): string =>\n text.replace(/[&<>\"']/g, (ch) => {\n switch (ch) {\n case \"&\":\n return \"&\";\n case \"<\":\n return \"<\";\n case \">\":\n return \">\";\n case '\"':\n return \""\";\n default:\n return \"'\";\n }\n });\n\n/** 四档条件色(与卡片 mw-danger/mw-caution 同族色相:灰=不明、红=差、琥珀=注意、绿=好) */\nconst TIER_COLORS: Record<ConditionTier, string> = {\n unknown: \"#8a94a0\",\n poor: \"#d05656\",\n caution: \"#e0a13c\",\n good: \"#3aa657\",\n};\n\ntype ConditionTier = \"unknown\" | \"poor\" | \"caution\" | \"good\";\n\n/** 档位可读名(aria-label 追加词——a11y 1.4.1:档位信息不只靠颜色传达) */\nconst TIER_WORDS: Record<\"zh\" | \"en\", Record<ConditionTier, string>> = {\n zh: { unknown: \"天气不明\", poor: \"天气差\", caution: \"天气注意\", good: \"天气好\" },\n en: {\n unknown: \"Weather unknown\",\n poor: \"Weather poor\",\n caution: \"Weather caution\",\n good: \"Weather good\",\n },\n};\n\n/** 降水类现象(caution 判据用,与 WMO 4678 降水族对应——RA/SN/SG/PL/GS/IC/DZ/UP) */\nconst PRECIP_PHENOMENA: ReadonlySet<string> = new Set([\n \"RA\",\n \"SN\",\n \"SG\",\n \"PL\",\n \"GS\",\n \"IC\",\n \"DZ\",\n \"UP\",\n]);\n\n/** 风速折米/秒(阵风判据统一单位:kt ×0.514444、kmh ÷3.6、mps ×1) */\nconst toMps = (value: number, unit: \"kt\" | \"mps\" | \"kmh\"): number =>\n unit === \"kt\" ? value * 0.514444 : unit === \"kmh\" ? value / 3.6 : value;\n\n/**\n * 四档气象条件分级(判据本库自拟、初稿待审——显示层扫视启发式):\n * 阈值由本库拟定,**不对应也不代表任何官方飞行天气分类;本库不提供飞行规则判定**(本期无此功能)。\n * 仅供「一眼扫视哪些站值得注意」,不得作为任何运行判据:\n * - unknown(灰)= NIL(台站无观测)或关键组全缺测(能见度与云均缺测且天气缺测/无——按可得要素无从判读)\n * - poor(红)= 能见度 < 1500 m,或云底 < 1000 ft,或天气含 TS 族(任何雷暴,含 VC 邻近)\n * 或现象含 GR/VA,或 + 强度显著降水,或阵风 ≥ 25 m/s,或云组含 CB/TCU,或跑道关闭\n * - caution(琥珀)= 能见度 1500–4800 m,或云底 1000–3000 ft,或任何降水族(RA/SN 等),\n * 或 FZ 族(结冰),或阵风 15–25 m/s\n * - good(绿)= 其余(含 CAVOK)\n * 缺测要素不参与限制(按可得要素判,见 conditionOf 内 unknown 判据的例外);阈值细则随口径审定后修订。\n */\nfunction conditionOf(report: MetarReport): ConditionTier {\n if (report.nil === true) return \"unknown\";\n const v = toValues(report);\n // 关键组全缺测:能见度与云均缺测(云组每个体均为全缺测形态)且天气缺测/无——判读无从下手,灰而非绿\n const visMissing = report.visibility?.kind === \"missing\";\n const elements = v.clouds?.elements ?? [];\n const cloudsAllMissing =\n !report.cavok &&\n elements.every(\n (e) => e.heightFt.value === null && (e.kind === \"vertical-visibility\" || e.amount === null),\n );\n const weatherMissingOrNone =\n report.weather === undefined ||\n report.weather.kind === \"missing\" ||\n (report.weather.kind === \"value\" && report.weather.value.length === 0);\n if (visMissing && cloudsAllMissing && weatherMissingOrNone) return \"unknown\";\n\n // —— poor 判据(任一命中即红)\n const vis = v.visibility;\n if (vis !== undefined) {\n const visMeters = vis.unit === \"m\" ? vis.value : vis.value * 1609.344;\n if (visMeters < 1500) return \"poor\";\n }\n const ceilings = elements\n .filter((e): e is Extract<CloudElement, { kind: \"layer\" }> => e.kind === \"layer\")\n .filter((e) => e.amount === \"BKN\" || e.amount === \"OVC\")\n .map((e) => e.heightFt.value ?? Number.POSITIVE_INFINITY);\n for (const e of elements) {\n if (e.kind === \"vertical-visibility\")\n ceilings.push(e.heightFt.value ?? Number.POSITIVE_INFINITY);\n }\n const ceiling = ceilings.length > 0 ? Math.min(...ceilings) : Number.POSITIVE_INFINITY;\n if (ceiling < 1000) return \"poor\";\n for (const g of v.weather ?? []) {\n const thunderstorm = g.descriptor === \"TS\"; // TS 族:任何雷暴(含 VCTS 邻近雷暴)\n const hailOrAsh = g.phenomena.includes(\"GR\") || g.phenomena.includes(\"VA\");\n const heavyPrecip =\n g.intensity === \"+\" &&\n (g.descriptor === \"SH\" || g.phenomena.some((p) => PRECIP_PHENOMENA.has(p)));\n if (thunderstorm || hailOrAsh || heavyPrecip) return \"poor\";\n }\n const gust = v.wind?.gust;\n if (gust !== undefined && toMps(gust.value, gust.unit) >= 25) return \"poor\";\n const convective = elements.some(\n (e): e is Extract<CloudElement, { kind: \"layer\" }> =>\n e.kind === \"layer\" && e.convective !== undefined,\n );\n if (convective) return \"poor\";\n if (v.runwayStates.some((st) => st.closed === true)) return \"poor\";\n\n // —— caution 判据(任一命中即琥珀)\n if (vis !== undefined) {\n const visMeters = vis.unit === \"m\" ? vis.value : vis.value * 1609.344;\n if (visMeters < 4800) return \"caution\";\n }\n if (ceiling < 3000) return \"caution\";\n for (const g of v.weather ?? []) {\n const freezing = g.descriptor === \"FZ\";\n const precip = g.phenomena.some((p) => PRECIP_PHENOMENA.has(p));\n if (freezing || precip) return \"caution\";\n }\n if (gust !== undefined && toMps(gust.value, gust.unit) >= 15) return \"caution\";\n return \"good\";\n}\n\n/** 天气组显示码:span 在位取原码,缺席由 IR 重建(摘要行的要素原样口径) */\nfunction weatherCode(report: MetarReport, g: WeatherGroup): string {\n return g.span === undefined\n ? `${g.proximity ? \"VC\" : \"\"}${g.intensity ?? \"\"}${g.descriptor ?? \"\"}${g.phenomena.join(\"\")}`\n : report.raw.slice(g.span.start, g.span.end);\n}\n\n/** 云层显示码:span 在位取原码,缺席由 IR 重建(缺测位还原为 ///) */\nfunction cloudCode(report: MetarReport, layer: CloudElement): string {\n if (layer.span === undefined) {\n if (layer.kind === \"vertical-visibility\") {\n return `VV${layer.heightFt.value === null ? \"///\" : String(Math.round(layer.heightFt.value / 100)).padStart(3, \"0\")}`;\n }\n const height =\n layer.heightFt.value === null\n ? \"///\"\n : String(Math.round(layer.heightFt.value / 100)).padStart(3, \"0\");\n return `${layer.amount ?? \"///\"}${height}${layer.convective ?? \"\"}`;\n }\n return report.raw.slice(layer.span.start, layer.span.end);\n}\n\n/**\n * tooltip 第二行要素摘要(扫视初筛):`2500m +TSRA BKN030CB` 式——\n * 能见度 / 最显著天气(优先 TS/GR/+ 强度族)/ 最差云(对流云优先,否则最低 BKN/OVC,VV 兜底)。\n * NIL 站显示「缺报(NIL)」、关键组全缺测站显示「数据缺测」(与 conditionOf 的 unknown 判据同款口径)。\n */\nfunction summarizeReport(report: MetarReport, locale: \"zh\" | \"en\"): string {\n if (report.nil === true) return locale === \"en\" ? \"No report (NIL)\" : \"缺报(NIL)\";\n if (report.cavok) return \"CAVOK\";\n const v = toValues(report);\n // 关键组全缺测(能见度与云均缺测且天气缺测/无):要素摘要无从拼起,显示缺测占位而非空行\n const elements = v.clouds?.elements ?? [];\n const visMissing = report.visibility?.kind === \"missing\";\n const cloudsAllMissing =\n !report.cavok &&\n elements.every(\n (e) => e.heightFt.value === null && (e.kind === \"vertical-visibility\" || e.amount === null),\n );\n const weatherMissingOrNone =\n report.weather === undefined ||\n report.weather.kind === \"missing\" ||\n (report.weather.kind === \"value\" && report.weather.value.length === 0);\n if (visMissing && cloudsAllMissing && weatherMissingOrNone)\n return locale === \"en\" ? \"Data missing\" : \"数据缺测\";\n const parts: string[] = [];\n const vis = v.visibility;\n if (vis !== undefined) {\n parts.push(\n vis.unit === \"m\"\n ? vis.exact\n ? `${vis.value} m`\n : \"≥10 km\"\n : vis.beyond === \"below\"\n ? `<${vis.value} SM`\n : vis.beyond === \"above\"\n ? `>${vis.value} SM`\n : `${vis.value} SM`,\n );\n }\n const weather = v.weather ?? [];\n const significant =\n weather.find(\n (g) => g.descriptor === \"TS\" || g.phenomena.includes(\"GR\") || g.intensity === \"+\",\n ) ?? weather[0];\n if (significant !== undefined) parts.push(weatherCode(report, significant));\n const layers = v.clouds?.elements ?? [];\n const convective = layers.find(\n (e): e is Extract<CloudElement, { kind: \"layer\" }> =>\n e.kind === \"layer\" && e.convective !== undefined,\n );\n const ceilingLayer = layers\n .filter((e): e is Extract<CloudElement, { kind: \"layer\" }> => e.kind === \"layer\")\n .filter((e) => e.amount === \"BKN\" || e.amount === \"OVC\")\n .reduce<Extract<CloudElement, { kind: \"layer\" }> | undefined>(\n (lowest, e) =>\n lowest === undefined ||\n (e.heightFt.value ?? Number.POSITIVE_INFINITY) <\n (lowest.heightFt.value ?? Number.POSITIVE_INFINITY)\n ? e\n : lowest,\n undefined,\n );\n const vertical = layers.find((e) => e.kind === \"vertical-visibility\");\n const worstCloud = convective ?? ceilingLayer ?? vertical;\n if (worstCloud !== undefined) parts.push(cloudCode(report, worstCloud));\n else if (v.clouds?.clear !== undefined) parts.push(v.clouds.clear.code);\n return parts.join(\" \");\n}\n\n/** tooltip 内容节点:标题行 + 要素摘要行(两行皆纯文本装载;locale 决定摘要占位词语言) */\nfunction tooltipContent(\n item: MetarLayerItem,\n withSummary: boolean,\n locale: \"zh\" | \"en\",\n): HTMLElement {\n const container = document.createElement(\"span\");\n container.append(textCarrier(item.title ?? item.report.station));\n if (withSummary) {\n container.append(\n document.createElement(\"br\"),\n textCarrier(summarizeReport(item.report, locale)),\n );\n }\n return container;\n}\n\n/** Escape 关闭已开弹窗的监听只挂一次/地图(多图层叠加不重复绑定) */\nconst escapeBoundMaps = new WeakSet<L.Map>();\n\n/**\n * Put a set of report stations onto a Leaflet map: markers + tooltips + card popups. Returns a removable layer group.\n * 把一组报文站点挂上地图:标记 + tooltip + 卡片弹窗。返回可整体移除的图层组。\n * @param map - A Leaflet map instance. Leaflet 地图实例。\n * @param items - Stations to plot (report + position + title). 待上图的站点列表。\n * @param options - See AddMetarLayerOptions. 见 AddMetarLayerOptions。\n */\n\nexport function addMetarLayer(\n map: L.Map,\n items: readonly MetarLayerItem[],\n options: AddMetarLayerOptions = {},\n): L.LayerGroup {\n // 未知选项运行时抛错:拼写错误的选项被静默忽略 = 显示语言/行为悄悄不符预期(2026-09-15\n // 五角色评测实测:顶层 locale 此前被静默忽略,popup 整卡仍中文)。中文提示 = v0.1 message 语言契约。\n for (const key of Object.keys(options)) {\n if (!ADD_METAR_LAYER_OPTION_KEYS.has(key)) {\n throw new Error(\n `addMetarLayer 收到未知选项 \"${key}\"——卡片级选项(locale/raw/className…)需包在 card 里传,可用项见 AddMetarLayerOptions`,\n );\n }\n }\n // 语言解析单一出口:card.locale 显式传入 > 顶层 locale 速记 > zh;非法值清晰报错不裸崩\n const locale = options.card?.locale ?? options.locale ?? \"zh\";\n if (locale !== \"zh\" && locale !== \"en\") {\n // never 收窄后的宽化中转:模板表达式不接受 never 字面量类型\n const bad: string = locale;\n throw new Error(`addMetarLayer 的 locale 选项值 \"${bad}\" 不受支持(可用:\"zh\" | \"en\")`);\n }\n const group = L.layerGroup();\n for (const item of items) {\n const name = item.title ?? item.report.station;\n // alt 写入图标 img 的 alt 属性:marker 在读屏下 role=button,可访问名称 = 站名(WCAG 4.1.2)\n let marker: L.Marker;\n if (options.conditionColors === true) {\n // 圆点 divIcon 无 img——alt 失效,改以 role=img + aria-label 保住可访问名称(内容经 HTML 转义);\n // aria-label 追加档位词(a11y 1.4.1:档位不只靠颜色传达;语言随 card.locale,缺省中文)\n const tier = conditionOf(item.report);\n const label = `${name} · ${TIER_WORDS[locale][tier]}`;\n marker = L.marker(item.position, {\n icon: L.divIcon({\n className: \"mw-cond-icon\",\n html: `<span role=\"img\" aria-label=\"${escapeHtml(label)}\" class=\"mw-dot mw-dot-${tier}\" style=\"display:inline-block;width:12px;height:12px;border-radius:50%;background:${TIER_COLORS[tier]};border:2px solid #fff;box-shadow:0 0 2px rgba(0,0,0,.4)\"></span>`,\n iconSize: [14, 14],\n iconAnchor: [7, 7],\n }),\n });\n } else {\n marker = L.marker(item.position, { alt: name });\n }\n marker.bindTooltip(tooltipContent(item, options.conditionColors === true, locale));\n if (options.popup ?? true) {\n // 弹窗内容是 renderCard 的 DOM 元素(createElement/textContent 构建),本就走非 HTML 路径;\n // maxWidth 420 = 卡片设计宽(Leaflet 缺省 300 会压窄)\n // 元数据联表得到的站名随弹窗进卡片站名行(title = \"ICAO 站名\",剥掉 ICAO 前缀);调用方显式传入时以其为准\n const cardOpts: Parameters<typeof renderCard>[1] = { ...options.card };\n if (cardOpts.locale === undefined && options.locale !== undefined) {\n cardOpts.locale = options.locale;\n }\n const stationName =\n item.title !== undefined && item.title.startsWith(`${item.report.station} `)\n ? item.title.slice(item.report.station.length + 1)\n : undefined;\n if (cardOpts.stationTitle === undefined && stationName !== undefined) {\n cardOpts.stationTitle = stationName;\n }\n marker.bindPopup(renderCard(item.report, cardOpts), { maxWidth: 420 });\n marker.on(\"popupopen\", () => {\n // 触屏双浮层消除:弹窗打开即收起 tooltip\n marker.closeTooltip();\n // 焦点移入弹窗首个可聚焦元素(Leaflet 关闭按钮)——键盘与读屏可直达\n const focusTarget = marker\n .getPopup()\n ?.getElement()\n ?.querySelector<HTMLElement>(\"a.leaflet-popup-close-button, button, [href]\");\n focusTarget?.focus();\n });\n }\n marker.addTo(group);\n }\n if (!escapeBoundMaps.has(map)) {\n escapeBoundMaps.add(map);\n // 地图容器键盘路径:Escape 关闭已开弹窗(closePopup 对未开弹窗是空操作)\n map.getContainer().addEventListener(\"keydown\", (event) => {\n if (event.key === \"Escape\") map.closePopup();\n });\n }\n group.addTo(map);\n return group;\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@metweave/leaflet",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Put parsed METAR/SPECI reports on a Leaflet map: markers, tooltips and report-card popups in one call — bring your own tiles.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"leaflet",
|
|
7
|
+
"map-layer",
|
|
8
|
+
"marker",
|
|
9
|
+
"metar",
|
|
10
|
+
"popup",
|
|
11
|
+
"weather"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "YaoJaro <yaojaro@metweave.com>",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/yaojaro/metweave.git",
|
|
18
|
+
"directory": "packages/leaflet"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"main": "./dist/index.cjs",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"import": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"require": {
|
|
37
|
+
"types": "./dist/index.d.cts",
|
|
38
|
+
"default": "./dist/index.cjs"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@metweave/core": "0.1.0",
|
|
47
|
+
"@metweave/render": "0.1.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/leaflet": "^1.9.22",
|
|
51
|
+
"leaflet": "^1.9.4",
|
|
52
|
+
"@metweave/parser": "0.1.0"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"leaflet": "^1.9.0"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "tsdown",
|
|
62
|
+
"typecheck": "tsc --noEmit"
|
|
63
|
+
}
|
|
64
|
+
}
|