@sayue_ltr/fleq 1.50.0 → 1.51.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 (92) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +47 -5
  3. package/dist/config.js +35 -2
  4. package/dist/dmdata/rest-client.js +58 -3
  5. package/dist/dmdata/telegram-parser.js +37 -59
  6. package/dist/dmdata/ws-client.js +49 -18
  7. package/dist/engine/cli/cli-run.js +71 -1
  8. package/dist/engine/cli/cli.js +12 -0
  9. package/dist/engine/filter/compile-filter.js +21 -0
  10. package/dist/engine/filter/compiler.js +188 -0
  11. package/dist/engine/filter/errors.js +41 -0
  12. package/dist/engine/filter/field-registry.js +78 -0
  13. package/dist/engine/filter/index.js +15 -0
  14. package/dist/engine/filter/parser.js +137 -0
  15. package/dist/engine/filter/rank-maps.js +34 -0
  16. package/dist/engine/filter/tokenizer.js +121 -0
  17. package/dist/engine/filter/type-checker.js +104 -0
  18. package/dist/engine/filter/types.js +2 -0
  19. package/dist/engine/filter-template/pipeline-controller.js +73 -0
  20. package/dist/engine/filter-template/pipeline.js +16 -0
  21. package/dist/engine/messages/display-callbacks.js +7 -0
  22. package/dist/engine/messages/message-router.js +114 -182
  23. package/dist/engine/messages/summary-tracker.js +106 -0
  24. package/dist/engine/messages/telegram-stats.js +103 -0
  25. package/dist/engine/messages/volcano-route-handler.js +122 -0
  26. package/dist/engine/monitor/monitor.js +51 -3
  27. package/dist/engine/monitor/shutdown.js +1 -0
  28. package/dist/engine/notification/notifier.js +16 -1
  29. package/dist/engine/notification/sound-player.js +193 -28
  30. package/dist/engine/presentation/diff-store.js +158 -0
  31. package/dist/engine/presentation/diff-types.js +2 -0
  32. package/dist/engine/presentation/events/from-earthquake.js +53 -0
  33. package/dist/engine/presentation/events/from-eew.js +72 -0
  34. package/dist/engine/presentation/events/from-lg-observation.js +58 -0
  35. package/dist/engine/presentation/events/from-nankai-trough.js +39 -0
  36. package/dist/engine/presentation/events/from-raw.js +35 -0
  37. package/dist/engine/presentation/events/from-seismic-text.js +37 -0
  38. package/dist/engine/presentation/events/from-tsunami.js +51 -0
  39. package/dist/engine/presentation/events/from-volcano.js +88 -0
  40. package/dist/engine/presentation/events/to-presentation-event.js +32 -0
  41. package/dist/engine/presentation/level-helpers.js +118 -0
  42. package/dist/engine/presentation/processors/process-earthquake.js +36 -0
  43. package/dist/engine/presentation/processors/process-eew.js +90 -0
  44. package/dist/engine/presentation/processors/process-lg-observation.js +30 -0
  45. package/dist/engine/presentation/processors/process-message.js +53 -0
  46. package/dist/engine/presentation/processors/process-nankai-trough.js +30 -0
  47. package/dist/engine/presentation/processors/process-raw.js +22 -0
  48. package/dist/engine/presentation/processors/process-seismic-text.js +30 -0
  49. package/dist/engine/presentation/processors/process-tsunami.js +42 -0
  50. package/dist/engine/presentation/processors/process-volcano.js +41 -0
  51. package/dist/engine/presentation/types.js +2 -0
  52. package/dist/engine/startup/config-resolver.js +2 -0
  53. package/dist/engine/template/compile-template.js +18 -0
  54. package/dist/engine/template/compiler.js +102 -0
  55. package/dist/engine/template/field-accessor.js +25 -0
  56. package/dist/engine/template/filters.js +94 -0
  57. package/dist/engine/template/index.js +5 -0
  58. package/dist/engine/template/parser.js +190 -0
  59. package/dist/engine/template/tokenizer.js +96 -0
  60. package/dist/engine/template/types.js +2 -0
  61. package/dist/types.js +2 -1
  62. package/dist/ui/display-adapter.js +60 -0
  63. package/dist/ui/earthquake-formatter.js +17 -5
  64. package/dist/ui/eew-formatter.js +25 -10
  65. package/dist/ui/formatter.js +67 -32
  66. package/dist/ui/minimap/grid-layout.js +91 -0
  67. package/dist/ui/minimap/index.js +16 -0
  68. package/dist/ui/minimap/minimap-renderer.js +277 -0
  69. package/dist/ui/minimap/pref-mapping.js +82 -0
  70. package/dist/ui/minimap/types.js +2 -0
  71. package/dist/ui/night-overlay.js +56 -0
  72. package/dist/ui/repl-handlers/command-definitions.js +320 -0
  73. package/dist/ui/repl-handlers/index.js +11 -0
  74. package/dist/ui/repl-handlers/info-handlers.js +577 -0
  75. package/dist/ui/repl-handlers/operation-handlers.js +233 -0
  76. package/dist/ui/repl-handlers/settings-handlers.js +923 -0
  77. package/dist/ui/repl-handlers/types.js +10 -0
  78. package/dist/ui/repl.js +81 -1752
  79. package/dist/ui/statistics-formatter.js +208 -0
  80. package/dist/ui/status-line.js +69 -0
  81. package/dist/ui/summary/index.js +5 -0
  82. package/dist/ui/summary/summary-line.js +18 -0
  83. package/dist/ui/summary/summary-model.js +31 -0
  84. package/dist/ui/summary/token-builders.js +317 -0
  85. package/dist/ui/summary/types.js +2 -0
  86. package/dist/ui/summary/width-fit.js +41 -0
  87. package/dist/ui/summary-interval-formatter.js +72 -0
  88. package/dist/ui/theme.js +34 -5
  89. package/dist/ui/tip-shuffler.js +81 -0
  90. package/dist/ui/volcano-formatter.js +15 -13
  91. package/dist/ui/waiting-tips.js +289 -249
  92. package/package.json +1 -1
@@ -47,14 +47,21 @@ const formatter_1 = require("../../ui/formatter");
47
47
  const repl_coordinator_1 = require("./repl-coordinator");
48
48
  const shutdown_1 = require("./shutdown");
49
49
  const log = __importStar(require("../../logger"));
50
- async function startMonitor(config) {
51
- const { handler: routeMessage, eewLogger, notifier, tsunamiState, volcanoState, flushAndDisposeVolcanoBuffer } = (0, message_router_1.createMessageHandler)();
50
+ const summary_interval_formatter_1 = require("../../ui/summary-interval-formatter");
51
+ const summary_tracker_1 = require("../messages/summary-tracker");
52
+ async function startMonitor(config, pipelineController) {
53
+ // display adapter は遅延ロードで ui 依存を monitor 側に限定する
54
+ const { createDisplayAdapter } = await Promise.resolve().then(() => __importStar(require("../../ui/display-adapter")));
55
+ const display = createDisplayAdapter();
56
+ const pipeline = pipelineController?.getPipeline();
57
+ const { handler: routeMessage, eewLogger, notifier, tsunamiState, volcanoState, stats, summaryTracker, flushAndDisposeVolcanoBuffer } = (0, message_router_1.createMessageHandler)({ pipeline: pipeline ?? undefined, display });
52
58
  // EEW ログ設定を反映
53
59
  eewLogger.setEnabled(config.eewLog);
54
60
  eewLogger.setFields(config.eewLogFields);
55
61
  let disconnectedAt = null;
56
62
  let isFirstConnection = true;
57
63
  let replHandler = null;
64
+ let summaryTimerControl = null;
58
65
  const manager = new multi_connection_manager_1.MultiConnectionManager(config, {
59
66
  onData: (msg) => {
60
67
  (0, repl_coordinator_1.withReplDisplay)(replHandler, () => routeMessage(msg));
@@ -88,11 +95,15 @@ async function startMonitor(config) {
88
95
  getReplHandler: () => replHandler,
89
96
  resetTerminalTitle: cli_run_1.resetTerminalTitle,
90
97
  flushAndDisposeVolcanoBuffer,
98
+ stopSummaryTimer: () => summaryTimerControl?.stop(),
91
99
  });
92
100
  // REPL ハンドラ (遅延ロード)
93
101
  const { ReplHandler } = await Promise.resolve().then(() => __importStar(require("../../ui/repl")));
94
- replHandler = new ReplHandler(config, manager, notifier, eewLogger, shutdown, [tsunamiState, volcanoState], [tsunamiState, volcanoState]);
102
+ replHandler = new ReplHandler(config, manager, notifier, eewLogger, shutdown, stats, [tsunamiState, volcanoState], [tsunamiState, volcanoState], pipelineController, summaryTracker);
95
103
  (0, shutdown_1.registerShutdownSignals)(shutdown);
104
+ // 定期要約タイマー
105
+ summaryTimerControl = createSummaryTimerControl(config, summaryTracker, () => replHandler);
106
+ replHandler.setSummaryTimerControl(summaryTimerControl);
96
107
  // REPL を先に起動 (接続中もコマンド入力可能にする)
97
108
  replHandler.start();
98
109
  // 起動時: 最新の津波・火山警報状態を復元 (WebSocket 接続前に実行)
@@ -116,3 +127,40 @@ async function startMonitor(config) {
116
127
  log.info("retry コマンドで再接続を試みることができます。");
117
128
  }
118
129
  }
130
+ /** 定期要約タイマーの制御オブジェクトを生成する。初期値が設定済みなら自動起動する。 */
131
+ function createSummaryTimerControl(config, tracker, getReplHandler) {
132
+ let timer = null;
133
+ function showOutput(intervalMinutes) {
134
+ const snapshot = tracker.getSnapshot();
135
+ const output = (0, summary_interval_formatter_1.formatSummaryInterval)(snapshot, intervalMinutes, true);
136
+ (0, repl_coordinator_1.withReplDisplay)(getReplHandler(), () => {
137
+ console.log(output);
138
+ });
139
+ }
140
+ const control = {
141
+ start(intervalMinutes) {
142
+ // 既存タイマーを停止してから再起動
143
+ control.stop();
144
+ const intervalMs = intervalMinutes * 60_000;
145
+ timer = setInterval(() => showOutput(intervalMinutes), intervalMs);
146
+ timer.unref();
147
+ },
148
+ stop() {
149
+ if (timer != null) {
150
+ clearInterval(timer);
151
+ timer = null;
152
+ }
153
+ },
154
+ isRunning() {
155
+ return timer != null;
156
+ },
157
+ showNow() {
158
+ showOutput(summary_tracker_1.WINDOW_MINUTES);
159
+ },
160
+ };
161
+ // 初期値が設定されていれば自動起動
162
+ if (config.summaryInterval != null) {
163
+ control.start(config.summaryInterval);
164
+ }
165
+ return control;
166
+ }
@@ -84,6 +84,7 @@ function createShutdownHandler(ctx) {
84
84
  return;
85
85
  shuttingDown = true;
86
86
  log.info("シャットダウン中...");
87
+ ctx.stopSummaryTimer?.();
87
88
  ctx.flushAndDisposeVolcanoBuffer?.();
88
89
  ctx.eewLogger.closeAll();
89
90
  try {
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.Notifier = exports.NOTIFY_CATEGORY_LABELS = void 0;
37
+ exports.clearIconPathCache = clearIconPathCache;
37
38
  exports.resolveIconPath = resolveIconPath;
38
39
  const path = __importStar(require("path"));
39
40
  const fs = __importStar(require("fs"));
@@ -55,12 +56,24 @@ const CATEGORY_ICON_PREFIX = {
55
56
  lgObservation: "lg-observation",
56
57
  volcano: "volcano",
57
58
  };
59
+ /** resolveIconPath の結果キャッシュ。キー: "{category}:{level|''}" */
60
+ const iconPathCache = new Map();
61
+ /**
62
+ * resolveIconPath のキャッシュをクリアする (テスト用)。
63
+ */
64
+ function clearIconPathCache() {
65
+ iconPathCache.clear();
66
+ }
58
67
  /**
59
68
  * カテゴリとレベルからアイコンパスを解決する。
60
69
  * 3段フォールバック: {prefix}-{level}.png → {prefix}.png → default.png
61
- * いずれも見つからなければ undefined を返す。
70
+ * いずれも見つからなければ undefined を返す。結果はキャッシュして再利用する。
62
71
  */
63
72
  function resolveIconPath(category, level) {
73
+ const cacheKey = `${category}:${level ?? ""}`;
74
+ if (iconPathCache.has(cacheKey)) {
75
+ return iconPathCache.get(cacheKey);
76
+ }
64
77
  const prefix = CATEGORY_ICON_PREFIX[category];
65
78
  const candidates = [];
66
79
  if (level) {
@@ -70,9 +83,11 @@ function resolveIconPath(category, level) {
70
83
  candidates.push(path.join(ICONS_DIR, "default.png"));
71
84
  for (const candidate of candidates) {
72
85
  if (fs.existsSync(candidate)) {
86
+ iconPathCache.set(cacheKey, candidate);
73
87
  return candidate;
74
88
  }
75
89
  }
90
+ iconPathCache.set(cacheKey, undefined);
76
91
  return undefined;
77
92
  }
78
93
  /** 通知アプリ名 */
@@ -35,7 +35,10 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.SOUND_LEVELS = void 0;
37
37
  exports.isSoundLevel = isSoundLevel;
38
+ exports.clearCustomSoundCache = clearCustomSoundCache;
38
39
  exports.playSound = playSound;
40
+ exports.dispose = dispose;
41
+ exports.resetSoundPlayer = resetSoundPlayer;
39
42
  const child_process_1 = require("child_process");
40
43
  const fs = __importStar(require("fs"));
41
44
  const path = __importStar(require("path"));
@@ -82,143 +85,305 @@ const LINUX_CANBERRA_EVENTS = {
82
85
  info: "dialog-information",
83
86
  cancel: "bell",
84
87
  };
88
+ // ── findCustomSound キャッシュ ──
89
+ /** findCustomSound の結果キャッシュ (null = 存在しない, undefined = 未検索) */
90
+ const customSoundCache = new Map();
85
91
  /**
86
92
  * カスタム効果音ファイルのパスを返す。見つからなければ null。
87
- * mp3 → wav の順で探索する。
93
+ * mp3 → wav の順で探索する。結果はキャッシュして再利用する。
88
94
  */
89
95
  function findCustomSound(level) {
96
+ if (customSoundCache.has(level)) {
97
+ return customSoundCache.get(level);
98
+ }
90
99
  const baseName = CUSTOM_SOUND_FILES[level];
91
100
  for (const ext of SUPPORTED_EXTENSIONS) {
92
101
  const filePath = path.join(CUSTOM_SOUNDS_DIR, baseName + ext);
93
102
  if (fs.existsSync(filePath)) {
103
+ customSoundCache.set(level, filePath);
94
104
  return filePath;
95
105
  }
96
106
  }
107
+ customSoundCache.set(level, null);
97
108
  return null;
98
109
  }
110
+ /** サウンドキャッシュをクリアする (テスト用) */
111
+ function clearCustomSoundCache() {
112
+ customSoundCache.clear();
113
+ windowsSoundCache.clear();
114
+ }
115
+ // ── 有界キュー ──
116
+ /** 同時再生の最大数 */
117
+ const MAX_CONCURRENT = 1;
118
+ /** キューの最大サイズ (超えたら古いエントリを破棄) */
119
+ const MAX_QUEUE_SIZE = 3;
120
+ /** 再生プロセスのタイムアウト (ms) */
121
+ const PLAY_TIMEOUT_MS = 10_000;
122
+ /** 再生中のプロセス数 */
123
+ let activeCount = 0;
124
+ /** 再生待ちキュー */
125
+ const playQueue = [];
126
+ /** 現在再生中のプロセス (タイムアウト kill 用) */
127
+ let activeProcess = null;
128
+ /** タイムアウトタイマー */
129
+ let activeTimer = null;
130
+ /** dispose 済みフラグ */
131
+ let disposed = false;
99
132
  /**
100
- * 通知音を再生する (fire-and-forget)。
101
- * カスタム効果音ファイルがあればそちらを優先、なければ OS システムサウンドにフォールバック。
102
- * 再生失敗はログに記録するのみで例外は投げない。
133
+ * 再生完了後にキューから次のエントリを取り出して再生する。
103
134
  */
104
- function playSound(level) {
135
+ function onPlayFinished() {
136
+ activeCount--;
137
+ activeProcess = null;
138
+ if (activeTimer != null) {
139
+ clearTimeout(activeTimer);
140
+ activeTimer = null;
141
+ }
142
+ if (disposed)
143
+ return;
144
+ if (playQueue.length > 0) {
145
+ const next = playQueue.shift();
146
+ runPlay(next);
147
+ }
148
+ }
149
+ /**
150
+ * プロセスを起動して再生を実行する。
151
+ * タイムアウトを設定し、完了時に onPlayFinished を呼ぶ。
152
+ */
153
+ function runPlay(level) {
154
+ activeCount++;
105
155
  try {
106
156
  const customPath = findCustomSound(level);
107
157
  const platform = process.platform;
108
158
  if (customPath) {
109
- playCustomSound(customPath, platform);
159
+ activeProcess = launchCustomSound(customPath, platform, onPlayFinished);
110
160
  }
111
161
  else if (platform === "win32") {
112
- playSystemSoundWindows(level);
162
+ activeProcess = launchSystemSoundWindows(level, onPlayFinished);
113
163
  }
114
164
  else if (platform === "darwin") {
115
- playSystemSoundMacOS(level);
165
+ activeProcess = launchSystemSoundMacOS(level, onPlayFinished);
116
166
  }
117
167
  else {
118
- playSystemSoundLinux(level);
168
+ activeProcess = launchSystemSoundLinux(level, onPlayFinished);
169
+ }
170
+ if (activeProcess != null) {
171
+ activeTimer = setTimeout(() => {
172
+ log.debug(`通知音の再生がタイムアウトしました (${PLAY_TIMEOUT_MS}ms)`);
173
+ try {
174
+ activeProcess?.kill();
175
+ }
176
+ catch {
177
+ // ignore
178
+ }
179
+ onPlayFinished();
180
+ }, PLAY_TIMEOUT_MS);
119
181
  }
120
182
  }
121
183
  catch (err) {
122
184
  if (err instanceof Error) {
123
185
  log.debug(`通知音の再生に失敗しました: ${err.message}`);
124
186
  }
187
+ onPlayFinished();
188
+ }
189
+ }
190
+ /**
191
+ * 通知音を再生する (fire-and-forget)。
192
+ * 同時再生は MAX_CONCURRENT 件に制限し、超えた場合はキューに積む。
193
+ * キューが MAX_QUEUE_SIZE を超えた場合は先頭 (最古) を破棄する。
194
+ * カスタム効果音ファイルがあればそちらを優先、なければ OS システムサウンドにフォールバック。
195
+ * 再生失敗はログに記録するのみで例外は投げない。
196
+ */
197
+ function playSound(level) {
198
+ if (disposed)
199
+ return;
200
+ if (activeCount < MAX_CONCURRENT) {
201
+ runPlay(level);
202
+ }
203
+ else {
204
+ if (playQueue.length >= MAX_QUEUE_SIZE) {
205
+ const dropped = playQueue.shift();
206
+ log.debug(`通知音キューが上限 (${MAX_QUEUE_SIZE}) に達したため破棄しました: ${dropped}`);
207
+ }
208
+ playQueue.push(level);
209
+ }
210
+ }
211
+ /**
212
+ * 進行中の再生を停止し、キューをクリアする。
213
+ * アプリ終了時に呼ぶことでプロセスリークを防ぐ。
214
+ */
215
+ function dispose() {
216
+ disposed = true;
217
+ playQueue.length = 0;
218
+ if (activeTimer != null) {
219
+ clearTimeout(activeTimer);
220
+ activeTimer = null;
221
+ }
222
+ if (activeProcess != null) {
223
+ try {
224
+ activeProcess.kill();
225
+ }
226
+ catch {
227
+ // ignore
228
+ }
229
+ activeProcess = null;
230
+ }
231
+ activeCount = 0;
232
+ }
233
+ /**
234
+ * dispose 後に再利用できるよう内部状態をリセットする (テスト用)。
235
+ */
236
+ function resetSoundPlayer() {
237
+ disposed = false;
238
+ activeCount = 0;
239
+ playQueue.length = 0;
240
+ activeProcess = null;
241
+ if (activeTimer != null) {
242
+ clearTimeout(activeTimer);
243
+ activeTimer = null;
125
244
  }
126
245
  }
127
246
  // ── カスタム効果音再生 ──
128
- function playCustomSound(filePath, platform) {
247
+ function launchCustomSound(filePath, platform, onDone) {
129
248
  if (platform === "win32") {
130
- playCustomSoundWindows(filePath);
249
+ return launchCustomSoundWindows(filePath, onDone);
131
250
  }
132
251
  else if (platform === "darwin") {
133
- playCustomSoundMacOS(filePath);
252
+ return launchCustomSoundMacOS(filePath, onDone);
134
253
  }
135
254
  else {
136
- playCustomSoundLinux(filePath);
255
+ return launchCustomSoundLinux(filePath, onDone);
137
256
  }
138
257
  }
139
258
  /** Windows: winmm.dll mciSendString で mp3/wav を同期再生 */
140
- function playCustomSoundWindows(filePath) {
259
+ function launchCustomSoundWindows(filePath, onDone) {
141
260
  // MediaPlayer (WPF) は非同期ロードのためタイミング問題が起きやすい。
142
261
  // mciSendString は同期 (wait) で確実に再生できる。
262
+ // execFile を使い cmd.exe のダブルクォーテーション解釈を回避する。
143
263
  const escaped = filePath.replace(/'/g, "''");
144
264
  const psCommand = [
145
- `Add-Type -Namespace Win32 -Name Mci -MemberDefinition '[DllImport("winmm.dll",CharSet=[System.Runtime.InteropServices.CharSet]::Unicode)]public static extern int mciSendStringW(string cmd,System.Text.StringBuilder ret,int retLen,System.IntPtr hwnd);';`,
265
+ `Add-Type -Namespace Win32 -Name Mci -MemberDefinition '[DllImport("winmm.dll",CharSet=CharSet.Unicode)]public static extern int mciSendStringW(string cmd,System.Text.StringBuilder ret,int retLen,System.IntPtr hwnd);';`,
146
266
  `[Win32.Mci]::mciSendStringW('open "' + '${escaped}' + '" type mpegvideo alias fleqsnd',$null,0,[IntPtr]::Zero)|Out-Null;`,
147
267
  `[Win32.Mci]::mciSendStringW('play fleqsnd wait',$null,0,[IntPtr]::Zero)|Out-Null;`,
148
268
  `[Win32.Mci]::mciSendStringW('close fleqsnd',$null,0,[IntPtr]::Zero)|Out-Null`,
149
269
  ].join(" ");
150
- (0, child_process_1.exec)(`powershell -NoProfile -Command "${psCommand}"`, (err) => {
270
+ const proc = (0, child_process_1.execFile)("powershell", ["-NoProfile", "-Command", psCommand], (err) => {
151
271
  if (err) {
152
272
  log.debug(`Windows カスタム通知音の再生に失敗しました: ${err.message}`);
153
273
  }
274
+ onDone();
154
275
  });
276
+ return proc;
155
277
  }
156
278
  /** macOS: afplay で mp3/wav を再生 */
157
- function playCustomSoundMacOS(filePath) {
158
- (0, child_process_1.execFile)("afplay", [filePath], (err) => {
279
+ function launchCustomSoundMacOS(filePath, onDone) {
280
+ const proc = (0, child_process_1.execFile)("afplay", [filePath], (err) => {
159
281
  if (err) {
160
282
  log.debug(`macOS カスタム通知音の再生に失敗しました: ${err.message}`);
161
283
  }
284
+ onDone();
162
285
  });
286
+ return proc;
163
287
  }
164
288
  /** Linux: ffplay → paplay → aplay のフォールバック */
165
- function playCustomSoundLinux(filePath) {
289
+ function launchCustomSoundLinux(filePath, onDone) {
166
290
  const ext = path.extname(filePath).toLowerCase();
167
291
  if (ext === ".mp3") {
168
292
  // mp3 は ffplay で再生
169
- (0, child_process_1.execFile)("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", filePath], (err) => {
293
+ const proc = (0, child_process_1.execFile)("ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", filePath], (err) => {
170
294
  if (err) {
171
295
  log.debug(`Linux ffplay での再生に失敗しました: ${err.message}`);
172
296
  printBell();
173
297
  }
298
+ onDone();
174
299
  });
300
+ return proc;
175
301
  }
176
302
  else {
177
303
  // wav は paplay → aplay のフォールバック
178
- (0, child_process_1.execFile)("paplay", [filePath], (err) => {
304
+ const proc = (0, child_process_1.execFile)("paplay", [filePath], (err) => {
179
305
  if (err) {
180
306
  (0, child_process_1.execFile)("aplay", ["-q", filePath], (err2) => {
181
307
  if (err2) {
182
308
  log.debug(`Linux 通知音の再生に失敗しました: ${err2.message}`);
183
309
  printBell();
184
310
  }
311
+ onDone();
185
312
  });
186
313
  }
314
+ else {
315
+ onDone();
316
+ }
187
317
  });
318
+ return proc;
188
319
  }
189
320
  }
190
321
  // ── システムサウンドフォールバック ──
191
- function playSystemSoundWindows(level) {
322
+ /** Windows システムサウンドの存在確認キャッシュ (null = 存在しない, undefined = 未検索) */
323
+ const windowsSoundCache = new Map();
324
+ /** Windows サウンドファイルの確認済みパスを返す。存在しなければ null。 */
325
+ function findWindowsSystemSound(level) {
326
+ if (windowsSoundCache.has(level)) {
327
+ return windowsSoundCache.get(level);
328
+ }
192
329
  const soundFile = WINDOWS_SOUNDS[level];
193
330
  const soundPath = path.join(process.env.SYSTEMROOT || "C:\\Windows", "Media", soundFile);
331
+ if (fs.existsSync(soundPath)) {
332
+ windowsSoundCache.set(level, soundPath);
333
+ return soundPath;
334
+ }
335
+ // フォールバック: Windows Default.wav
336
+ const defaultPath = path.join(process.env.SYSTEMROOT || "C:\\Windows", "Media", "Windows Default.wav");
337
+ if (fs.existsSync(defaultPath)) {
338
+ windowsSoundCache.set(level, defaultPath);
339
+ return defaultPath;
340
+ }
341
+ windowsSoundCache.set(level, null);
342
+ return null;
343
+ }
344
+ function launchSystemSoundWindows(level, onDone) {
345
+ const soundPath = findWindowsSystemSound(level);
346
+ if (soundPath == null) {
347
+ log.debug(`Windows 通知音が見つかりません。bell にフォールバックします: ${WINDOWS_SOUNDS[level]}`);
348
+ printBell();
349
+ onDone();
350
+ return null;
351
+ }
194
352
  const psCommand = `(New-Object System.Media.SoundPlayer '${soundPath}').PlaySync()`;
195
- (0, child_process_1.exec)(`powershell -NoProfile -Command "${psCommand}"`, (err) => {
353
+ const proc = (0, child_process_1.execFile)("powershell", ["-NoProfile", "-Command", psCommand], (err) => {
196
354
  if (err) {
197
355
  log.debug(`Windows 通知音の再生に失敗しました: ${err.message}`);
198
356
  }
357
+ onDone();
199
358
  });
359
+ return proc;
200
360
  }
201
- function playSystemSoundMacOS(level) {
361
+ function launchSystemSoundMacOS(level, onDone) {
202
362
  const soundFile = MACOS_SOUNDS[level];
203
363
  const soundPath = `/System/Library/Sounds/${soundFile}`;
204
- (0, child_process_1.execFile)("afplay", [soundPath], (err) => {
364
+ const proc = (0, child_process_1.execFile)("afplay", [soundPath], (err) => {
205
365
  if (err) {
206
366
  log.debug(`macOS 通知音の再生に失敗しました: ${err.message}`);
207
367
  }
368
+ onDone();
208
369
  });
370
+ return proc;
209
371
  }
210
- function playSystemSoundLinux(level) {
372
+ function launchSystemSoundLinux(level, onDone) {
211
373
  const eventName = LINUX_CANBERRA_EVENTS[level];
212
374
  if (eventName === "bell") {
213
375
  printBell();
214
- return;
376
+ onDone();
377
+ return null;
215
378
  }
216
- (0, child_process_1.execFile)("canberra-gtk-play", ["-i", eventName], (err) => {
379
+ const proc = (0, child_process_1.execFile)("canberra-gtk-play", ["-i", eventName], (err) => {
217
380
  if (err) {
218
381
  log.debug(`canberra-gtk-play 失敗、bell にフォールバック: ${err.message}`);
219
382
  printBell();
220
383
  }
384
+ onDone();
221
385
  });
386
+ return proc;
222
387
  }
223
388
  function printBell() {
224
389
  try {
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PresentationDiffStore = void 0;
4
+ /** TTL のデフォルト値 (30分) */
5
+ const DEFAULT_TTL_MS = 30 * 60 * 1000;
6
+ /** プルーニング実行間隔 (apply 呼び出し回数) */
7
+ const PRUNE_INTERVAL = 50;
8
+ /**
9
+ * PresentationEvent の差分を検出・保持するストア。
10
+ *
11
+ * apply() は PresentationEvent を受け取り、同一 diffKey を持つ直前のイベントとの
12
+ * 差分を検出して diff プロパティを付与して返す。
13
+ *
14
+ * TTL ベースの自動クリーンアップにより、長時間稼働時のメモリ蓄積を防止する。
15
+ */
16
+ class PresentationDiffStore {
17
+ previous = new Map();
18
+ ttlMs;
19
+ applyCount = 0;
20
+ constructor(ttlMs) {
21
+ this.ttlMs = ttlMs ?? DEFAULT_TTL_MS;
22
+ }
23
+ /**
24
+ * イベントを受け取り、差分を検出する。
25
+ * - 初回 (同一 diffKey で初めて) → diff なし
26
+ * - 2回目以降 → diff あり (changed フラグ + summary + fields)
27
+ * - diffKey が解決できないドメイン → diff なし (対象外)
28
+ */
29
+ apply(event) {
30
+ this.applyCount++;
31
+ if (this.applyCount % PRUNE_INTERVAL === 0) {
32
+ this.prune();
33
+ }
34
+ const diffKey = this.resolveDiffKey(event);
35
+ if (diffKey == null)
36
+ return event;
37
+ const entry = this.previous.get(diffKey);
38
+ const diff = entry ? this.computeDiff(entry.event, event) : undefined;
39
+ this.previous.set(diffKey, { event, updatedAt: Date.now() });
40
+ return diff ? { ...event, diff } : event;
41
+ }
42
+ /** 指定した diffKey のエントリを削除する */
43
+ remove(diffKey) {
44
+ this.previous.delete(diffKey);
45
+ }
46
+ /** テスト用: ストアをクリアする */
47
+ clear() {
48
+ this.previous.clear();
49
+ }
50
+ /** TTL を超過した古いエントリを削除する */
51
+ prune() {
52
+ const now = Date.now();
53
+ for (const [key, entry] of this.previous) {
54
+ if (now - entry.updatedAt > this.ttlMs) {
55
+ this.previous.delete(key);
56
+ }
57
+ }
58
+ }
59
+ // ── diffKey 解決 ──
60
+ resolveDiffKey(event) {
61
+ switch (event.domain) {
62
+ case "eew":
63
+ return event.eventId ? `eew:${event.eventId}` : null;
64
+ case "tsunami":
65
+ return event.type === "VTSE41" ? "tsunami:vtse41" : null;
66
+ case "volcano":
67
+ return event.type === "VFVO50" && event.volcanoCode
68
+ ? `volcano:${event.volcanoCode}`
69
+ : null;
70
+ default:
71
+ return null;
72
+ }
73
+ }
74
+ // ── diff 算出 ──
75
+ computeDiff(prev, curr) {
76
+ const fields = [];
77
+ const summary = [];
78
+ switch (curr.domain) {
79
+ case "eew":
80
+ this.compareEew(prev, curr, fields, summary);
81
+ break;
82
+ case "tsunami":
83
+ this.compareTsunami(prev, curr, fields, summary);
84
+ break;
85
+ case "volcano":
86
+ this.compareVolcano(prev, curr, fields, summary);
87
+ break;
88
+ }
89
+ return {
90
+ changed: fields.length > 0,
91
+ summary,
92
+ fields,
93
+ };
94
+ }
95
+ compareEew(prev, curr, fields, summary) {
96
+ // magnitude
97
+ if (prev.magnitude !== curr.magnitude) {
98
+ fields.push({
99
+ key: "magnitude",
100
+ previous: prev.magnitude ?? null,
101
+ current: curr.magnitude ?? null,
102
+ significance: "major",
103
+ });
104
+ if (prev.magnitude != null && curr.magnitude != null) {
105
+ summary.push(`M${prev.magnitude}→${curr.magnitude}`);
106
+ }
107
+ }
108
+ // maxInt (forecastMaxInt for EEW)
109
+ const prevInt = prev.forecastMaxInt ?? prev.maxInt;
110
+ const currInt = curr.forecastMaxInt ?? curr.maxInt;
111
+ if (prevInt !== currInt) {
112
+ fields.push({
113
+ key: "maxInt",
114
+ previous: prevInt ?? null,
115
+ current: currInt ?? null,
116
+ significance: "major",
117
+ });
118
+ if (prevInt != null && currInt != null) {
119
+ summary.push(`${prevInt}→${currInt}`);
120
+ }
121
+ }
122
+ // hypocenterName
123
+ if (prev.hypocenterName !== curr.hypocenterName) {
124
+ fields.push({
125
+ key: "hypocenterName",
126
+ previous: prev.hypocenterName ?? null,
127
+ current: curr.hypocenterName ?? null,
128
+ significance: "minor",
129
+ });
130
+ summary.push("震源変更");
131
+ }
132
+ }
133
+ compareTsunami(prev, curr, fields, summary) {
134
+ if (prev.areaCount !== curr.areaCount) {
135
+ fields.push({
136
+ key: "areaCount",
137
+ previous: prev.areaCount,
138
+ current: curr.areaCount,
139
+ significance: "major",
140
+ });
141
+ summary.push(`${prev.areaCount}区域→${curr.areaCount}区域`);
142
+ }
143
+ }
144
+ compareVolcano(prev, curr, fields, summary) {
145
+ if (prev.alertLevel !== curr.alertLevel) {
146
+ fields.push({
147
+ key: "alertLevel",
148
+ previous: prev.alertLevel ?? null,
149
+ current: curr.alertLevel ?? null,
150
+ significance: "major",
151
+ });
152
+ if (prev.alertLevel != null && curr.alertLevel != null) {
153
+ summary.push(`Lv${prev.alertLevel}→${curr.alertLevel}`);
154
+ }
155
+ }
156
+ }
157
+ }
158
+ exports.PresentationDiffStore = PresentationDiffStore;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });