@sayue_ltr/fleq 1.50.1 → 2.0.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +42 -6
  3. package/dist/config.js +37 -4
  4. package/dist/dmdata/rest-client.js +58 -3
  5. package/dist/dmdata/telegram-parser.js +115 -64
  6. package/dist/dmdata/ws-client.js +49 -18
  7. package/dist/engine/cli/cli-run.js +88 -3
  8. package/dist/engine/cli/cli.js +12 -0
  9. package/dist/engine/eew/eew-tracker.js +41 -15
  10. package/dist/engine/filter/compile-filter.js +21 -0
  11. package/dist/engine/filter/compiler.js +188 -0
  12. package/dist/engine/filter/errors.js +41 -0
  13. package/dist/engine/filter/field-registry.js +78 -0
  14. package/dist/engine/filter/index.js +15 -0
  15. package/dist/engine/filter/parser.js +137 -0
  16. package/dist/engine/filter/rank-maps.js +34 -0
  17. package/dist/engine/filter/tokenizer.js +121 -0
  18. package/dist/engine/filter/type-checker.js +104 -0
  19. package/dist/engine/filter/types.js +2 -0
  20. package/dist/engine/filter-template/pipeline-controller.js +73 -0
  21. package/dist/engine/filter-template/pipeline.js +16 -0
  22. package/dist/engine/messages/display-callbacks.js +7 -0
  23. package/dist/engine/messages/message-router.js +114 -182
  24. package/dist/engine/messages/summary-tracker.js +106 -0
  25. package/dist/engine/messages/telegram-stats.js +103 -0
  26. package/dist/engine/messages/volcano-route-handler.js +122 -0
  27. package/dist/engine/monitor/monitor.js +52 -4
  28. package/dist/engine/monitor/shutdown.js +1 -0
  29. package/dist/engine/notification/notifier.js +21 -4
  30. package/dist/engine/notification/sound-player.js +398 -36
  31. package/dist/engine/presentation/diff-store.js +158 -0
  32. package/dist/engine/presentation/diff-types.js +2 -0
  33. package/dist/engine/presentation/events/from-earthquake.js +53 -0
  34. package/dist/engine/presentation/events/from-eew.js +72 -0
  35. package/dist/engine/presentation/events/from-lg-observation.js +58 -0
  36. package/dist/engine/presentation/events/from-nankai-trough.js +39 -0
  37. package/dist/engine/presentation/events/from-raw.js +35 -0
  38. package/dist/engine/presentation/events/from-seismic-text.js +37 -0
  39. package/dist/engine/presentation/events/from-tsunami.js +51 -0
  40. package/dist/engine/presentation/events/from-volcano.js +88 -0
  41. package/dist/engine/presentation/events/to-presentation-event.js +32 -0
  42. package/dist/engine/presentation/level-helpers.js +118 -0
  43. package/dist/engine/presentation/processors/process-earthquake.js +36 -0
  44. package/dist/engine/presentation/processors/process-eew.js +105 -0
  45. package/dist/engine/presentation/processors/process-lg-observation.js +30 -0
  46. package/dist/engine/presentation/processors/process-message.js +53 -0
  47. package/dist/engine/presentation/processors/process-nankai-trough.js +30 -0
  48. package/dist/engine/presentation/processors/process-raw.js +22 -0
  49. package/dist/engine/presentation/processors/process-seismic-text.js +30 -0
  50. package/dist/engine/presentation/processors/process-tsunami.js +42 -0
  51. package/dist/engine/presentation/processors/process-volcano.js +41 -0
  52. package/dist/engine/presentation/types.js +2 -0
  53. package/dist/engine/startup/config-resolver.js +2 -0
  54. package/dist/engine/template/compile-template.js +18 -0
  55. package/dist/engine/template/compiler.js +105 -0
  56. package/dist/engine/template/field-accessor.js +31 -0
  57. package/dist/engine/template/filters.js +100 -0
  58. package/dist/engine/template/index.js +5 -0
  59. package/dist/engine/template/parser.js +185 -0
  60. package/dist/engine/template/tokenizer.js +96 -0
  61. package/dist/engine/template/types.js +2 -0
  62. package/dist/types.js +3 -2
  63. package/dist/ui/display-adapter.js +60 -0
  64. package/dist/ui/earthquake-formatter.js +22 -5
  65. package/dist/ui/eew-formatter.js +25 -10
  66. package/dist/ui/formatter.js +116 -32
  67. package/dist/ui/minimap/grid-layout.js +91 -0
  68. package/dist/ui/minimap/index.js +16 -0
  69. package/dist/ui/minimap/minimap-renderer.js +277 -0
  70. package/dist/ui/minimap/pref-mapping.js +82 -0
  71. package/dist/ui/minimap/types.js +2 -0
  72. package/dist/ui/night-overlay.js +56 -0
  73. package/dist/ui/repl-handlers/command-definitions.js +327 -0
  74. package/dist/ui/repl-handlers/index.js +11 -0
  75. package/dist/ui/repl-handlers/info-handlers.js +633 -0
  76. package/dist/ui/repl-handlers/operation-handlers.js +233 -0
  77. package/dist/ui/repl-handlers/settings-handlers.js +927 -0
  78. package/dist/ui/repl-handlers/types.js +10 -0
  79. package/dist/ui/repl.js +81 -1752
  80. package/dist/ui/statistics-formatter.js +258 -0
  81. package/dist/ui/status-line.js +80 -0
  82. package/dist/ui/summary/index.js +5 -0
  83. package/dist/ui/summary/summary-line.js +18 -0
  84. package/dist/ui/summary/summary-model.js +31 -0
  85. package/dist/ui/summary/token-builders.js +317 -0
  86. package/dist/ui/summary/types.js +2 -0
  87. package/dist/ui/summary/width-fit.js +41 -0
  88. package/dist/ui/summary-interval-formatter.js +72 -0
  89. package/dist/ui/test-samples.js +6 -0
  90. package/dist/ui/theme.js +43 -5
  91. package/dist/ui/tip-shuffler.js +81 -0
  92. package/dist/ui/volcano-formatter.js +15 -13
  93. package/dist/ui/waiting-tips-eew.js +63 -0
  94. package/dist/ui/waiting-tips-info-systems.js +81 -0
  95. package/dist/ui/waiting-tips-seismology.js +97 -0
  96. package/dist/ui/waiting-tips-tsunami.js +72 -0
  97. package/dist/ui/waiting-tips-weather.js +189 -0
  98. package/dist/ui/waiting-tips.js +420 -249
  99. package/package.json +1 -1
@@ -41,6 +41,7 @@ exports.parseXml = parseXml;
41
41
  exports.dig = dig;
42
42
  exports.str = str;
43
43
  exports.first = first;
44
+ exports.extractBaseReport = extractBaseReport;
44
45
  exports.parseEarthquakeTelegram = parseEarthquakeTelegram;
45
46
  exports.parseEewTelegram = parseEewTelegram;
46
47
  exports.parseTsunamiTelegram = parseTsunamiTelegram;
@@ -151,10 +152,15 @@ function extractEarthquake(earthquake) {
151
152
  const area = first(dig(hypo, "Area"));
152
153
  const name = str(dig(area, "Name"));
153
154
  // 座標パース: "+35.7+139.8-10000/" 形式
154
- const coordStr = str(dig(area, "jmx_eb:Coordinate", "#text") ||
155
- dig(area, "Coordinate", "#text") ||
156
- dig(area, "jmx_eb:Coordinate") ||
157
- dig(area, "Coordinate"));
155
+ // VXSE61 等では jmx_eb:Coordinate が複数 (十進度 + 度分) 存在し配列になる。
156
+ // type="震源位置(度分)" を除外して十進度を優先選択する。
157
+ const rawCoord = dig(area, "jmx_eb:Coordinate") || dig(area, "Coordinate");
158
+ const coordNode = Array.isArray(rawCoord)
159
+ ? rawCoord.find((c) => str(dig(c, "@_type")) !== "震源位置(度分)") ?? rawCoord[0]
160
+ : rawCoord;
161
+ const coordStr = str(coordNode != null && typeof coordNode === "object"
162
+ ? dig(coordNode, "#text")
163
+ : coordNode);
158
164
  const { lat, lon, depth } = parseCoordinate(coordStr);
159
165
  const magRaw = str(dig(earthquake, "jmx_eb:Magnitude", "#text") ||
160
166
  dig(earthquake, "Magnitude", "#text") ||
@@ -237,6 +243,54 @@ function extractTsunami(body) {
237
243
  return { text };
238
244
  }
239
245
  // ── EEW ヘルパー ──
246
+ /** Headline Information の Kind Code に警報コード (31) が含まれるか */
247
+ function hasWarningHeadlineCode(head) {
248
+ const headline = dig(head, "Headline");
249
+ const informations = dig(headline, "Information");
250
+ const infoList = Array.isArray(informations) ? informations : informations ? [informations] : [];
251
+ for (const info of infoList) {
252
+ const items = dig(info, "Item");
253
+ const itemList = Array.isArray(items) ? items : items ? [items] : [];
254
+ for (const item of itemList) {
255
+ const kinds = dig(item, "Kind");
256
+ const kindList = Array.isArray(kinds) ? kinds : kinds ? [kinds] : [];
257
+ for (const kind of kindList) {
258
+ const code = parseInt(str(dig(kind, "Code")), 10);
259
+ if (code === 31)
260
+ return true;
261
+ }
262
+ }
263
+ }
264
+ return false;
265
+ }
266
+ /** 予測地域の Category Kind Code に警報コード (10-19) が含まれるか */
267
+ function hasWarningAreaKind(body) {
268
+ const forecast = dig(body, "Intensity", "Forecast");
269
+ if (!forecast)
270
+ return false;
271
+ const prefs = dig(forecast, "Pref");
272
+ if (!Array.isArray(prefs))
273
+ return false;
274
+ for (const pref of prefs) {
275
+ const areas = dig(pref, "Area");
276
+ if (!Array.isArray(areas))
277
+ continue;
278
+ for (const area of areas) {
279
+ const categories = dig(area, "Category");
280
+ const catList = Array.isArray(categories) ? categories : categories ? [categories] : [];
281
+ for (const cat of catList) {
282
+ const kinds = dig(cat, "Kind");
283
+ const kindList = Array.isArray(kinds) ? kinds : kinds ? [kinds] : [];
284
+ for (const kind of kindList) {
285
+ const code = parseInt(str(dig(kind, "Code")), 10);
286
+ if (code >= 10 && code <= 19)
287
+ return true;
288
+ }
289
+ }
290
+ }
291
+ }
292
+ return false;
293
+ }
240
294
  function parseMaxIntChangeReason(body) {
241
295
  const raw = str(dig(body, "Intensity", "Forecast", "Appendix", "MaxIntChangeReason"));
242
296
  if (!raw)
@@ -384,22 +438,32 @@ function extractLgObservationDetails(body) {
384
438
  }
385
439
  return result;
386
440
  }
441
+ // ── 共通前処理 ──
442
+ /** decodeBody → parseXml → Report/Head/Body を抽出する共通前処理 */
443
+ function extractBaseReport(msg) {
444
+ const xmlStr = decodeBody(msg);
445
+ const parsed = parseXml(xmlStr);
446
+ const report = dig(parsed, "Report") ||
447
+ dig(parsed, "jmx:Report") ||
448
+ dig(parsed, "jmx_seis:Report");
449
+ if (!report) {
450
+ log.debug("Report ノードが見つかりません");
451
+ return null;
452
+ }
453
+ return {
454
+ report,
455
+ head: dig(report, "Head"),
456
+ body: dig(report, "Body"),
457
+ };
458
+ }
387
459
  // ── 公開API ──
388
460
  /** 地震関連電文(VXSE51/52/53等)をパース */
389
461
  function parseEarthquakeTelegram(msg) {
390
462
  try {
391
- const xmlStr = decodeBody(msg);
392
- const parsed = parseXml(xmlStr);
393
- // Report > Body を探す
394
- const report = dig(parsed, "Report") ||
395
- dig(parsed, "jmx:Report") ||
396
- dig(parsed, "jmx_seis:Report");
397
- if (!report) {
398
- log.debug("Report ノードが見つかりません");
463
+ const base = extractBaseReport(msg);
464
+ if (!base)
399
465
  return null;
400
- }
401
- const body = dig(report, "Body");
402
- const head = dig(report, "Head");
466
+ const { head, body } = base;
403
467
  const info = {
404
468
  type: msg.head.type,
405
469
  infoType: str(dig(head, "InfoType")),
@@ -407,6 +471,7 @@ function parseEarthquakeTelegram(msg) {
407
471
  reportDateTime: str(dig(head, "ReportDateTime")),
408
472
  headline: str(dig(head, "Headline", "Text")) || null,
409
473
  publishingOffice: msg.xmlReport?.control?.publishingOffice || "",
474
+ eventId: str(dig(head, "EventID")) || null,
410
475
  isTest: msg.head.test,
411
476
  };
412
477
  // 震源
@@ -432,15 +497,10 @@ function parseEarthquakeTelegram(msg) {
432
497
  /** EEW電文をパース */
433
498
  function parseEewTelegram(msg) {
434
499
  try {
435
- const xmlStr = decodeBody(msg);
436
- const parsed = parseXml(xmlStr);
437
- const report = dig(parsed, "Report") ||
438
- dig(parsed, "jmx:Report") ||
439
- dig(parsed, "jmx_seis:Report");
440
- if (!report)
500
+ const base = extractBaseReport(msg);
501
+ if (!base)
441
502
  return null;
442
- const head = dig(report, "Head");
443
- const body = dig(report, "Body");
503
+ const { head, body } = base;
444
504
  // 仮定震源要素の検出
445
505
  const earthquake = dig(body, "Earthquake");
446
506
  const earthquakeCondition = str(dig(earthquake, "Condition"));
@@ -455,7 +515,7 @@ function parseEewTelegram(msg) {
455
515
  serial: str(dig(head, "Serial")) || null,
456
516
  eventId: str(dig(head, "EventID")) || null,
457
517
  isTest: msg.head.test,
458
- isWarning: msg.classification === "eew.warning",
518
+ isWarning: false, // 仮値 — 後で XML から判定
459
519
  isAssumedHypocenter: false,
460
520
  };
461
521
  info.maxIntChangeReason = parseMaxIntChangeReason(body);
@@ -474,6 +534,25 @@ function parseEewTelegram(msg) {
474
534
  (info.maxIntChangeReason === 9 || hasPlumArea);
475
535
  info.isAssumedHypocenter =
476
536
  assumedHypocenterByCondition || assumedHypocenterByFallback;
537
+ // isWarning: XML ベース主判定 + classification を安全側フォールバック
538
+ // xmlWarning / classWarning を先に変数化し、判定と観測ログで同じ値を共用する。
539
+ const xmlWarning = msg.head.type === "VXSE43" ||
540
+ hasWarningAreaKind(body) ||
541
+ hasWarningHeadlineCode(head);
542
+ const classWarning = msg.classification === "eew.warning";
543
+ info.isWarning = xmlWarning || classWarning;
544
+ // 観測ログ (仕様不整合の検知用):
545
+ // (1) classification=eew.warning だが XML ベース判定で警報条件を検出できない
546
+ // (2) VXSE43 電文なのに classification が eew.warning ではない(契約差分・仕様変更の兆候)
547
+ // 逆方向の一般形 (xmlWarning && !classWarning) は VXSE44/VXSE45 警報相当の正常パターンなのでログしない。
548
+ if (classWarning && !xmlWarning) {
549
+ log.warn(`EEW classification=eew.warning だが XML ベース判定で警報条件を検出できず: ` +
550
+ `type=${msg.head.type} EventID=${str(dig(head, "EventID"))}`);
551
+ }
552
+ else if (msg.head.type === "VXSE43" && !classWarning) {
553
+ log.warn(`EEW VXSE43 電文だが classification=${msg.classification} (eew.warning ではない): ` +
554
+ `EventID=${str(dig(head, "EventID"))}`);
555
+ }
477
556
  // NextAdvisory (最終報)
478
557
  const nextAdvisory = str(dig(body, "NextAdvisory"));
479
558
  if (nextAdvisory) {
@@ -489,17 +568,10 @@ function parseEewTelegram(msg) {
489
568
  /** 津波電文(VTSE41/51/52)をパース */
490
569
  function parseTsunamiTelegram(msg) {
491
570
  try {
492
- const xmlStr = decodeBody(msg);
493
- const parsed = parseXml(xmlStr);
494
- const report = dig(parsed, "Report") ||
495
- dig(parsed, "jmx:Report") ||
496
- dig(parsed, "jmx_seis:Report");
497
- if (!report) {
498
- log.debug("Report ノードが見つかりません");
571
+ const base = extractBaseReport(msg);
572
+ if (!base)
499
573
  return null;
500
- }
501
- const head = dig(report, "Head");
502
- const body = dig(report, "Body");
574
+ const { head, body } = base;
503
575
  const warningComment = dig(body, "Comments", "WarningComment");
504
576
  const warningCommentText = Array.isArray(warningComment)
505
577
  ? str(dig(warningComment[0], "Text"))
@@ -566,17 +638,10 @@ function parseTsunamiTelegram(msg) {
566
638
  /** 地震活動テキスト電文(VXSE56/VXSE60/VZSE40)をパース */
567
639
  function parseSeismicTextTelegram(msg) {
568
640
  try {
569
- const xmlStr = decodeBody(msg);
570
- const parsed = parseXml(xmlStr);
571
- const report = dig(parsed, "Report") ||
572
- dig(parsed, "jmx:Report") ||
573
- dig(parsed, "jmx_seis:Report");
574
- if (!report) {
575
- log.debug("Report ノードが見つかりません");
641
+ const base = extractBaseReport(msg);
642
+ if (!base)
576
643
  return null;
577
- }
578
- const head = dig(report, "Head");
579
- const body = dig(report, "Body");
644
+ const { head, body } = base;
580
645
  const info = {
581
646
  type: msg.head.type,
582
647
  infoType: str(dig(head, "InfoType")),
@@ -597,17 +662,10 @@ function parseSeismicTextTelegram(msg) {
597
662
  /** 南海トラフ関連電文(VYSE50/51/52/VYSE60)をパース */
598
663
  function parseNankaiTroughTelegram(msg) {
599
664
  try {
600
- const xmlStr = decodeBody(msg);
601
- const parsed = parseXml(xmlStr);
602
- const report = dig(parsed, "Report") ||
603
- dig(parsed, "jmx:Report") ||
604
- dig(parsed, "jmx_seis:Report");
605
- if (!report) {
606
- log.debug("Report ノードが見つかりません");
665
+ const base = extractBaseReport(msg);
666
+ if (!base)
607
667
  return null;
608
- }
609
- const head = dig(report, "Head");
610
- const body = dig(report, "Body");
668
+ const { head, body } = base;
611
669
  const info = {
612
670
  type: msg.head.type,
613
671
  infoType: str(dig(head, "InfoType")),
@@ -651,17 +709,10 @@ function parseNankaiTroughTelegram(msg) {
651
709
  /** 長周期地震動観測情報(VXSE62)をパース */
652
710
  function parseLgObservationTelegram(msg) {
653
711
  try {
654
- const xmlStr = decodeBody(msg);
655
- const parsed = parseXml(xmlStr);
656
- const report = dig(parsed, "Report") ||
657
- dig(parsed, "jmx:Report") ||
658
- dig(parsed, "jmx_seis:Report");
659
- if (!report) {
660
- log.debug("Report ノードが見つかりません");
712
+ const base = extractBaseReport(msg);
713
+ if (!base)
661
714
  return null;
662
- }
663
- const head = dig(report, "Head");
664
- const body = dig(report, "Body");
715
+ const { head, body } = base;
665
716
  const info = {
666
717
  type: msg.head.type,
667
718
  infoType: str(dig(head, "InfoType")),
@@ -90,6 +90,8 @@ class WebSocketManager {
90
90
  /** 接続を開始する */
91
91
  async connect() {
92
92
  this.shouldRun = true;
93
+ // 既存の再接続タイマーと CONNECTING 中のソケットを中止してから新規接続する
94
+ this.cancelInflight();
93
95
  const seq = ++this.connectSeq;
94
96
  await this.doConnect(seq);
95
97
  }
@@ -123,13 +125,46 @@ class WebSocketManager {
123
125
  this.heartbeatTimer = null;
124
126
  }
125
127
  }
128
+ /** 再接続タイマーと CONNECTING 中のソケットを中止する */
129
+ cancelInflight() {
130
+ if (this.reconnectTimer) {
131
+ clearTimeout(this.reconnectTimer);
132
+ this.reconnectTimer = null;
133
+ }
134
+ // CONNECTING 中のソケットがあれば閉じて孤立を防止
135
+ if (this.ws && this.ws.readyState !== ws_1.default.OPEN) {
136
+ try {
137
+ this.ws.close();
138
+ }
139
+ catch {
140
+ // close() 自体の失敗は無視
141
+ }
142
+ this.ws = null;
143
+ }
144
+ }
145
+ /** close/error 共通の切断後処理 */
146
+ onDisconnect(reason) {
147
+ this.clearTimers();
148
+ this.ws = null;
149
+ this.previousSocketId = this.socketId;
150
+ this.socketId = null;
151
+ this.heartbeatDeadlineAt = null;
152
+ this.endpointSelector.recordDisconnected();
153
+ this.events.onDisconnected(reason);
154
+ this.scheduleReconnect();
155
+ }
126
156
  async doConnect(seq) {
127
157
  try {
158
+ // REST API 呼び出し前に世代チェック — 古い再接続タイマーやシャットダウン後の呼び出しを弾く
159
+ if (!this.shouldRun || seq !== this.connectSeq) {
160
+ log.debug("接続中断(pre-API): shouldRun=false または世代不一致");
161
+ return;
162
+ }
128
163
  log.info("Socket Start を実行中...");
129
164
  const startRes = await (0, rest_client_1.prepareAndStartSocket)(this.config, this.previousSocketId ?? undefined);
130
- // close() が呼ばれていたら接続を中断
165
+ // REST API 完了後に再度世代チェック — API 呼び出し中に close() や新しい connect() が来た場合を検出
131
166
  if (!this.shouldRun || seq !== this.connectSeq) {
132
- log.debug("接続中断: close() が呼ばれたため新しいソケットを作成しません");
167
+ log.debug("接続中断(post-API): close() または新しい connect() が呼ばれたため新しいソケットを作成しません");
133
168
  return;
134
169
  }
135
170
  if (!startRes.websocket) {
@@ -137,6 +172,11 @@ class WebSocketManager {
137
172
  }
138
173
  const wsUrl = this.endpointSelector.resolveUrl(startRes.websocket.url);
139
174
  log.info(`WebSocket に接続中: ${wsUrl.replace(/ticket=.*/, "ticket=***")}`);
175
+ // WebSocket 作成前に最終チェック
176
+ if (!this.shouldRun || seq !== this.connectSeq) {
177
+ log.debug("接続中断(pre-WS): close() または新しい connect() が呼ばれたため WebSocket を作成しません");
178
+ return;
179
+ }
140
180
  const socket = new ws_1.default(wsUrl, ["dmdata.v2"]);
141
181
  this.ws = socket;
142
182
  socket.on("open", () => {
@@ -161,14 +201,7 @@ class WebSocketManager {
161
201
  return;
162
202
  const reasonStr = reason.toString() || `code=${code}`;
163
203
  log.warn(`WebSocket 切断: ${reasonStr}`);
164
- this.clearTimers();
165
- this.ws = null;
166
- this.previousSocketId = this.socketId;
167
- this.socketId = null;
168
- this.heartbeatDeadlineAt = null;
169
- this.endpointSelector.recordDisconnected();
170
- this.events.onDisconnected(reasonStr);
171
- this.scheduleReconnect();
204
+ this.onDisconnect(reasonStr);
172
205
  });
173
206
  socket.on("error", (err) => {
174
207
  log.error(`WebSocket エラー: ${err.message}`);
@@ -181,14 +214,7 @@ class WebSocketManager {
181
214
  catch {
182
215
  // close() 自体の失敗は無視
183
216
  }
184
- this.clearTimers();
185
- this.ws = null;
186
- this.previousSocketId = this.socketId;
187
- this.socketId = null;
188
- this.heartbeatDeadlineAt = null;
189
- this.endpointSelector.recordDisconnected();
190
- this.events.onDisconnected(`error: ${err.message}`);
191
- this.scheduleReconnect();
217
+ this.onDisconnect(`error: ${err.message}`);
192
218
  });
193
219
  }
194
220
  catch (err) {
@@ -329,6 +355,11 @@ class WebSocketManager {
329
355
  const currentSeq = this.connectSeq;
330
356
  this.reconnectTimer = setTimeout(async () => {
331
357
  this.reconnectTimer = null;
358
+ // タイマー発火時に世代が変わっていたら(手動 retry や close() があった)何もしない
359
+ if (!this.shouldRun || currentSeq !== this.connectSeq) {
360
+ log.debug("再接続タイマー発火をスキップ: shouldRun=false または世代不一致");
361
+ return;
362
+ }
332
363
  await this.doConnect(currentSeq);
333
364
  }, delay);
334
365
  }
@@ -39,6 +39,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.runMonitor = runMonitor;
40
40
  exports.resetTerminalTitle = resetTerminalTitle;
41
41
  const chalk_1 = __importDefault(require("chalk"));
42
+ const fs = __importStar(require("fs"));
43
+ const os = __importStar(require("os"));
42
44
  const rest_client_1 = require("../../dmdata/rest-client");
43
45
  const monitor_1 = require("../monitor/monitor");
44
46
  const formatter_1 = require("../../ui/formatter");
@@ -46,6 +48,7 @@ const theme_1 = require("../../ui/theme");
46
48
  const config_resolver_1 = require("../startup/config-resolver");
47
49
  const updateChecker = __importStar(require("../startup/update-checker"));
48
50
  const log = __importStar(require("../../logger"));
51
+ const pipeline_controller_1 = require("../filter-template/pipeline-controller");
49
52
  // eslint-disable-next-line @typescript-eslint/no-var-requires
50
53
  const { version: VERSION } = require("../../../package.json");
51
54
  async function runMonitor(opts) {
@@ -84,6 +87,11 @@ async function runMonitor(opts) {
84
87
  for (const w of themeWarnings) {
85
88
  log.warn(w);
86
89
  }
90
+ // ナイトモード (resolveConfig で解決済み: CLI --night > Config > デフォルト)
91
+ if (config.nightMode) {
92
+ (0, theme_1.setNightMode)(true);
93
+ log.info("ナイトモード: ON");
94
+ }
87
95
  // formatter キャッシュ初期化
88
96
  if (config.tableWidth != null) {
89
97
  (0, formatter_1.setFrameWidth)(config.tableWidth);
@@ -92,9 +100,71 @@ async function runMonitor(opts) {
92
100
  (0, formatter_1.setDisplayMode)(config.displayMode);
93
101
  (0, formatter_1.setMaxObservations)(config.maxObservations);
94
102
  (0, formatter_1.setTruncation)(config.truncation);
95
- printBanner(config);
103
+ // Filter / Template コンパイル
104
+ const pipelineController = new pipeline_controller_1.PipelineController();
105
+ if (opts.filter && opts.filter.length > 0) {
106
+ try {
107
+ // 複数フィルタは括弧付きで AND 結合
108
+ const combined = opts.filter.map((e) => `(${e})`).join(" and ");
109
+ pipelineController.setFilter(combined);
110
+ log.info(`フィルタ: ${opts.filter.join(" AND ")}`);
111
+ }
112
+ catch (err) {
113
+ if (err instanceof Error) {
114
+ log.error(`フィルタのコンパイルに失敗しました:\n${err.message}`);
115
+ }
116
+ process.exit(1);
117
+ }
118
+ }
119
+ if (opts.template) {
120
+ try {
121
+ let tplSource = opts.template;
122
+ if (tplSource.startsWith("@")) {
123
+ const filePath = tplSource.slice(1).replace(/^~/, os.homedir());
124
+ const MAX_TEMPLATE_SIZE = 1024 * 1024; // 1MB
125
+ const stat = fs.statSync(filePath);
126
+ if (stat.size > MAX_TEMPLATE_SIZE) {
127
+ log.error(`テンプレートファイルが大きすぎます (${stat.size} bytes, 上限 ${MAX_TEMPLATE_SIZE} bytes): ${filePath}`);
128
+ process.exit(1);
129
+ }
130
+ tplSource = fs.readFileSync(filePath, "utf-8").trim();
131
+ }
132
+ pipelineController.setTemplate(tplSource);
133
+ log.info("テンプレート: カスタム");
134
+ }
135
+ catch (err) {
136
+ if (err instanceof Error) {
137
+ log.warn(`テンプレートのコンパイルに失敗しました:\n${err.message}`);
138
+ }
139
+ // template エラーは警告のみ — 通常表示にフォールバック
140
+ }
141
+ }
142
+ if (opts.focus) {
143
+ try {
144
+ pipelineController.setFocus(opts.focus);
145
+ log.info(`フォーカス: ${opts.focus}`);
146
+ }
147
+ catch (err) {
148
+ if (err instanceof Error) {
149
+ log.error(`フォーカスのコンパイルに失敗しました:\n${err.message}`);
150
+ }
151
+ process.exit(1);
152
+ }
153
+ }
154
+ // summaryInterval (CLI > Config > デフォルト, 0 = 無効化)
155
+ if (opts.summaryInterval != null) {
156
+ if (opts.summaryInterval === 0) {
157
+ config.summaryInterval = null;
158
+ log.info("定期要約: 無効");
159
+ }
160
+ else {
161
+ config.summaryInterval = opts.summaryInterval;
162
+ log.info(`定期要約: ${opts.summaryInterval}分間隔`);
163
+ }
164
+ }
165
+ await printBanner(config);
96
166
  updateChecker.checkForUpdates("fleq", VERSION);
97
- await (0, monitor_1.startMonitor)(config);
167
+ await (0, monitor_1.startMonitor)(config, pipelineController);
98
168
  }
99
169
  /** ターミナルタイトルを設定する (ANSI OSC sequence) */
100
170
  function setTerminalTitle(title) {
@@ -110,12 +180,27 @@ function resetTerminalTitle() {
110
180
  }
111
181
  }
112
182
  /** 起動バナー表示 */
113
- function printBanner(config) {
183
+ async function printBanner(config) {
114
184
  log.info(`受信区分: ${config.classifications.join(", ")}`);
115
185
  log.info(`テストモード: ${config.testMode}`);
116
186
  if (config.displayMode !== "normal") {
117
187
  log.info(`表示モード: ${config.displayMode}`);
118
188
  }
189
+ // 音声バックエンド状態 (起動バナー末尾、Linux は実再生プローブ)
190
+ try {
191
+ const { checkSoundBackend } = await Promise.resolve().then(() => __importStar(require("../notification/sound-player")));
192
+ const result = await checkSoundBackend();
193
+ // CUD パレット: blueGreen = RGB(0, 158, 115), vermillion = RGB(213, 94, 0)
194
+ const line = result.ok
195
+ ? chalk_1.default.rgb(0, 158, 115)(`音声: ${result.label} OK`)
196
+ : chalk_1.default.rgb(213, 94, 0)(`音声: ${result.label} NG${result.reason ? ` (${result.reason})` : ""}`);
197
+ console.log(line);
198
+ }
199
+ catch (err) {
200
+ if (err instanceof Error) {
201
+ log.warn(`音声バックエンド確認エラー: ${err.message}`);
202
+ }
203
+ }
119
204
  log.info("接続を開始します...");
120
205
  console.log();
121
206
  }
@@ -51,6 +51,18 @@ function buildProgram() {
51
51
  .option("--keep-existing", "既存のWebSocket接続を維持します(互換オプション。現在はこちらがデフォルトです)")
52
52
  .option("--close-others", "同一APIキーの既存 open socket を閉じてから接続します")
53
53
  .option("--mode <mode>", '表示モードを指定します: "normal" | "compact"')
54
+ .option("--filter <expr>", "条件式で電文を絞り込みます (複数指定で AND 結合)", (val, prev) => [...prev, val], [])
55
+ .option("--template <template>", "電文の1行要約テンプレートを指定します (@ でファイル読込)")
56
+ .option("--focus <expr>", "条件に一致しない電文を dim 表示に落とします")
57
+ .option("--summary-interval [minutes]", "N分ごとに受信要約を表示 (デフォルト10分, 0で無効化)", (val) => {
58
+ if (val === undefined || val === "true")
59
+ return 10;
60
+ const n = parseInt(val, 10);
61
+ if (n === 0)
62
+ return 0;
63
+ return Number.isFinite(n) && n > 0 ? n : 10;
64
+ })
65
+ .option("--night", "ナイトモードを有効にします")
54
66
  .option("--debug", "デバッグログを表示します", false)
55
67
  .action(async (opts) => {
56
68
  const { runMonitor } = await Promise.resolve().then(() => __importStar(require("./cli-run")));
@@ -118,6 +118,8 @@ class EewTracker {
118
118
  isNew: true,
119
119
  isDuplicate: false,
120
120
  isCancelled: info.infoType === "取消",
121
+ isSuppressed: false,
122
+ isUpgradeToWarning: false,
121
123
  activeCount: this.getActiveCount(),
122
124
  colorIndex: 0,
123
125
  };
@@ -125,56 +127,80 @@ class EewTracker {
125
127
  const serialRaw = parseInt(info.serial || "", 10);
126
128
  const serial = Number.isFinite(serialRaw) ? serialRaw : null;
127
129
  const isCancelled = info.infoType === "取消";
130
+ const headType = info.type;
128
131
  const existing = this.events.get(eventId);
129
132
  if (existing) {
130
- // 既知のイベント 報数チェック
131
- if (!isCancelled && serial != null && serial > 0 && serial <= existing.lastSerial) {
132
- // 同じか古い報数 重複
133
+ const typeState = existing.byType.get(headType);
134
+ // 同一 type 内の重複判定
135
+ if (!isCancelled && serial != null && serial > 0 && typeState && serial <= typeState.lastSerial) {
133
136
  return {
134
137
  isNew: false,
135
138
  isDuplicate: true,
136
139
  isCancelled: false,
140
+ isSuppressed: false,
141
+ isUpgradeToWarning: false,
137
142
  activeCount: this.getActiveCount(),
138
143
  colorIndex: existing.colorIndex,
139
144
  };
140
145
  }
141
- // 差分計算
142
- const diff = existing.previousInfo ? computeDiff(existing.previousInfo, info) : undefined;
143
- const previousInfo = existing.previousInfo;
144
- // 状態更新
145
- if (serial != null) {
146
- existing.lastSerial = Math.max(existing.lastSerial, serial);
146
+ // 抑制判定: VXSE45 受信済みなら VXSE43/44 は抑制
147
+ const isSuppressed = existing.hasSeen45 && (headType === "VXSE43" || headType === "VXSE44");
148
+ // type 状態の更新 (抑制されても serial・lastUpdate は更新する)
149
+ const previousInfo = typeState?.previousInfo;
150
+ if (!typeState) {
151
+ existing.byType.set(headType, { lastSerial: serial ?? 0, previousInfo: info });
152
+ }
153
+ else {
154
+ if (serial != null) {
155
+ typeState.lastSerial = Math.max(typeState.lastSerial, serial);
156
+ }
157
+ typeState.previousInfo = info;
158
+ }
159
+ // hasSeen45 更新
160
+ if (headType === "VXSE45") {
161
+ existing.hasSeen45 = true;
162
+ }
163
+ // 差分計算: 同一 type 内の連続更新でのみ (初めての type では diff なし)
164
+ const diff = previousInfo ? computeDiff(previousInfo, info) : undefined;
165
+ // 警報昇格判定 (イベント単位)
166
+ const isUpgradeToWarning = !isSuppressed && !existing.hasWarningIssued && info.isWarning;
167
+ if (!isSuppressed) {
168
+ existing.hasWarningIssued = existing.hasWarningIssued || info.isWarning;
147
169
  }
148
- existing.isWarning = existing.isWarning || info.isWarning;
149
170
  existing.isCancelled = isCancelled;
150
171
  existing.lastUpdate = new Date();
151
- existing.previousInfo = info;
152
172
  return {
153
173
  isNew: false,
154
174
  isDuplicate: false,
155
175
  isCancelled,
176
+ isSuppressed,
177
+ isUpgradeToWarning,
156
178
  activeCount: this.getActiveCount(),
157
- diff,
179
+ diff: isSuppressed ? undefined : diff,
158
180
  previousInfo,
159
181
  colorIndex: existing.colorIndex,
160
182
  };
161
183
  }
162
184
  // 新規イベント
163
185
  const colorIndex = this.nextColorIndex();
186
+ const byType = new Map();
187
+ byType.set(headType, { lastSerial: serial ?? 0, previousInfo: info });
164
188
  this.events.set(eventId, {
165
189
  eventId,
166
- lastSerial: serial ?? 0,
167
- isWarning: info.isWarning,
190
+ byType,
191
+ hasSeen45: headType === "VXSE45",
192
+ hasWarningIssued: info.isWarning,
168
193
  isCancelled,
169
194
  isFinalized: false,
170
195
  lastUpdate: new Date(),
171
- previousInfo: info,
172
196
  colorIndex,
173
197
  });
174
198
  return {
175
199
  isNew: true,
176
200
  isDuplicate: false,
177
201
  isCancelled,
202
+ isSuppressed: false,
203
+ isUpgradeToWarning: false,
178
204
  activeCount: this.getActiveCount(),
179
205
  colorIndex,
180
206
  };
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.compileFilter = compileFilter;
4
+ const tokenizer_1 = require("./tokenizer");
5
+ const parser_1 = require("./parser");
6
+ const type_checker_1 = require("./type-checker");
7
+ const compiler_1 = require("./compiler");
8
+ /**
9
+ * フィルタ式文字列を受け取り、tokenize → parse → typeCheck → compile の
10
+ * パイプラインを通して FilterPredicate を返す公開 API。
11
+ *
12
+ * @throws FilterSyntaxError — 構文エラー
13
+ * @throws FilterFieldError — 未知フィールド
14
+ * @throws FilterTypeError — 型不整合
15
+ */
16
+ function compileFilter(expr) {
17
+ const tokens = (0, tokenizer_1.tokenize)(expr);
18
+ const ast = (0, parser_1.parse)(tokens, expr);
19
+ (0, type_checker_1.typeCheck)(ast, expr);
20
+ return (0, compiler_1.compile)(ast);
21
+ }