@metweave/leaflet 0.1.2 → 0.2.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/README.md +8 -0
- package/dist/index.cjs +528 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +117 -1
- package/dist/index.d.ts +117 -1
- package/dist/index.js +524 -19
- package/dist/index.js.map +1 -1
- package/package.json +31 -21
- package/LICENSE +0 -21
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { toValues } from "@metweave/core";
|
|
2
|
-
import { renderCard } from "@metweave/render";
|
|
1
|
+
import { toValues, unwrap } from "@metweave/core";
|
|
2
|
+
import { renderCard, renderTafCard, summarizeTafConditions } from "@metweave/render";
|
|
3
|
+
import { expandTaf } from "@metweave/parser";
|
|
3
4
|
//#region src/index.ts
|
|
4
5
|
/**
|
|
5
6
|
* leaflet 惰性装载:import 本包时不再静态加载 leaflet——peer 库是浏览器专属实现,
|
|
@@ -14,7 +15,8 @@ const ADD_METAR_LAYER_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
|
14
15
|
"locale",
|
|
15
16
|
"popup",
|
|
16
17
|
"card",
|
|
17
|
-
"conditionColors"
|
|
18
|
+
"conditionColors",
|
|
19
|
+
"popupOptions"
|
|
18
20
|
]);
|
|
19
21
|
/**
|
|
20
22
|
* tooltip 纯文本承载:Leaflet 对 string 内容走 innerHTML——宿主自传的 title 是任意串,
|
|
@@ -35,7 +37,11 @@ const escapeHtml = (text) => text.replace(/[&<>"']/g, (ch) => {
|
|
|
35
37
|
default: return "'";
|
|
36
38
|
}
|
|
37
39
|
});
|
|
38
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* 四档条件色单一来源(2026-09-24 评测批2#3:图例/面板/圆点色值不一致——两套色系并存)。
|
|
42
|
+
* 宿主图例与列表色点应引用本表而非自抄色值(编译期同源,显示层永不漂移);
|
|
43
|
+
* 与卡片 mw-danger/mw-caution 同族色相:灰=不明、红=差、琥珀=注意、绿=好
|
|
44
|
+
*/
|
|
39
45
|
const TIER_COLORS = {
|
|
40
46
|
unknown: "#8a94a0",
|
|
41
47
|
poor: "#d05656",
|
|
@@ -70,21 +76,15 @@ const PRECIP_PHENOMENA = /* @__PURE__ */ new Set([
|
|
|
70
76
|
]);
|
|
71
77
|
/** 风速折米/秒(阵风判据统一单位:kt ×0.514444、kmh ÷3.6、mps ×1) */
|
|
72
78
|
const toMps = (value, unit) => unit === "kt" ? value * .514444 : unit === "kmh" ? value / 3.6 : value;
|
|
73
|
-
/**
|
|
74
|
-
* 四档气象条件分级(判据本库自拟、初稿待审——显示层扫视启发式):
|
|
75
|
-
* 阈值由本库拟定,**不对应也不代表任何官方飞行天气分类;本库不提供飞行规则判定**(本期无此功能)。
|
|
76
|
-
* 仅供「一眼扫视哪些站值得注意」,不得作为任何运行判据:
|
|
77
|
-
* - unknown(灰)= NIL(台站无观测)或关键组全缺测(能见度与云均缺测且天气缺测/无——按可得要素无从判读)
|
|
78
|
-
* - poor(红)= 能见度 < 1500 m,或 BKN/OVC 云层(含垂直能见度)云底 < 1000 ft,或天气含 TS 族(任何雷暴,含 VC 邻近)
|
|
79
|
-
* 或现象含 GR/VA,或冻降水(FZ 描述符族,冻雨/冻毛毛雨),或 + 强度显著降水,或阵风 ≥ 25 m/s,或云组含 CB/TCU,或跑道关闭
|
|
80
|
-
* - caution(琥珀)= 能见度 1500–5000 m(能见度分档取国内通行 1500/5000 m 口径),或 BKN/OVC 云底 1000–3000 ft,或任何降水族(RA/SN 等),
|
|
81
|
-
* 或 FZ 描述符以外的结冰现象,或阵风 15–25 m/s
|
|
82
|
-
* - good(绿)= 其余(含 CAVOK)
|
|
83
|
-
* 缺测要素不参与限制(按可得要素判,见 conditionOf 内 unknown 判据的例外);阈值细则随口径审定后修订。
|
|
84
|
-
*/
|
|
85
79
|
function conditionOf(report) {
|
|
86
80
|
if (report.nil === true) return "unknown";
|
|
87
|
-
const v =
|
|
81
|
+
const v = {
|
|
82
|
+
wind: unwrap(report.wind),
|
|
83
|
+
visibility: unwrap(report.visibility),
|
|
84
|
+
weather: unwrap(report.weather),
|
|
85
|
+
clouds: report.clouds,
|
|
86
|
+
runwayStates: report.runwayStates ?? []
|
|
87
|
+
};
|
|
88
88
|
const visMissing = report.visibility?.kind === "missing";
|
|
89
89
|
const elements = v.clouds?.elements ?? [];
|
|
90
90
|
const cloudsAllMissing = !report.cavok && elements.every((e) => e.heightFt.value === null && (e.kind === "vertical-visibility" || e.amount === null));
|
|
@@ -203,7 +203,10 @@ async function addMetarLayer(map, items, options = {}) {
|
|
|
203
203
|
if (cardOpts.locale === void 0 && options.locale !== void 0) cardOpts.locale = options.locale;
|
|
204
204
|
const stationName = item.title !== void 0 && item.title.startsWith(`${item.report.station} `) ? item.title.slice(item.report.station.length + 1) : void 0;
|
|
205
205
|
if (cardOpts.stationTitle === void 0 && stationName !== void 0) cardOpts.stationTitle = stationName;
|
|
206
|
-
marker.bindPopup(renderCard(item.report, cardOpts), {
|
|
206
|
+
marker.bindPopup(renderCard(item.report, cardOpts), {
|
|
207
|
+
maxWidth: 420,
|
|
208
|
+
...options.popupOptions
|
|
209
|
+
});
|
|
207
210
|
marker.on("popupopen", () => {
|
|
208
211
|
marker.closeTooltip();
|
|
209
212
|
(marker.getPopup()?.getElement()?.querySelector("a.leaflet-popup-close-button, button, [href]"))?.focus();
|
|
@@ -220,7 +223,509 @@ async function addMetarLayer(map, items, options = {}) {
|
|
|
220
223
|
group.addTo(map);
|
|
221
224
|
return group;
|
|
222
225
|
}
|
|
226
|
+
/** TAF 展开结果 → 判据输入面投影(runwayStates 恒缺省——TAF 无跑道状态语汇) */
|
|
227
|
+
function asConditionInput(c, nilLike) {
|
|
228
|
+
return {
|
|
229
|
+
nil: nilLike,
|
|
230
|
+
cavok: c.cavok,
|
|
231
|
+
...c.wind !== void 0 ? { wind: {
|
|
232
|
+
kind: "value",
|
|
233
|
+
value: c.wind
|
|
234
|
+
} } : {},
|
|
235
|
+
...c.visibility !== void 0 ? { visibility: {
|
|
236
|
+
kind: "value",
|
|
237
|
+
value: c.visibility
|
|
238
|
+
} } : {},
|
|
239
|
+
...c.weather.length > 0 ? { weather: {
|
|
240
|
+
kind: "value",
|
|
241
|
+
value: c.weather
|
|
242
|
+
} } : {},
|
|
243
|
+
...c.clouds !== void 0 ? { clouds: c.clouds } : {}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* TAF 展开摘要(一行,人话优先——2026-09-24 评测批2#4:旧电码串「≥10 km · -TSRA · SCT040」
|
|
248
|
+
* 对非专业是密码;现复用 @metweave/render 的 gloss 词表单一来源,tooltip/置顶提示同款):
|
|
249
|
+
* 「能见度 ≥10 km · 雷暴伴雨(飞行威胁大) · 疏云,云底约 700 米」;电码在卡内 RAW 区可查。
|
|
250
|
+
*/
|
|
251
|
+
function summarizeTaf(c, locale) {
|
|
252
|
+
if (c.cavok) return "CAVOK";
|
|
253
|
+
const text = summarizeTafConditions(c, locale);
|
|
254
|
+
if (text !== "") return text;
|
|
255
|
+
return locale === "en" ? "No elements" : "无要素组";
|
|
256
|
+
}
|
|
257
|
+
const ADD_TAF_LAYER_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
258
|
+
"locale",
|
|
259
|
+
"at",
|
|
260
|
+
"anchorDays",
|
|
261
|
+
"calendarAnchor",
|
|
262
|
+
"popup",
|
|
263
|
+
"card",
|
|
264
|
+
"popupOptions"
|
|
265
|
+
]);
|
|
266
|
+
/**
|
|
267
|
+
* Put parsed TAF stations onto a Leaflet map as forecast markers at one instant — the four-tier
|
|
268
|
+
* dot, tooltip and popup are driven by `expandTaf` (renderer layer ①: forecast-as-observation).
|
|
269
|
+
* 把一组 TAF 站点按同一时刻的预报值挂上地图:四档圆点/tooltip/弹窗全部由 expandTaf 展开
|
|
270
|
+
* 结果驱动(渲染层①「预报当观测渲」——判据/圆点/摘要与 METAR 侧同一条管线)。
|
|
271
|
+
* 档位判据为本库自拟扫视启发式(同 conditionOf 注释)——**预报值套判据同样不得用作运行判据**,
|
|
272
|
+
* tooltip/弹窗均显式标注「预报」,不与实况混淆。
|
|
273
|
+
*/
|
|
274
|
+
async function addTafLayer(map, items, options = {}) {
|
|
275
|
+
for (const key of Object.keys(options)) if (!ADD_TAF_LAYER_OPTION_KEYS.has(key)) throw new Error(`addTafLayer 收到未知选项 "${key}"(可用项见 AddTafLayerOptions)`);
|
|
276
|
+
const locale = options.locale ?? "zh";
|
|
277
|
+
if (locale !== "zh" && locale !== "en") throw new Error(`addTafLayer 的 locale 选项值 "${locale}" 不受支持(可用:"zh" | "en")`);
|
|
278
|
+
const L = await loadLeaflet();
|
|
279
|
+
const group = L.layerGroup();
|
|
280
|
+
await populateTafLayer(map, group, items, options, L);
|
|
281
|
+
group.addTo(map);
|
|
282
|
+
return group;
|
|
283
|
+
}
|
|
284
|
+
/** 常显站码标签样式(评测签派 P0-1:无站码=「有红的看不出是谁红的」);缩放门控类由 zoomend 维护 */
|
|
285
|
+
const TAF_LAYER_STYLE_ID = "mw-taf-layer-style";
|
|
286
|
+
const injectTafLayerStyle = () => {
|
|
287
|
+
if (document.getElementById(TAF_LAYER_STYLE_ID) !== null) return;
|
|
288
|
+
const style = document.createElement("style");
|
|
289
|
+
style.id = TAF_LAYER_STYLE_ID;
|
|
290
|
+
style.append(document.createTextNode(".mw-code-label { position:absolute; left:15px; top:-3px; white-space:nowrap; font:600 10px/1.4 system-ui,sans-serif; color:#2c3e50; text-shadow:0 0 3px #fff,0 0 3px #fff,0 0 3px #fff; pointer-events:none; }.mw-hide-codes .mw-code-label { display:none; }"));
|
|
291
|
+
document.head.append(style);
|
|
292
|
+
};
|
|
293
|
+
const codeZoomBound = /* @__PURE__ */ new WeakSet();
|
|
294
|
+
/** zoom ≥ 5 常显 ICAO 码,低于则收(38 站全显互相压盖;评测签派 P0-1 的缩放分级折中) */
|
|
295
|
+
const bindCodeZoom = (map) => {
|
|
296
|
+
if (codeZoomBound.has(map)) return;
|
|
297
|
+
codeZoomBound.add(map);
|
|
298
|
+
const container = map.getContainer();
|
|
299
|
+
const toggle = () => {
|
|
300
|
+
container.classList.toggle("mw-hide-codes", map.getZoom() < 5);
|
|
301
|
+
};
|
|
302
|
+
map.on("zoomend", toggle);
|
|
303
|
+
toggle();
|
|
304
|
+
};
|
|
305
|
+
/** 各层当前展开时刻(setTafLayerTime 换时刻不再清层重建——marker 原地 setIcon/setTooltipContent,评测工程 P2-1) */
|
|
306
|
+
const tafLayerAt = /* @__PURE__ */ new WeakMap();
|
|
307
|
+
/** 各层的日历月锚(2026-09-24 评测 P1 月界批):at 连续序「自该月 1 日起」的基准月 */
|
|
308
|
+
const tafLayerCalendar = /* @__PURE__ */ new WeakMap();
|
|
309
|
+
/** 各层的可变 card 选项覆盖(时区单制:setTafLayerTime 带 card 即合并——切时区不清层重建、
|
|
310
|
+
* 滑杆换时刻不带 card 不回退;弹窗刷新读此处而非建层闭包,已开弹窗即时随时区换内容) */
|
|
311
|
+
const tafLayerCard = /* @__PURE__ */ new WeakMap();
|
|
312
|
+
/** marker → 其 TafLayerItem(换时刻原地更新时的数据面) */
|
|
313
|
+
const tafMarkerItems = /* @__PURE__ */ new WeakMap();
|
|
314
|
+
/** marker → 弹窗内容刷新函数(复测 N1/N2:刷新不走合成 popupopen——真实打开才移焦点,刷新按当前时刻重算置顶提示) */
|
|
315
|
+
const tafPopupRefresh = /* @__PURE__ */ new WeakMap();
|
|
316
|
+
/** 查看时刻是否在该报文有效期外(含窗前/窗后——出窗=灰点「预报未生效/已过期」) */
|
|
317
|
+
/** 日时 → 分钟序(出窗比较用;TAF 无月,同报文语境内日号自洽) */
|
|
318
|
+
const absDayHour = (d, h) => (d - 1) * 1440 + h * 60;
|
|
319
|
+
/** 真实月历:某年月的天数(2026-09-24 评测 P1 月界批) */
|
|
320
|
+
const daysInMonthOf = (year, month) => new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
321
|
+
/** 年月 → 序数(锚距比较用) */
|
|
322
|
+
const calendarIndex = (c) => c.year * 12 + (c.month - 1);
|
|
323
|
+
/**
|
|
324
|
+
* 单站时刻归一(2026-09-24 评测 P1 月界批):
|
|
325
|
+
* - 显式层时刻 contAt(calendarAnchor 基的连续日序,可超月长)→ 经 anchorize 换算到本报锚月;
|
|
326
|
+
* - contAt 缺省(层尚无统一时刻)→ 各站自身有效期起点(已在本报锚内,原样直用,月天数按报锚真月历)。
|
|
327
|
+
* 返回喂 expandTaf/tafMarkerState 的 at+daysIn,cal 供 renderTafCard 走真月历显示。
|
|
328
|
+
*/
|
|
329
|
+
function stationNorm(item, contAt, layerCal, fallbackDaysIn) {
|
|
330
|
+
const v = item.report.validity;
|
|
331
|
+
const ownStart = {
|
|
332
|
+
day: v?.startDay ?? 0,
|
|
333
|
+
hour: v?.startHour ?? 0,
|
|
334
|
+
minute: 0
|
|
335
|
+
};
|
|
336
|
+
const itemCal = item.monthAnchor;
|
|
337
|
+
if (contAt === void 0) return {
|
|
338
|
+
at: ownStart,
|
|
339
|
+
daysIn: itemCal !== void 0 ? daysInMonthOf(itemCal.year, itemCal.month) : fallbackDaysIn,
|
|
340
|
+
cal: itemCal
|
|
341
|
+
};
|
|
342
|
+
return anchorize(item, contAt, layerCal, fallbackDaysIn);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* 层连续序 at → 该报锚月内的归一(2026-09-24 评测 P1 月界批,跨月报池收口):
|
|
346
|
+
* item.monthAnchor 与层 calendarAnchor 都在位时,把 at 的连续日序(自层锚月 1 日起、可超月长)
|
|
347
|
+
* 换算到本报锚月内的日号,并按真实月历给该月天数(供报文 validity 回绕)——月末跨月的
|
|
348
|
+
* 混合报池(9 月末的在效报 + 10 月 1 日生效报)展开与显示各报正确。
|
|
349
|
+
* 缺任一锚(或锚病态相差超 24 个月)时原样返回 + 层全局 anchorDays 的 %31 折回——残余近似
|
|
350
|
+
* 仅显示与回绕位:报文本身无月份,无锚即无从换算;档位判读的要素合成不依赖日号显示 */
|
|
351
|
+
function anchorize(item, at, layerCal, fallbackDaysIn) {
|
|
352
|
+
const itemCal = item.monthAnchor;
|
|
353
|
+
if (itemCal === void 0 || layerCal === void 0) return {
|
|
354
|
+
at,
|
|
355
|
+
daysIn: fallbackDaysIn
|
|
356
|
+
};
|
|
357
|
+
const diff = calendarIndex(itemCal) - calendarIndex(layerCal);
|
|
358
|
+
if (diff < -24 || diff > 24) return {
|
|
359
|
+
at,
|
|
360
|
+
daysIn: fallbackDaysIn
|
|
361
|
+
};
|
|
362
|
+
let firstCont = 1;
|
|
363
|
+
let y = layerCal.year;
|
|
364
|
+
let m = layerCal.month;
|
|
365
|
+
for (let i = 0; i < Math.abs(diff); i += 1) if (diff > 0) {
|
|
366
|
+
firstCont += daysInMonthOf(y, m);
|
|
367
|
+
m += 1;
|
|
368
|
+
if (m > 12) {
|
|
369
|
+
m = 1;
|
|
370
|
+
y += 1;
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
m -= 1;
|
|
374
|
+
if (m < 1) {
|
|
375
|
+
m = 12;
|
|
376
|
+
y -= 1;
|
|
377
|
+
}
|
|
378
|
+
firstCont -= daysInMonthOf(y, m);
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
at: {
|
|
382
|
+
...at,
|
|
383
|
+
day: at.day - (firstCont - 1)
|
|
384
|
+
},
|
|
385
|
+
daysIn: daysInMonthOf(itemCal.year, itemCal.month),
|
|
386
|
+
cal: itemCal
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
const outOfValidity = (r, at) => {
|
|
390
|
+
const v = r.validity;
|
|
391
|
+
if (v === void 0 || r.nil === true || r.cancelled === true) return false;
|
|
392
|
+
const atAbs = absDayHour(at.day, at.hour) + at.minute;
|
|
393
|
+
return atAbs < absDayHour(v.startDay, v.startHour) || atAbs >= absDayHour(v.endDay < v.startDay ? v.endDay + 31 : v.endDay, v.endHour);
|
|
394
|
+
};
|
|
395
|
+
/** 单站展开视觉态(首建与换时刻共用——tier/摘要/提示单一来源) */
|
|
396
|
+
function tafMarkerState(item, at, anchor, locale) {
|
|
397
|
+
const r = item.report;
|
|
398
|
+
const name = item.title ?? r.station;
|
|
399
|
+
const noTimeline = r.nil === true || r.cancelled === true;
|
|
400
|
+
const v = r.validity;
|
|
401
|
+
let tier = "unknown";
|
|
402
|
+
let summary = r.nil === true ? "缺报(NIL)" : r.cancelled === true ? "预报取消(CNL)" : "";
|
|
403
|
+
const notes = [];
|
|
404
|
+
if (!noTimeline && v !== void 0 && outOfValidity(r, at)) {
|
|
405
|
+
const early = absDayHour(at.day, at.hour) < absDayHour(v.startDay, v.startHour);
|
|
406
|
+
notes.push(early ? "预报尚未生效(按发布时基况显示)" : "预报已过期(按末段显示)");
|
|
407
|
+
return {
|
|
408
|
+
tier: "unknown",
|
|
409
|
+
label: `${name} · 预报${early ? "未生效" : "已过期"}`,
|
|
410
|
+
summary,
|
|
411
|
+
notes
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
if (!noTimeline && v !== void 0) {
|
|
415
|
+
const expansion = expandTaf(r, at, anchor);
|
|
416
|
+
const effective = expansion.tempo !== void 0 ? {
|
|
417
|
+
...expansion.conditions,
|
|
418
|
+
...expansion.tempo.conditions,
|
|
419
|
+
weather: expansion.tempo.conditions.weather ?? [],
|
|
420
|
+
cavok: expansion.tempo.conditions.cavok
|
|
421
|
+
} : expansion.conditions;
|
|
422
|
+
tier = conditionOf(asConditionInput(effective, false));
|
|
423
|
+
summary = summarizeTaf(effective, locale);
|
|
424
|
+
if (expansion.uncertain) notes.push(locale === "en" ? "Transition band — timing uncertain" : "过渡带(变化时刻不确定)");
|
|
425
|
+
if (expansion.tempo !== void 0) {
|
|
426
|
+
const tempoSummary = summarizeTaf(effective, locale);
|
|
427
|
+
notes.push((locale === "en" ? "TEMPO bursts: " : "TEMPO 发作可能:") + tempoSummary);
|
|
428
|
+
}
|
|
429
|
+
} else if (v !== void 0) notes.push(`有效期 ${v.raw}`);
|
|
430
|
+
return {
|
|
431
|
+
tier,
|
|
432
|
+
label: `${name} · ${locale === "en" ? "Forecast " : "预报"}${TIER_WORDS[locale][tier]}`,
|
|
433
|
+
summary,
|
|
434
|
+
notes
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/** 点位 divIcon(含常显 ICAO 站码标签)与悬停 tooltip 内容 */
|
|
438
|
+
function tafMarkerIcon(L, item, state) {
|
|
439
|
+
const name = item.title ?? item.report.station;
|
|
440
|
+
return {
|
|
441
|
+
icon: L.divIcon({
|
|
442
|
+
className: "mw-cond-icon",
|
|
443
|
+
html: `<span role="img" aria-label="${escapeHtml(state.label)}" class="mw-dot mw-dot-${state.tier}" style="display:inline-block;width:12px;height:12px;border-radius:50%;background:${TIER_COLORS[state.tier]};border:2px solid #fff;box-shadow:0 0 2px rgba(0,0,0,.4)"></span><span class="mw-code-label" aria-hidden="true">${escapeHtml(item.report.station)}</span>`,
|
|
444
|
+
iconSize: [14, 14],
|
|
445
|
+
iconAnchor: [7, 7]
|
|
446
|
+
}),
|
|
447
|
+
tip: tafTipContent(name, state)
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function tafTipContent(name, state) {
|
|
451
|
+
const tip = document.createElement("span");
|
|
452
|
+
tip.append(textCarrier(name));
|
|
453
|
+
if (state.summary !== "") tip.append(document.createElement("br"), textCarrier(state.summary));
|
|
454
|
+
for (const n of state.notes) tip.append(document.createElement("br"), textCarrier(n));
|
|
455
|
+
return tip;
|
|
456
|
+
}
|
|
457
|
+
/** TAF 标记构建核心:addTafLayer 首建用(标记构建 + 常显站码 + 惰性弹窗) */
|
|
458
|
+
async function populateTafLayer(map, group, items, options, L) {
|
|
459
|
+
const fallbackDaysIn = options.anchorDays ?? 31;
|
|
460
|
+
const layerCal = options.calendarAnchor;
|
|
461
|
+
const locale = options.locale ?? "zh";
|
|
462
|
+
if (layerCal !== void 0) tafLayerCalendar.set(group, layerCal);
|
|
463
|
+
tafLayerCard.set(group, options.card ?? {});
|
|
464
|
+
for (const item of items) {
|
|
465
|
+
const norm = stationNorm(item, options.at, layerCal, fallbackDaysIn);
|
|
466
|
+
const { icon, tip } = tafMarkerIcon(L, item, tafMarkerState(item, norm.at, { daysIn: norm.daysIn }, locale));
|
|
467
|
+
const marker = L.marker(item.position, { icon });
|
|
468
|
+
tafMarkerItems.set(marker, item);
|
|
469
|
+
marker.bindTooltip(tip);
|
|
470
|
+
if (options.popup ?? true) {
|
|
471
|
+
marker.bindPopup(document.createElement("div"), {
|
|
472
|
+
maxWidth: 480,
|
|
473
|
+
...options.popupOptions
|
|
474
|
+
});
|
|
475
|
+
const refresh = (popup) => {
|
|
476
|
+
const rNow = item.report;
|
|
477
|
+
const noTimelineNow = rNow.nil === true || rNow.cancelled === true;
|
|
478
|
+
const currentCont = tafLayerAt.get(group);
|
|
479
|
+
const normNow = stationNorm(item, noTimelineNow ? void 0 : currentCont, tafLayerCalendar.get(group), fallbackDaysIn);
|
|
480
|
+
const fresh = tafMarkerState(item, normNow.at, { daysIn: normNow.daysIn }, locale);
|
|
481
|
+
const cardOpts = {
|
|
482
|
+
locale,
|
|
483
|
+
raw: true,
|
|
484
|
+
...currentCont === void 0 || noTimelineNow ? {} : { at: normNow.at },
|
|
485
|
+
...tafLayerCard.get(group)
|
|
486
|
+
};
|
|
487
|
+
if (normNow.cal !== void 0) cardOpts.monthAnchor = normNow.cal;
|
|
488
|
+
if (cardOpts.stationTitle === void 0 && item.title !== void 0) {
|
|
489
|
+
const stationName = item.title.startsWith(`${rNow.station} `) ? item.title.slice(rNow.station.length + 1) : void 0;
|
|
490
|
+
if (stationName !== void 0) cardOpts.stationTitle = stationName;
|
|
491
|
+
}
|
|
492
|
+
const card = renderTafCard(rNow, cardOpts);
|
|
493
|
+
if (fresh.notes.length > 0) {
|
|
494
|
+
const lead = document.createElement("p");
|
|
495
|
+
lead.style.margin = "0 0 4px";
|
|
496
|
+
lead.className = "mw-taf-meta";
|
|
497
|
+
for (const [i, n] of fresh.notes.entries()) {
|
|
498
|
+
if (i > 0) lead.append(document.createElement("br"));
|
|
499
|
+
lead.append(textCarrier(n));
|
|
500
|
+
}
|
|
501
|
+
card.prepend(lead);
|
|
502
|
+
}
|
|
503
|
+
popup.setContent(card);
|
|
504
|
+
};
|
|
505
|
+
tafPopupRefresh.set(marker, refresh);
|
|
506
|
+
marker.on("popupopen", (e) => {
|
|
507
|
+
if (e.popup === void 0) return;
|
|
508
|
+
marker.closeTooltip();
|
|
509
|
+
refresh(e.popup);
|
|
510
|
+
(e.popup.getElement()?.querySelector("a.leaflet-popup-close-button, button, [href]"))?.focus();
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
marker.addTo(group);
|
|
514
|
+
}
|
|
515
|
+
if (!escapeBoundMaps.has(map)) {
|
|
516
|
+
escapeBoundMaps.add(map);
|
|
517
|
+
map.getContainer().addEventListener("keydown", (event) => {
|
|
518
|
+
if (event.key === "Escape") map.closePopup();
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
injectTafLayerStyle();
|
|
522
|
+
bindCodeZoom(map);
|
|
523
|
+
if (options.at !== void 0) tafLayerAt.set(group, options.at);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* 单站某时刻的档位(数据直读,2026-09-24 评测批3#14):与地图圆点同一判据管线
|
|
527
|
+
* (含出窗灰、TEMPO 发作窗升档、NIL/CNL 灰)——宿主面板/对比基准不再从 marker DOM
|
|
528
|
+
* className 正则回读(状态经渲染产物回流的工程债收口),DOM 只做展示。
|
|
529
|
+
* at 语义同 addTafLayer:calendarAnchor 在位时为层连续序(逐报归一),否则为报锚内日号。
|
|
530
|
+
*/
|
|
531
|
+
/**
|
|
532
|
+
* METAR 档位判据公共面(conditionOf 薄包装,tafTierOf 同款):实况面板/图例等消费方
|
|
533
|
+
* 与圆点同一判据管线(数据直读,不从 DOM 回流——批3#14 口径)。
|
|
534
|
+
*/
|
|
535
|
+
function metarTierOf(report) {
|
|
536
|
+
return conditionOf(report);
|
|
537
|
+
}
|
|
538
|
+
/** METAR 要素摘要公共面(summarizeReport 薄包装):「2500m +TSRA BKN030CB」式扫视摘要。 */
|
|
539
|
+
function summarizeMetarConditions(report, locale) {
|
|
540
|
+
return summarizeReport(report, locale);
|
|
541
|
+
}
|
|
542
|
+
function tafTierOf(item, at, options = {}) {
|
|
543
|
+
const norm = stationNorm(item, at, options.calendarAnchor, options.anchorDays ?? 31);
|
|
544
|
+
return tafMarkerState(item, norm.at, { daysIn: norm.daysIn }, "zh").tier;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Re-expand every TAF marker at a new instant — in-place icon/tooltip update, no rebuild.
|
|
548
|
+
* 全图统一换时刻(评测工程 P2-1 瘦身版):各标记原地 setIcon/setTooltipContent,不再清层重建——
|
|
549
|
+
* 换时刻不销毁已开弹窗(打开中的弹窗即时换内容)、无 DOM/监听器 churn(38 站滑杆拖动不再百卡重建)。
|
|
550
|
+
* 展开是纯函数、遍历同步,无 clearLayers/populate 竞态窗口(旧代际令牌机制随重建路径一并退役)。
|
|
551
|
+
*/
|
|
552
|
+
async function setTafLayerTime(map, layer, items, options = {}) {
|
|
553
|
+
const L = await loadLeaflet();
|
|
554
|
+
const fallbackDaysIn = options.anchorDays ?? 31;
|
|
555
|
+
const locale = options.locale ?? "zh";
|
|
556
|
+
if (options.calendarAnchor !== void 0) tafLayerCalendar.set(layer, options.calendarAnchor);
|
|
557
|
+
const at = options.at ?? tafLayerAt.get(layer) ?? {
|
|
558
|
+
day: items[0]?.report.validity?.startDay ?? 1,
|
|
559
|
+
hour: items[0]?.report.validity?.startHour ?? 0,
|
|
560
|
+
minute: 0
|
|
561
|
+
};
|
|
562
|
+
if (options.at !== void 0) tafLayerAt.set(layer, options.at);
|
|
563
|
+
if (options.card !== void 0) tafLayerCard.set(layer, {
|
|
564
|
+
...tafLayerCard.get(layer),
|
|
565
|
+
...options.card
|
|
566
|
+
});
|
|
567
|
+
const openRefresh = [];
|
|
568
|
+
layer.eachLayer((ml) => {
|
|
569
|
+
if (!(ml instanceof L.Marker)) return;
|
|
570
|
+
const marker = ml;
|
|
571
|
+
const item = tafMarkerItems.get(marker);
|
|
572
|
+
if (item === void 0) return;
|
|
573
|
+
const norm = stationNorm(item, at, tafLayerCalendar.get(layer), fallbackDaysIn);
|
|
574
|
+
const state = tafMarkerState(item, norm.at, { daysIn: norm.daysIn }, locale);
|
|
575
|
+
const { icon, tip } = tafMarkerIcon(L, item, state);
|
|
576
|
+
marker.setIcon(icon);
|
|
577
|
+
marker.setTooltipContent(tip);
|
|
578
|
+
if (marker.isPopupOpen()) openRefresh.push(marker);
|
|
579
|
+
});
|
|
580
|
+
for (const marker of openRefresh) {
|
|
581
|
+
const popup = marker.getPopup();
|
|
582
|
+
if (popup === void 0) continue;
|
|
583
|
+
tafPopupRefresh.get(marker)?.(popup);
|
|
584
|
+
}
|
|
585
|
+
return layer;
|
|
586
|
+
}
|
|
587
|
+
/** 时刻展示串(控件与卡片共用口径;en 无「日」字) */
|
|
588
|
+
const fmtTafAt = (at, locale = "zh") => locale === "zh" ? `${String(at.day).padStart(2, "0")}日 ${String(at.hour).padStart(2, "0")}:${String(at.minute).padStart(2, "0")}Z` : `Day ${String(at.day).padStart(2, "0")} ${String(at.hour).padStart(2, "0")}:${String(at.minute).padStart(2, "0")} Z`;
|
|
589
|
+
/** 控件时刻显示·本地时(时区单制):tag+M月D日HH:MM——月位显式(2026-09-24 评测 P1 月界批):
|
|
590
|
+
* calendarAnchor 在位时走真实月历(at 为自锚月 1 日起的连续日序,day>31 按进位恒正确、跨月不回绕);
|
|
591
|
+
* 缺席时按 31 天折回(残余近似仅显示位:无月语境无从判读真实月份,控件值本身不受影响) */
|
|
592
|
+
const fmtTafZone = (at, offset, tag, cal) => {
|
|
593
|
+
if (cal !== void 0 && at.day >= 1) {
|
|
594
|
+
const z = new Date(Date.UTC(cal.year, cal.month - 1, at.day, at.hour, at.minute) + offset * 6e4);
|
|
595
|
+
return `${tag}${z.getUTCMonth() + 1}月${z.getUTCDate()}日${String(z.getUTCHours()).padStart(2, "0")}:${String(z.getUTCMinutes()).padStart(2, "0")}`;
|
|
596
|
+
}
|
|
597
|
+
const total = (at.day - 1) * 1440 + at.hour * 60 + at.minute + offset;
|
|
598
|
+
const d = Math.floor(total / 1440) % 31 + 1;
|
|
599
|
+
const h = Math.floor(total % 1440 / 60);
|
|
600
|
+
const m = total % 60;
|
|
601
|
+
return `${tag}${String(d).padStart(2, "0")}日${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
602
|
+
};
|
|
603
|
+
/** TafExpandAt ↔ 绝对分钟序(窗/刻度格计算共用;day-1 基准使跨日差值可直接加减) */
|
|
604
|
+
const tafAbsOf = (at) => (at.day - 1) * 1440 + at.hour * 60 + at.minute;
|
|
605
|
+
const tafAtOfAbs = (abs) => ({
|
|
606
|
+
day: Math.floor(abs / 1440) + 1,
|
|
607
|
+
hour: Math.floor(abs % 1440 / 60),
|
|
608
|
+
minute: abs % 60
|
|
609
|
+
});
|
|
610
|
+
/**
|
|
611
|
+
* A framework-free time-scrub control element for a TAF layer: one range input drives every
|
|
612
|
+
* station's re-expansion (renderer layer ③). Host mounts it anywhere; pure DOM, no Leaflet control
|
|
613
|
+
* inheritance required.
|
|
614
|
+
* TAF 图层的时间滑杆控件(零框架纯 DOM):一个 range 输入驱动全图各站重展开;宿主自行挂载定位。
|
|
615
|
+
*/
|
|
616
|
+
function createTafTimeControl(map, options) {
|
|
617
|
+
const step = options.stepMinutes ?? 60;
|
|
618
|
+
const locale = options.locale ?? "zh";
|
|
619
|
+
const ltOffset = options.utcOffsetMinutes ?? null;
|
|
620
|
+
const cal = options.calendarAnchor;
|
|
621
|
+
const zoneText = (at) => ltOffset !== null && locale === "zh" ? fmtTafZone(at, ltOffset, "北京时", cal) : fmtTafAt(at, locale);
|
|
622
|
+
const box = document.createElement("div");
|
|
623
|
+
box.className = "mw-taf-timectrl";
|
|
624
|
+
box.style.cssText = "display:flex;gap:8px;align-items:center;padding:6px 10px;background:#fff;border:1px solid #d8dee6;border-radius:8px;font:12px/1.4 system-ui,sans-serif;color:#1c2733";
|
|
625
|
+
const label = document.createElement("span");
|
|
626
|
+
const input = document.createElement("input");
|
|
627
|
+
input.type = "range";
|
|
628
|
+
input.min = "0";
|
|
629
|
+
input.step = "1";
|
|
630
|
+
input.setAttribute("aria-label", locale === "en" ? "forecast time" : "预报时刻");
|
|
631
|
+
const from = options.from ?? options.items.reduce((acc, it) => {
|
|
632
|
+
const v = it.report.validity;
|
|
633
|
+
if (v === void 0) return acc;
|
|
634
|
+
const cand = {
|
|
635
|
+
day: v.startDay,
|
|
636
|
+
hour: v.startHour,
|
|
637
|
+
minute: 0
|
|
638
|
+
};
|
|
639
|
+
if (acc.day === 0 && acc.hour === 0) return cand;
|
|
640
|
+
return cand.day < acc.day || cand.day === acc.day && cand.hour < acc.hour ? cand : acc;
|
|
641
|
+
}, {
|
|
642
|
+
day: 0,
|
|
643
|
+
hour: 0,
|
|
644
|
+
minute: 0
|
|
645
|
+
});
|
|
646
|
+
let toAbsMax = null;
|
|
647
|
+
for (const it of options.items) {
|
|
648
|
+
const v = it.report.validity;
|
|
649
|
+
if (v === void 0) continue;
|
|
650
|
+
const abs = ((v.endDay < v.startDay ? v.endDay + 31 : v.endDay) - 1) * 1440 + v.endHour * 60;
|
|
651
|
+
if (toAbsMax === null || abs > toAbsMax) toAbsMax = abs;
|
|
652
|
+
}
|
|
653
|
+
const fromAbs = tafAbsOf(from);
|
|
654
|
+
const toAbs = options.to !== void 0 ? tafAbsOf(options.to) : toAbsMax ?? fromAbs + 100 * step;
|
|
655
|
+
const spanSteps = Math.max(1, Math.round((toAbs - fromAbs) / step));
|
|
656
|
+
input.max = String(spanSteps);
|
|
657
|
+
const atOfValue = (value) => {
|
|
658
|
+
const base = from.day * 1440 + from.hour * 60 + from.minute + value * step;
|
|
659
|
+
return {
|
|
660
|
+
day: Math.floor(base / 1440),
|
|
661
|
+
hour: Math.floor(base % 1440 / 60),
|
|
662
|
+
minute: base % 60
|
|
663
|
+
};
|
|
664
|
+
};
|
|
665
|
+
const apply = (at) => {
|
|
666
|
+
const text = zoneText(at);
|
|
667
|
+
label.textContent = text;
|
|
668
|
+
input.setAttribute("aria-valuetext", text);
|
|
669
|
+
setTafLayerTime(map, options.layer, options.items, {
|
|
670
|
+
...options.layerOptions,
|
|
671
|
+
at
|
|
672
|
+
});
|
|
673
|
+
options.onTime?.(at);
|
|
674
|
+
};
|
|
675
|
+
let rafPending = false;
|
|
676
|
+
input.addEventListener("input", () => {
|
|
677
|
+
if (rafPending) return;
|
|
678
|
+
rafPending = true;
|
|
679
|
+
requestAnimationFrame(() => {
|
|
680
|
+
rafPending = false;
|
|
681
|
+
apply(atOfValue(Number(input.value)));
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
const idxOf = (at) => Math.max(0, Math.min(spanSteps, Math.round((at.day * 1440 + at.hour * 60 + at.minute - (from.day * 1440 + from.hour * 60 + from.minute)) / step)));
|
|
685
|
+
input.value = String(idxOf(options.initialAt ?? from));
|
|
686
|
+
apply(atOfValue(Number(input.value)));
|
|
687
|
+
box.append(input, label);
|
|
688
|
+
if (options.tickEveryMinutes !== void 0) {
|
|
689
|
+
const every = Math.max(step, options.tickEveryMinutes);
|
|
690
|
+
const first = Math.ceil((fromAbs + 1) / every) * every;
|
|
691
|
+
const total = toAbs - fromAbs;
|
|
692
|
+
const axis = document.createElement("div");
|
|
693
|
+
axis.style.cssText = "position:relative;height:15px;margin-top:4px;width:100%;font-size:10px;color:#6b7785";
|
|
694
|
+
/** 刻度短标签:整点 HH:00;展示时区 00 时=日界 M月D日(calendarAnchor 真月历,月界批) */
|
|
695
|
+
const tickText = (at) => {
|
|
696
|
+
if (ltOffset !== null && locale === "zh") {
|
|
697
|
+
if (cal !== void 0 && at.day >= 1) {
|
|
698
|
+
const z = new Date(Date.UTC(cal.year, cal.month - 1, at.day, at.hour, at.minute) + ltOffset * 6e4);
|
|
699
|
+
return z.getUTCHours() === 0 && z.getUTCMinutes() === 0 ? `北京时${z.getUTCMonth() + 1}月${z.getUTCDate()}日` : `北京时${String(z.getUTCHours()).padStart(2, "0")}:${String(z.getUTCMinutes()).padStart(2, "0")}`;
|
|
700
|
+
}
|
|
701
|
+
const z = tafAtOfAbs(tafAbsOf(at) + ltOffset);
|
|
702
|
+
const day = (z.day - 1) % 31 + 1;
|
|
703
|
+
return z.hour === 0 && z.minute === 0 ? `北京时${String(day).padStart(2, "0")}日` : `北京时${String(z.hour).padStart(2, "0")}:${String(z.minute).padStart(2, "0")}`;
|
|
704
|
+
}
|
|
705
|
+
return at.hour === 0 && at.minute === 0 ? `${String(at.day).padStart(2, "0")}日` : `${String(at.hour).padStart(2, "0")}:${String(at.minute).padStart(2, "0")}`;
|
|
706
|
+
};
|
|
707
|
+
const addTick = (abs, anchorRight = false) => {
|
|
708
|
+
const s = document.createElement("span");
|
|
709
|
+
s.textContent = tickText(tafAtOfAbs(abs));
|
|
710
|
+
s.style.cssText = anchorRight ? "position:absolute;right:0;white-space:nowrap" : `position:absolute;left:${((abs - fromAbs) / total * 100).toFixed(3)}%;transform:translateX(-50%);white-space:nowrap`;
|
|
711
|
+
axis.append(s);
|
|
712
|
+
};
|
|
713
|
+
for (let a = first; a <= toAbs - every / 2; a += every) addTick(a);
|
|
714
|
+
addTick(toAbs, true);
|
|
715
|
+
box.append(axis);
|
|
716
|
+
return box;
|
|
717
|
+
}
|
|
718
|
+
const ticks = document.createElement("div");
|
|
719
|
+
ticks.style.cssText = "display:flex;justify-content:space-between;width:100%;font-size:10px;color:#6b7785";
|
|
720
|
+
const tickL = document.createElement("span");
|
|
721
|
+
tickL.textContent = zoneText(from);
|
|
722
|
+
const tickR = document.createElement("span");
|
|
723
|
+
tickR.textContent = zoneText(atOfValue(spanSteps));
|
|
724
|
+
ticks.append(tickL, tickR);
|
|
725
|
+
box.append(ticks);
|
|
726
|
+
return box;
|
|
727
|
+
}
|
|
223
728
|
//#endregion
|
|
224
|
-
export { addMetarLayer };
|
|
729
|
+
export { TIER_COLORS, addMetarLayer, addTafLayer, createTafTimeControl, metarTierOf, setTafLayerTime, summarizeMetarConditions, tafTierOf };
|
|
225
730
|
|
|
226
731
|
//# sourceMappingURL=index.js.map
|