@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
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseTemplate = parseTemplate;
4
+ const tokenizer_1 = require("./tokenizer");
5
+ const MAX_DEPTH = 32;
6
+ function parseTemplate(source) {
7
+ const tokens = (0, tokenizer_1.tokenizeTemplate)(source);
8
+ return new TemplateParser(tokens).parse();
9
+ }
10
+ class TemplateParser {
11
+ tokens;
12
+ pos = 0;
13
+ depth = 0;
14
+ constructor(tokens) {
15
+ this.tokens = tokens;
16
+ }
17
+ parse() {
18
+ return this.parseNodes();
19
+ }
20
+ parseNodes() {
21
+ const nodes = [];
22
+ while (this.pos < this.tokens.length) {
23
+ const token = this.tokens[this.pos];
24
+ if (token.kind === "eof" || token.kind === "else" || token.kind === "endif")
25
+ break;
26
+ if (token.kind === "text" && this.peek(-1)?.kind !== "open" && this.peek(-1)?.kind !== "if_open") {
27
+ // 外側テキスト
28
+ nodes.push({ kind: "text", value: token.value });
29
+ this.pos++;
30
+ continue;
31
+ }
32
+ if (token.kind === "if_open") {
33
+ nodes.push(this.parseIfBlock());
34
+ continue;
35
+ }
36
+ if (token.kind === "open") {
37
+ nodes.push(this.parseInterpolation());
38
+ continue;
39
+ }
40
+ // 未消費トークンはテキストとして扱う
41
+ nodes.push({ kind: "text", value: token.value });
42
+ this.pos++;
43
+ }
44
+ return nodes;
45
+ }
46
+ parseInterpolation() {
47
+ this.expect("open"); // {{
48
+ const exprToken = this.tokens[this.pos];
49
+ this.pos++;
50
+ const expr = this.parseExpr(exprToken.value);
51
+ const filters = [];
52
+ while (this.pos < this.tokens.length && this.tokens[this.pos].kind === "pipe") {
53
+ this.pos++; // |
54
+ const nameToken = this.tokens[this.pos];
55
+ this.pos++;
56
+ const args = [];
57
+ // 複数引数対応: colon が続く限り引数を読む
58
+ while (this.pos < this.tokens.length && this.tokens[this.pos].kind === "colon") {
59
+ this.pos++; // :
60
+ const argToken = this.tokens[this.pos];
61
+ this.pos++;
62
+ args.push(this.parseExpr(argToken.value));
63
+ }
64
+ filters.push({ name: nameToken.value, args });
65
+ }
66
+ this.expect("close"); // }}
67
+ return { kind: "interpolation", expr, filters };
68
+ }
69
+ parseIfBlock() {
70
+ this.expect("if_open"); // {{#if
71
+ const condToken = this.tokens[this.pos];
72
+ this.pos++;
73
+ const test = this.parsePredicate(condToken.value);
74
+ this.expect("close"); // }}
75
+ this.depth++;
76
+ if (this.depth > MAX_DEPTH) {
77
+ throw new Error(`テンプレート構文エラー: #if のネストが深すぎる (最大 ${MAX_DEPTH} 段)`);
78
+ }
79
+ const body = this.parseNodes();
80
+ let elseBody;
81
+ if (this.pos < this.tokens.length && this.tokens[this.pos].kind === "else") {
82
+ this.pos++; // {{else}}
83
+ elseBody = this.parseNodes();
84
+ }
85
+ if (this.pos < this.tokens.length && this.tokens[this.pos].kind === "endif") {
86
+ this.pos++; // {{/if}}
87
+ }
88
+ else {
89
+ this.depth--;
90
+ throw new Error('テンプレート構文エラー: "endif" が必要ですが見つかりません。{{/if}} で閉じてください');
91
+ }
92
+ this.depth--;
93
+ return { kind: "if", test, body, elseBody };
94
+ }
95
+ parseExpr(raw) {
96
+ const trimmed = raw.trim();
97
+ // 文字列リテラル
98
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
99
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
100
+ return { kind: "literal", value: unescapeString(trimmed.slice(1, -1)) };
101
+ }
102
+ // 数値
103
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
104
+ return { kind: "literal", value: Number(trimmed) };
105
+ }
106
+ // boolean / null
107
+ if (trimmed === "true")
108
+ return { kind: "literal", value: true };
109
+ if (trimmed === "false")
110
+ return { kind: "literal", value: false };
111
+ if (trimmed === "null")
112
+ return { kind: "literal", value: null };
113
+ // パス: dot + bracket 記法を (string | number)[] に分割
114
+ return { kind: "path", segments: parsePathSegments(trimmed) };
115
+ }
116
+ parsePredicate(raw) {
117
+ const trimmed = raw.trim();
118
+ // 簡易比較: "field op value" 形式
119
+ const cmpMatch = trimmed.match(/^(\S+)\s+(=|!=|>|>=|<|<=)\s+(.+)$/);
120
+ if (cmpMatch) {
121
+ const opMap = {
122
+ "=": "eq", "!=": "ne", ">": "gt", ">=": "ge", "<": "lt", "<=": "le",
123
+ };
124
+ return {
125
+ kind: "compare",
126
+ op: opMap[cmpMatch[2]],
127
+ left: this.parseExpr(cmpMatch[1]),
128
+ right: this.parseExpr(cmpMatch[3]),
129
+ };
130
+ }
131
+ // truthy
132
+ return { kind: "truthy", expr: this.parseExpr(trimmed) };
133
+ }
134
+ expect(kind) {
135
+ if (this.pos < this.tokens.length && this.tokens[this.pos].kind === kind) {
136
+ this.pos++;
137
+ return;
138
+ }
139
+ const actual = this.pos < this.tokens.length ? this.tokens[this.pos].kind : "eof";
140
+ throw new Error(`テンプレート構文エラー: "${kind}" が必要ですが "${actual}" が見つかりました`);
141
+ }
142
+ peek(offset) {
143
+ return this.tokens[this.pos + offset];
144
+ }
145
+ }
146
+ /**
147
+ * ドットパスおよびブラケット記法を segments 配列に分割する。
148
+ * 例: "areaItems[0].name" → ["areaItems", 0, "name"]
149
+ * "foo.bar.baz" → ["foo", "bar", "baz"]
150
+ */
151
+ function parsePathSegments(path) {
152
+ const segments = [];
153
+ let i = 0;
154
+ while (i < path.length) {
155
+ if (path[i] === ".") {
156
+ i++; // ドット区切りをスキップ
157
+ continue;
158
+ }
159
+ if (path[i] === "[") {
160
+ // ブラケットアクセス: [N]
161
+ i++; // [
162
+ const start = i;
163
+ while (i < path.length && path[i] !== "]")
164
+ i++;
165
+ const inner = path.slice(start, i);
166
+ if (i < path.length)
167
+ i++; // ]
168
+ // 数値ならnumber、そうでなければstring
169
+ const num = Number(inner);
170
+ segments.push(Number.isNaN(num) ? inner : num);
171
+ continue;
172
+ }
173
+ // 識別子: 次の . または [ まで
174
+ const start = i;
175
+ while (i < path.length && path[i] !== "." && path[i] !== "[")
176
+ i++;
177
+ segments.push(path.slice(start, i));
178
+ }
179
+ return segments;
180
+ }
181
+ /** 文字列リテラル内のエスケープシーケンスを復元する */
182
+ function unescapeString(s) {
183
+ return s.replace(/\\(["'\\nt])/g, (_, ch) => {
184
+ switch (ch) {
185
+ case "n": return "\n";
186
+ case "t": return "\t";
187
+ default: return ch; // \\, \", \'
188
+ }
189
+ });
190
+ }
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tokenizeTemplate = tokenizeTemplate;
4
+ function tokenizeTemplate(source) {
5
+ const tokens = [];
6
+ let i = 0;
7
+ while (i < source.length) {
8
+ // {{#if ...}}
9
+ if (source.startsWith("{{#if", i)) {
10
+ tokens.push({ kind: "if_open", value: "{{#if", pos: i });
11
+ i += 5;
12
+ // 空白スキップ
13
+ while (i < source.length && source[i] === " ")
14
+ i++;
15
+ // 条件式を "}}" まで読む
16
+ const start = i;
17
+ while (i < source.length && !source.startsWith("}}", i))
18
+ i++;
19
+ tokens.push({ kind: "text", value: source.slice(start, i), pos: start });
20
+ if (source.startsWith("}}", i)) {
21
+ tokens.push({ kind: "close", value: "}}", pos: i });
22
+ i += 2;
23
+ }
24
+ continue;
25
+ }
26
+ // {{else}}
27
+ if (source.startsWith("{{else}}", i)) {
28
+ tokens.push({ kind: "else", value: "{{else}}", pos: i });
29
+ i += 8;
30
+ continue;
31
+ }
32
+ // {{/if}}
33
+ if (source.startsWith("{{/if}}", i)) {
34
+ tokens.push({ kind: "endif", value: "{{/if}}", pos: i });
35
+ i += 7;
36
+ continue;
37
+ }
38
+ // {{ interpolation }}
39
+ if (source.startsWith("{{", i)) {
40
+ tokens.push({ kind: "open", value: "{{", pos: i });
41
+ i += 2;
42
+ // interpolation 内容を解析
43
+ while (i < source.length && !source.startsWith("}}", i)) {
44
+ if (source[i] === "|") {
45
+ tokens.push({ kind: "pipe", value: "|", pos: i });
46
+ i++;
47
+ }
48
+ else if (source[i] === ":") {
49
+ tokens.push({ kind: "colon", value: ":", pos: i });
50
+ i++;
51
+ }
52
+ else if (source[i] === " ") {
53
+ i++;
54
+ }
55
+ else if (source[i] === '"' || source[i] === "'") {
56
+ // 文字列リテラル
57
+ const quote = source[i];
58
+ const start = i;
59
+ i++;
60
+ while (i < source.length && source[i] !== quote) {
61
+ if (source[i] === "\\" && i + 1 < source.length)
62
+ i++;
63
+ i++;
64
+ }
65
+ if (i < source.length)
66
+ i++; // 閉じ引用符
67
+ tokens.push({ kind: "text", value: source.slice(start, i), pos: start });
68
+ }
69
+ else {
70
+ // 識別子/数値
71
+ const start = i;
72
+ while (i < source.length &&
73
+ !source.startsWith("}}", i) &&
74
+ source[i] !== "|" &&
75
+ source[i] !== ":" &&
76
+ source[i] !== " ") {
77
+ i++;
78
+ }
79
+ tokens.push({ kind: "text", value: source.slice(start, i), pos: start });
80
+ }
81
+ }
82
+ if (source.startsWith("}}", i)) {
83
+ tokens.push({ kind: "close", value: "}}", pos: i });
84
+ i += 2;
85
+ }
86
+ continue;
87
+ }
88
+ // テキスト
89
+ const start = i;
90
+ while (i < source.length && !source.startsWith("{{", i))
91
+ i++;
92
+ tokens.push({ kind: "text", value: source.slice(start, i), pos: start });
93
+ }
94
+ tokens.push({ kind: "eof", value: "", pos: source.length });
95
+ return tokens;
96
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/dist/types.js CHANGED
@@ -39,6 +39,8 @@ exports.DEFAULT_CONFIG = {
39
39
  maxIntChangeReason: true,
40
40
  },
41
41
  maxObservations: null,
42
+ nightMode: false,
43
+ summaryInterval: null,
42
44
  backup: false,
43
45
  truncation: {
44
46
  seismicTextLines: 15,
@@ -50,7 +52,6 @@ exports.DEFAULT_CONFIG = {
50
52
  volcanoAshfallDetailLines: 16,
51
53
  volcanoAshfallRegularLines: 10,
52
54
  volcanoPreventionLines: 8,
53
- volcanoMunicipalities: 6,
54
55
  ashfallAreasQuick: 5,
55
56
  ashfallAreasOther: 3,
56
57
  ashfallPeriodsQuick: 1,
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ /**
3
+ * engine 層の DisplayCallbacks を実装する UI アダプター。
4
+ * 全ての display 関数をここに集約し、engine → ui の逆方向依存を断つ。
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.createDisplayAdapter = createDisplayAdapter;
8
+ const formatter_1 = require("./formatter");
9
+ const summary_1 = require("./summary");
10
+ const eew_formatter_1 = require("./eew-formatter");
11
+ const earthquake_formatter_1 = require("./earthquake-formatter");
12
+ const volcano_formatter_1 = require("./volcano-formatter");
13
+ /** DisplayCallbacks の実装を生成する */
14
+ function createDisplayAdapter() {
15
+ return {
16
+ displayOutcome(outcome) {
17
+ switch (outcome.domain) {
18
+ case "eew":
19
+ (0, eew_formatter_1.displayEewInfo)(outcome.parsed, {
20
+ activeCount: outcome.eewResult.activeCount,
21
+ diff: outcome.eewResult.diff,
22
+ colorIndex: outcome.eewResult.colorIndex,
23
+ });
24
+ break;
25
+ case "earthquake":
26
+ (0, earthquake_formatter_1.displayEarthquakeInfo)(outcome.parsed);
27
+ break;
28
+ case "seismicText":
29
+ (0, earthquake_formatter_1.displaySeismicTextInfo)(outcome.parsed);
30
+ break;
31
+ case "lgObservation":
32
+ (0, earthquake_formatter_1.displayLgObservationInfo)(outcome.parsed);
33
+ break;
34
+ case "tsunami":
35
+ (0, earthquake_formatter_1.displayTsunamiInfo)(outcome.parsed);
36
+ break;
37
+ case "nankaiTrough":
38
+ (0, earthquake_formatter_1.displayNankaiTroughInfo)(outcome.parsed);
39
+ break;
40
+ case "raw":
41
+ (0, formatter_1.displayRawHeader)(outcome.msg);
42
+ break;
43
+ // volcano: VolcanoRouteHandler 経由で displayVolcano/displayVolcanoBatch を直接呼ぶ
44
+ }
45
+ },
46
+ displayRawHeader(msg) {
47
+ (0, formatter_1.displayRawHeader)(msg);
48
+ },
49
+ displayVolcano(info, presentation) {
50
+ (0, volcano_formatter_1.displayVolcanoInfo)(info, presentation);
51
+ },
52
+ displayVolcanoBatch(batch, presentation) {
53
+ (0, volcano_formatter_1.displayVolcanoAshfallBatch)(batch, presentation);
54
+ },
55
+ getDisplayMode() {
56
+ return (0, formatter_1.getDisplayMode)();
57
+ },
58
+ renderSummaryLine: summary_1.renderSummaryLine,
59
+ };
60
+ }
@@ -825,7 +825,7 @@ function displayLgObservationInfo(info) {
825
825
  buf.push((0, formatter_1.frameLine)(level, chalk_1.default.white("位置: ") + chalk_1.default.white(`${eq.latitude} ${eq.longitude}`), width));
826
826
  }
827
827
  }
828
- // 地域リスト (LgInt 降順)
828
+ // 地域リスト (LgInt 降順 → 階級別グループ化)
829
829
  if (info.areas.length > 0) {
830
830
  buf.push((0, formatter_1.frameDivider)(level, width));
831
831
  const sorted = [...info.areas].sort((a, b) => (0, formatter_1.lgIntToNumeric)(b.maxLgInt) - (0, formatter_1.lgIntToNumeric)(a.maxLgInt));
@@ -833,13 +833,25 @@ function displayLgObservationInfo(info) {
833
833
  const maxObs = (0, formatter_1.getMaxObservations)();
834
834
  const displayAreas = maxObs != null ? sorted.slice(0, maxObs) : sorted;
835
835
  const hiddenCount = sorted.length - displayAreas.length;
836
+ // 長周期階級別にグループ化
837
+ const byLgInt = new Map();
836
838
  for (const area of displayAreas) {
837
- const lc = (0, formatter_1.lgIntensityColor)(area.maxLgInt);
839
+ const key = area.maxLgInt;
840
+ if (!byLgInt.has(key))
841
+ byLgInt.set(key, []);
838
842
  const ic = (0, formatter_1.intensityColor)(area.maxInt);
839
- buf.push((0, formatter_1.frameLine)(level, lc(`長周期${area.maxLgInt}: `) +
840
- chalk_1.default.white(area.name) +
841
- ic(` (震度${area.maxInt})`), width));
843
+ byLgInt.get(key).push({
844
+ primary: chalk_1.default.white(area.name),
845
+ badges: [ic(` (震度${area.maxInt})`)],
846
+ });
842
847
  }
848
+ // LgInt 降順でソート
849
+ const sortedEntries = [...byLgInt.entries()].sort((a, b) => (0, formatter_1.lgIntToNumeric)(b[0]) - (0, formatter_1.lgIntToNumeric)(a[0]));
850
+ const groups = sortedEntries.map(([lgInt, items]) => ({
851
+ prefix: (0, formatter_1.lgIntensityColor)(lgInt)(`長周期${lgInt}: `),
852
+ items,
853
+ }));
854
+ (0, formatter_1.renderGroupedItemList)({ level, width, groups, buf });
843
855
  if (hiddenCount > 0) {
844
856
  buf.push((0, formatter_1.frameLine)(level, chalk_1.default.gray(`... 他 ${hiddenCount} 地点`), width));
845
857
  }
@@ -282,23 +282,38 @@ function displayEewInfo(info, context) {
282
282
  const allAreas = info.forecastIntensity.areas;
283
283
  const displayAreas = maxObs != null ? allAreas.slice(0, maxObs) : allAreas;
284
284
  const hiddenCount = allAreas.length - displayAreas.length;
285
+ // 震度別にグループ化
286
+ const byIntensity = new Map();
285
287
  for (const area of displayAreas) {
286
- const color = (0, formatter_1.intensityColor)(area.intensity);
287
- let areaText = chalk_1.default.white(area.name);
288
+ const key = area.intensity;
289
+ if (!byIntensity.has(key))
290
+ byIntensity.set(key, []);
291
+ const badges = [];
288
292
  if (area.isPlum) {
289
- areaText += theme.getRoleChalk("plumLabel")(" [PLUM]");
290
- }
291
- if (area.hasArrived) {
292
- areaText += theme.getRoleChalk("arrivedLabel")(" [到達]");
293
+ badges.push(theme.getRoleChalk("plumLabel")(" [PLUM]"));
293
294
  }
295
+ // [到達] は下部の専用セクションに集約するため、ここでは付与しない
294
296
  if (area.lgIntensity && (0, formatter_1.lgIntToNumeric)(area.lgIntensity) >= 1) {
295
297
  const lc = (0, formatter_1.lgIntensityColor)(area.lgIntensity);
296
- areaText += lc(` [長周期${area.lgIntensity}]`);
297
- }
298
- for (const wl of (0, formatter_1.wrapFrameLines)(level, color(`震度${area.intensity}: `) + areaText, width)) {
299
- buf.push(wl);
298
+ badges.push(lc(` [長周期${area.lgIntensity}]`));
300
299
  }
300
+ byIntensity.get(key).push({
301
+ primary: chalk_1.default.white(area.name),
302
+ badges: badges.length > 0 ? badges : undefined,
303
+ });
301
304
  }
305
+ // 震度降順でソート
306
+ const order = ["7", "6+", "6強", "6-", "6弱", "5+", "5強", "5-", "5弱", "4", "3", "2", "1"];
307
+ const sortedEntries = [...byIntensity.entries()].sort((a, b) => {
308
+ const ai = order.indexOf(a[0]);
309
+ const bi = order.indexOf(b[0]);
310
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
311
+ });
312
+ const groups = sortedEntries.map(([int, items]) => ({
313
+ prefix: (0, formatter_1.intensityColor)(int)(`震度${int}: `),
314
+ items,
315
+ }));
316
+ (0, formatter_1.renderGroupedItemList)({ level, width, groups, buf });
302
317
  if (hiddenCount > 0) {
303
318
  buf.push((0, formatter_1.frameLine)(level, chalk_1.default.gray(`... 他 ${hiddenCount} 地点`), width));
304
319
  }
@@ -61,6 +61,8 @@ exports.visualPadEnd = visualPadEnd;
61
61
  exports.renderFrameTable = renderFrameTable;
62
62
  exports.wrapFrameLines = wrapFrameLines;
63
63
  exports.wrapTextLines = wrapTextLines;
64
+ exports.renderGroupedItemList = renderGroupedItemList;
65
+ exports.renderSimpleNameList = renderSimpleNameList;
64
66
  exports.collectHighlightSpans = collectHighlightSpans;
65
67
  exports.highlightAndWrap = highlightAndWrap;
66
68
  exports.formatTimestamp = formatTimestamp;
@@ -311,30 +313,28 @@ function sanitizeForTerminal(str) {
311
313
  // eslint-disable-next-line no-control-regex
312
314
  return stripAnsi(str).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
313
315
  }
316
+ /** コードポイントが CJK 等の全角文字かどうかを判定する */
317
+ function isWideChar(cp) {
318
+ return ((cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo
319
+ (cp >= 0x2E80 && cp <= 0x303E) || // CJK部首・記号
320
+ (cp >= 0x3041 && cp <= 0x33BF) || // ひらがな・カタカナ・CJK互換
321
+ (cp >= 0x3400 && cp <= 0x4DBF) || // CJK統合漢字拡張A
322
+ (cp >= 0x4E00 && cp <= 0xA4CF) || // CJK統合漢字 + Yi
323
+ (cp >= 0xAC00 && cp <= 0xD7AF) || // Hangul Syllables
324
+ (cp >= 0xF900 && cp <= 0xFAFF) || // CJK互換漢字
325
+ (cp >= 0xFE30 && cp <= 0xFE4F) || // CJK互換形
326
+ (cp >= 0xFF01 && cp <= 0xFF60) || // 全角ASCII・半角カタカナ
327
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || // 全角記号
328
+ (cp >= 0x20000 && cp <= 0x2FA1F) // CJK統合漢字拡張B-F
329
+ );
330
+ }
314
331
  /** 文字列の視覚的な幅を計算(全角文字を2として数える) */
315
332
  function visualWidth(str) {
316
333
  const plain = stripAnsi(str);
317
334
  let width = 0;
318
335
  for (const ch of plain) {
319
336
  const cp = ch.codePointAt(0) ?? 0;
320
- // CJK統合漢字、ひらがな、カタカナ、全角記号、全角括弧等
321
- if ((cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo
322
- (cp >= 0x2E80 && cp <= 0x303E) || // CJK部首・記号
323
- (cp >= 0x3041 && cp <= 0x33BF) || // ひらがな・カタカナ・CJK互換
324
- (cp >= 0x3400 && cp <= 0x4DBF) || // CJK統合漢字拡張A
325
- (cp >= 0x4E00 && cp <= 0xA4CF) || // CJK統合漢字 + Yi
326
- (cp >= 0xAC00 && cp <= 0xD7AF) || // Hangul Syllables
327
- (cp >= 0xF900 && cp <= 0xFAFF) || // CJK互換漢字
328
- (cp >= 0xFE30 && cp <= 0xFE4F) || // CJK互換形
329
- (cp >= 0xFF01 && cp <= 0xFF60) || // 全角ASCII・半角カタカナ
330
- (cp >= 0xFFE0 && cp <= 0xFFE6) || // 全角記号
331
- (cp >= 0x20000 && cp <= 0x2FA1F) // CJK統合漢字拡張B-F
332
- ) {
333
- width += 2;
334
- }
335
- else {
336
- width += 1;
337
- }
337
+ width += isWideChar(cp) ? 2 : 1;
338
338
  }
339
339
  return width;
340
340
  }
@@ -467,20 +467,7 @@ function wrapTextLines(text, maxWidth) {
467
467
  let currentWidth = 0;
468
468
  for (const ch of text) {
469
469
  const cp = ch.codePointAt(0) ?? 0;
470
- let charWidth = 1;
471
- if ((cp >= 0x1100 && cp <= 0x115F) ||
472
- (cp >= 0x2E80 && cp <= 0x303E) ||
473
- (cp >= 0x3041 && cp <= 0x33BF) ||
474
- (cp >= 0x3400 && cp <= 0x4DBF) ||
475
- (cp >= 0x4E00 && cp <= 0xA4CF) ||
476
- (cp >= 0xAC00 && cp <= 0xD7AF) ||
477
- (cp >= 0xF900 && cp <= 0xFAFF) ||
478
- (cp >= 0xFE30 && cp <= 0xFE4F) ||
479
- (cp >= 0xFF01 && cp <= 0xFF60) ||
480
- (cp >= 0xFFE0 && cp <= 0xFFE6) ||
481
- (cp >= 0x20000 && cp <= 0x2FA1F)) {
482
- charWidth = 2;
483
- }
470
+ const charWidth = isWideChar(cp) ? 2 : 1;
484
471
  if (currentWidth + charWidth > maxWidth) {
485
472
  lines.push(currentLine);
486
473
  currentLine = ch;
@@ -496,6 +483,54 @@ function wrapTextLines(text, maxWidth) {
496
483
  }
497
484
  return lines;
498
485
  }
486
+ /**
487
+ * 震度別・階級別にグループ化された項目リストを描画する。
488
+ * 各グループは `prefix + item, item, ...` 形式で、2行目以降は prefix 幅分のハンギングインデント。
489
+ * badges は primary の直後に連結される(先頭スペースは呼び出し側で付与済み想定)。
490
+ */
491
+ function renderGroupedItemList(options) {
492
+ const { level, width, groups, itemSeparator = ", " } = options;
493
+ const out = options.buf
494
+ ? (line) => options.buf.push(line)
495
+ : (line) => console.log(line);
496
+ for (const group of groups) {
497
+ if (group.items.length === 0)
498
+ continue;
499
+ const prefix = group.prefix;
500
+ const indentWidth = visualWidth(stripAnsi(prefix));
501
+ // 各項目を "primary + badges" 形式の文字列に組み立て
502
+ const itemTexts = group.items.map((item) => {
503
+ let text = item.primary;
504
+ if (item.badges != null && item.badges.length > 0) {
505
+ text += item.badges.join("");
506
+ }
507
+ return text;
508
+ });
509
+ const content = prefix + itemTexts.join(itemSeparator);
510
+ for (const line of wrapFrameLines(level, content, width, indentWidth)) {
511
+ out(line);
512
+ }
513
+ }
514
+ }
515
+ /**
516
+ * 単純な名前リストを描画する。
517
+ * `label + name, name, ...` 形式。2行目以降は label 幅分のハンギングインデント。
518
+ * label 指定時は ` label ` の形式(先頭・末尾にスペース)で gray 表示される。
519
+ */
520
+ function renderSimpleNameList(options) {
521
+ const { level, width, items, label, separator = ", " } = options;
522
+ if (items.length === 0)
523
+ return;
524
+ const out = options.buf
525
+ ? (line) => options.buf.push(line)
526
+ : (line) => console.log(line);
527
+ const prefix = label ? ` ${chalk_1.default.gray(label)} ` : "";
528
+ const indentWidth = label ? visualWidth(stripAnsi(prefix)) : 0;
529
+ const content = prefix + items.join(separator);
530
+ for (const line of wrapFrameLines(level, content, width, indentWidth)) {
531
+ out(line);
532
+ }
533
+ }
499
534
  /** 元の行からマッチspanを収集する */
500
535
  function collectHighlightSpans(line, rules) {
501
536
  const spans = [];