@sayue_ltr/fleq 1.49.2

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +898 -0
  2. package/LICENSE +21 -0
  3. package/README.md +535 -0
  4. package/assets/icons/.gitkeep +0 -0
  5. package/assets/sounds/.gitkeep +0 -0
  6. package/assets/sounds/cancel.mp3 +0 -0
  7. package/assets/sounds/critical.mp3 +0 -0
  8. package/assets/sounds/info.mp3 +0 -0
  9. package/assets/sounds/normal.mp3 +0 -0
  10. package/assets/sounds/warning.mp3 +0 -0
  11. package/dist/config.js +638 -0
  12. package/dist/dmdata/connection-manager.js +2 -0
  13. package/dist/dmdata/endpoint-selector.js +185 -0
  14. package/dist/dmdata/multi-connection-manager.js +158 -0
  15. package/dist/dmdata/rest-client.js +281 -0
  16. package/dist/dmdata/telegram-parser.js +704 -0
  17. package/dist/dmdata/volcano-parser.js +647 -0
  18. package/dist/dmdata/ws-client.js +336 -0
  19. package/dist/engine/cli/cli-init.js +266 -0
  20. package/dist/engine/cli/cli-run.js +121 -0
  21. package/dist/engine/cli/cli.js +121 -0
  22. package/dist/engine/eew/eew-logger.js +355 -0
  23. package/dist/engine/eew/eew-tracker.js +229 -0
  24. package/dist/engine/messages/message-router.js +261 -0
  25. package/dist/engine/messages/tsunami-state.js +96 -0
  26. package/dist/engine/messages/volcano-state.js +131 -0
  27. package/dist/engine/messages/volcano-vfvo53-aggregator.js +173 -0
  28. package/dist/engine/monitor/monitor.js +118 -0
  29. package/dist/engine/monitor/repl-coordinator.js +63 -0
  30. package/dist/engine/monitor/shutdown.js +114 -0
  31. package/dist/engine/notification/node-notifier-loader.js +19 -0
  32. package/dist/engine/notification/notifier.js +338 -0
  33. package/dist/engine/notification/sound-player.js +230 -0
  34. package/dist/engine/notification/volcano-presentation.js +166 -0
  35. package/dist/engine/startup/config-resolver.js +139 -0
  36. package/dist/engine/startup/tsunami-initializer.js +91 -0
  37. package/dist/engine/startup/update-checker.js +229 -0
  38. package/dist/engine/startup/volcano-initializer.js +89 -0
  39. package/dist/index.js +24 -0
  40. package/dist/logger.js +95 -0
  41. package/dist/types.js +61 -0
  42. package/dist/ui/earthquake-formatter.js +871 -0
  43. package/dist/ui/eew-formatter.js +335 -0
  44. package/dist/ui/formatter.js +689 -0
  45. package/dist/ui/repl.js +2059 -0
  46. package/dist/ui/test-samples.js +880 -0
  47. package/dist/ui/theme.js +516 -0
  48. package/dist/ui/volcano-formatter.js +667 -0
  49. package/dist/ui/waiting-tips.js +227 -0
  50. package/dist/utils/intensity.js +13 -0
  51. package/dist/utils/secrets.js +14 -0
  52. package/package.json +69 -0
@@ -0,0 +1,667 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.volcanoTypeLabel = volcanoTypeLabel;
7
+ exports.displayVolcanoInfo = displayVolcanoInfo;
8
+ exports.displayVolcanoAshfallBatch = displayVolcanoAshfallBatch;
9
+ const chalk_1 = __importDefault(require("chalk"));
10
+ const formatter_1 = require("./formatter");
11
+ const theme_1 = require("./theme");
12
+ // ── ヘルパー ──
13
+ /** 電文タイプの日本語名 */
14
+ function volcanoTypeLabel(type) {
15
+ const map = {
16
+ VFVO50: "噴火警報・予報",
17
+ VFVO51: "火山の状況に関する解説情報",
18
+ VFVO52: "噴火に関する火山観測報",
19
+ VFVO53: "降灰予報(定時)",
20
+ VFVO54: "降灰予報(速報)",
21
+ VFVO55: "降灰予報(詳細)",
22
+ VFVO56: "噴火速報",
23
+ VFVO60: "推定噴煙流向報",
24
+ VZVO40: "火山に関するお知らせ",
25
+ };
26
+ // VFSVii は先頭4文字 "VFSV" でマッチ
27
+ if (type.startsWith("VFSV"))
28
+ return "火山現象に関する海上警報";
29
+ return map[type] || type;
30
+ }
31
+ /** 噴火警戒レベルに対応するテーマロール */
32
+ function levelRole(level) {
33
+ switch (level) {
34
+ case 1: return "volcanoLevel1";
35
+ case 2: return "volcanoLevel2";
36
+ case 3: return "volcanoLevel3";
37
+ case 4: return "volcanoLevel4";
38
+ case 5: return "volcanoLevel5";
39
+ default: return "textMuted";
40
+ }
41
+ }
42
+ /** 現象コードに対応するテーマロール */
43
+ function phenomenonRole(code) {
44
+ switch (code) {
45
+ case "51": return "volcanoPhenomenonExplosion"; // 爆発
46
+ case "52": return "volcanoPhenomenonEruption"; // 噴火
47
+ case "56": return "volcanoPhenomenonFrequent"; // 噴火多発
48
+ case "62": return "volcanoPhenomenonPossible"; // 噴火したもよう
49
+ default: return "textMuted";
50
+ }
51
+ }
52
+ /** 降灰コードに対応するテーマロール */
53
+ function ashfallRole(code) {
54
+ switch (code) {
55
+ case "75": return "volcanoAshfallBallistic"; // 小さな噴石
56
+ case "73": return "volcanoAshfallHeavy"; // 多量
57
+ case "72": return "volcanoAshfallModerate"; // やや多量
58
+ case "71": return "volcanoAshfallLight"; // 少量
59
+ case "70": return "volcanoAshfallLight"; // 降灰
60
+ default: return "textMuted";
61
+ }
62
+ }
63
+ /** アクションの日本語ラベル */
64
+ function actionLabel(action) {
65
+ const map = {
66
+ raise: "引上げ",
67
+ lower: "引下げ",
68
+ release: "解除",
69
+ continue: "継続",
70
+ issue: "発表",
71
+ cancel: "取消",
72
+ };
73
+ return map[action] ?? action;
74
+ }
75
+ /** レベルコード表示変換 */
76
+ function levelCodeToDisplay(code) {
77
+ const map = {
78
+ "11": "Lv1", "12": "Lv2", "13": "Lv3", "14": "Lv4", "15": "Lv5",
79
+ "21": "活火山であることに留意",
80
+ "22": "火口周辺危険",
81
+ "23": "入山危険",
82
+ "31": "海上警報",
83
+ "33": "海上予報",
84
+ "35": "活火山であることに留意(海底火山)",
85
+ "36": "周辺海域警戒",
86
+ };
87
+ return map[code] ?? code;
88
+ }
89
+ /** wrapFrameLines の結果を RenderBuffer に push */
90
+ function pushWrapped(buf, level, content, width) {
91
+ const lines = (0, formatter_1.wrapFrameLines)(level, content, width);
92
+ for (const line of lines) {
93
+ buf.push(line);
94
+ }
95
+ }
96
+ // ── 火山本文ハイライト ──
97
+ /** 火山本文キーワード強調ルール */
98
+ const VOLCANO_HIGHLIGHT_RULES = [
99
+ // 警戒・危険語
100
+ { source: "噴火警報|噴火予報|噴火速報|海上警報|海上予報", flags: "", style: () => chalk_1.default.bold.white },
101
+ { source: "噴火警戒レベル[1-51-5]?|レベル[1-51-5]|Lv[1-5]", flags: "", style: () => chalk_1.default.bold.white },
102
+ { source: "避難|警戒|規制|立入禁止|危険|注意", flags: "", style: () => (0, theme_1.getRoleChalk)("warningComment") },
103
+ // 現象語
104
+ { source: "噴火|爆発|噴煙|降灰|噴石|大きな噴石|小さな噴石|火砕流|溶岩流|火山泥流|火山ガス", flags: "", style: () => (0, theme_1.getRoleChalk)("warningComment") },
105
+ // 観測語
106
+ { source: "火山性微動|山体膨張|傾斜変動|空振|火映|鳴動", flags: "", style: () => chalk_1.default.white },
107
+ // レベル変更語
108
+ { source: "引き上げ|引き下げ|引上げ|引下げ|継続|解除|発表", flags: "", style: () => chalk_1.default.white },
109
+ // 海域関連
110
+ { source: "海底火山|周辺海域警戒|周辺海域", flags: "", style: () => chalk_1.default.white },
111
+ ];
112
+ /**
113
+ * 火山本文をハイライト付きで表示する共通ヘルパー。
114
+ * 改行で段落分割 → 各行にハイライト+折り返し → frameLine で出力。
115
+ */
116
+ function pushHighlightedBody(buf, level, bodyText, width, maxLines) {
117
+ const innerWidth = width - 4;
118
+ const bodyLines = bodyText
119
+ .split(/\r?\n/)
120
+ .map((line) => line.trimEnd());
121
+ const outputLines = [];
122
+ for (const line of bodyLines) {
123
+ if (line.trim().length === 0) {
124
+ outputLines.push("");
125
+ continue;
126
+ }
127
+ const highlighted = (0, formatter_1.highlightAndWrap)(` ${line}`, VOLCANO_HIGHLIGHT_RULES, innerWidth);
128
+ outputLines.push(...highlighted);
129
+ }
130
+ const displayLines = outputLines.slice(0, maxLines);
131
+ for (const line of displayLines) {
132
+ buf.push((0, formatter_1.frameLine)(level, line, width));
133
+ }
134
+ if (outputLines.length > maxLines) {
135
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray(`(以下省略、全${outputLines.length}行)`)}`, width));
136
+ }
137
+ }
138
+ /** 電文・レベルに基づくバナー表示判定 */
139
+ function getVolcanoBannerSpec(info, level) {
140
+ if (info.kind === "alert" && level === "critical") {
141
+ return { role: "volcanoAlertBanner", text: volcanoTypeLabel(info.type) };
142
+ }
143
+ if (info.kind === "eruption" && info.isFlashReport && level === "critical") {
144
+ return { role: "volcanoFlashBanner", text: "噴火速報" };
145
+ }
146
+ if (info.kind === "ashfall" && level === "warning" && info.type === "VFVO54") {
147
+ return { role: "volcanoAlertBanner", text: volcanoTypeLabel(info.type) };
148
+ }
149
+ return null;
150
+ }
151
+ /** フレーム外全幅バナー描画 */
152
+ function pushFullWidthBanner(buf, style, text, width) {
153
+ buf.push(style(" ".repeat(width)));
154
+ buf.push(style((0, formatter_1.visualPadEnd)(` ${text}`, width)));
155
+ buf.push(style(" ".repeat(width)));
156
+ }
157
+ // ── 共通ヘッダー ──
158
+ function renderVolcanoHeader(info, level, width, buf) {
159
+ buf.push((0, formatter_1.frameTop)(level, width));
160
+ // タイトル行 — pushTitle で recap 対応
161
+ const titleContent = info.isTest
162
+ ? ` ${(0, theme_1.getRoleChalk)("testBadge")(" TEST ")} ${info.title} ${chalk_1.default.gray(formatter_1.SEVERITY_LABELS[level])}`
163
+ : ` ${info.title} ${chalk_1.default.gray(formatter_1.SEVERITY_LABELS[level])}`;
164
+ buf.pushTitle((0, formatter_1.frameLine)(level, titleContent, width));
165
+ // 火山名(日時はフッターに表示するため省略)
166
+ const volcanoLabel = info.volcanoName || "(不明)";
167
+ buf.push((0, formatter_1.frameLine)(level, ` ${volcanoLabel}`, width));
168
+ // ヘッドライン — pushHeadline で recap 対応(最初の行のみ)
169
+ if (info.headline) {
170
+ buf.push((0, formatter_1.frameDivider)(level, width));
171
+ // 複数行ヘッドラインの各行に先頭スペースを付与して整列
172
+ const indentedHeadline = info.headline.replace(/\r\n?/g, "\n").split("\n").map((l) => ` ${l}`).join("\n");
173
+ const wrapped = (0, formatter_1.wrapFrameLines)(level, indentedHeadline, width)
174
+ // <>内は他セクションと重複するためグレーアウト(折り返し後に適用)
175
+ .map((line) => line.replace(/<[^>]*>/g, (m) => chalk_1.default.gray(m)));
176
+ for (let i = 0; i < wrapped.length; i++) {
177
+ if (i === 0) {
178
+ buf.pushHeadline(wrapped[i]);
179
+ }
180
+ else {
181
+ buf.push(wrapped[i]);
182
+ }
183
+ }
184
+ }
185
+ }
186
+ // ── 電文タイプ別レンダラ ──
187
+ function renderAlert(info, level, width, buf) {
188
+ renderVolcanoHeader(info, level, width, buf);
189
+ buf.push((0, formatter_1.frameDivider)(level, width));
190
+ // カード行 (recap 用 1行サマリー)
191
+ if (info.alertLevel != null) {
192
+ const role = levelRole(info.alertLevel);
193
+ const colorFn = (0, theme_1.getRoleChalk)(role);
194
+ const actionStr = actionLabel(info.action);
195
+ // 引上げ/引下げ時: 矢印形式で前回→現在を1行表示
196
+ if (info.previousLevelCode && (info.action === "raise" || info.action === "lower")) {
197
+ const prevDisplay = levelCodeToDisplay(info.previousLevelCode);
198
+ buf.pushCard((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray(prevDisplay)} → ${colorFn(`Lv${info.alertLevel}`)} ${chalk_1.default.white(info.warningKind)} (${actionStr})`, width));
199
+ }
200
+ else {
201
+ buf.pushCard((0, formatter_1.frameLine)(level, ` ${colorFn(`Lv${info.alertLevel}`)} ${chalk_1.default.white(info.warningKind)} (${actionStr})`, width));
202
+ }
203
+ }
204
+ else if (info.isMarine) {
205
+ // 海上警報: warningKind + marineWarningKind + action で情報を強化
206
+ const cardParts = [];
207
+ if (info.warningKind)
208
+ cardParts.push(chalk_1.default.bold.white(info.warningKind));
209
+ if (info.marineWarningKind)
210
+ cardParts.push(chalk_1.default.white(info.marineWarningKind));
211
+ cardParts.push(`(${actionLabel(info.action)})`);
212
+ buf.pushCard((0, formatter_1.frameLine)(level, ` ${cardParts.join(" ")}`, width));
213
+ }
214
+ // 対象市町村
215
+ if (info.municipalities.length > 0) {
216
+ buf.push((0, formatter_1.frameDivider)(level, width));
217
+ const muniNames = info.municipalities.map((m) => m.name);
218
+ const maxMuni = (0, formatter_1.getTruncation)().volcanoMunicipalities;
219
+ const muniLine = muniNames.slice(0, maxMuni).join(", ");
220
+ const suffix = muniNames.length > maxMuni ? ` 他${muniNames.length - maxMuni}件` : "";
221
+ pushWrapped(buf, level, ` ${chalk_1.default.gray("対象:")} ${muniLine}${suffix}`, width);
222
+ }
223
+ // 対象海上予報区
224
+ if (info.marineAreas.length > 0) {
225
+ buf.push((0, formatter_1.frameDivider)(level, width));
226
+ const areaNames = info.marineAreas.map((a) => a.name);
227
+ pushWrapped(buf, level, ` ${chalk_1.default.gray("対象海域:")} ${areaNames.join(", ")}`, width);
228
+ }
229
+ // 本文 (VolcanoActivity) / 防災事項 (VolcanoPrevention)
230
+ if (info.bodyText) {
231
+ buf.push((0, formatter_1.frameDivider)(level, width));
232
+ const fullText = (0, formatter_1.getInfoFullText)();
233
+ const bodyLines = info.bodyText.split(/\r?\n/).filter((l) => l.trim().length > 0);
234
+ const maxLines = fullText ? bodyLines.length * 2 : (0, formatter_1.getTruncation)().volcanoAlertLines;
235
+ pushHighlightedBody(buf, level, info.bodyText, width, maxLines);
236
+ }
237
+ if (info.isMarine && info.preventionText) {
238
+ buf.push((0, formatter_1.frameDivider)(level, width));
239
+ pushHighlightedBody(buf, level, info.preventionText, width, (0, formatter_1.getTruncation)().volcanoPreventionLines);
240
+ }
241
+ }
242
+ function renderEruption(info, level, width, buf) {
243
+ renderVolcanoHeader(info, level, width, buf);
244
+ buf.push((0, formatter_1.frameDivider)(level, width));
245
+ // 現象 — pushCard で recap 用サマリー
246
+ const phenRole = phenomenonRole(info.phenomenonCode);
247
+ const phenFn = (0, theme_1.getRoleChalk)(phenRole);
248
+ const cardContent = info.craterName
249
+ ? ` ${phenFn(info.phenomenonName)} ${chalk_1.default.gray(info.craterName)}`
250
+ : ` ${phenFn(info.phenomenonName)}`;
251
+ buf.pushCard((0, formatter_1.frameLine)(level, cardContent, width));
252
+ // 火口名
253
+ if (info.craterName) {
254
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray("火口:")} ${info.craterName}`, width));
255
+ }
256
+ // 噴煙高度・流向
257
+ if (info.plumeHeight != null) {
258
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray("噴煙:")} 火口上${info.plumeHeight}m`, width));
259
+ }
260
+ else if (info.plumeHeightUnknown) {
261
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray("噴煙:")} 高度不明`, width));
262
+ }
263
+ if (info.plumeDirection) {
264
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray("流向:")} ${info.plumeDirection}`, width));
265
+ }
266
+ // 本文
267
+ if (info.bodyText) {
268
+ buf.push((0, formatter_1.frameDivider)(level, width));
269
+ const fullText = (0, formatter_1.getInfoFullText)();
270
+ const maxLines = fullText ? 999 : (0, formatter_1.getTruncation)().volcanoEruptionLines;
271
+ pushHighlightedBody(buf, level, info.bodyText, width, maxLines);
272
+ }
273
+ }
274
+ function renderAshfall(info, level, width, buf) {
275
+ renderVolcanoHeader(info, level, width, buf);
276
+ // カード行 (recap 用 1行サマリー) — 全降灰Kindを表示
277
+ {
278
+ const totalAreas = new Set(info.ashForecasts.flatMap((p) => p.areas).map((a) => a.name)).size;
279
+ // ashCode ごとに一意化し、重い順に全Kindを並べる
280
+ const allAreas = info.ashForecasts.flatMap((p) => p.areas);
281
+ const ashKinds = Array.from(new Map([...allAreas]
282
+ .sort((a, b) => (parseInt(b.ashCode, 10) || 0) - (parseInt(a.ashCode, 10) || 0))
283
+ .map((a) => [a.ashCode, a])).values());
284
+ const cardParts = ashKinds.map((a) => (0, theme_1.getRoleChalk)(ashfallRole(a.ashCode))(a.ashName));
285
+ cardParts.push(`${totalAreas}地域`);
286
+ buf.push((0, formatter_1.frameDivider)(level, width));
287
+ buf.pushCard((0, formatter_1.frameLine)(level, ` ${cardParts.join(" ")}`, width));
288
+ }
289
+ // 火口名
290
+ if (info.craterName) {
291
+ buf.push((0, formatter_1.frameDivider)(level, width));
292
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray("火口:")} ${info.craterName}`, width));
293
+ }
294
+ // 噴煙高度
295
+ if (info.plumeHeight != null) {
296
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray("噴煙:")} 火口上${info.plumeHeight}m`, width));
297
+ }
298
+ // 本文 (VolcanoActivity) — 降灰テーブルより先に表示
299
+ if (info.bodyText) {
300
+ buf.push((0, formatter_1.frameDivider)(level, width));
301
+ const fullText = (0, formatter_1.getInfoFullText)();
302
+ const t = (0, formatter_1.getTruncation)();
303
+ const maxLines = fullText ? 999
304
+ : info.type === "VFVO55" ? t.volcanoAshfallDetailLines
305
+ : info.type === "VFVO54" ? t.volcanoAshfallQuickLines
306
+ : t.volcanoAshfallRegularLines;
307
+ pushHighlightedBody(buf, level, info.bodyText, width, maxLines);
308
+ }
309
+ // 降灰予報データ
310
+ if (info.ashForecasts.length > 0) {
311
+ buf.push((0, formatter_1.frameDivider)(level, width));
312
+ const tr = (0, formatter_1.getTruncation)();
313
+ const maxPeriods = info.type === "VFVO54" ? tr.ashfallPeriodsQuick : tr.ashfallPeriodsOther;
314
+ const periods = info.ashForecasts.slice(0, maxPeriods);
315
+ // 幅80以上: renderFrameTable で表組み
316
+ if (width >= 80) {
317
+ const headers = ["時間帯", "地域", "降灰量"];
318
+ const rows = [];
319
+ for (const period of periods) {
320
+ const endStr = period.endTime ? (0, formatter_1.formatTimestamp)(period.endTime) : "";
321
+ const sortedAreas = [...period.areas].sort((a, b) => {
322
+ const codeA = parseInt(a.ashCode, 10) || 0;
323
+ const codeB = parseInt(b.ashCode, 10) || 0;
324
+ return codeB - codeA;
325
+ });
326
+ const maxAreas = info.type === "VFVO54" ? tr.ashfallAreasQuick : tr.ashfallAreasOther;
327
+ const displayed = sortedAreas.slice(0, maxAreas);
328
+ for (let i = 0; i < displayed.length; i++) {
329
+ const area = displayed[i];
330
+ rows.push([
331
+ i === 0 ? `~${endStr}` : "",
332
+ area.name,
333
+ area.ashName,
334
+ ]);
335
+ }
336
+ if (sortedAreas.length > maxAreas) {
337
+ rows.push(["", chalk_1.default.gray(`他${sortedAreas.length - maxAreas}件`), ""]);
338
+ }
339
+ }
340
+ (0, formatter_1.renderFrameTable)(level, headers, rows, width, buf);
341
+ }
342
+ else {
343
+ // 狭幅: 時間帯ごとに frameDivider で区切り
344
+ for (let pi = 0; pi < periods.length; pi++) {
345
+ const period = periods[pi];
346
+ if (pi > 0)
347
+ buf.push((0, formatter_1.frameDivider)(level, width));
348
+ if (period.endTime) {
349
+ const endStr = (0, formatter_1.formatTimestamp)(period.endTime);
350
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray(`~${endStr}`)}`, width));
351
+ }
352
+ const sortedAreas = [...period.areas].sort((a, b) => {
353
+ const codeA = parseInt(a.ashCode, 10) || 0;
354
+ const codeB = parseInt(b.ashCode, 10) || 0;
355
+ return codeB - codeA;
356
+ });
357
+ const maxAreas = info.type === "VFVO54" ? tr.ashfallAreasQuick : tr.ashfallAreasOther;
358
+ const displayed = sortedAreas.slice(0, maxAreas);
359
+ for (const area of displayed) {
360
+ const ashRole = ashfallRole(area.ashCode);
361
+ const ashFn = (0, theme_1.getRoleChalk)(ashRole);
362
+ for (const wl of (0, formatter_1.wrapFrameLines)(level, ` ${ashFn(area.ashName)} ${area.name}`, width)) {
363
+ buf.push(wl);
364
+ }
365
+ }
366
+ if (sortedAreas.length > maxAreas) {
367
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray(`他${sortedAreas.length - maxAreas}件`)}`, width));
368
+ }
369
+ }
370
+ }
371
+ if (info.ashForecasts.length > maxPeriods) {
372
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray(`(以降${info.ashForecasts.length - maxPeriods}時間帯省略)`)}`, width));
373
+ }
374
+ }
375
+ }
376
+ function renderText(info, level, width, buf) {
377
+ renderVolcanoHeader(info, level, width, buf);
378
+ // レベル (VFVO51)
379
+ if (info.alertLevel != null) {
380
+ buf.push((0, formatter_1.frameDivider)(level, width));
381
+ const role = levelRole(info.alertLevel);
382
+ const colorFn = (0, theme_1.getRoleChalk)(role);
383
+ buf.push((0, formatter_1.frameLine)(level, ` ${colorFn(`Lv${info.alertLevel}`)}`, width));
384
+ }
385
+ // 臨時バッジ(インライン表示)
386
+ if (info.isExtraordinary) {
387
+ const bannerFn = (0, theme_1.getRoleChalk)("volcanoAlertBanner");
388
+ buf.push((0, formatter_1.frameLine)(level, ` ${bannerFn(" 臨時 ")}`, width));
389
+ }
390
+ // 本文 — getInfoFullText() で全文表示
391
+ if (info.bodyText) {
392
+ buf.push((0, formatter_1.frameDivider)(level, width));
393
+ const fullText = (0, formatter_1.getInfoFullText)();
394
+ const maxLines = fullText ? 999 : (0, formatter_1.getTruncation)().volcanoTextLines;
395
+ pushHighlightedBody(buf, level, info.bodyText, width, maxLines);
396
+ }
397
+ // NextAdvisory
398
+ if (info.nextAdvisory) {
399
+ buf.push((0, formatter_1.frameDivider)(level, width));
400
+ const naFn = (0, theme_1.getRoleChalk)("nextAdvisory");
401
+ for (const wl of (0, formatter_1.wrapFrameLines)(level, ` ${naFn(info.nextAdvisory)}`, width)) {
402
+ buf.push(wl);
403
+ }
404
+ }
405
+ }
406
+ function renderPlume(info, level, width, buf) {
407
+ renderVolcanoHeader(info, level, width, buf);
408
+ buf.push((0, formatter_1.frameDivider)(level, width));
409
+ // 現象 — pushCard で recap 用サマリー
410
+ if (info.phenomenonCode) {
411
+ const phenRole = phenomenonRole(info.phenomenonCode);
412
+ const phenFn = (0, theme_1.getRoleChalk)(phenRole);
413
+ const phenNames = {
414
+ "51": "爆発", "52": "噴火", "56": "噴火多発", "62": "噴火したもよう",
415
+ };
416
+ const name = phenNames[info.phenomenonCode] ?? info.phenomenonCode;
417
+ const cardParts = [phenFn(name)];
418
+ if (info.plumeHeight != null)
419
+ cardParts.push(`${info.plumeHeight}m`);
420
+ if (info.plumeDirection)
421
+ cardParts.push(info.plumeDirection);
422
+ buf.pushCard((0, formatter_1.frameLine)(level, ` ${cardParts.join(" ")}`, width));
423
+ }
424
+ // 火口名
425
+ if (info.craterName) {
426
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray("火口:")} ${info.craterName}`, width));
427
+ }
428
+ // 噴煙高度・流向
429
+ if (info.plumeHeight != null) {
430
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray("噴煙:")} 火口上${info.plumeHeight}m`, width));
431
+ }
432
+ if (info.plumeDirection) {
433
+ buf.push((0, formatter_1.frameLine)(level, ` ${chalk_1.default.gray("流向:")} ${info.plumeDirection}`, width));
434
+ }
435
+ // 風向データ — renderFrameTable で表組み
436
+ if (info.windProfile.length > 0) {
437
+ buf.push((0, formatter_1.frameDivider)(level, width));
438
+ const headers = ["高度", "風向(°)", "風速"];
439
+ // 代表高度を間引き
440
+ const maxWind = (0, formatter_1.getTruncation)().plumeWindSampleRows;
441
+ const step = Math.max(1, Math.floor(info.windProfile.length / maxWind));
442
+ const sampled = info.windProfile.filter((_, i) => i % step === 0).slice(0, maxWind);
443
+ const rows = sampled.map((wp) => [
444
+ wp.altitude,
445
+ wp.degree != null ? `${wp.degree}°` : "—",
446
+ wp.speed != null ? `${wp.speed}kt` : "—",
447
+ ]);
448
+ (0, formatter_1.renderFrameTable)(level, headers, rows, width, buf);
449
+ }
450
+ }
451
+ // ── 取消報 ──
452
+ function renderCancel(info, width, buf) {
453
+ const level = "cancel";
454
+ buf.push((0, formatter_1.frameTop)(level, width));
455
+ buf.pushTitle((0, formatter_1.frameLine)(level, ` ${formatter_1.SEVERITY_LABELS[level]} ${info.title}`, width));
456
+ buf.push((0, formatter_1.frameLine)(level, ` ${info.volcanoName}`, width));
457
+ buf.push((0, formatter_1.frameDivider)(level, width));
458
+ const cancelFn = (0, theme_1.getRoleChalk)("cancelText");
459
+ buf.push((0, formatter_1.frameLine)(level, ` ${cancelFn("この情報は取り消されました")}`, width));
460
+ }
461
+ // ── 公開 API ──
462
+ /** 火山電文の表示 */
463
+ function displayVolcanoInfo(info, presentation) {
464
+ const width = (0, formatter_1.getFrameWidth)();
465
+ const level = presentation.frameLevel;
466
+ // コンパクトモード: 1行サマリー
467
+ if ((0, formatter_1.getDisplayMode)() === "compact") {
468
+ const parts = [formatter_1.SEVERITY_LABELS[level], volcanoTypeLabel(info.type), info.volcanoName];
469
+ switch (info.kind) {
470
+ case "alert":
471
+ if (info.alertLevel != null)
472
+ parts.push(`Lv${info.alertLevel} ${actionLabel(info.action)}`);
473
+ break;
474
+ case "eruption":
475
+ parts.push(info.phenomenonName);
476
+ break;
477
+ case "ashfall":
478
+ parts.push(`${new Set(info.ashForecasts.flatMap((p) => p.areas).map((a) => a.name)).size}地域`);
479
+ break;
480
+ case "text":
481
+ if (info.headline)
482
+ parts.push(info.headline.slice(0, 30));
483
+ break;
484
+ case "plume":
485
+ if (info.plumeHeight != null)
486
+ parts.push(`${info.plumeHeight}m`);
487
+ if (info.plumeDirection)
488
+ parts.push(info.plumeDirection);
489
+ break;
490
+ }
491
+ const color = (0, formatter_1.frameColor)(level);
492
+ console.log(color(parts.join(" ")));
493
+ return;
494
+ }
495
+ const renderBuf = (0, formatter_1.createRenderBuffer)();
496
+ // 前方空行
497
+ renderBuf.pushEmpty();
498
+ // フレーム外バナー(EEW/津波方式に統一)
499
+ const banner = getVolcanoBannerSpec(info, level);
500
+ if (banner != null) {
501
+ pushFullWidthBanner(renderBuf, (0, theme_1.getRoleChalk)(banner.role), banner.text, width);
502
+ }
503
+ if (info.infoType === "取消") {
504
+ renderCancel(info, width, renderBuf);
505
+ }
506
+ else {
507
+ switch (info.kind) {
508
+ case "alert":
509
+ renderAlert(info, level, width, renderBuf);
510
+ break;
511
+ case "eruption":
512
+ renderEruption(info, level, width, renderBuf);
513
+ break;
514
+ case "ashfall":
515
+ renderAshfall(info, level, width, renderBuf);
516
+ break;
517
+ case "text":
518
+ renderText(info, level, width, renderBuf);
519
+ break;
520
+ case "plume":
521
+ renderPlume(info, level, width, renderBuf);
522
+ break;
523
+ }
524
+ }
525
+ // 共通フッター
526
+ (0, formatter_1.renderFooter)(level, info.type, info.reportDateTime, info.publishingOffice, width, renderBuf);
527
+ renderBuf.push((0, formatter_1.frameBottom)(level, width));
528
+ renderBuf.pushEmpty();
529
+ (0, formatter_1.flushWithRecap)(renderBuf, level, width);
530
+ }
531
+ // ── VFVO53 バッチ表示 ──
532
+ /** 各火山の最大降灰コードを取得 */
533
+ function getMaxAshCode(info) {
534
+ let max = "0";
535
+ for (const period of info.ashForecasts) {
536
+ for (const area of period.areas) {
537
+ if (area.ashCode > max)
538
+ max = area.ashCode;
539
+ }
540
+ }
541
+ return max;
542
+ }
543
+ /** 各火山の最大降灰名を取得 */
544
+ function getMaxAshName(info) {
545
+ let maxCode = "0";
546
+ let maxName = "";
547
+ for (const period of info.ashForecasts) {
548
+ for (const area of period.areas) {
549
+ if (area.ashCode > maxCode) {
550
+ maxCode = area.ashCode;
551
+ maxName = area.ashName;
552
+ }
553
+ }
554
+ }
555
+ return maxName;
556
+ }
557
+ /** 注目地域(最大降灰コードの地域名、最大3件) */
558
+ function getNotableAreas(info, maxCount) {
559
+ const maxCode = getMaxAshCode(info);
560
+ const names = new Set();
561
+ for (const period of info.ashForecasts) {
562
+ for (const area of period.areas) {
563
+ if (area.ashCode === maxCode)
564
+ names.add(area.name);
565
+ if (names.size >= maxCount)
566
+ return [...names];
567
+ }
568
+ }
569
+ return [...names];
570
+ }
571
+ /** 注目火山かどうか(やや多量72以上 or 小さな噴石75) */
572
+ function isNotable(info) {
573
+ const code = getMaxAshCode(info);
574
+ return code >= "72";
575
+ }
576
+ /** VFVO53 バッチのまとめ表示 */
577
+ function displayVolcanoAshfallBatch(batch, presentation) {
578
+ const width = (0, formatter_1.getFrameWidth)();
579
+ const level = presentation.frameLevel;
580
+ const count = batch.items.length;
581
+ const notableCount = batch.items.filter(isNotable).length;
582
+ // バッチタイトル
583
+ const titleBase = `降灰予報(定時) ${count}火山`;
584
+ const title = batch.isTest
585
+ ? `${(0, theme_1.getRoleChalk)("testBadge")(" TEST ")} ${titleBase}`
586
+ : titleBase;
587
+ // コンパクトモード: 1行サマリー
588
+ if ((0, formatter_1.getDisplayMode)() === "compact") {
589
+ const parts = [formatter_1.SEVERITY_LABELS[level], titleBase];
590
+ // 注目火山を先頭に最大2件
591
+ const notable = batch.items.filter(isNotable).slice(0, 2);
592
+ if (notable.length > 0) {
593
+ parts.push(notable.map((i) => {
594
+ const ashName = getMaxAshName(i);
595
+ return `${i.volcanoName}(${ashName})`;
596
+ }).join(", "));
597
+ }
598
+ const rest = count - notable.length;
599
+ if (rest > 0)
600
+ parts.push(`他${rest}`);
601
+ const color = (0, formatter_1.frameColor)(level);
602
+ console.log(color(parts.join(" ")));
603
+ return;
604
+ }
605
+ const buf = (0, formatter_1.createRenderBuffer)();
606
+ buf.pushEmpty();
607
+ // フレーム開始
608
+ buf.push((0, formatter_1.frameTop)(level, width));
609
+ // タイトル行
610
+ const titleContent = ` ${title} ${chalk_1.default.gray(formatter_1.SEVERITY_LABELS[level])}`;
611
+ buf.pushTitle((0, formatter_1.frameLine)(level, titleContent, width));
612
+ // カード行 (recap用)
613
+ {
614
+ const totalAreas = new Set(batch.items.flatMap((i) => i.ashForecasts.flatMap((p) => p.areas).map((a) => a.name))).size;
615
+ const cardText = notableCount > 0
616
+ ? `${count}火山 注目${notableCount} 計${totalAreas}地域`
617
+ : `${count}火山 計${totalAreas}地域`;
618
+ buf.push((0, formatter_1.frameDivider)(level, width));
619
+ buf.pushCard((0, formatter_1.frameLine)(level, ` ${cardText}`, width));
620
+ }
621
+ // テーブル or リスト
622
+ buf.push((0, formatter_1.frameDivider)(level, width));
623
+ // 降灰量が強い順にソート
624
+ const sorted = [...batch.items].sort((a, b) => {
625
+ const codeA = getMaxAshCode(a);
626
+ const codeB = getMaxAshCode(b);
627
+ if (codeB !== codeA)
628
+ return codeB.localeCompare(codeA);
629
+ return a.volcanoName.localeCompare(b.volcanoName, "ja");
630
+ });
631
+ if (width >= 80) {
632
+ // テーブル形式
633
+ const headers = ["火山", "最大降灰", "注目地域", "時間帯数"];
634
+ const rows = sorted.map((info) => {
635
+ const maxAshCode = getMaxAshCode(info);
636
+ const maxAshName = getMaxAshName(info);
637
+ const notable = isNotable(info);
638
+ const ashFn = notable ? (0, theme_1.getRoleChalk)(ashfallRole(maxAshCode)) : (s) => s;
639
+ const areas = getNotableAreas(info, 3);
640
+ const areaStr = areas.join(", ");
641
+ return [
642
+ notable ? chalk_1.default.bold(info.volcanoName) : info.volcanoName,
643
+ ashFn(maxAshName),
644
+ areaStr,
645
+ `${info.ashForecasts.length}`,
646
+ ];
647
+ });
648
+ (0, formatter_1.renderFrameTable)(level, headers, rows, width, buf);
649
+ }
650
+ else {
651
+ // 狭幅: 1火山1行
652
+ for (const info of sorted) {
653
+ const maxAshCode = getMaxAshCode(info);
654
+ const maxAshName = getMaxAshName(info);
655
+ const ashFn = (0, theme_1.getRoleChalk)(ashfallRole(maxAshCode));
656
+ const line = ` ${info.volcanoName} ${ashFn(maxAshName)} ${info.ashForecasts.length}時間帯`;
657
+ for (const wl of (0, formatter_1.wrapFrameLines)(level, line, width)) {
658
+ buf.push(wl);
659
+ }
660
+ }
661
+ }
662
+ // 共通フッター
663
+ (0, formatter_1.renderFooter)(level, "VFVO53", batch.reportDateTime, batch.items[0].publishingOffice, width, buf);
664
+ buf.push((0, formatter_1.frameBottom)(level, width));
665
+ buf.pushEmpty();
666
+ (0, formatter_1.flushWithRecap)(buf, level, width);
667
+ }