@cardenelabs/dragon 0.30.0 → 0.32.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 (55) hide show
  1. package/README.md +314 -12
  2. package/dist/index.cjs +6966 -1438
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +2150 -118
  5. package/dist/index.d.ts +2150 -118
  6. package/dist/index.js +6950 -1440
  7. package/dist/index.js.map +1 -1
  8. package/package.json +12 -13
  9. package/src/compile/actor-ids.ts +6 -15
  10. package/src/compile/actor-option-notice.ts +379 -0
  11. package/src/compile/actors.ts +32 -3
  12. package/src/compile/c4.ts +0 -3
  13. package/src/compile/chain.ts +43 -4
  14. package/src/compile/chart-fields.ts +16 -1
  15. package/src/compile/direction.ts +50 -11
  16. package/src/compile/edge-option-notice.ts +112 -0
  17. package/src/compile/flowchart.ts +344 -0
  18. package/src/compile/funnel.ts +14 -2
  19. package/src/compile/gantt.ts +141 -36
  20. package/src/compile/generic.ts +473 -61
  21. package/src/compile/journey.ts +14 -5
  22. package/src/compile/kinds.ts +5 -13
  23. package/src/compile/lanes.ts +95 -13
  24. package/src/compile/metro.ts +271 -0
  25. package/src/compile/mind.ts +55 -5
  26. package/src/compile/notice.ts +51 -4
  27. package/src/compile/order.ts +62 -0
  28. package/src/compile/parts.ts +107 -33
  29. package/src/compile/placement-apply.ts +27 -11
  30. package/src/compile/quadrant.ts +171 -16
  31. package/src/compile/record.ts +75 -0
  32. package/src/compile/row-marks.ts +128 -0
  33. package/src/compile/sequence.ts +105 -26
  34. package/src/compile/slug.ts +9 -1
  35. package/src/compile/stages.ts +247 -0
  36. package/src/compile/subtitle.ts +15 -2
  37. package/src/compile/swimlane-marks.ts +87 -0
  38. package/src/compile/swimlane.ts +13 -14
  39. package/src/compile/timeline.ts +213 -0
  40. package/src/compile/topology.ts +3 -9
  41. package/src/compile/tree.ts +13 -4
  42. package/src/compile/value-chart.ts +179 -25
  43. package/src/compile.ts +1550 -370
  44. package/src/focus.ts +2 -2
  45. package/src/index.ts +34 -2
  46. package/src/json-parser.ts +1407 -48
  47. package/src/keywords.ts +251 -17
  48. package/src/notation-lint.ts +41 -64
  49. package/src/schemas/diagram.json +643 -20
  50. package/src/types.ts +336 -35
  51. package/src/v05/parser.ts +2451 -271
  52. package/src/compile/class.ts +0 -100
  53. package/src/compile/er.ts +0 -44
  54. package/src/compile/solidity.ts +0 -39
  55. package/src/compile/state.ts +0 -71
package/src/v05/parser.ts CHANGED
@@ -41,13 +41,102 @@
41
41
  * 出力は v0.4 と同じ DslDocument。 既存 compile.ts で CdlDiagram に変換できる。
42
42
  */
43
43
 
44
- import type { NodeKind, Tone, EdgeStyle, EdgeHead, EdgeHeadFill, ClassRelationType, SequenceMessageKind } from "@cardenelabs/cdl";
45
- import { TONES, NODE_KINDS, EDGE_HEADS, EDGE_HEAD_FILLS, EDGE_STYLES, EDGE_REVEALS, RELATION_FOCUSES, CLASS_RELATION_LOOK, SEQUENCE_MESSAGE_LOOK, parseFormula } from "@cardenelabs/cdl";
44
+ import type {
45
+ CdlLegendItem,
46
+ LegendMark,
47
+ NodeKind,
48
+ Tone,
49
+ EdgeStyle,
50
+ EdgeHead,
51
+ EdgeHeadFill,
52
+ ClassRelationType,
53
+ SequenceMessageKind,
54
+ } from "@cardenelabs/cdl";
55
+ import {
56
+ TONES,
57
+ LEGEND_MARKS,
58
+ NODE_KINDS,
59
+ EDGE_HEADS,
60
+ EDGE_HEAD_FILLS,
61
+ EDGE_STYLES,
62
+ EDGE_REVEALS,
63
+ RELATION_FOCUSES,
64
+ CLASS_RELATION_LOOK,
65
+ SEQUENCE_MESSAGE_LOOK,
66
+ parseFormula,
67
+ } from "@cardenelabs/cdl";
46
68
  import type { EdgeReveal, RelationFocus } from "@cardenelabs/cdl";
47
- import { TONE_ALIAS, NODE_KIND_ALIAS, DIRECTIONS, resolveDirection, PALETTES, resolvePalette } from "../keywords";
48
- import type { DslPalette } from "../keywords";
69
+ import {
70
+ TONE_ALIAS,
71
+ NODE_KIND_ALIAS,
72
+ DIRECTIONS,
73
+ resolveDirection,
74
+ ORDERS,
75
+ resolveOrder,
76
+ SHAPES,
77
+ DIAGRAM_SHAPES,
78
+ resolveDiagramShape,
79
+ shapesForDiagramType,
80
+ THEMES,
81
+ THEME_ALIAS,
82
+ resolveTheme,
83
+ LEGEND_MARK_ALIAS,
84
+ resolveLegendMark,
85
+ } from "../keywords";
86
+ import { 区画 } from "../compile/word-state";
87
+ // 行頭の印の古い語の読み替え (#2782)。 印を作る側と同じ file が表を持つ = 縦に並べた形と
88
+ // 中括弧の形と JSON の 3 経路が同じ表を通る
89
+ import { 行頭の語へ読み替える } from "../compile/row-marks";
90
+ import { PHASE_BODY_KEYS } from "../keywords";
91
+ import type { DslTheme } from "../keywords";
49
92
  import type { DslDirection } from "../keywords";
93
+ import type { DslOrder } from "../keywords";
94
+ import type { DslDiagramShape, DslShape } from "../keywords";
50
95
  import { parseRelativePos, findRelativeProblems, type RelativeProblem } from "../relative-pos";
96
+
97
+ /** #2854 で記法に足した図表欄の閉じた語。JSON / schema と共有する。 */
98
+ export const CHART_LINE_DASH_VALUES = ["solid", "dotted"] as const;
99
+ export const CHART_LINE_MARKER_VALUES = ["dot", "hollow", "none"] as const;
100
+ export const CHART_PIE_TABLE_COLUMN_VALUES = ["share-value", "value"] as const;
101
+ export const CHART_PIE_RING_WIDTH_VALUES = ["thick", "thin"] as const;
102
+ export const CHART_STACKED_LEGEND_POSITION_VALUES = ["top", "bottom"] as const;
103
+ export const CHART_WAFFLE_LEGEND_POSITION_VALUES = ["left", "right"] as const;
104
+ export const FUNNEL_FORM_VALUES = ["trapezoid", "proportional-bars"] as const;
105
+ export const FUNNEL_RATE_VALUES = ["drop", "conversion", "none"] as const;
106
+ export const QUADRANT_POINT_LABEL_SIDE_VALUES = ["auto", "right"] as const;
107
+ export const TREE_NODE_FORM_VALUES = ["accent", "frame"] as const;
108
+ export const TREE_EDGE_TONE_VALUES = ["accent", "depth"] as const;
109
+ export const TREE_EDGE_HEAD_VALUES = ["none", "triangle"] as const;
110
+ export const MIND_FORM_VALUES = ["filled", "outline"] as const;
111
+ export const JOURNEY_FORM_VALUES = ["bands", "rules"] as const;
112
+ export const JOURNEY_LINE_FORM_VALUES = ["curve", "straight"] as const;
113
+ export const JOURNEY_OPPORTUNITY_POSITION_VALUES = ["below-chart", "below-point"] as const;
114
+ export const JOURNEY_LABEL_KEYS = ["delighted", "happy", "neutral", "frustrated", "angry"] as const;
115
+ export const CORNER_FORM_VALUES = ["theme-variants"] as const;
116
+ export const CHART_BAR_SHAPE_VALUES = ["split", "top-rounded"] as const;
117
+ export const QUADRANT_LABEL_POSITION_VALUES = ["outside-corners"] as const;
118
+ export const LABEL_COLLISION_VALUES = ["auto", "author"] as const;
119
+ export const GANTT_TASK_LABEL_ALIGN_VALUES = ["start", "end"] as const;
120
+ const TREE_SIZE_KEYS = [
121
+ "nodeWidths",
122
+ "nodeHeight",
123
+ "titleFontSize",
124
+ "subtitleFontSize",
125
+ "siblingGap",
126
+ "levelGap",
127
+ ] as const;
128
+ const MIND_SIZE_KEYS = [
129
+ "contentOffset",
130
+ "root",
131
+ "branch",
132
+ "leaf",
133
+ "rootBranchGap",
134
+ "branchLeafGap",
135
+ "branchRowGap",
136
+ "branchRowOffset",
137
+ "leafRowGap",
138
+ "leafRowOffset",
139
+ ] as const;
51
140
  import {
52
141
  checkValueExpression,
53
142
  isTriggerBody,
@@ -57,7 +146,9 @@ import {
57
146
  } from "../value-syntax";
58
147
  import type {
59
148
  DslAxes,
149
+ DslRegions,
60
150
  DslDocument,
151
+ DslStageHeader,
61
152
  DslActor,
62
153
  DslNodeKind,
63
154
  DslDynShape,
@@ -97,6 +188,110 @@ export const EDGE_HEAD_VALUES: readonly string[] = EDGE_HEADS;
97
188
  /** 端の印の塗り方 (#1466)。 描画側の一覧から導く = 描画側が増やせば書ける */
98
189
  export const EDGE_HEAD_FILL_VALUES: readonly string[] = EDGE_HEAD_FILLS;
99
190
 
191
+ /** cdl 0.130.0 の共通部品箱へ描ける線画。 */
192
+ export const CDL_ICON_VALUES = ["person", "box", "database", "lightning", "cloud", "terminal"] as const satisfies readonly NonNullable<DslActor["icon"]>[];
193
+
194
+ /** cdl 0.130.0 の表・部品箱と順序図が受ける閉じた値。 */
195
+ export const KIND_FORM_VALUES = ["icon"] as const;
196
+ export const TITLE_ALIGN_VALUES = ["center"] as const;
197
+ export const ROW_MARK_FORM_VALUES = ["tone"] as const;
198
+ export const ROW_MARK_TONE_VALUES = ["primary", "muted"] as const;
199
+ export const ROW_DIVIDER_FORM_VALUES = ["full"] as const;
200
+ export const SEQUENCE_SELF_LOOP_FORM_VALUES = ["orthogonal"] as const;
201
+ export const SEQUENCE_ACTOR_FORM_VALUES = ["box"] as const;
202
+ export const SEQUENCE_LABEL_FORM_VALUES = ["kind"] as const;
203
+ export const SEQUENCE_RETURN_HEAD_VALUES = ["solid"] as const;
204
+ export const SEQUENCE_KIND_LABEL_KEYS = ["call", "return", "fire"] as const;
205
+ export const SEQUENCE_SELF_LOOP_KEYS = ["form", "width", "drop"] as const;
206
+ export const ROW_WELL_KEYS = ["top", "right", "bottom", "left", "radius"] as const;
207
+ export const ROW_LAYOUT_KEYS = ["titleBaseline", "rowTop", "rowPitch"] as const;
208
+ export const LEGEND_LINE_STYLE_VALUES = ["solid", "dotted"] as const;
209
+ export const LEGEND_NOTE_ALIGN_VALUES = ["start", "end"] as const;
210
+
211
+ /** 表の行の受け皿を閉じた object として読み、寸法を 0 以上に限る。 */
212
+ function 行の受け皿として読む(
213
+ raw: string | undefined,
214
+ line: number,
215
+ errors: DslError[],
216
+ ): DslActor["rowWell"] {
217
+ const value = JSONの欄を読む(raw ?? null, "rowWell", line, errors);
218
+ const unknown = 図表の知らない項目を知らせる(
219
+ value,
220
+ ROW_WELL_KEYS,
221
+ "rowWell",
222
+ line,
223
+ errors,
224
+ );
225
+ if (unknown || value === undefined) return undefined;
226
+ if (!objectか(value)) {
227
+ errors.push({ line, message: "rowWell の値の形が読めません" });
228
+ return undefined;
229
+ }
230
+ for (const key of ROW_WELL_KEYS) {
231
+ const item = value[key];
232
+ if (item !== undefined && (typeof item !== "number" || !Number.isFinite(item) || item < 0)) {
233
+ errors.push({ line, message: `rowWell.${key} が読めません`, hint: "0 以上の数で書く" });
234
+ return undefined;
235
+ }
236
+ }
237
+ return value;
238
+ }
239
+
240
+ /** 表の題と行の baseline を閉じた object として読む。 */
241
+ function 行の配置として読む(
242
+ raw: string | undefined,
243
+ line: number,
244
+ errors: DslError[],
245
+ ): DslActor["rowLayout"] {
246
+ const value = JSONの欄を読む(raw ?? null, "rowLayout", line, errors);
247
+ const unknown = 図表の知らない項目を知らせる(value, ROW_LAYOUT_KEYS, "rowLayout", line, errors);
248
+ if (unknown || value === undefined) return undefined;
249
+ if (!objectか(value)) {
250
+ errors.push({ line, message: "rowLayout の値の形が読めません" });
251
+ return undefined;
252
+ }
253
+ for (const key of ROW_LAYOUT_KEYS) {
254
+ const item = value[key];
255
+ const valid = typeof item === "number" && Number.isFinite(item) &&
256
+ (key === "rowPitch" ? item > 0 : item >= 0);
257
+ if (item !== undefined && !valid) {
258
+ errors.push({ line, message: `rowLayout.${key} が読めません`, hint: key === "rowPitch" ? "正の数で書く" : "0 以上の数で書く" });
259
+ return undefined;
260
+ }
261
+ }
262
+ return value;
263
+ }
264
+
265
+ /** 固定線の途中の点を読み、始点から終点までの各区間を直角に限る。 */
266
+ function 線の途中の点として読む(
267
+ raw: string | undefined,
268
+ ends: readonly [number | undefined, number | undefined, number | undefined, number | undefined],
269
+ line: number,
270
+ errors: DslError[],
271
+ ): DslStep["waypoints"] {
272
+ if (raw === undefined) return undefined;
273
+ const value = JSONの欄を読む(raw ?? null, "waypoints", line, errors);
274
+ if (value === undefined) return undefined;
275
+ if (Array.isArray(value)) {
276
+ const unknown = value.some((point, index) =>
277
+ 図表の知らない項目を知らせる(point, ["x", "y"], `waypoints[${index}]`, line, errors));
278
+ if (unknown) return undefined;
279
+ }
280
+ if (!Array.isArray(value) || value.some((point) => !objectか(point) || typeof point.x !== "number" || !Number.isFinite(point.x) || typeof point.y !== "number" || !Number.isFinite(point.y))) {
281
+ errors.push({ line, message: "waypoints の値の形が読めません", hint: '`[{"x":232,"y":293.45}]` の形で書く' });
282
+ return undefined;
283
+ }
284
+ const [x1, y1, x2, y2] = ends;
285
+ if ([x1, y1, x2, y2].every((item) => item !== undefined)) {
286
+ const points = [{ x: x1!, y: y1! }, ...value, { x: x2!, y: y2! }];
287
+ if (points.some((point, index) => index > 0 && point.x !== points[index - 1]!.x && point.y !== points[index - 1]!.y)) {
288
+ errors.push({ line, message: "waypoints の各区間は縦か横にする" });
289
+ return undefined;
290
+ }
291
+ }
292
+ return value;
293
+ }
294
+
100
295
  /**
101
296
  * 辺の役目として書ける語 (#1559)。
102
297
  *
@@ -114,7 +309,7 @@ export const EDGE_ROLE_VALUES = ["main"] as const;
114
309
  export const SHAPE_ORIENT_VALUES = ["up", "down", "left", "right"] as const;
115
310
 
116
311
  /**
117
- * クラス図の関係の種類 (#1466)。 描画側の表 (`CLASS_RELATION_LOOK`) の key から導く。
312
+ * record に書ける UML の関係の種類 (#1466 / #2783)。描画側の表 (`CLASS_RELATION_LOOK`) の key から導く。
118
313
  *
119
314
  * 手で並べると、描画側が種類を足した時にここだけ取り残されて書けないままになる。
120
315
  */
@@ -145,6 +340,8 @@ export const TOP_LEVEL_KEYS = [
145
340
  "eyebrow",
146
341
  // 2 軸で仕分ける図の軸の名前 (#1251)
147
342
  "axes",
343
+ // 2 軸で仕分ける図の区画の名前 (#2667)
344
+ "regions",
148
345
  // 値を見せる部品 (#1374)
149
346
  "readouts",
150
347
  // 読む人が動かすつまみ (#1389)
@@ -173,24 +370,110 @@ export const TOP_LEVEL_KEYS = [
173
370
  */
174
371
  "direction",
175
372
  /*
176
- * 図の配色 (#1553)。
373
+ * 箱を並べる順 (#2655)。 並べ替えの鍵しか持たない図種を畳んだ先で、書かなければ
374
+ * 書いた順のまま。 最上位の語は英語にする決まりに従う (向きの語と同じ)
375
+ */
376
+ "order",
377
+ /*
378
+ * 数を描く図の形 (#2657)。 形しか違わない図種 9 つを畳んだ先で、`type: chart` だけが読む。
379
+ * 最上位の語は英語にする決まりに従う (向きの語と同じ)
380
+ */
381
+ "shape",
382
+ // 円グラフの見せ方 (#2837)
383
+ "form",
384
+ // ガントチャートの目盛り (#2837)
385
+ "ticks",
386
+ "ganttToday",
387
+ "ganttTickLabels",
388
+ "ganttBarEnd",
389
+ "ganttBarThickness",
390
+ "chartSeriesSkipMuted",
391
+ "chartLineSeries",
392
+ "chartPieCenterLabel",
393
+ "chartPieTableColumns",
394
+ "chartPieRingWidth",
395
+ "chartSlopePeriods",
396
+ "chartSlopeEmphasisIds",
397
+ "chartSlopeUnit",
398
+ "chartStackedPeriods",
399
+ "chartStackedRateId",
400
+ "chartStackedLegendPosition",
401
+ "chartGaugeValue",
402
+ "chartWaffleLegendPosition",
403
+ "funnelForm",
404
+ "funnelRate",
405
+ "quadrantPointLabelSide",
406
+ "treeNodeForm",
407
+ "treeSize",
408
+ "treeEdgeTone",
409
+ "treeEdgeHead",
410
+ "mindForm",
411
+ "mindSize",
412
+ "journeyForm",
413
+ "journeyLineForm",
414
+ "journeyLabels",
415
+ "journeySize",
416
+ "sequenceActorForm",
417
+ "sequenceLabelForm",
418
+ "sequenceReturnHead",
419
+ "sequenceKindLabels",
420
+ "sequenceRowGap",
421
+ "sequenceGutter",
422
+ "sequenceBandWidth",
423
+ "sequenceSelfLoop",
424
+ "figureCard",
425
+ "figureSize",
426
+ "terminalDecoration",
427
+ "cornerForm",
428
+ "figureKindLabel",
429
+ "figureInset",
430
+ "chartBarSize",
431
+ "chartBarShape",
432
+ "chartLineSize",
433
+ "chartPieSize",
434
+ "chartWaffleSize",
435
+ "chartSlopeLabelTemplate",
436
+ "chartSlopeSize",
437
+ "chartStackedLayout",
438
+ "chartGaugeSize",
439
+ "ganttSize",
440
+ "quadrantLabelPosition",
441
+ "quadrantSize",
442
+ "stageLayout",
443
+ /*
444
+ * 図の意匠 (#1553 / #2790)。
177
445
  *
178
446
  * cdl は色を持たないので、名前だけを図に載せる。 消費側 (`cdl-theme.css`) が名前を見て
179
447
  * 7 つの口 (台 / 行の面 / 縞 / 枠 / 字 / 型名 / 線) に色を当てる。
180
448
  *
181
- * ER 図とクラス図は書かなくても `kinari` (生成りに茶) になる。 どちらも箱の作りが同じ
182
- * (行頭の印 + 左に名前 + 右に型) で、名前と型が離れて並ぶため、行を横に追う目印
183
- * (行の縞) が要る。 縞の色は配色からしか来ないので、既定が無いと縞が箱の面と同じ色に
184
- * 落ちて 1 本も出ない。 書き手が `palette:` を書いた時はそちらが勝つ。
449
+ * 意匠は dragon で読み、cdl へは `palette` として渡す。
185
450
  */
186
- "palette",
451
+ "theme",
452
+ "legend",
453
+ "legendOffset",
454
+ "legendFontSize",
455
+ "edgeEndGap",
456
+ "stageHeader",
187
457
  ] as const;
188
458
 
459
+ /**
460
+ * 最上位の語の別名 (#2790)。
461
+ *
462
+ * 一覧は正の語だけを持つ。 README と変更履歴の検査が一覧を読むため、別名を混ぜると
463
+ * 公開する正の語が 2 つに見える。
464
+ */
465
+ export const TOP_LEVEL_KEY_ALIASES: ReadonlyMap<string, (typeof TOP_LEVEL_KEYS)[number]> = new Map([
466
+ ["palette", "theme"],
467
+ ["凡例", "legend"],
468
+ ["見せ方", "form"],
469
+ ["目盛り", "ticks"],
470
+ ]);
471
+
189
472
  /**
190
473
  * `lanes:` / `groups:` の 1 行を読む形 (#1241)。
191
474
  *
192
475
  * **id は英数字と下線に限らない**。 組み立て側は登場人物の名前から縦列 id を作るため、
193
- * hyphen と日本語が入る (実測 = `type: state` で `lane-idle` / `lane-待機`、
476
+ * hyphen と日本語が入る (実測 = `type: record` で `lane-idle` / `lane-待機`、
194
477
  * `type: swimlane` で `lane-sign-up`)。 英数字と下線だけを受けていた間、
195
478
  * **自動で作られた縦列の幅や見出しを書き直す手段が無かった**。
196
479
  *
@@ -308,22 +591,12 @@ function isTopLevelKey(key: string): key is (typeof TOP_LEVEL_KEYS)[number] {
308
591
  export const PRESET_TYPES: ReadonlySet<PresetType> = new Set([
309
592
  "sequence",
310
593
  "flow",
594
+ "flowchart",
311
595
  "swimlane",
312
- "er",
313
- "state",
596
+ "record",
314
597
  "topology",
315
- "solidity",
316
598
  "gantt",
317
- "class",
318
- "pie",
319
- "bar",
320
- "line",
321
- "gauge",
322
- "radial",
323
- "stat",
324
- "waffle",
325
- "stacked",
326
- "slope",
599
+ "chart",
327
600
  "funnel",
328
601
  "tree",
329
602
  "journey",
@@ -332,6 +605,41 @@ export const PRESET_TYPES: ReadonlySet<PresetType> = new Set([
332
605
  "mind",
333
606
  ]);
334
607
 
608
+ /**
609
+ * 図種の別名 (#2655 / #2657)。
610
+ *
611
+ * **差が語 1 つしかない図種を、畳んだ先の型とその語に読み替える**。
612
+ *
613
+ * | 古い綴り | 読み替え先 | 差 |
614
+ * |---|---|---|
615
+ * | `solidity` | `sequence` + `order: 種類` | 箱を種類で並べ替えるだけで、独自の組み立てを持たない |
616
+ * | 数を描く 9 つ | `chart` + `shape: <綴り>` | 同じ組み立て関数に渡す文字列 2 つしか違わない |
617
+ *
618
+ * 型として持つと、同じ扱いをする条件と表が型の数だけ増える。 `solidity` は
619
+ * 「順序図 または その型」 という条件を 12 か所に増やし、数を描く 9 つは 4 つの表に
620
+ * 同じ値の行を 9 行ずつ並べていた (足す人が 1 か所書き忘れると黙って外れる)。
621
+ *
622
+ * **読み替えは読み取りの入口 1 か所に閉じる**。 ここを通った後は古い綴りが
623
+ * どこにも残らないので、組み立て側は畳んだ先の型として 1 通りに扱える。
624
+ *
625
+ * 古い名前で書いた記法はそのまま動く = 別名を消すのは別の回にする。
626
+ */
627
+ type 読み替え先 = { type: PresetType; order?: DslOrder; shape?: DslShape };
628
+
629
+ export const TYPE_ALIASES: ReadonlyMap<string, 読み替え先> = new Map<string, 読み替え先>([
630
+ ["solidity", { type: "sequence", order: "種類" }],
631
+ ...SHAPES.map((s): [string, 読み替え先] => [s, { type: "chart", shape: s }]),
632
+ ]);
633
+
634
+ /** `shape:` の綴り違いへ、図種ごとに書ける語を案内する (#2797)。 */
635
+ function shapeの案内(type: PresetType | null): string {
636
+ const その図種 = type === null ? [] : shapesForDiagramType(type);
637
+ if (その図種.length > 0) return `使える語 = ${その図種.join(" / ")}`;
638
+ return `shape を書ける図種と語 = ${Object.entries(DIAGRAM_SHAPES)
639
+ .map(([図種, 語]) => `type: ${図種} (${語.join(" / ")})`)
640
+ .join(" / ")}`;
641
+ }
642
+
335
643
  const NODE_KIND_DEFAULT: NodeKind = "actor";
336
644
 
337
645
  /**
@@ -353,6 +661,10 @@ export const DSL_ONLY_KINDS = [
353
661
  "proxy",
354
662
  "library",
355
663
  "interface",
664
+ // 繰り返しの箱 (#2523)。 描画側は分かれ道の図の形としてだけ持ち、種類としては持たないため
665
+ // ここに置く。 分かれ道の図では形に読み替わり (`compile/flowchart.ts`)、それ以外の図種では
666
+ // `記法だけの種類の読み替え` が札に落とす
667
+ "loop",
356
668
  ] as const;
357
669
 
358
670
  /** 記法だけが持つ種類。 描画側の `NodeKind` には含まれない */
@@ -459,6 +771,20 @@ type Line = {
459
771
  no: number;
460
772
  };
461
773
 
774
+ /**
775
+ * 読めなかった最上位の項目名を、行から取り出す (#2341)。
776
+ *
777
+ * **行ではなく名前だけを引用する**。 行をまるごと引用すると、値に引用符がある時に
778
+ * `"題: "t""` のように入れ子になって、どこまでが名前か読めない (実測)。
779
+ *
780
+ * 箱 / 矢印 / 縦列の読めない項目名はどれも名前だけを引用しており、最上位だけが
781
+ * 揃っていなかった。 `:` を持たない行はそのまま返す = その時は行全体が名前の位置にある。
782
+ */
783
+ function 最上位の項目名(trimmed: string): string {
784
+ const idx = trimmed.indexOf(":");
785
+ return (idx < 0 ? trimmed : trimmed.slice(0, idx)).trim();
786
+ }
787
+
462
788
  export function parseTextDslV05(src: string): V05ParseResult {
463
789
  const errors: DslError[] = [];
464
790
  const lines = tokenize(src);
@@ -471,9 +797,79 @@ export function parseTextDslV05(src: string): V05ParseResult {
471
797
  let relations: RelationFocus | null = null;
472
798
  let direction: DslDirection | null = null;
473
799
  let directionLine = 0;
474
- let palette: DslPalette | null = null;
800
+ let order: DslOrder | null = null;
801
+ let orderLine = 0;
802
+ // `shape:` は `type:` より前にも書ける。 図種が決まるまで語と行だけを控える (#2797)。
803
+ let shapeWritten: { value: string; line: number } | null = null;
804
+ let formWritten: { value: string; line: number } | null = null;
805
+ let ticks: string[] | undefined;
806
+ let ticksLine = 0;
807
+ let ganttToday: DslDocument["ganttToday"];
808
+ let ganttTickLabels: string[] | undefined;
809
+ let ganttBarEnd: DslDocument["ganttBarEnd"];
810
+ let ganttBarThickness: DslDocument["ganttBarThickness"];
811
+ let chartSeriesSkipMuted: DslDocument["chartSeriesSkipMuted"];
812
+ let chartLineSeries: DslDocument["chartLineSeries"];
813
+ let chartPieCenterLabel: DslDocument["chartPieCenterLabel"];
814
+ let chartPieTableColumns: DslDocument["chartPieTableColumns"];
815
+ let chartPieRingWidth: DslDocument["chartPieRingWidth"];
816
+ let chartSlopePeriods: DslDocument["chartSlopePeriods"];
817
+ let chartSlopeEmphasisIds: DslDocument["chartSlopeEmphasisIds"];
818
+ let chartSlopeUnit: DslDocument["chartSlopeUnit"];
819
+ let chartStackedPeriods: DslDocument["chartStackedPeriods"];
820
+ let chartStackedRateId: DslDocument["chartStackedRateId"];
821
+ let chartStackedLegendPosition: DslDocument["chartStackedLegendPosition"];
822
+ let chartGaugeValue: DslDocument["chartGaugeValue"];
823
+ let chartWaffleLegendPosition: DslDocument["chartWaffleLegendPosition"];
824
+ let funnelForm: DslDocument["funnelForm"];
825
+ let funnelRate: DslDocument["funnelRate"];
826
+ let quadrantPointLabelSide: DslDocument["quadrantPointLabelSide"];
827
+ let treeNodeForm: DslDocument["treeNodeForm"];
828
+ let treeSize: DslDocument["treeSize"];
829
+ let treeEdgeTone: DslDocument["treeEdgeTone"];
830
+ let treeEdgeHead: DslDocument["treeEdgeHead"];
831
+ let mindForm: DslDocument["mindForm"];
832
+ let mindSize: DslDocument["mindSize"];
833
+ let journeyForm: DslDocument["journeyForm"];
834
+ let journeyLineForm: DslDocument["journeyLineForm"];
835
+ let journeyLabels: DslDocument["journeyLabels"];
836
+ let journeySize: DslDocument["journeySize"];
837
+ let sequenceActorForm: DslDocument["sequenceActorForm"];
838
+ let sequenceLabelForm: DslDocument["sequenceLabelForm"];
839
+ let sequenceReturnHead: DslDocument["sequenceReturnHead"];
840
+ let sequenceKindLabels: DslDocument["sequenceKindLabels"];
841
+ let sequenceRowGap: DslDocument["sequenceRowGap"];
842
+ let sequenceGutter: DslDocument["sequenceGutter"];
843
+ let sequenceBandWidth: DslDocument["sequenceBandWidth"];
844
+ let sequenceSelfLoop: DslDocument["sequenceSelfLoop"];
845
+ let figureCard: DslDocument["figureCard"];
846
+ let figureSize: DslDocument["figureSize"];
847
+ let terminalDecoration: DslDocument["terminalDecoration"];
848
+ let cornerForm: DslDocument["cornerForm"];
849
+ let figureKindLabel: DslDocument["figureKindLabel"];
850
+ let figureInset: DslDocument["figureInset"];
851
+ let chartBarSize: DslDocument["chartBarSize"];
852
+ let chartBarShape: DslDocument["chartBarShape"];
853
+ let chartLineSize: DslDocument["chartLineSize"];
854
+ let chartPieSize: DslDocument["chartPieSize"];
855
+ let chartWaffleSize: DslDocument["chartWaffleSize"];
856
+ let chartSlopeLabelTemplate: DslDocument["chartSlopeLabelTemplate"];
857
+ let chartSlopeSize: DslDocument["chartSlopeSize"];
858
+ let chartStackedLayout: DslDocument["chartStackedLayout"];
859
+ let chartGaugeSize: DslDocument["chartGaugeSize"];
860
+ let ganttSize: DslDocument["ganttSize"];
861
+ let quadrantLabelPosition: DslDocument["quadrantLabelPosition"];
862
+ let quadrantSize: DslDocument["quadrantSize"];
863
+ let stageLayout: DslDocument["stageLayout"];
864
+ let aliasShape: DslShape | null = null;
865
+ let aliasShapeLine = 0;
866
+ let theme: DslTheme | null = null;
867
+ let themeLine = 0;
868
+ let palette: DslTheme | null = null;
869
+ let paletteLine = 0;
475
870
  let axes: DslAxes | undefined = undefined;
476
871
  let axesLine = 0;
872
+ let regions: DslRegions | undefined = undefined;
477
873
  let actors: DslActor[] = [];
478
874
  const flow: DslStep[] = [];
479
875
  let animate: DslAnimate | undefined = undefined;
@@ -489,6 +885,11 @@ export function parseTextDslV05(src: string): V05ParseResult {
489
885
  let scrollsList: DslScrollTrigger[] | undefined = undefined;
490
886
  let scrollLines = new Map<string, number>();
491
887
  let groupsMap: Record<string, DslGroup> | undefined = undefined;
888
+ const legend: CdlLegendItem[] = [];
889
+ let legendOffset: DslDocument["legendOffset"];
890
+ let legendFontSize: number | undefined;
891
+ let edgeEndGap: DslDocument["edgeEndGap"];
892
+ let stageHeader: DslDocument["stageHeader"];
492
893
 
493
894
  let i = 0;
494
895
  while (i < lines.length) {
@@ -497,12 +898,18 @@ export function parseTextDslV05(src: string): V05ParseResult {
497
898
  i += 1;
498
899
  continue;
499
900
  }
500
- const head = matchTopHeader(line.trimmed);
901
+ const 書いた頭 = matchTopHeader(line.trimmed);
902
+ const head = 書いた頭
903
+ ? {
904
+ ...書いた頭,
905
+ key: TOP_LEVEL_KEY_ALIASES.get(書いた頭.key) ?? 書いた頭.key,
906
+ }
907
+ : null;
501
908
  if (!head || !isTopLevelKey(head.key)) {
502
909
  errors.push({
503
910
  line: line.no,
504
- message: `unknown top-level key: "${line.trimmed}"`,
505
- hint: `expected one of: ${TOP_LEVEL_KEYS.join(", ")}`,
911
+ message: `最上位の項目名が読めません: "${最上位の項目名(line.trimmed)}"`,
912
+ hint: `使える項目 = ${TOP_LEVEL_KEYS.join(", ")}`,
506
913
  });
507
914
  i += 1;
508
915
  continue;
@@ -510,7 +917,11 @@ export function parseTextDslV05(src: string): V05ParseResult {
510
917
  if (head.key === "title") {
511
918
  title = head.value ?? null;
512
919
  if (!title) {
513
- errors.push({ line: line.no, message: "title is required", hint: 'use `title: "..."`' });
920
+ errors.push({
921
+ line: line.no,
922
+ message: "title が書かれていません",
923
+ hint: "`title: \"...\"` の形で書く",
924
+ });
514
925
  }
515
926
  i += 1;
516
927
  continue;
@@ -542,7 +953,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
542
953
  /*
543
954
  * 箱に触れると関係する線だけを光らせるか (#1757)。
544
955
  *
545
- * クラス図と ER 図は順番を持たない図で、段を追う見せ方は読み手の問い
956
+ * UML や表の関係を描く record は順番を持たない図で、段を追う見せ方は読み手の問い
546
957
  * (この箱はどこと繋がっているか) に答えない。 `hover` と書くと、箱に触れた時に
547
958
  * その箱 ・ 繋がる線 ・ 相手の箱だけが光る (`cdl#806`)。
548
959
  */
@@ -587,29 +998,134 @@ export function parseTextDslV05(src: string): V05ParseResult {
587
998
  i += 1;
588
999
  continue;
589
1000
  }
590
- if (head.key === "palette") {
1001
+ if (head.key === "order") {
1002
+ /*
1003
+ * 箱を並べる順 (#2655)。
1004
+ *
1005
+ * 書かなければ書いた順。 `種類` と書くと箱の種類で並べ替えてから置く。
1006
+ * 向きと同じく **書いた行を覚える** = 効かない図種に書いた時の知らせが場所を指せる。
1007
+ */
1008
+ const v = (head.value ?? "").trim();
1009
+ if (v.length > 0) {
1010
+ const 解けた = resolveOrder(v);
1011
+ if (解けた !== null) {
1012
+ order = 解けた;
1013
+ orderLine = line.no;
1014
+ } else {
1015
+ errors.push({
1016
+ line: line.no,
1017
+ message: `order が読めません (書いた値: ${v})`,
1018
+ hint: `使える語 = ${ORDERS.join(" / ")} / kind`,
1019
+ });
1020
+ }
1021
+ }
1022
+ i += 1;
1023
+ continue;
1024
+ }
1025
+ if (head.key === "theme") {
591
1026
  /*
592
- * 図の配色 (#1553)。
1027
+ * 図の意匠 (#1553 / #2790)。
593
1028
  *
594
1029
  * 読めない語はその場で知らせる。 黙って既定に落とすと、書き手には「書いたのに効かない」
595
1030
  * としか見えず、書き間違いか未対応かを分けられない。
596
1031
  */
597
1032
  const v = (head.value ?? "").trim();
1033
+ const 書いた語 = 書いた頭?.key ?? head.key;
598
1034
  if (v.length > 0) {
599
- const 解けた = resolvePalette(v);
1035
+ const 解けた = resolveTheme(v);
600
1036
  if (解けた !== null) {
601
- palette = 解けた;
1037
+ if (書いた語 === "theme") {
1038
+ theme = 解けた;
1039
+ themeLine = line.no;
1040
+ } else {
1041
+ palette = 解けた;
1042
+ paletteLine = line.no;
1043
+ }
602
1044
  } else {
603
1045
  errors.push({
604
1046
  line: line.no,
605
- message: `palette が読めません (書いた値: ${v})`,
606
- hint: `使える語 = ${PALETTES.join(" / ")} / 生成り / 青磁`,
1047
+ message: `${書いた語} が読めません (書いた値: ${v}。 使える名前 = ${THEMES.join(" / ")})`,
1048
+ hint: `使える語 = ${Object.keys(THEME_ALIAS).join(" / ")}`,
607
1049
  });
608
1050
  }
609
1051
  }
610
1052
  i += 1;
611
1053
  continue;
612
1054
  }
1055
+ if (head.key === "legend") {
1056
+ const inline = head.value?.trim();
1057
+ if (inline) {
1058
+ errors.push({
1059
+ line: line.no,
1060
+ message: "legend は 1 行にまとめて書けない",
1061
+ hint: '`legend:` の次の行から `{ mark: diamond, text: "分かれ道" }` の形で並べる',
1062
+ });
1063
+ i += 1;
1064
+ continue;
1065
+ }
1066
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
1067
+ for (const item of items) {
1068
+ const parsed = parseLegendItem(item, errors);
1069
+ if (parsed !== null) legend.push(parsed);
1070
+ }
1071
+ i = next;
1072
+ continue;
1073
+ }
1074
+ if (head.key === "legendFontSize") {
1075
+ const value = Number((head.value ?? "").trim());
1076
+ if (Number.isFinite(value) && value > 0) legendFontSize = value;
1077
+ else {
1078
+ errors.push({
1079
+ line: line.no,
1080
+ message: `legendFontSize が読めません: "${head.value ?? ""}"`,
1081
+ hint: "0 より大きい数を書く",
1082
+ });
1083
+ }
1084
+ i += 1;
1085
+ continue;
1086
+ }
1087
+ if (head.key === "edgeEndGap") {
1088
+ const written = (head.value ?? "").trim();
1089
+ const value = Number(written);
1090
+ if (written !== "" && Number.isFinite(value) && value >= 0) edgeEndGap = value;
1091
+ else {
1092
+ errors.push({
1093
+ line: line.no,
1094
+ message: `edgeEndGap が読めません: "${head.value ?? ""}"`,
1095
+ hint: "0 以上の数を書く",
1096
+ });
1097
+ }
1098
+ i += 1;
1099
+ continue;
1100
+ }
1101
+ if (head.key === "stageHeader") {
1102
+ const written = (head.value ?? "").trim();
1103
+ const isInlineMapping = written.startsWith("{") && written.endsWith("}");
1104
+ const inner = isInlineMapping ? written.slice(1, -1) : "";
1105
+ const opts = parseInlineMapping(inner);
1106
+ const keys = ["leftPad", "topPad", "numberSize", "gap", "nameSize", "bottomPad"] as const;
1107
+ if (isInlineMapping) {
1108
+ 中括弧の知らない項目名を知らせる(inner, keys, "段の見出しの ", line.no, errors);
1109
+ }
1110
+ const values = Object.fromEntries(
1111
+ keys.map((key) => [key, opts[key] === undefined ? undefined : Number(opts[key])]),
1112
+ ) as Record<(typeof keys)[number], number | undefined>;
1113
+ const required = ["topPad", "numberSize", "gap", "nameSize", "bottomPad"] as const;
1114
+ if (
1115
+ required.some((key) => !Number.isFinite(values[key])) ||
1116
+ (values.leftPad !== undefined && !Number.isFinite(values.leftPad))
1117
+ ) {
1118
+ errors.push({
1119
+ line: line.no,
1120
+ message: "段の見出しの寸法が読めません",
1121
+ hint: "{ leftPad: 26, topPad: 20, numberSize: 16, gap: 10, nameSize: 29, bottomPad: 12 } の形で書く",
1122
+ });
1123
+ } else {
1124
+ stageHeader = values as DslStageHeader;
1125
+ }
1126
+ i += 1;
1127
+ continue;
1128
+ }
613
1129
  if (head.key === "eyebrow") {
614
1130
  // 空で書いた形 (`eyebrow:`) は「書かなかった」 と同じにする。 空文字を残すと
615
1131
  // 描画側が中身のない帯を出す
@@ -619,13 +1135,392 @@ export function parseTextDslV05(src: string): V05ParseResult {
619
1135
  i += 1;
620
1136
  continue;
621
1137
  }
1138
+ if (head.key === "shape") {
1139
+ /*
1140
+ * 数を描く図の形 (#2657)。
1141
+ *
1142
+ * 並べ替えの語と同じ形にする。 **書いた行を覚える** = 効かない図種に書いた時の
1143
+ * 知らせが場所を指せる。
1144
+ */
1145
+ const v = (head.value ?? "").trim();
1146
+ if (v.length > 0) {
1147
+ shapeWritten = { value: v, line: line.no };
1148
+ }
1149
+ i += 1;
1150
+ continue;
1151
+ }
1152
+ if (head.key === "form") {
1153
+ const v = stripQuotes((head.value ?? "").trim());
1154
+ if (v.length > 0) formWritten = { value: v, line: line.no };
1155
+ i += 1;
1156
+ continue;
1157
+ }
1158
+ if (head.key === "ticks") {
1159
+ const raw = (head.value ?? "").trim();
1160
+ if (!raw.startsWith("[") || !raw.endsWith("]")) {
1161
+ errors.push({
1162
+ line: line.no,
1163
+ message: `ticks の書き方が読めません: "${raw}"`,
1164
+ hint: "`ticks: [6月, 7月, 8月]` の形で書く",
1165
+ });
1166
+ } else {
1167
+ const values = splitTopLevelCommas(raw.slice(1, -1))
1168
+ .map((value) => stripQuotes(value.trim()))
1169
+ .filter((value) => value.length > 0);
1170
+ if (values.length === 0) {
1171
+ errors.push({
1172
+ line: line.no,
1173
+ message: "ticks に目盛りがありません",
1174
+ hint: "`ticks: [6月, 7月, 8月]` の形で 1 つ以上書く",
1175
+ });
1176
+ } else {
1177
+ ticks = values;
1178
+ ticksLine = line.no;
1179
+ }
1180
+ }
1181
+ i += 1;
1182
+ continue;
1183
+ }
1184
+ if (head.key === "ganttToday") {
1185
+ const raw = (head.value ?? "").trim();
1186
+ if (raw.startsWith("{") && raw.endsWith("}")) {
1187
+ 中括弧の知らない項目名を知らせる(
1188
+ raw.slice(1, -1),
1189
+ ["index", "label"],
1190
+ "ganttToday の ",
1191
+ line.no,
1192
+ errors,
1193
+ );
1194
+ }
1195
+ const inner = raw.startsWith("{") && raw.endsWith("}")
1196
+ ? parseInlineMapping(raw.slice(1, -1))
1197
+ : {};
1198
+ const index = Number(inner.index);
1199
+ const label = inner.label === undefined ? undefined : stripQuotes(inner.label);
1200
+ if (!Number.isFinite(index) || label === undefined) {
1201
+ errors.push({
1202
+ line: line.no,
1203
+ message: `ganttToday の書き方が読めません: "${raw}"`,
1204
+ hint: '`ganttToday: { index: 3.3, label: "今日" }` の形で書く',
1205
+ });
1206
+ } else {
1207
+ ganttToday = { index, label };
1208
+ }
1209
+ i += 1;
1210
+ continue;
1211
+ }
1212
+ if (head.key === "ganttTickLabels") {
1213
+ const raw = (head.value ?? "").trim();
1214
+ const values = raw.startsWith("[") && raw.endsWith("]")
1215
+ ? splitTopLevelCommas(raw.slice(1, -1)).map((value) => stripQuotes(value.trim()))
1216
+ : [];
1217
+ if (values.length === 0 || values.some((value) => value.length === 0)) {
1218
+ errors.push({
1219
+ line: line.no,
1220
+ message: `ganttTickLabels の書き方が読めません: "${raw}"`,
1221
+ hint: "`ganttTickLabels: [6月, 7月, 8月]` の形で 1 つ以上書く",
1222
+ });
1223
+ } else {
1224
+ ganttTickLabels = values;
1225
+ }
1226
+ i += 1;
1227
+ continue;
1228
+ }
1229
+ if (head.key === "ganttBarEnd") {
1230
+ const value = stripQuotes((head.value ?? "").trim());
1231
+ if (value === "position") ganttBarEnd = value;
1232
+ else errors.push({ line: line.no, message: `ganttBarEnd が読めません: "${value}"`, hint: "使える値 = position" });
1233
+ i += 1;
1234
+ continue;
1235
+ }
1236
+ if (head.key === "ganttBarThickness") {
1237
+ const value = stripQuotes((head.value ?? "").trim());
1238
+ if (value === "thin") ganttBarThickness = value;
1239
+ else errors.push({ line: line.no, message: `ganttBarThickness が読めません: "${value}"`, hint: "使える値 = thin" });
1240
+ i += 1;
1241
+ continue;
1242
+ }
1243
+ if (head.key === "chartSeriesSkipMuted") {
1244
+ const value = stripQuotes((head.value ?? "").trim());
1245
+ if (value === "true") chartSeriesSkipMuted = true;
1246
+ else errors.push({ line: line.no, message: `chartSeriesSkipMuted が読めません: "${value}"`, hint: "使える値 = true" });
1247
+ i += 1;
1248
+ continue;
1249
+ }
1250
+ if (head.key === "chartLineSeries") {
1251
+ const value = JSONの欄を読む(head.value, "chartLineSeries", line.no, errors);
1252
+ const unknown = chartLineSeriesの知らない項目を知らせる(value, line.no, errors);
1253
+ if (!unknown && chartLineSeriesの形(value)) chartLineSeries = value;
1254
+ else if (value !== undefined) errors.push({ line: line.no, message: "chartLineSeries の系列または点の形が読めません" });
1255
+ i += 1;
1256
+ continue;
1257
+ }
1258
+ if (head.key === "chartStackedPeriods") {
1259
+ const value = JSONの欄を読む(head.value, "chartStackedPeriods", line.no, errors);
1260
+ const unknown = chartStackedPeriodsの知らない項目を知らせる(value, line.no, errors);
1261
+ if (!unknown && chartStackedPeriodsの形(value)) chartStackedPeriods = value;
1262
+ else if (value !== undefined) errors.push({ line: line.no, message: "chartStackedPeriods の期間または値の形が読めません" });
1263
+ i += 1;
1264
+ continue;
1265
+ }
1266
+ if (head.key === "chartGaugeValue") {
1267
+ const value = JSONの欄を読む(head.value, "chartGaugeValue", line.no, errors);
1268
+ const unknown = chartGaugeValueの知らない項目を知らせる(value, line.no, errors);
1269
+ if (!unknown && chartGaugeValueの形(value)) chartGaugeValue = value;
1270
+ else if (value !== undefined) errors.push({ line: line.no, message: "chartGaugeValue の値の形が読めません" });
1271
+ i += 1;
1272
+ continue;
1273
+ }
1274
+ if (head.key === "chartSlopePeriods" || head.key === "chartSlopeEmphasisIds") {
1275
+ const raw = (head.value ?? "").trim();
1276
+ const values = raw.startsWith("[") && raw.endsWith("]")
1277
+ ? splitTopLevelCommas(raw.slice(1, -1)).map((value) => stripQuotes(value.trim()))
1278
+ : [];
1279
+ if (values.length === 0 || values.some((value) => value.length === 0)) {
1280
+ errors.push({ line: line.no, message: `${head.key} の書き方が読めません`, hint: `${head.key}: ["先月", "今月"] の形で書く` });
1281
+ } else if (head.key === "chartSlopePeriods") {
1282
+ if (values.length === 2) chartSlopePeriods = [values[0]!, values[1]!];
1283
+ else errors.push({ line: line.no, message: "chartSlopePeriods は時点名を 2 つ書く" });
1284
+ } else chartSlopeEmphasisIds = values;
1285
+ i += 1;
1286
+ continue;
1287
+ }
1288
+ if (head.key === "chartPieCenterLabel" || head.key === "chartSlopeUnit" || head.key === "chartStackedRateId") {
1289
+ const value = stripQuotes((head.value ?? "").trim());
1290
+ if (head.key === "chartPieCenterLabel") chartPieCenterLabel = value;
1291
+ else if (head.key === "chartSlopeUnit") chartSlopeUnit = value;
1292
+ else chartStackedRateId = value;
1293
+ i += 1;
1294
+ continue;
1295
+ }
1296
+ if (head.key === "chartPieTableColumns") {
1297
+ const value = stripQuotes((head.value ?? "").trim());
1298
+ if ((CHART_PIE_TABLE_COLUMN_VALUES as readonly string[]).includes(value)) chartPieTableColumns = value as typeof chartPieTableColumns;
1299
+ else errors.push({ line: line.no, message: `chartPieTableColumns が読めません: "${value}"`, hint: `使える値 = ${CHART_PIE_TABLE_COLUMN_VALUES.join(", ")}` });
1300
+ i += 1;
1301
+ continue;
1302
+ }
1303
+ if (head.key === "chartPieRingWidth") {
1304
+ const value = stripQuotes((head.value ?? "").trim());
1305
+ if ((CHART_PIE_RING_WIDTH_VALUES as readonly string[]).includes(value)) chartPieRingWidth = value as typeof chartPieRingWidth;
1306
+ else errors.push({ line: line.no, message: `chartPieRingWidth が読めません: "${value}"`, hint: `使える値 = ${CHART_PIE_RING_WIDTH_VALUES.join(", ")}` });
1307
+ i += 1;
1308
+ continue;
1309
+ }
1310
+ if (head.key === "chartStackedLegendPosition") {
1311
+ const value = stripQuotes((head.value ?? "").trim());
1312
+ if ((CHART_STACKED_LEGEND_POSITION_VALUES as readonly string[]).includes(value)) chartStackedLegendPosition = value as typeof chartStackedLegendPosition;
1313
+ else errors.push({ line: line.no, message: `chartStackedLegendPosition が読めません: "${value}"`, hint: `使える値 = ${CHART_STACKED_LEGEND_POSITION_VALUES.join(", ")}` });
1314
+ i += 1;
1315
+ continue;
1316
+ }
1317
+ if (
1318
+ head.key === "sequenceActorForm" || head.key === "sequenceLabelForm" ||
1319
+ head.key === "sequenceReturnHead"
1320
+ ) {
1321
+ const value = stripQuotes((head.value ?? "").trim());
1322
+ const values = head.key === "sequenceActorForm"
1323
+ ? SEQUENCE_ACTOR_FORM_VALUES
1324
+ : head.key === "sequenceLabelForm" ? SEQUENCE_LABEL_FORM_VALUES : SEQUENCE_RETURN_HEAD_VALUES;
1325
+ if ((values as readonly string[]).includes(value)) {
1326
+ if (head.key === "sequenceActorForm") sequenceActorForm = value as "box";
1327
+ else if (head.key === "sequenceLabelForm") sequenceLabelForm = value as "kind";
1328
+ else sequenceReturnHead = value as "solid";
1329
+ } else errors.push({ line: line.no, message: `${head.key} が読めません: "${value}"`, hint: `使える値 = ${values.join(", ")}` });
1330
+ i += 1;
1331
+ continue;
1332
+ }
1333
+ if (head.key === "sequenceKindLabels") {
1334
+ const value = JSONの欄を読む(head.value, head.key, line.no, errors);
1335
+ const unknown = 図表の知らない項目を知らせる(value, SEQUENCE_KIND_LABEL_KEYS, head.key, line.no, errors);
1336
+ if (!unknown && objectか(value) && Object.values(value).every((label) => typeof label === "string")) {
1337
+ sequenceKindLabels = value;
1338
+ } else if (value !== undefined && !unknown) errors.push({ line: line.no, message: "sequenceKindLabels の値の形が読めません" });
1339
+ i += 1;
1340
+ continue;
1341
+ }
1342
+ if (head.key === "sequenceRowGap" || head.key === "sequenceGutter" || head.key === "sequenceBandWidth") {
1343
+ const value = Number(stripQuotes((head.value ?? "").trim()));
1344
+ if (Number.isFinite(value) && value > 0) {
1345
+ if (head.key === "sequenceRowGap") sequenceRowGap = value;
1346
+ else if (head.key === "sequenceGutter") sequenceGutter = value;
1347
+ else sequenceBandWidth = value;
1348
+ } else errors.push({ line: line.no, message: `${head.key} が読めません: "${head.value ?? ""}"`, hint: "正の数で書く" });
1349
+ i += 1;
1350
+ continue;
1351
+ }
1352
+ if (head.key === "sequenceSelfLoop") {
1353
+ const value = JSONの欄を読む(head.value, head.key, line.no, errors);
1354
+ const unknown = 図表の知らない項目を知らせる(value, SEQUENCE_SELF_LOOP_KEYS, head.key, line.no, errors);
1355
+ if (!unknown && objectか(value)) {
1356
+ const form = value.form;
1357
+ const width = value.width;
1358
+ const drop = value.drop;
1359
+ const validForm =
1360
+ form === undefined ||
1361
+ (SEQUENCE_SELF_LOOP_FORM_VALUES as readonly unknown[]).includes(form);
1362
+ const validWidth = width === undefined || (typeof width === "number" && Number.isFinite(width) && width > 0);
1363
+ const validDrop = drop === undefined || (typeof drop === "number" && Number.isFinite(drop) && drop > 0);
1364
+ if (validForm && validWidth && validDrop) sequenceSelfLoop = value;
1365
+ else errors.push({ line: line.no, message: "sequenceSelfLoop の値の形が読めません", hint: '`{"form":"orthogonal","width":127,"drop":46}` の形で書く' });
1366
+ } else if (value !== undefined && !unknown) errors.push({ line: line.no, message: "sequenceSelfLoop の値の形が読めません" });
1367
+ i += 1;
1368
+ continue;
1369
+ }
1370
+ if (head.key === "chartWaffleLegendPosition") {
1371
+ const value = stripQuotes((head.value ?? "").trim());
1372
+ if ((CHART_WAFFLE_LEGEND_POSITION_VALUES as readonly string[]).includes(value)) chartWaffleLegendPosition = value as typeof chartWaffleLegendPosition;
1373
+ else errors.push({ line: line.no, message: `chartWaffleLegendPosition が読めません: "${value}"`, hint: `使える値 = ${CHART_WAFFLE_LEGEND_POSITION_VALUES.join(", ")}` });
1374
+ i += 1;
1375
+ continue;
1376
+ }
1377
+ if (head.key === "funnelForm") {
1378
+ const value = stripQuotes((head.value ?? "").trim());
1379
+ if ((FUNNEL_FORM_VALUES as readonly string[]).includes(value)) funnelForm = value as typeof funnelForm;
1380
+ else errors.push({ line: line.no, message: `funnelForm が読めません: "${value}"`, hint: `使える値 = ${FUNNEL_FORM_VALUES.join(", ")}` });
1381
+ i += 1;
1382
+ continue;
1383
+ }
1384
+ if (head.key === "funnelRate") {
1385
+ const value = stripQuotes((head.value ?? "").trim());
1386
+ if ((FUNNEL_RATE_VALUES as readonly string[]).includes(value)) funnelRate = value as typeof funnelRate;
1387
+ else errors.push({ line: line.no, message: `funnelRate が読めません: "${value}"`, hint: `使える値 = ${FUNNEL_RATE_VALUES.join(", ")}` });
1388
+ i += 1;
1389
+ continue;
1390
+ }
1391
+ if (head.key === "quadrantPointLabelSide") {
1392
+ const value = stripQuotes((head.value ?? "").trim());
1393
+ if ((QUADRANT_POINT_LABEL_SIDE_VALUES as readonly string[]).includes(value)) quadrantPointLabelSide = value as typeof quadrantPointLabelSide;
1394
+ else errors.push({ line: line.no, message: `quadrantPointLabelSide が読めません: "${value}"`, hint: `使える値 = ${QUADRANT_POINT_LABEL_SIDE_VALUES.join(", ")}` });
1395
+ i += 1;
1396
+ continue;
1397
+ }
1398
+ if (
1399
+ head.key === "treeNodeForm" || head.key === "treeEdgeTone" || head.key === "treeEdgeHead" ||
1400
+ head.key === "mindForm" || head.key === "journeyForm" || head.key === "journeyLineForm"
1401
+ ) {
1402
+ const value = stripQuotes((head.value ?? "").trim());
1403
+ const values = {
1404
+ treeNodeForm: TREE_NODE_FORM_VALUES,
1405
+ treeEdgeTone: TREE_EDGE_TONE_VALUES,
1406
+ treeEdgeHead: TREE_EDGE_HEAD_VALUES,
1407
+ mindForm: MIND_FORM_VALUES,
1408
+ journeyForm: JOURNEY_FORM_VALUES,
1409
+ journeyLineForm: JOURNEY_LINE_FORM_VALUES,
1410
+ }[head.key];
1411
+ if ((values as readonly string[]).includes(value)) {
1412
+ if (head.key === "treeNodeForm") treeNodeForm = value as typeof treeNodeForm;
1413
+ else if (head.key === "treeEdgeTone") treeEdgeTone = value as typeof treeEdgeTone;
1414
+ else if (head.key === "treeEdgeHead") treeEdgeHead = value as typeof treeEdgeHead;
1415
+ else if (head.key === "mindForm") mindForm = value as typeof mindForm;
1416
+ else if (head.key === "journeyForm") journeyForm = value as typeof journeyForm;
1417
+ else journeyLineForm = value as typeof journeyLineForm;
1418
+ } else {
1419
+ errors.push({ line: line.no, message: `${head.key} が読めません: "${value}"`, hint: `使える値 = ${values.join(", ")}` });
1420
+ }
1421
+ i += 1;
1422
+ continue;
1423
+ }
1424
+ if (head.key === "treeSize" || head.key === "mindSize") {
1425
+ const value = JSONの欄を読む(head.value, head.key, line.no, errors);
1426
+ if (value !== undefined) {
1427
+ if (head.key === "treeSize" && 木の寸法を読む(value, line.no, errors)) treeSize = value;
1428
+ if (head.key === "mindSize" && 放射の寸法を読む(value, line.no, errors)) mindSize = value;
1429
+ }
1430
+ i += 1;
1431
+ continue;
1432
+ }
1433
+ if (head.key === "cornerForm" || head.key === "quadrantLabelPosition" || head.key === "chartBarShape") {
1434
+ const value = stripQuotes((head.value ?? "").trim());
1435
+ const values = head.key === "cornerForm"
1436
+ ? CORNER_FORM_VALUES
1437
+ : head.key === "chartBarShape" ? CHART_BAR_SHAPE_VALUES : QUADRANT_LABEL_POSITION_VALUES;
1438
+ if ((values as readonly string[]).includes(value)) {
1439
+ if (head.key === "cornerForm") cornerForm = value as typeof cornerForm;
1440
+ else if (head.key === "chartBarShape") chartBarShape = value as typeof chartBarShape;
1441
+ else quadrantLabelPosition = value as typeof quadrantLabelPosition;
1442
+ } else errors.push({ line: line.no, message: `${head.key} が読めません: "${value}"`, hint: `使える値 = ${values.join(", ")}` });
1443
+ i += 1;
1444
+ continue;
1445
+ }
1446
+ if (head.key === "stageLayout") {
1447
+ const value = JSONの欄を読む(head.value, head.key, line.no, errors);
1448
+ if (value !== undefined && cdl0132の欄を読む(head.key, value, line.no, errors)) {
1449
+ stageLayout = value as NonNullable<typeof stageLayout>;
1450
+ }
1451
+ i += 1;
1452
+ continue;
1453
+ }
1454
+ if ((CDL_0132_OBJECT_FIELDS as readonly string[]).includes(head.key)) {
1455
+ const value = JSONの欄を読む(head.value, head.key, line.no, errors);
1456
+ if (value !== undefined && cdl0132の欄を読む(head.key as Cdl0132ObjectField, value, line.no, errors)) {
1457
+ if (head.key === "terminalDecoration") terminalDecoration = value as NonNullable<typeof terminalDecoration>;
1458
+ else if (head.key === "figureKindLabel") figureKindLabel = value as NonNullable<typeof figureKindLabel>;
1459
+ else if (head.key === "figureInset") figureInset = value as NonNullable<typeof figureInset>;
1460
+ else if (head.key === "journeySize") journeySize = value as NonNullable<typeof journeySize>;
1461
+ else if (head.key === "chartBarSize") chartBarSize = value as NonNullable<typeof chartBarSize>;
1462
+ else if (head.key === "chartLineSize") chartLineSize = value as NonNullable<typeof chartLineSize>;
1463
+ else if (head.key === "chartPieSize") chartPieSize = value as NonNullable<typeof chartPieSize>;
1464
+ else if (head.key === "chartWaffleSize") chartWaffleSize = value as NonNullable<typeof chartWaffleSize>;
1465
+ else if (head.key === "chartSlopeLabelTemplate") chartSlopeLabelTemplate = value as NonNullable<typeof chartSlopeLabelTemplate>;
1466
+ else if (head.key === "chartSlopeSize") chartSlopeSize = value as NonNullable<typeof chartSlopeSize>;
1467
+ else if (head.key === "chartStackedLayout") chartStackedLayout = value as NonNullable<typeof chartStackedLayout>;
1468
+ else if (head.key === "chartGaugeSize") chartGaugeSize = value as NonNullable<typeof chartGaugeSize>;
1469
+ else if (head.key === "ganttSize") ganttSize = value as NonNullable<typeof ganttSize>;
1470
+ else if (head.key === "quadrantSize") quadrantSize = value as NonNullable<typeof quadrantSize>;
1471
+ else if (head.key === "legendOffset") legendOffset = value as NonNullable<typeof legendOffset>;
1472
+ else stageLayout = value as NonNullable<typeof stageLayout>;
1473
+ }
1474
+ i += 1;
1475
+ continue;
1476
+ }
1477
+ if (head.key === "journeyLabels" || head.key === "figureCard" || head.key === "figureSize") {
1478
+ const value = JSONの欄を読む(head.value, head.key, line.no, errors);
1479
+ const keys = head.key === "journeyLabels"
1480
+ ? JOURNEY_LABEL_KEYS
1481
+ : head.key === "figureCard" ? (["label", "note"] as const) : (["width", "height"] as const);
1482
+ const unknown = 図表の知らない項目を知らせる(value, keys, head.key, line.no, errors);
1483
+ if (!unknown && objectか(value)) {
1484
+ if (
1485
+ head.key === "journeyLabels" &&
1486
+ JOURNEY_LABEL_KEYS.every((key) => typeof value[key] === "string" && value[key].trim() !== "")
1487
+ ) journeyLabels = value as typeof journeyLabels;
1488
+ else if (
1489
+ head.key === "figureCard" && typeof value.label === "string" &&
1490
+ (value.note === undefined || typeof value.note === "string")
1491
+ ) figureCard = value as typeof figureCard;
1492
+ else if (
1493
+ head.key === "figureSize" && typeof value.width === "number" && Number.isFinite(value.width) && value.width > 0 &&
1494
+ typeof value.height === "number" && Number.isFinite(value.height) && value.height > 0
1495
+ ) figureSize = value as typeof figureSize;
1496
+ else errors.push({ line: line.no, message: `${head.key} の値の形が読めません` });
1497
+ } else if (value !== undefined && !unknown) {
1498
+ errors.push({ line: line.no, message: `${head.key} は object で書きます` });
1499
+ }
1500
+ i += 1;
1501
+ continue;
1502
+ }
622
1503
  if (head.key === "type") {
623
1504
  const v = (head.value ?? "").trim().toLowerCase();
624
- if (!PRESET_TYPES.has(v as PresetType)) {
1505
+ // **別名を先に引く** (#2655)。 引けたら図種と並び順の 2 つに開いて、以降は
1506
+ // その語が 1 か所も残らないようにする
1507
+ const 別名 = TYPE_ALIASES.get(v);
1508
+ if (別名 !== undefined) {
1509
+ type = 別名.type;
1510
+ // 書き手が語も書いていればそちらが勝つ = 別名は既定を入れるだけ
1511
+ if (order === null && 別名.order !== undefined) {
1512
+ order = 別名.order;
1513
+ orderLine = line.no;
1514
+ }
1515
+ if (別名.shape !== undefined) {
1516
+ aliasShape = 別名.shape;
1517
+ aliasShapeLine = line.no;
1518
+ }
1519
+ } else if (!PRESET_TYPES.has(v as PresetType)) {
625
1520
  errors.push({
626
1521
  line: line.no,
627
- message: `unknown type: "${v}"`,
628
- hint: `expected: ${Array.from(PRESET_TYPES).join(", ")}`,
1522
+ message: `図種が読めません: "${v}"`,
1523
+ hint: `使える図種 = ${[...PRESET_TYPES, ...TYPE_ALIASES.keys()].join(", ")}`,
629
1524
  });
630
1525
  } else {
631
1526
  type = v as PresetType;
@@ -635,15 +1530,23 @@ export function parseTextDslV05(src: string): V05ParseResult {
635
1530
  }
636
1531
  if (head.key === "actors") {
637
1532
  // 1 行で書いた形と、 続く字下げ行に項目を並べた形の両方を受け付ける
638
- const { items, next } = collectActorEntries(lines, i + 1, line.indent);
1533
+ const { items, 届かない, next } = collectActorEntries(lines, i + 1, line.indent);
1534
+ // 1 件の頭にも続きにもならなかった行を知らせる (#2400)。 捨てると箱が黙って消える
1535
+ for (const ln of 届かない) {
1536
+ errors.push({
1537
+ line: ln.no,
1538
+ message: `登場人物の行が読めません: "${ln.trimmed}"`,
1539
+ hint: "`- Client` の形で書く (先頭の `- ` を落とすと 1 件として読めない)",
1540
+ });
1541
+ }
639
1542
  actors = [];
640
1543
  for (const entry of items) {
641
1544
  const base = parseActor(entry[0]!, errors);
642
1545
  if (base === null) {
643
1546
  errors.push({
644
1547
  line: entry[0]!.no,
645
- message: `invalid actor entry: "${entry[0]!.trimmed}"`,
646
- hint: "use `- Client` or `- Client: storage`",
1548
+ message: `登場人物の行が読めません: "${entry[0]!.trimmed}"`,
1549
+ hint: "`- Client` または `- Client: storage` の形で書く",
647
1550
  });
648
1551
  continue;
649
1552
  }
@@ -664,8 +1567,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
664
1567
  } else {
665
1568
  errors.push({
666
1569
  line: it.no,
667
- message: `invalid flow entry: "${it.trimmed}"`,
668
- hint: 'use `- A -> B: "label"` or `- A -> B: "label" (success)`',
1570
+ message: `矢印の行が読めません: "${it.trimmed}"`,
1571
+ hint: '`- A -> B: "label"` または `- A -> B: "label" (success)` の形で書く',
669
1572
  });
670
1573
  }
671
1574
  }
@@ -679,7 +1582,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
679
1582
  const inner = inlineMatch.slice(1, -1).trim();
680
1583
  animate = ensureAnimate(animate, line.no);
681
1584
  for (const pair of splitTopLevelCommas(inner)) {
682
- const st = parseStateEntry(pair, line.no);
1585
+ const st = parseStateEntry(pair, line.no, errors);
683
1586
  if (st) animate.states.push(st);
684
1587
  }
685
1588
  i += 1;
@@ -688,14 +1591,10 @@ export function parseTextDslV05(src: string): V05ParseResult {
688
1591
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
689
1592
  animate = ensureAnimate(animate, line.no);
690
1593
  for (const it of items) {
691
- const st = parseStateEntry(it.trimmed.replace(/^-\s*/, ""), it.no);
1594
+ // 断り方ごとの文は読み手が持つ (#2401)。 ここで `null` をまとめて叱ると、
1595
+ // 名前の規則を外れただけの行に形の案内が返る
1596
+ const st = parseStateEntry(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
692
1597
  if (st) animate.states.push(st);
693
- else
694
- errors.push({
695
- line: it.no,
696
- message: `invalid state entry: "${it.trimmed}"`,
697
- hint: "use `name: initial`",
698
- });
699
1598
  }
700
1599
  i = next;
701
1600
  continue;
@@ -705,8 +1604,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
705
1604
  // 入る (`min({a}, {b})`) ため、 `states` が使う素朴な `,` 分割では式が壊れる。
706
1605
  //
707
1606
  // **`{` で始まるかを見てはいけない** (#1169)。 `values: a: "{b} + 1"` は `{` で始まらない
708
- // ため判定を通り抜け、 その後 `collectIndentedRaw` が次行以降しか見ないので **値が
709
- // 1 件も読まれずに黙って消える**。 書き間違いを黙って捨てないという `collectIndentedRaw`
1607
+ // ため判定を通り抜け、 その後 `collectIndentedList` が次行以降しか見ないので **値が
1608
+ // 1 件も読まれずに黙って消える**。 書き間違いを黙って捨てないという `collectIndentedList`
710
1609
  // を自前で持った理由と矛盾する。
711
1610
  //
712
1611
  // 同じ行に何か書いてあれば形を問わず弾く = 1 行形は全て受けないという規則そのもの。
@@ -720,9 +1619,9 @@ export function parseTextDslV05(src: string): V05ParseResult {
720
1619
  i += 1;
721
1620
  continue;
722
1621
  }
723
- // `collectIndentedList` は `:` を含まない行を黙って捨てる。 捨てられると
724
- // 書き間違えた行が「書かなかった」 と同じになり、 値が 1 つ消えたことに気付けない
725
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1622
+ // 字下げした行を 1 行も落とさずに集める。 捨てられると書き間違えた行が
1623
+ // 「書かなかった」 と同じになり、 値が 1 つ消えたことに気付けない
1624
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
726
1625
  for (const it of items) {
727
1626
  const v = parseValueEntry(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
728
1627
  if (v) values.push(v);
@@ -732,8 +1631,16 @@ export function parseTextDslV05(src: string): V05ParseResult {
732
1631
  }
733
1632
  if (head.key === "animation") {
734
1633
  // animation: は step を list で並べる
735
- const { items: stepBlocks, next } = collectAnimationSteps(lines, i + 1, line.indent);
1634
+ const { items: stepBlocks, 届かない, next } = collectAnimationSteps(lines, i + 1, line.indent);
736
1635
  animate = ensureAnimate(animate, line.no);
1636
+ // どの段にも属さなかった行を知らせる (#2400)。 捨てると段が丸ごと黙って消える
1637
+ for (const ln of 届かない) {
1638
+ errors.push({
1639
+ line: ln.no,
1640
+ message: `段の行が読めません: "${ln.trimmed}"`,
1641
+ hint: '`- step: "名前" 1.2s` の形で書く (先頭の `- ` を落とすと段として読めない)',
1642
+ });
1643
+ }
737
1644
  for (const block of stepBlocks) {
738
1645
  const ph = parsePhase(block, errors);
739
1646
  if (ph) animate.phases.push(ph);
@@ -763,7 +1670,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
763
1670
  // block: viewport:\n width: 1400\n height: 900\n ...
764
1671
  // **`collectIndentedList` を使わない** (#1968)。 あちらは `:` を含まない行を黙って捨てるため、
765
1672
  // 書き間違えた行が「書かなかった」 と同じになる (`axes:` と同じ理由)
766
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1673
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
767
1674
  const opts: Record<string, string> = {};
768
1675
  // 知らせは **値を書いた行** を指す (#1306)。 `viewport:` の行を指すと、欄が縦に並ぶ形で
769
1676
  // どの行を直せばよいか分からない
@@ -824,7 +1731,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
824
1731
  }
825
1732
  // **`collectIndentedList` を使わない** (Round 1 の指摘)。 あちらは `:` を含まない行を
826
1733
  // 黙って捨てるため、書き間違えた行が「書かなかった」 と同じになる
827
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1734
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
828
1735
  axesLine = line.no;
829
1736
  const 組み立て: DslAxes = {};
830
1737
  for (const it of items) {
@@ -832,8 +1739,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
832
1739
  if (!m) {
833
1740
  errors.push({
834
1741
  line: it.no,
835
- message: `invalid axes entry: "${it.trimmed}"`,
836
- hint: 'use `x: { left: "...", right: "..." }` or `y: { bottom: "...", top: "..." }`',
1742
+ message: `軸の行が読めません: "${it.trimmed}"`,
1743
+ hint: '`x: { left: "...", right: "..." }` または `y: { bottom: "...", top: "..." }` の形で書く',
837
1744
  });
838
1745
  continue;
839
1746
  }
@@ -845,7 +1752,12 @@ export function parseTextDslV05(src: string): V05ParseResult {
845
1752
  it.no,
846
1753
  errors,
847
1754
  );
848
- if (m[1] === "x") 組み立て.x = { left: opts.left, right: opts.right };
1755
+ const direction = opts.direction === "true";
1756
+ if (direction && opts.label !== undefined) {
1757
+ const axis = { label: stripQuotes(opts.label), direction: true } as const;
1758
+ if (m[1] === "x") 組み立て.x = axis;
1759
+ else 組み立て.y = axis;
1760
+ } else if (m[1] === "x") 組み立て.x = { left: opts.left, right: opts.right };
849
1761
  else 組み立て.y = { bottom: opts.bottom, top: opts.top };
850
1762
  }
851
1763
  // 1 本も読めなかった形は「書かなかった」 と同じにする。 空の軸を渡すと、
@@ -854,9 +1766,55 @@ export function parseTextDslV05(src: string): V05ParseResult {
854
1766
  i = next;
855
1767
  continue;
856
1768
  }
1769
+ if (head.key === "regions") {
1770
+ // regions:\n 左上: "すぐやる"\n 右上: "計画してやる"
1771
+ //
1772
+ // 1 行にまとめて書く形は受けない (`axes:` と同じ理由)。 受けないなら黙って捨てず、
1773
+ // その場で伝える = 捨てると名前を書いたつもりの本文が既定のまま描かれる
1774
+ if (head.value !== null && head.value.trim() !== "") {
1775
+ errors.push({
1776
+ line: line.no,
1777
+ message: "regions は 1 行にまとめて書けない",
1778
+ hint: '次の行から字下げして `左上: "すぐやる"` の形で並べる',
1779
+ });
1780
+ i += 1;
1781
+ continue;
1782
+ }
1783
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
1784
+ const 組み立て: DslRegions = {};
1785
+ for (const it of items) {
1786
+ const m = it.trimmed.match(/^(.+?)\s*:\s*(.+)$/);
1787
+ if (!m) {
1788
+ errors.push({
1789
+ line: it.no,
1790
+ message: `区画の行が読めません: "${it.trimmed}"`,
1791
+ hint: '`左上: "すぐやる"` の形で書く',
1792
+ });
1793
+ continue;
1794
+ }
1795
+ // 区画を指す語は項目の欄と同じ 4 語。 別の言い方を足すと同じ区画を 2 通りで呼ぶ
1796
+ const key = 区画.get((m[1] ?? "").trim());
1797
+ if (key === undefined) {
1798
+ // 知らせの形は他の欄と揃える (#1968)。 揃えないと、知らない項目名を
1799
+ // 走査する検査がこの欄だけ見つけられない
1800
+ errors.push({
1801
+ line: it.no,
1802
+ message: `regions の項目名が読めません: "${(m[1] ?? "").trim()}"`,
1803
+ hint: `使える項目 = ${[...区画.keys()].join(", ")}`,
1804
+ });
1805
+ continue;
1806
+ }
1807
+ 組み立て[key] = stripQuotes((m[2] ?? "").trim());
1808
+ }
1809
+ // 1 つも読めなかった形は「書かなかった」 と同じにする (`axes:` と同じ)
1810
+ regions = Object.keys(組み立て).length > 0 ? 組み立て : undefined;
1811
+ i = next;
1812
+ continue;
1813
+ }
857
1814
  if (head.key === "bands") {
858
1815
  /*
859
- * 動いている間の帯 (#1466)。 `- DB: 1..2` の形で、段の番号の区間を書く。
1816
+ * 動いている間の帯 (#1466)。 `- DB: 1..2` の形で、言づての番号の区間を書く。
1817
+ * 番号は板に載る言づての行で 0 から数える (#2404 で実測、 段ではない)。
860
1818
  *
861
1819
  * 面ごとに 2 行以上書ける = 途中で手が空く面はそこで切れる。 書かない図は
862
1820
  * 組み立て器が「最初に関わった段から最後まで」 の 1 本にする。
@@ -870,11 +1828,11 @@ export function parseTextDslV05(src: string): V05ParseResult {
870
1828
  errors.push({
871
1829
  line: it.no,
872
1830
  message: `帯の書き方が読めません: "${it.trimmed}"`,
873
- hint: "use `- DB: 1..2` (面の名前と、段の番号の区間)",
1831
+ hint: "`- DB: 1..2` の形で書く (面の名前と、言づての番号の区間。 番号は 0 から数える)",
874
1832
  });
875
1833
  continue;
876
1834
  }
877
- bands.push({ actor: m[1]!, from: Number(m[2]), to: Number(m[3]) });
1835
+ bands.push({ actor: m[1]!, from: Number(m[2]), to: Number(m[3]), pos: { line: it.no } });
878
1836
  }
879
1837
  i = next;
880
1838
  continue;
@@ -899,6 +1857,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
899
1857
  id,
900
1858
  ...表で読む(LANE_VALUE_KINDS, opts, `縦列 ${id} の `, it.no, errors),
901
1859
  label: opts.label,
1860
+ subtitle: opts.subtitle,
902
1861
  // 位置のずらし (#1971)
903
1862
  layoutPos: ずらしにまとめる(
904
1863
  表で読む(OFFSET_VALUE_KINDS, opts, `縦列 ${id} の `, it.no, errors),
@@ -908,8 +1867,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
908
1867
  } else {
909
1868
  errors.push({
910
1869
  line: it.no,
911
- message: `invalid lane entry: "${it.trimmed}"`,
912
- hint: 'use `id: { x: 0, width: 320, label: "..." }`',
1870
+ message: `縦列の行が読めません: "${it.trimmed}"`,
1871
+ hint: '`id: { x: 0, width: 320, label: "..." }` の形で書く',
913
1872
  });
914
1873
  }
915
1874
  }
@@ -930,7 +1889,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
930
1889
  }
931
1890
  // `collectIndentedList` は `:` の無い行を落とす。 部品を綴り違えた行も知らせるため、
932
1891
  // 字下げした行を全て読み手へ渡す。
933
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1892
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
934
1893
  readoutsList = [];
935
1894
  for (const it of items) {
936
1895
  const m = 名前と中括弧に割る(it.trimmed);
@@ -940,8 +1899,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
940
1899
  } else {
941
1900
  errors.push({
942
1901
  line: it.no,
943
- message: `invalid readout entry: "${it.trimmed}"`,
944
- hint: "use `id: { kind: percent-ring, source: total, max: 500 }`",
1902
+ message: `読み取り値の行が読めません: "${it.trimmed}"`,
1903
+ hint: "`id: { kind: percent-ring, source: total, max: 500 }` の形で書く",
945
1904
  });
946
1905
  }
947
1906
  }
@@ -962,7 +1921,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
962
1921
  }
963
1922
  // `collectIndentedList` は `:` の無い行を落とす。 つまみを綴り違えた行も知らせるため、
964
1923
  // 字下げした行を全て読み手へ渡す。
965
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1924
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
966
1925
  inputsList = [];
967
1926
  for (const it of items) {
968
1927
  const m = 名前と中括弧に割る(it.trimmed);
@@ -972,8 +1931,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
972
1931
  } else {
973
1932
  errors.push({
974
1933
  line: it.no,
975
- message: `invalid input entry: "${it.trimmed}"`,
976
- hint: "use `id: { kind: slider, min: 0, max: 100, defaultValue: 50 }`",
1934
+ message: `つまみの行が読めません: "${it.trimmed}"`,
1935
+ hint: "`id: { kind: slider, min: 0, max: 100, defaultValue: 50 }` の形で書く",
977
1936
  });
978
1937
  }
979
1938
  }
@@ -995,7 +1954,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
995
1954
  continue;
996
1955
  }
997
1956
  // 字下げした行を全て読み手へ渡す = 綴りを誤った行も知らせるため
998
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1957
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
999
1958
  formulasList = [];
1000
1959
  for (const it of items) {
1001
1960
  const f = 式として読む(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
@@ -1015,7 +1974,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
1015
1974
  i += 1;
1016
1975
  continue;
1017
1976
  }
1018
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1977
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
1019
1978
  eventsList = [];
1020
1979
  for (const it of items) {
1021
1980
  const e = 出来事として読む(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
@@ -1035,7 +1994,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
1035
1994
  i += 1;
1036
1995
  continue;
1037
1996
  }
1038
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1997
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
1039
1998
  scrollsList = [];
1040
1999
  scrollLines = new Map();
1041
2000
  for (const it of items) {
@@ -1057,8 +2016,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
1057
2016
  } else {
1058
2017
  errors.push({
1059
2018
  line: it.no,
1060
- message: `invalid scroll entry: "${it.trimmed}"`,
1061
- hint: "use `id: { start: 0.9, end: 0.1, scrub: 1 }`",
2019
+ message: `巻き上げの行が読めません: "${it.trimmed}"`,
2020
+ hint: "`id: { start: 0.9, end: 0.1, scrub: 1 }` の形で書く",
1062
2021
  });
1063
2022
  }
1064
2023
  }
@@ -1089,8 +2048,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
1089
2048
  } else {
1090
2049
  errors.push({
1091
2050
  line: it.no,
1092
- message: `invalid group entry: "${it.trimmed}"`,
1093
- hint: 'use `id: { label: "...", lanes: [a, b] }`',
2051
+ message: `組の行が読めません: "${it.trimmed}"`,
2052
+ hint: '`id: { label: "...", lanes: [a, b] }` の形で書く',
1094
2053
  });
1095
2054
  }
1096
2055
  }
@@ -1101,12 +2060,21 @@ export function parseTextDslV05(src: string): V05ParseResult {
1101
2060
  }
1102
2061
 
1103
2062
  if (!title)
1104
- errors.push({ line: 1, message: "title is required", hint: 'add `title: "..."` at top' });
2063
+ errors.push({
2064
+ line: 1,
2065
+ message: "title が書かれていません",
2066
+ hint: "`title: \"...\"` を最上位に書く",
2067
+ });
1105
2068
  if (!type)
1106
2069
  errors.push({
1107
2070
  line: 1,
1108
- message: "type is required",
1109
- hint: "add `type: sequence|flow|swimlane|er|state|topology|solidity|gantt|class|pie|c4|mind`",
2071
+ message: "type が書かれていません",
2072
+ /*
2073
+ * **図種の一覧を手で並べない** (#2341)。 並べていた間、ここは実装が受ける図種の
2074
+ * 半分しか挙げず、値を並べる図種 (棒 / 折れ線 / 木 など) がまるごと抜けていた。
2075
+ * すぐ上の `図種が読めません` は元から実装を読んでおり、2 つの一覧が食い違っていた。
2076
+ */
2077
+ hint: `\`type: flow\` の形で最上位に書く (使える図種 = ${Array.from(PRESET_TYPES).join(", ")})`,
1110
2078
  });
1111
2079
 
1112
2080
  // つまみ・式・巻き上げは同じ名前空間で値を作る。 重なると後から作る値が効かない。
@@ -1123,7 +2091,25 @@ export function parseTextDslV05(src: string): V05ParseResult {
1123
2091
  });
1124
2092
  }
1125
2093
 
2094
+ let shape: DslDiagramShape | null = aliasShape;
2095
+ let shapeLine = aliasShapeLine;
2096
+ if (shapeWritten !== null) {
2097
+ const 解けた = resolveDiagramShape(shapeWritten.value);
2098
+ const 図種の語 = type === null ? [] : shapesForDiagramType(type);
2099
+ if (解けた === null || (type === "chart" && !図種の語.includes(解けた))) {
2100
+ errors.push({
2101
+ line: shapeWritten.line,
2102
+ message: `shape が読めません (書いた値: ${shapeWritten.value})`,
2103
+ hint: shapeの案内(type),
2104
+ });
2105
+ } else {
2106
+ shape = 解けた;
2107
+ shapeLine = shapeWritten.line;
2108
+ }
2109
+ }
2110
+
1126
2111
  if (errors.length > 0) return { ok: false, errors };
2112
+ const 選んだ意匠 = theme ?? palette;
1127
2113
 
1128
2114
  return {
1129
2115
  ok: true,
@@ -1134,8 +2120,80 @@ export function parseTextDslV05(src: string): V05ParseResult {
1134
2120
  ...(reveal !== null ? { reveal } : {}),
1135
2121
  ...(relations !== null ? { relations } : {}),
1136
2122
  ...(direction !== null ? { direction, directionPos: { line: directionLine } } : {}),
1137
- ...(palette !== null ? { palette } : {}),
2123
+ ...(order !== null ? { order, orderPos: { line: orderLine } } : {}),
2124
+ ...(shape !== null ? { shape, shapePos: { line: shapeLine } } : {}),
2125
+ ...(formWritten !== null
2126
+ ? { form: formWritten.value, formPos: { line: formWritten.line } }
2127
+ : {}),
2128
+ ...(ticks !== undefined ? { ticks, ticksPos: { line: ticksLine } } : {}),
2129
+ ...(ganttToday !== undefined ? { ganttToday } : {}),
2130
+ ...(ganttTickLabels !== undefined ? { ganttTickLabels } : {}),
2131
+ ...(ganttBarEnd !== undefined ? { ganttBarEnd } : {}),
2132
+ ...(ganttBarThickness !== undefined ? { ganttBarThickness } : {}),
2133
+ ...(chartSeriesSkipMuted !== undefined ? { chartSeriesSkipMuted } : {}),
2134
+ ...(chartLineSeries !== undefined ? { chartLineSeries } : {}),
2135
+ ...(chartPieCenterLabel !== undefined ? { chartPieCenterLabel } : {}),
2136
+ ...(chartPieTableColumns !== undefined ? { chartPieTableColumns } : {}),
2137
+ ...(chartPieRingWidth !== undefined ? { chartPieRingWidth } : {}),
2138
+ ...(chartSlopePeriods !== undefined ? { chartSlopePeriods } : {}),
2139
+ ...(chartSlopeEmphasisIds !== undefined ? { chartSlopeEmphasisIds } : {}),
2140
+ ...(chartSlopeUnit !== undefined ? { chartSlopeUnit } : {}),
2141
+ ...(chartStackedPeriods !== undefined ? { chartStackedPeriods } : {}),
2142
+ ...(chartStackedRateId !== undefined ? { chartStackedRateId } : {}),
2143
+ ...(chartStackedLegendPosition !== undefined ? { chartStackedLegendPosition } : {}),
2144
+ ...(chartGaugeValue !== undefined ? { chartGaugeValue } : {}),
2145
+ ...(chartWaffleLegendPosition !== undefined ? { chartWaffleLegendPosition } : {}),
2146
+ ...(funnelForm !== undefined ? { funnelForm } : {}),
2147
+ ...(funnelRate !== undefined ? { funnelRate } : {}),
2148
+ ...(quadrantPointLabelSide !== undefined ? { quadrantPointLabelSide } : {}),
2149
+ ...(treeNodeForm !== undefined ? { treeNodeForm } : {}),
2150
+ ...(treeSize !== undefined ? { treeSize } : {}),
2151
+ ...(treeEdgeTone !== undefined ? { treeEdgeTone } : {}),
2152
+ ...(treeEdgeHead !== undefined ? { treeEdgeHead } : {}),
2153
+ ...(mindForm !== undefined ? { mindForm } : {}),
2154
+ ...(mindSize !== undefined ? { mindSize } : {}),
2155
+ ...(journeyForm !== undefined ? { journeyForm } : {}),
2156
+ ...(journeyLineForm !== undefined ? { journeyLineForm } : {}),
2157
+ ...(journeyLabels !== undefined ? { journeyLabels } : {}),
2158
+ ...(journeySize !== undefined ? { journeySize } : {}),
2159
+ ...(sequenceActorForm !== undefined ? { sequenceActorForm } : {}),
2160
+ ...(sequenceLabelForm !== undefined ? { sequenceLabelForm } : {}),
2161
+ ...(sequenceReturnHead !== undefined ? { sequenceReturnHead } : {}),
2162
+ ...(sequenceKindLabels !== undefined ? { sequenceKindLabels } : {}),
2163
+ ...(sequenceRowGap !== undefined ? { sequenceRowGap } : {}),
2164
+ ...(sequenceGutter !== undefined ? { sequenceGutter } : {}),
2165
+ ...(sequenceBandWidth !== undefined ? { sequenceBandWidth } : {}),
2166
+ ...(sequenceSelfLoop !== undefined ? { sequenceSelfLoop } : {}),
2167
+ ...(figureCard !== undefined ? { figureCard } : {}),
2168
+ ...(figureSize !== undefined ? { figureSize } : {}),
2169
+ ...(terminalDecoration !== undefined ? { terminalDecoration } : {}),
2170
+ ...(cornerForm !== undefined ? { cornerForm } : {}),
2171
+ ...(figureKindLabel !== undefined ? { figureKindLabel } : {}),
2172
+ ...(figureInset !== undefined ? { figureInset } : {}),
2173
+ ...(chartBarSize !== undefined ? { chartBarSize } : {}),
2174
+ ...(chartBarShape !== undefined ? { chartBarShape } : {}),
2175
+ ...(chartLineSize !== undefined ? { chartLineSize } : {}),
2176
+ ...(chartPieSize !== undefined ? { chartPieSize } : {}),
2177
+ ...(chartWaffleSize !== undefined ? { chartWaffleSize } : {}),
2178
+ ...(chartSlopeLabelTemplate !== undefined ? { chartSlopeLabelTemplate } : {}),
2179
+ ...(chartSlopeSize !== undefined ? { chartSlopeSize } : {}),
2180
+ ...(chartStackedLayout !== undefined ? { chartStackedLayout } : {}),
2181
+ ...(chartGaugeSize !== undefined ? { chartGaugeSize } : {}),
2182
+ ...(ganttSize !== undefined ? { ganttSize } : {}),
2183
+ ...(quadrantLabelPosition !== undefined ? { quadrantLabelPosition } : {}),
2184
+ ...(quadrantSize !== undefined ? { quadrantSize } : {}),
2185
+ ...(stageLayout !== undefined ? { stageLayout } : {}),
2186
+ ...(選んだ意匠 !== null ? { theme: 選んだ意匠 } : {}),
2187
+ ...(legend.length > 0 ? { legend } : {}),
2188
+ ...(legendOffset !== undefined ? { legendOffset } : {}),
2189
+ ...(legendFontSize !== undefined ? { legendFontSize } : {}),
2190
+ ...(edgeEndGap !== undefined ? { edgeEndGap } : {}),
2191
+ ...(stageHeader !== undefined ? { stageHeader } : {}),
2192
+ ...(theme !== null && palette !== null
2193
+ ? { themeAlsoPalettePos: { themeLine, paletteLine } }
2194
+ : {}),
1138
2195
  ...(axes !== undefined ? { axes, axesPos: { line: axesLine } } : {}),
2196
+ ...(regions !== undefined ? { regions } : {}),
1139
2197
  actors,
1140
2198
  flow,
1141
2199
  animate,
@@ -1170,10 +2228,125 @@ type TopHeader = { key: string; value: string | null };
1170
2228
 
1171
2229
  function matchTopHeader(trimmed: string): TopHeader | null {
1172
2230
  // 形式: `key:` or `key: value`
1173
- const m = trimmed.match(/^([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
2231
+ const m = trimmed.match(/^([^\s:]+)\s*:\s*(.*)$/u);
1174
2232
  if (!m) return null;
1175
2233
  const value = (m[2] ?? "").trim();
1176
- return { key: (m[1] ?? "").toLowerCase(), value: value.length ? stripQuotes(value) : null };
2234
+ const writtenKey = m[1] ?? "";
2235
+ const lowerKey = writtenKey.toLowerCase();
2236
+ const canonicalKey = TOP_LEVEL_KEYS.find((key) => key.toLowerCase() === lowerKey);
2237
+ return { key: canonicalKey ?? lowerKey, value: value.length ? stripQuotes(value) : null };
2238
+ }
2239
+
2240
+ const LEGEND_ITEM_KEYS = ["mark", "text", "tone", "lineStyle", "align", "lead", "印", "説明"] as const;
2241
+ const LEGEND_LINE_MARKS = ["line-triangle-hollow", "line-diamond-hollow", "line-diamond-solid"] as const;
2242
+
2243
+ /** 凡例の 1 行を読み、描画側へ渡せる印と文字へ揃える。 */
2244
+ function parseLegendItem(
2245
+ line: Line,
2246
+ errors: DslError[],
2247
+ ): CdlLegendItem | null {
2248
+ const match = line.trimmed.match(/^\{([\s\S]*)\}$/u);
2249
+ if (!match) {
2250
+ errors.push({
2251
+ line: line.no,
2252
+ message: "凡例の行が読めません",
2253
+ hint: '`{ mark: diamond, text: "分かれ道" }` の形で書く',
2254
+ });
2255
+ return null;
2256
+ }
2257
+
2258
+ const fields = new Map<string, string>();
2259
+ let unreadable = false;
2260
+ for (const field of splitInlineFields(match[1] ?? "")) {
2261
+ const separator = field.indexOf(":");
2262
+ if (separator < 0) {
2263
+ unreadable = true;
2264
+ continue;
2265
+ }
2266
+ const key = field.slice(0, separator).trim();
2267
+ const value = field.slice(separator + 1).trim();
2268
+ if (!LEGEND_ITEM_KEYS.includes(key as (typeof LEGEND_ITEM_KEYS)[number])) {
2269
+ errors.push({
2270
+ line: line.no,
2271
+ message: `凡例の項目名が読めません: "${key}"`,
2272
+ hint: `使える項目 = ${LEGEND_ITEM_KEYS.join(", ")}`,
2273
+ });
2274
+ unreadable = true;
2275
+ continue;
2276
+ }
2277
+ fields.set(key, value);
2278
+ }
2279
+
2280
+ const markRaw = fields.get("mark") ?? fields.get("印");
2281
+ const markValue = markRaw === undefined ? undefined : stripQuotes(markRaw);
2282
+ const mark = markValue === undefined ? undefined : resolveLegendMark(markValue);
2283
+ if (markValue !== undefined && mark === null) {
2284
+ errors.push({
2285
+ line: line.no,
2286
+ message: `凡例の印が読めません: "${markValue}"`,
2287
+ hint: `使える印 = ${[...LEGEND_MARKS, ...Object.keys(LEGEND_MARK_ALIAS)].join(" / ")}`,
2288
+ });
2289
+ unreadable = true;
2290
+ }
2291
+
2292
+ const textRaw = fields.get("text") ?? fields.get("説明") ?? "";
2293
+ const quoted =
2294
+ (textRaw.startsWith('"') && textRaw.endsWith('"')) ||
2295
+ (textRaw.startsWith("'") && textRaw.endsWith("'"));
2296
+ const text = stripQuotes(textRaw).trim();
2297
+ const bracketed =
2298
+ textRaw.startsWith("[") ||
2299
+ textRaw.startsWith("{") ||
2300
+ textRaw.endsWith("]") ||
2301
+ textRaw.endsWith("}");
2302
+ if (text.length === 0 || (!quoted && bracketed)) {
2303
+ errors.push({
2304
+ line: line.no,
2305
+ message: "凡例の説明は文字で書く",
2306
+ hint: '`text: "分かれ道"` または `説明: "分かれ道"` の形で書く',
2307
+ });
2308
+ unreadable = true;
2309
+ }
2310
+
2311
+ const toneRaw = fields.get("tone");
2312
+ const tone = toneRaw === undefined ? undefined : resolveTone(stripQuotes(toneRaw));
2313
+ if (toneRaw !== undefined && tone === undefined) {
2314
+ errors.push({ line: line.no, message: `凡例の tone が読めません: "${stripQuotes(toneRaw)}"`, hint: `使える値 = ${書ける色名().join(", ")}` });
2315
+ unreadable = true;
2316
+ }
2317
+ const lineStyleRaw = fields.get("lineStyle");
2318
+ const lineStyle = lineStyleRaw === undefined ? undefined : stripQuotes(lineStyleRaw);
2319
+ if (lineStyle !== undefined && !(LEGEND_LINE_STYLE_VALUES as readonly string[]).includes(lineStyle)) {
2320
+ errors.push({ line: line.no, message: `凡例の lineStyle が読めません: "${lineStyle}"`, hint: `使える値 = ${LEGEND_LINE_STYLE_VALUES.join(", ")}` });
2321
+ unreadable = true;
2322
+ }
2323
+ const alignRaw = fields.get("align");
2324
+ const align = alignRaw === undefined ? undefined : stripQuotes(alignRaw);
2325
+ if (align !== undefined && !(LEGEND_NOTE_ALIGN_VALUES as readonly string[]).includes(align)) {
2326
+ errors.push({ line: line.no, message: `凡例の align が読めません: "${align}"`, hint: `使える値 = ${LEGEND_NOTE_ALIGN_VALUES.join(", ")}` });
2327
+ unreadable = true;
2328
+ }
2329
+ const lead = fields.get("lead") === undefined ? undefined : stripQuotes(fields.get("lead")!);
2330
+ if (mark === undefined && (tone !== undefined || lineStyle !== undefined)) {
2331
+ errors.push({ line: line.no, message: "印のない凡例に tone / lineStyle は書けません", hint: "注記には text / align / lead を使う" });
2332
+ unreadable = true;
2333
+ }
2334
+ if (mark !== undefined && (align !== undefined || lead !== undefined)) {
2335
+ errors.push({ line: line.no, message: "印を持つ凡例に align / lead は書けません", hint: "align / lead は印のない注記に使う" });
2336
+ unreadable = true;
2337
+ }
2338
+ if (lineStyle !== undefined && (mark === undefined || mark === null || !(LEGEND_LINE_MARKS as readonly LegendMark[]).includes(mark))) {
2339
+ errors.push({ line: line.no, message: "lineStyle は線端を持つ凡例の印にだけ書けます", hint: `使える印 = ${LEGEND_LINE_MARKS.join(", ")}` });
2340
+ unreadable = true;
2341
+ }
2342
+ if (unreadable || mark === null) return null;
2343
+ if (mark === undefined) return { text, ...(align === undefined ? {} : { align: align as "start" | "end" }), ...(lead === undefined ? {} : { lead }) };
2344
+ return {
2345
+ mark,
2346
+ ...(tone === undefined ? {} : { tone }),
2347
+ ...(lineStyle === undefined ? {} : { lineStyle }),
2348
+ text,
2349
+ } as CdlLegendItem;
1177
2350
  }
1178
2351
 
1179
2352
  /**
@@ -1470,6 +2643,7 @@ function 表で読む<T extends Record<string, 値の形>>(
1470
2643
  export const VIEWPORT_VALUE_KINDS = {
1471
2644
  width: "数",
1472
2645
  height: "数",
2646
+ contentOffsetX: "数",
1473
2647
  scale: "数",
1474
2648
  laneWidth: "数",
1475
2649
  gap: "数",
@@ -1508,13 +2682,14 @@ export const LANE_VALUE_KINDS = {
1508
2682
 
1509
2683
  /**
1510
2684
  * 縦列の中括弧に書ける項目 (#1968)。 数と真偽の欄は `LANE_VALUE_KINDS` と `OFFSET_VALUE_KINDS` から
1511
- * 導き、表に載せない文字列の `label` だけを足す。
2685
+ * 導き、表に載せない文字列の `label` と `subtitle` だけを足す。
1512
2686
  */
1513
2687
  export const LANE_INLINE_KEYS: readonly (keyof DslLane | keyof typeof OFFSET_VALUE_KINDS)[] = [
1514
2688
  ...(Object.keys(LANE_VALUE_KINDS) as (keyof typeof LANE_VALUE_KINDS)[]),
1515
2689
  // 位置のずらし (#1971)。 JSON の縦列の `pos` と同じ `layoutPos` に入る
1516
2690
  ...(Object.keys(OFFSET_VALUE_KINDS) as (keyof typeof OFFSET_VALUE_KINDS)[]),
1517
2691
  "label",
2692
+ "subtitle",
1518
2693
  ];
1519
2694
 
1520
2695
  /** 組の中括弧に書ける項目 (#1968)。 `groups:` の読み手が拾う欄と揃える */
@@ -1524,12 +2699,18 @@ export const GROUP_INLINE_KEYS = ["label", "lanes"] as const satisfies readonly
1524
2699
  >[];
1525
2700
 
1526
2701
  /** 軸の名前の中括弧に書ける項目 (#1968)。 横軸は左右、縦軸は下上 */
1527
- export const AXIS_X_KEYS = ["left", "right"] as const satisfies readonly (keyof NonNullable<
1528
- DslAxes["x"]
1529
- >)[];
1530
- export const AXIS_Y_KEYS = ["bottom", "top"] as const satisfies readonly (keyof NonNullable<
1531
- DslAxes["y"]
1532
- >)[];
2702
+ export const AXIS_X_KEYS = ["left", "right", "label", "direction"] as const satisfies readonly (
2703
+ | "left"
2704
+ | "right"
2705
+ | "label"
2706
+ | "direction"
2707
+ )[];
2708
+ export const AXIS_Y_KEYS = ["bottom", "top", "label", "direction"] as const satisfies readonly (
2709
+ | "bottom"
2710
+ | "top"
2711
+ | "label"
2712
+ | "direction"
2713
+ )[];
1533
2714
 
1534
2715
  /**
1535
2716
  * 箱の中に描く図形 (`shape:`) と、値を見せる部品 (`readouts:`) の欄 (#1374)。
@@ -1732,7 +2913,11 @@ function 選択肢の並びとして読む(
1732
2913
  ): (string | { value: string; label: string })[] | undefined {
1733
2914
  const 中身 = 値.trim();
1734
2915
  if (!中身.startsWith("[") || !中身.endsWith("]")) {
1735
- errors.push({ line, message: `${名} の並びが読めません: "${値}"`, hint: '`["green", { value: "red", label: "失敗" }]` の形で書く' });
2916
+ errors.push({
2917
+ line,
2918
+ message: `${名} の並びが読めません: "${値}"`,
2919
+ hint: '`["green", { value: "red", label: "失敗" }]` の形で書く',
2920
+ });
1736
2921
  return undefined;
1737
2922
  }
1738
2923
  const 出: (string | { value: string; label: string })[] = [];
@@ -1745,19 +2930,35 @@ function 選択肢の並びとして読む(
1745
2930
  return;
1746
2931
  }
1747
2932
  if (!要素.endsWith("}")) {
1748
- errors.push({ line, message: `${名}[${i}] の組が読めません: "${要素}"`, hint: '`{ value: "red", label: "失敗" }` の形で書く' });
2933
+ errors.push({
2934
+ line,
2935
+ message: `${名}[${i}] の組が読めません: "${要素}"`,
2936
+ hint: '`{ value: "red", label: "失敗" }` の形で書く',
2937
+ });
1749
2938
  return;
1750
2939
  }
1751
2940
  const 組 = parseInlineMapping(要素.slice(1, -1));
1752
2941
  for (const k of Object.keys(組)) {
1753
2942
  if ((選択肢の組の項目 as readonly string[]).includes(k)) continue;
1754
- errors.push({ line, message: `${名}[${i}] の項目名が読めません: "${k}"`, hint: `使える項目 = ${選択肢の組の項目.join(", ")}` });
2943
+ errors.push({
2944
+ line,
2945
+ message: `${名}[${i}] の項目名が読めません: "${k}"`,
2946
+ hint: `使える項目 = ${選択肢の組の項目.join(", ")}`,
2947
+ });
1755
2948
  }
1756
2949
  for (const k of 選択肢の組の項目) {
1757
2950
  if (組[k] === undefined) {
1758
- errors.push({ line, message: `${名}[${i}].${k} は必ず書きます`, hint: `必須の項目 = ${選択肢の組の項目.join(", ")}` });
2951
+ errors.push({
2952
+ line,
2953
+ message: `${名}[${i}].${k} は必ず書きます`,
2954
+ hint: `必須の項目 = ${選択肢の組の項目.join(", ")}`,
2955
+ });
1759
2956
  } else if (組[k] === "") {
1760
- errors.push({ line, message: `${名}[${i}].${k} が空です`, hint: k === "label" ? "名前を書かないなら文字列だけを並べる" : "値を 1 文字以上書く" });
2957
+ errors.push({
2958
+ line,
2959
+ message: `${名}[${i}].${k} が空です`,
2960
+ hint: k === "label" ? "名前を書かないなら文字列だけを並べる" : "値を 1 文字以上書く",
2961
+ });
1761
2962
  }
1762
2963
  }
1763
2964
  if (組.value && 組.label) 出.push({ value: 組.value, label: 組.label });
@@ -2059,7 +3260,7 @@ function 部品として読む(
2059
3260
  const 読めた = 表に従って読む(定義, opts, `部品 ${id} の `, line, errors);
2060
3261
  部品の組を検査する(kind, 読めた, line, errors);
2061
3262
  for (const 欄 of 定義.必須) if (読めた[欄] === undefined) return undefined;
2062
- return { id, kind, ...読めた } as DslReadout;
3263
+ return { id, kind, ...読めた, pos: { line } } as DslReadout;
2063
3264
  }
2064
3265
 
2065
3266
  /**
@@ -2315,7 +3516,12 @@ function 式として読む(行: string, line: number, errors: DslError[]): DslF
2315
3516
  });
2316
3517
  return undefined;
2317
3518
  }
2318
- return { id: 名前, expression: 式, ...(名札 !== undefined ? { label: 名札 } : {}), pos: { line } };
3519
+ return {
3520
+ id: 名前,
3521
+ expression: 式,
3522
+ ...(名札 !== undefined ? { label: 名札 } : {}),
3523
+ pos: { line },
3524
+ };
2319
3525
  }
2320
3526
 
2321
3527
  /** 組の中の項目名の形。 `parseInlineMapping` が項目名として受ける字と同じ */
@@ -2348,7 +3554,9 @@ function 式の組を読む(
2348
3554
  ): { expression: string; label?: string } | undefined {
2349
3555
  const 使える = ["expression", "label"];
2350
3556
  const 読めた: Record<string, string> = {};
2351
- for (const 欄 of splitInlineFields(中身).map((s) => s.trim()).filter((s) => s !== "")) {
3557
+ for (const 欄 of splitInlineFields(中身)
3558
+ .map((s) => s.trim())
3559
+ .filter((s) => s !== "")) {
2352
3560
  const m = 欄.match(組の項目名);
2353
3561
  if (!m) {
2354
3562
  errors.push({
@@ -2419,19 +3627,40 @@ export const ACTOR_INLINE_VALUE_KINDS = {
2419
3627
  posY: "数",
2420
3628
  posW: "数",
2421
3629
  posH: "数",
3630
+ titleFontSize: "数",
3631
+ markGap: "数",
3632
+ milestone: "真偽",
3633
+ rowRules: "真偽",
3634
+ rowStripe: "真偽",
2422
3635
  } as const satisfies Record<string, 値の形>;
2423
3636
 
2424
- /** 縦に並べる形で箱に書ける、数と真偽の欄 (#1306)。 `posW` / `posH` は `大きさ:` が受ける */
3637
+ /**
3638
+ * 縦に並べる形で箱に書ける、数と真偽の欄 (#1306)。 `posW` / `posH` は `大きさ:` が受ける。
3639
+ *
3640
+ * 始まりと終わりの印 (`initial` / `final`) は #2346 で足した。 中括弧の表にはあったのに
3641
+ * こちらに無く、**段を分けた側には書き方が 1 つも無かった** = 書くと綴りの誤りとして落ちる
3642
+ */
2425
3643
  export const ACTOR_BLOCK_VALUE_KINDS = {
2426
3644
  stack: "数",
2427
3645
  posX: "数",
2428
3646
  posY: "数",
3647
+ initial: "真偽",
3648
+ final: "真偽",
3649
+ titleFontSize: "数",
3650
+ markGap: "数",
3651
+ milestone: "真偽",
3652
+ rowRules: "真偽",
3653
+ rowStripe: "真偽",
2429
3654
  } as const satisfies Record<string, 値の形>;
2430
3655
 
2431
3656
  /** 矢印の中括弧に書ける、数と真偽の欄 (#1306) */
2432
3657
  export const FLOW_INLINE_VALUE_KINDS = {
2433
3658
  labelOffsetX: "数",
2434
3659
  labelOffsetY: "数",
3660
+ posX1: "数",
3661
+ posY1: "数",
3662
+ posX2: "数",
3663
+ posY2: "数",
2435
3664
  overlay: "真偽",
2436
3665
  } as const satisfies Record<string, 値の形>;
2437
3666
 
@@ -2520,10 +3749,10 @@ function parseInlineMapping(inner: string): Record<string, string> {
2520
3749
  // 読める名前を広げても、**知っている名前しか使われない**。 パーツの状態の上書きに
2521
3750
  // 流れるのは `ACTOR_RESERVED_FIELDS` に無い名前だけで、日本語の項目名 (`位置` / `大きさ`
2522
3751
  // 等) はそこに載せてあるため、これまでどおり落ちる
2523
- const m = p.match(/^\s*([^\s:,{}[\]"']+)\s*:\s*(.+?)\s*$/);
3752
+ const m = p.match(/^\s*(?:"([^"]+)"|'([^']+)'|([^\s:,{}[\]"']+))\s*:\s*(.+?)\s*$/);
2524
3753
  if (m) {
2525
- const key = m[1]!;
2526
- out[key] = stripQuotes(m[2]!.trim());
3754
+ const key = m[1] ?? m[2] ?? m[3]!;
3755
+ out[key] = stripQuotes(m[4]!.trim());
2527
3756
  }
2528
3757
  }
2529
3758
  return out;
@@ -2541,7 +3770,7 @@ function writtenScaleFields(inner: string): Map<string, string> {
2541
3770
  for (const field of splitInlineFields(inner)) {
2542
3771
  const idx = field.indexOf(":");
2543
3772
  if (idx < 0) continue;
2544
- const key = field.slice(0, idx).trim();
3773
+ const key = stripQuotes(field.slice(0, idx).trim());
2545
3774
  if (!SCALE_KEYS.has(key)) continue;
2546
3775
  // 同じ名前を 2 度書いたら後の値を採る
2547
3776
  out.set(key, stripQuotes(field.slice(idx + 1).trim()));
@@ -2613,6 +3842,81 @@ function splitInlineFields(inner: string): string[] {
2613
3842
  return 見た.閉じた ? 見た.parts : 引用符を見て割る(false).parts;
2614
3843
  }
2615
3844
 
3845
+ /** 行頭の印を、従来の語または色の段を持つ組として読む。 */
3846
+ function 行頭の印の並びとして読む(
3847
+ raw: string,
3848
+ line: number,
3849
+ errors: DslError[],
3850
+ ): DslActor["marks"] {
3851
+ if (!raw.includes("{")) {
3852
+ return raw
3853
+ .replace(/^\[|\]$/g, "")
3854
+ .split(/,(?![^[]*\])/)
3855
+ .map((x) => 行頭の語へ読み替える(stripQuotes(x.trim())));
3856
+ }
3857
+ const value = JSONの欄を読む(raw, "marks", line, errors);
3858
+ if (!Array.isArray(value)) {
3859
+ errors.push({ line, message: "marks は並びで書きます" });
3860
+ return undefined;
3861
+ }
3862
+ const out: NonNullable<DslActor["marks"]> = [];
3863
+ value.forEach((item, index) => {
3864
+ if (typeof item === "string") {
3865
+ out.push(行頭の語へ読み替える(item));
3866
+ return;
3867
+ }
3868
+ if (!objectか(item)) {
3869
+ errors.push({ line, message: `marks[${index}] の値の形が読めません` });
3870
+ return;
3871
+ }
3872
+ const 受ける項目 = ["mark", "tone"] as const;
3873
+ const unknown = Object.keys(item).filter((key) => !(受ける項目 as readonly string[]).includes(key));
3874
+ if (unknown.length > 0) {
3875
+ for (const key of unknown) errors.push({ line, message: `marks[${index}] の項目名が読めません: "${key}"`, hint: `使える項目 = ${受ける項目.join(", ")}` });
3876
+ return;
3877
+ }
3878
+ if (typeof item.mark !== "string") {
3879
+ errors.push({ line, message: `marks[${index}].mark は文字で書きます` });
3880
+ return;
3881
+ }
3882
+ if (item.tone !== undefined && !(ROW_MARK_TONE_VALUES as readonly unknown[]).includes(item.tone)) {
3883
+ const shown = typeof item.tone === "string" ? item.tone : (JSON.stringify(item.tone) ?? typeof item.tone);
3884
+ errors.push({ line, message: `marks[${index}].tone が読めません: "${shown}"`, hint: `使える値 = ${ROW_MARK_TONE_VALUES.join(", ")}` });
3885
+ return;
3886
+ }
3887
+ out.push({
3888
+ mark: 行頭の語へ読み替える(item.mark),
3889
+ ...(item.tone === undefined ? {} : { tone: item.tone as "primary" | "muted" }),
3890
+ });
3891
+ });
3892
+ return out;
3893
+ }
3894
+
3895
+ /**
3896
+ * 字下げした行を 1 行も落とさずに集める (#2400)。
3897
+ *
3898
+ * 形が合うかを **ここでは見ない**。 集めた行は呼出側の読み手 (`parseFlowStep` /
3899
+ * `parseStateEntry` / `parseLane` 等) へ渡し、読めない行は読み手が「…の行が読めません」 として
3900
+ * 報告する。
3901
+ *
3902
+ * **形の合わない行をここで捨てない**。 捨てると知らせにも図にも現れず、書いた人には
3903
+ * 「1 行書いたのに増えない」 だけが残る (実測 = `states:` に `progress 0` と書くと
3904
+ * 知らせ 0 件でその状態が消えた)。 判定を読み手 1 か所に寄せると、受ける形を広げた日に
3905
+ * 集める側を直し忘れて黙る形も生まれない。
3906
+ *
3907
+ * **記法が受ける全ての節がここを通る** (#2406)。 以前は先頭の `- ` を外す版と外さない版の
3908
+ * 2 つに分かれていたが、 形の合わない行を捨てない振る舞いが #2400 で揃った時点で違いが
3909
+ * 「`- ` を外すかどうか」 の 1 行だけになり、 外す側へ寄せて 1 つにした。
3910
+ *
3911
+ * **外す側へ寄せると、 受ける形が増える節がある**。 まとめる前に測ると、 11 節のうち 8 節は
3912
+ * 既に `- ` を受けており (5 節はここで外し、 3 節は読み手が自分で外していた)、 受けないのは
3913
+ * `readouts` / `inputs` / `scrolls` の 3 節だけだった。 寄せると この 3 節も受ける =
3914
+ * **既にある図の意味は 1 つも変わらず**、 節によって受ける形が違う不揃いが消える。
3915
+ *
3916
+ * **空行とコメント行はここで飛ばす**。 読み手はどれも「書いた指定」 として読むため、
3917
+ * 渡すとコメントが誤りとして報告される (実測 = コロンを含むコメント `# めも: あ` が
3918
+ * `状態の行が読めません` になっていた)。
3919
+ */
2616
3920
  function collectIndentedList(
2617
3921
  lines: Line[],
2618
3922
  start: number,
@@ -2622,17 +3926,13 @@ function collectIndentedList(
2622
3926
  let i = start;
2623
3927
  while (i < lines.length) {
2624
3928
  const ln = lines[i]!;
2625
- if (!ln.trimmed) {
3929
+ if (!ln.trimmed || ln.trimmed.startsWith("#")) {
2626
3930
  i += 1;
2627
3931
  continue;
2628
3932
  }
2629
3933
  if (ln.indent <= parentIndent) break;
2630
- if (ln.trimmed.startsWith("- ")) {
2631
- items.push({ ...ln, trimmed: ln.trimmed.slice(2).trim() });
2632
- } else if (ln.trimmed.includes(":")) {
2633
- // YAML 風 inline (key: value) は state 用 block で許容
2634
- items.push(ln);
2635
- }
3934
+ // 先頭の `- ` を外して渡す。 外した形と、 YAML 風の `key: value` を読み手が同じに読む
3935
+ items.push(ln.trimmed.startsWith("- ") ? { ...ln, trimmed: ln.trimmed.slice(2).trim() } : ln);
2636
3936
  i += 1;
2637
3937
  }
2638
3938
  return { items, next: i };
@@ -2741,7 +4041,7 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2741
4041
  /** 倍率を名前ごとに最後に書いた行。 別名の優先順と行番号を取り違えないために保持する。 */
2742
4042
  const scaleLines = new Map<string, number>();
2743
4043
  /**
2744
- * 縦に並べて書かれた体験の道筋の欄 (#1251)。
4044
+ * 縦に並べて書かれたユーザージャーニーの欄 (#1251)。
2745
4045
  *
2746
4046
  * パーツでは状態の上書きとして意味を持つため、どちらに入れるかは block を読み終わってから
2747
4047
  * 決める。 `kind:` の行が後ろに書かれることもあり、読んだ時点ではパーツか分からない。
@@ -2801,6 +4101,111 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2801
4101
  case "補足":
2802
4102
  out.subtitle = stripQuotes(raw);
2803
4103
  break;
4104
+ case "kindForm": {
4105
+ const value = stripQuotes(raw);
4106
+ if (value === "icon") out.kindForm = value;
4107
+ else errors.push({ line: ln.no, message: `箱の kindForm が読めません: "${value}"`, hint: `使える値 = ${KIND_FORM_VALUES.join(", ")}` });
4108
+ break;
4109
+ }
4110
+ case "icon": {
4111
+ const value = stripQuotes(raw);
4112
+ if ((CDL_ICON_VALUES as readonly string[]).includes(value)) out.icon = value as NonNullable<DslActor["icon"]>;
4113
+ else errors.push({ line: ln.no, message: `箱の icon が読めません: "${value}"`, hint: `使える値 = ${CDL_ICON_VALUES.join(", ")}` });
4114
+ break;
4115
+ }
4116
+ case "emptyRowsNote":
4117
+ out.emptyRowsNote = stripQuotes(raw);
4118
+ break;
4119
+ case "titleAlign": {
4120
+ const value = stripQuotes(raw);
4121
+ if (value === "center") out.titleAlign = value;
4122
+ else errors.push({ line: ln.no, message: `箱の titleAlign が読めません: "${value}"`, hint: `使える値 = ${TITLE_ALIGN_VALUES.join(", ")}` });
4123
+ break;
4124
+ }
4125
+ case "rowRules": {
4126
+ const read = 表で読む(ACTOR_BLOCK_VALUE_KINDS, { rowRules: raw }, "箱の ", ln.no, errors);
4127
+ out.rowRules = read.rowRules;
4128
+ break;
4129
+ }
4130
+ case "rowStripe": {
4131
+ const read = 表で読む(ACTOR_BLOCK_VALUE_KINDS, { rowStripe: raw }, "箱の ", ln.no, errors);
4132
+ out.rowStripe = read.rowStripe;
4133
+ break;
4134
+ }
4135
+ case "rowLayout":
4136
+ out.rowLayout = 行の配置として読む(raw, ln.no, errors);
4137
+ break;
4138
+ case "rowDividerForm": {
4139
+ const value = stripQuotes(raw);
4140
+ if (value === "full") out.rowDividerForm = value;
4141
+ else errors.push({ line: ln.no, message: `箱の rowDividerForm が読めません: "${value}"`, hint: `使える値 = ${ROW_DIVIDER_FORM_VALUES.join(", ")}` });
4142
+ break;
4143
+ }
4144
+ case "rowWell":
4145
+ out.rowWell = 行の受け皿として読む(raw, ln.no, errors);
4146
+ break;
4147
+ case "rowMarkForm": {
4148
+ const value = stripQuotes(raw);
4149
+ if (value === "tone") out.rowMarkForm = value;
4150
+ else errors.push({ line: ln.no, message: `箱の rowMarkForm が読めません: "${value}"`, hint: `使える値 = ${ROW_MARK_FORM_VALUES.join(", ")}` });
4151
+ break;
4152
+ }
4153
+ case "nestIn":
4154
+ out.nestIn = stripQuotes(raw);
4155
+ break;
4156
+ case "figureCard": {
4157
+ const value = JSONの欄を読む(raw, "figureCard", ln.no, errors);
4158
+ const unknown = 図表の知らない項目を知らせる(value, ["label", "note"] as const, "figureCard", ln.no, errors);
4159
+ if (!unknown && objectか(value) && typeof value.label === "string" && (value.note === undefined || typeof value.note === "string")) out.figureCard = value as NonNullable<DslActor["figureCard"]>;
4160
+ else if (!unknown && value !== undefined) errors.push({ line: ln.no, message: "figureCard の値の形が読めません" });
4161
+ break;
4162
+ }
4163
+ case "stationNamePosition":
4164
+ if (stripQuotes(raw) === "bottom") out.stationNamePosition = "bottom";
4165
+ break;
4166
+ case "subtitlePlacement":
4167
+ if (stripQuotes(raw) === "right") out.subtitlePlacement = "right";
4168
+ break;
4169
+ case "titleFontSize":
4170
+ case "markGap": {
4171
+ const read = 表で読む(ACTOR_BLOCK_VALUE_KINDS, { [key]: raw }, "箱の ", ln.no, errors);
4172
+ if (key === "titleFontSize") out.titleFontSize = read.titleFontSize;
4173
+ else out.markGap = read.markGap;
4174
+ break;
4175
+ }
4176
+ case "milestone": {
4177
+ const read = 表で読む(ACTOR_BLOCK_VALUE_KINDS, { milestone: raw }, "箱の ", ln.no, errors);
4178
+ out.milestone = read.milestone;
4179
+ break;
4180
+ }
4181
+ case "startLabel":
4182
+ out.startLabel = stripQuotes(raw);
4183
+ break;
4184
+ case "emphasis": {
4185
+ const value = stripQuotes(raw);
4186
+ if (value === "primary") out.emphasis = value;
4187
+ else errors.push({ line: ln.no, message: `箱の emphasis が読めません: "${value}"`, hint: "使える値 = primary" });
4188
+ break;
4189
+ }
4190
+ /*
4191
+ * 箱の目次と、始まりと終わりの印 (#2346)。
4192
+ *
4193
+ * 中括弧の形にしか無く、段を分けて書くと綴りの誤りとして落ちていた。
4194
+ * 中括弧と同じく **見本 (parts) でも横取りしない** = この 3 つは
4195
+ * `ACTOR_RESERVED_FIELDS` が状態の上書きから外しているので、どちらの形で書いても
4196
+ * 見本の状態にはならない。
4197
+ */
4198
+ case "eyebrow":
4199
+ out.eyebrow = stripQuotes(raw);
4200
+ break;
4201
+ case "initial":
4202
+ case "final": {
4203
+ // 中括弧と同じ表で読む。 読めない値の知らせも同じ文になる
4204
+ const 読めた = 表で読む(ACTOR_BLOCK_VALUE_KINDS, { [key]: raw }, "箱の ", ln.no, errors);
4205
+ if (key === "initial") out.initial = 読めた.initial;
4206
+ else out.final = 読めた.final;
4207
+ break;
4208
+ }
2804
4209
  case "value":
2805
4210
  case "値":
2806
4211
  out.value = stripQuotes(raw);
@@ -2848,20 +4253,20 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2848
4253
  .filter(Boolean);
2849
4254
  break;
2850
4255
  /*
2851
- * 行頭の印 (#1466)。 `rows` と同じ並びで、空文字はその行に印を付けない。
4256
+ * 行頭の印 (#1466)。 `rows` と同じ並びで書く。
4257
+ *
4258
+ * **語は dragon の 3 つ** (`鍵` / `外` / `条件`)。 印の 3 軸 (下線 / 形 / 塗り) に
4259
+ * 1 対 1 で当たる (`compile/row-marks.ts` が表を持つ)。
2852
4260
  *
2853
- * **語の意味は図の種類が決める**。 ER は `pk` / `fk` / `opt`、状態遷移は
2854
- * `entry` / `exit` / `do` / `internal`。 印の 2 軸 (形 × 塗り) は共通だが、その軸が
2855
- * 何を指すかは種類ごとに違う = 1 つの語彙に畳むと、どの図でも意味が合わない語が残る。
4261
+ * 古い語 (`pk` / `fk` / `opt` / `entry` / `exit` / `do` / `internal`) はここで
4262
+ * 3 語へ読み替える (#2782)。 **読み替えは読み取りの入口 1 か所に閉じる** =
4263
+ * ここを通った後は古い語がどこにも残らないので、組み立て側は 3 語だけを扱う。
2856
4264
  *
2857
4265
  * 空の要素を捨てない (`filter(Boolean)` を掛けない) = 並びが `rows` とずれる。
2858
4266
  */
2859
4267
  case "marks":
2860
4268
  case "印":
2861
- out.marks = raw
2862
- .replace(/^\[|\]$/g, "")
2863
- .split(/,(?![^[]*\])/)
2864
- .map((x) => stripQuotes(x.trim()));
4269
+ out.marks = 行頭の印の並びとして読む(raw, ln.no, errors);
2865
4270
  break;
2866
4271
  case "位置":
2867
4272
  case "pos": {
@@ -2909,6 +4314,11 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2909
4314
  case "posY":
2910
4315
  out.posY = 数として読む(raw, "箱の posY", ln.no, errors);
2911
4316
  break;
4317
+ case "at":
4318
+ case "点の位置":
4319
+ out.at = 四象限の座標として控える(raw);
4320
+ out.atPos = { line: ln.no };
4321
+ break;
2912
4322
  // 位置のずらし (#1971)。 中括弧の形と同じ表で読み、片方だけ書いた時は残りを 0 とする
2913
4323
  case "offsetX":
2914
4324
  case "offsetY": {
@@ -2941,12 +4351,15 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2941
4351
  }
2942
4352
  case "touchpoint":
2943
4353
  case "opportunity":
4354
+ case "opportunityPosition":
2944
4355
  case "owner":
2945
4356
  case "end":
4357
+ case "stage":
4358
+ case "段階":
2946
4359
  // **どちらに入れるかは block を読み終わるまで決まらない** (#1251 Round 1 の指摘)。
2947
4360
  // パーツかどうかは `kind:` の行で決まり、それが後ろに書かれることもある。
2948
4361
  // 倍率 (`scaleWritten`) と読めない項目名 (`unknownKeys`) が同じ理由で後回しにしている
2949
- 図種ごとの欄.set(key, stripQuotes(raw));
4362
+ 図種ごとの欄.set(key === "段階" ? "stage" : key, stripQuotes(raw));
2950
4363
  break;
2951
4364
  case "lane":
2952
4365
  out.lane = stripQuotes(raw);
@@ -2969,7 +4382,7 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2969
4382
  out.scale = s.scale;
2970
4383
  out.scaleKeys = [...new Set([...(actor.scaleKeys ?? []), ...s.keys])];
2971
4384
  }
2972
- // 体験の道筋の欄は、パーツなら状態の上書き、そうでなければ道筋の欄として入れる
4385
+ // ユーザージャーニーの欄は、パーツなら状態の上書き、そうでなければ道筋の欄として入れる
2973
4386
  for (const [key, v] of 図種ごとの欄) {
2974
4387
  if (out.partId !== undefined) {
2975
4388
  state[key] = coerceStateValue(v);
@@ -2978,8 +4391,20 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2978
4391
  }
2979
4392
  if (key === "touchpoint") out.touchpoint = v;
2980
4393
  else if (key === "opportunity") out.opportunity = v;
4394
+ else if (key === "opportunityPosition") {
4395
+ if ((JOURNEY_OPPORTUNITY_POSITION_VALUES as readonly string[]).includes(v)) {
4396
+ out.opportunityPosition = v as DslActor["opportunityPosition"];
4397
+ } else {
4398
+ errors.push({
4399
+ line: actor.pos.line,
4400
+ message: `opportunityPosition が読めません: "${v}"`,
4401
+ hint: `使える値 = ${JOURNEY_OPPORTUNITY_POSITION_VALUES.join(", ")}`,
4402
+ });
4403
+ }
4404
+ }
2981
4405
  else if (key === "owner") out.owner = v;
2982
- else out.end = v;
4406
+ else if (key === "end") out.end = v;
4407
+ else out.stage = v;
2983
4408
  }
2984
4409
  // 状態も倍率も parts でだけ意味を持つ。 パーツなら知らせずに返す
2985
4410
  if (out.partId !== undefined) {
@@ -3019,7 +4444,7 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
3019
4444
  errors.push({
3020
4445
  line: u.line,
3021
4446
  message: `項目名が読めません: "${u.key}"`,
3022
- hint: `使える項目 = ${[...ACTOR_ITEM_KEYS].join(", ")}`,
4447
+ hint: 弾いた項目の案内(u.key),
3023
4448
  });
3024
4449
  }
3025
4450
  return out;
@@ -3035,6 +4460,22 @@ export const ACTOR_ITEM_KEYS: ReadonlySet<string> = new Set([
3035
4460
  "kind",
3036
4461
  "種類",
3037
4462
  "subtitle",
4463
+ "kindForm",
4464
+ "icon",
4465
+ "emptyRowsNote",
4466
+ "titleAlign",
4467
+ "rowRules",
4468
+ "rowStripe",
4469
+ "rowLayout",
4470
+ "rowDividerForm",
4471
+ "rowWell",
4472
+ "rowMarkForm",
4473
+ "nestIn",
4474
+ "figureCard",
4475
+ "stationNamePosition",
4476
+ "subtitlePlacement",
4477
+ "titleFontSize",
4478
+ "markGap",
3038
4479
  "補足",
3039
4480
  "value",
3040
4481
  "値",
@@ -3074,14 +4515,61 @@ export const ACTOR_ITEM_KEYS: ReadonlySet<string> = new Set([
3074
4515
  "scale",
3075
4516
  "lane",
3076
4517
  "stack",
3077
- // 体験の道筋の欄 (#1251)
4518
+ "stage",
4519
+ "段階",
4520
+ // ユーザージャーニーの欄 (#1251)
3078
4521
  "touchpoint",
3079
4522
  "opportunity",
4523
+ "opportunityPosition",
3080
4524
  // 工程の並びの欄 (#1251)
3081
4525
  "owner",
3082
4526
  "end",
4527
+ "startLabel",
4528
+ "milestone",
4529
+ "emphasis",
4530
+ "at",
4531
+ "点の位置",
4532
+ // 箱の目次と、始まりと終わりの印 (#2346)。 中括弧にしか書き方が無かった 3 件
4533
+ "eyebrow",
4534
+ "initial",
4535
+ "final",
3083
4536
  ]);
3084
4537
 
4538
+ /**
4539
+ * 部品の箱にだけ効く項目 (#1026)。
4540
+ *
4541
+ * 普通の箱に書いた時は綴りの誤りとして知らせる。 黙って捨てると
4542
+ * 「書いたのに大きさが変わらない」 が手掛かりなしで起きるため。
4543
+ */
4544
+ export const PARTS_ONLY_ITEM_KEYS: ReadonlySet<string> = new Set(["倍率", "scale"]);
4545
+
4546
+ /**
4547
+ * 項目名を弾いた時の案内 (#2330)。
4548
+ *
4549
+ * **いま弾いた名前を「使える項目」 として並べない**。 語彙には `倍率` と `scale` が
4550
+ * 入っているので、そのまま並べると読み手は同じ名前を書き直して同じ知らせを受ける
4551
+ * (実測 = 語彙のうち普通の箱で弾かれるのはこの 2 件だけ)。
4552
+ *
4553
+ * 部品にだけ効く項目は **どこでなら書けるか** を出す。 綴りの誤りは語彙を並べる =
4554
+ * そちらは弾いた名前が語彙に無いので食い違わない。
4555
+ */
4556
+ function 弾いた項目の案内(key: string): string {
4557
+ if (PARTS_ONLY_ITEM_KEYS.has(key)) {
4558
+ return "`倍率` と `scale` は部品の箱 (`kind` に部品の名前を書いた箱) にだけ効きます";
4559
+ }
4560
+ /*
4561
+ * 1 行の中括弧でだけ読める名前 (#2346)。
4562
+ *
4563
+ * 語彙を並べるだけだと、**同じ名前が別の書き方で効くこと**が伝わらない =
4564
+ * 読み手は綴りを誤ったと受け取り、正しい名前を書いたまま直しようがなくなる。
4565
+ * #2344 で足した逆向きの案内 (中括弧で弾いた名前に段を分けた形を案内する) と対にする。
4566
+ */
4567
+ if (INLINE_ACTOR_KEYS.has(key)) {
4568
+ return `段を分けた形では読めません。 \`- 名前: { ${key}: 値 }\` のように 1 行の中括弧に書くと効きます`;
4569
+ }
4570
+ return `使える項目 = ${[...ACTOR_ITEM_KEYS].filter((k) => k !== key).join(", ")}`;
4571
+ }
4572
+
3085
4573
  /**
3086
4574
  * 相対で書かれた位置が解けるかを確かめる。
3087
4575
  *
@@ -3159,14 +4647,16 @@ function collectActorEntries(
3159
4647
  lines: Line[],
3160
4648
  start: number,
3161
4649
  parentIndent: number,
3162
- ): { items: Line[][]; next: number } {
4650
+ ): { items: Line[][]; 届かない: Line[]; next: number } {
3163
4651
  const items: Line[][] = [];
4652
+ /** 1 件の頭にも続きにもならなかった行 (#2400)。 呼出側が知らせにする */
4653
+ const 届かない: Line[] = [];
3164
4654
  let cur: Line[] | null = null;
3165
4655
  let headIndent = -1;
3166
4656
  let i = start;
3167
4657
  while (i < lines.length) {
3168
4658
  const ln = lines[i]!;
3169
- if (!ln.trimmed) {
4659
+ if (!ln.trimmed || ln.trimmed.startsWith("#")) {
3170
4660
  i += 1;
3171
4661
  continue;
3172
4662
  }
@@ -3178,25 +4668,34 @@ function collectActorEntries(
3178
4668
  } else if (cur && ln.indent > headIndent) {
3179
4669
  // 頭より深い字下げは、 直前の 1 件の続き
3180
4670
  cur.push(ln);
4671
+ } else {
4672
+ // 続きにも頭にもならない行を **捨てない** (#2400)。 呼出側が「読めません」 として知らせる。
4673
+ // 捨てると `- ` を書き落とした 1 行が知らせにも図にも現れない (実測 = 箱が 1 つ消えた)
4674
+ //
4675
+ // **ここで 1 件の頭にはしない**。 `- Web:` と同じ深さに書いた `補足: "..."` が
4676
+ // 登場人物 `補足` になり、書き間違いが別の意味に化ける (#1367 の検査が守っている形)
4677
+ 届かない.push(ln);
3181
4678
  }
3182
4679
  i += 1;
3183
4680
  }
3184
4681
  if (cur) items.push(cur);
3185
- return { items, next: i };
4682
+ return { items, 届かない, next: i };
3186
4683
  }
3187
4684
 
3188
4685
  function collectAnimationSteps(
3189
4686
  lines: Line[],
3190
4687
  start: number,
3191
4688
  parentIndent: number,
3192
- ): { items: Line[][]; next: number } {
4689
+ ): { items: Line[][]; 届かない: Line[]; next: number } {
3193
4690
  // 各 `- step: "..."` 開始を 1 block の頭として識別、 後続の同 indent 以下を block 本文として吸収
3194
4691
  const out: Line[][] = [];
4692
+ /** どの段にも属さなかった行 (#2400)。 呼出側が知らせにする */
4693
+ const 届かない: Line[] = [];
3195
4694
  let i = start;
3196
4695
  let cur: Line[] | null = null;
3197
4696
  while (i < lines.length) {
3198
4697
  const ln = lines[i]!;
3199
- if (!ln.trimmed) {
4698
+ if (!ln.trimmed || ln.trimmed.startsWith("#")) {
3200
4699
  i += 1;
3201
4700
  continue;
3202
4701
  }
@@ -3210,11 +4709,15 @@ function collectAnimationSteps(
3210
4709
  } else if (cur) {
3211
4710
  // 続く property line (focus / tween / set / badge / description)
3212
4711
  cur.push(ln);
4712
+ } else {
4713
+ // 最初の段が始まる前の行を **捨てない** (#2400)。 呼出側が「読めません」 として知らせる。
4714
+ // 捨てると `- ` を書き落とした段が丸ごと黙って消える
4715
+ 届かない.push(ln);
3213
4716
  }
3214
4717
  i += 1;
3215
4718
  }
3216
4719
  if (cur) out.push(cur);
3217
- return { items: out, next: i };
4720
+ return { items: out, 届かない, next: i };
3218
4721
  }
3219
4722
 
3220
4723
  /**
@@ -3226,6 +4729,18 @@ function collectAnimationSteps(
3226
4729
  export const ACTOR_RESERVED_FIELDS: ReadonlySet<string> = new Set([
3227
4730
  "kind",
3228
4731
  "subtitle",
4732
+ "kindForm",
4733
+ "icon",
4734
+ "emptyRowsNote",
4735
+ "titleAlign",
4736
+ "rowRules",
4737
+ "rowStripe",
4738
+ "rowLayout",
4739
+ "rowDividerForm",
4740
+ "rowWell",
4741
+ "rowMarkForm",
4742
+ "nestIn",
4743
+ "figureCard",
3229
4744
  "eyebrow",
3230
4745
  "value",
3231
4746
  "previous",
@@ -3233,10 +4748,15 @@ export const ACTOR_RESERVED_FIELDS: ReadonlySet<string> = new Set([
3233
4748
  "marks",
3234
4749
  "lane",
3235
4750
  "stack",
4751
+ "stage",
3236
4752
  "initial",
3237
4753
  "final",
4754
+ "startLabel",
4755
+ "milestone",
4756
+ "emphasis",
4757
+ "at",
3238
4758
  "state",
3239
- // 体験の道筋の欄 (`touchpoint` / `opportunity`) はここに載せない (#1251 Round 1 の指摘)。
4759
+ // ユーザージャーニーの欄 (`touchpoint` / `opportunity`) はここに載せない (#1251 Round 1 の指摘)。
3240
4760
  // 載せるとパーツで同じ名前の状態を書いた時に横取りされる = 既に動いている見本が静かに変わる。
3241
4761
  // パーツでない箱でだけ道筋の欄として読む (`parseActor` / `applyContinuationLines` が分岐する)
3242
4762
  // canvas pivot 新 spec = 絶対座標 4 field (dragon canvas pivot spec §layout-role-conversion)
@@ -3298,12 +4818,33 @@ function coerceStateValue(raw: string): number | string | boolean {
3298
4818
  return stripped;
3299
4819
  }
3300
4820
 
4821
+ /** 四象限の座標を `[x, y]` の順で控える。 妥当性は CompileNotice を出せる組み立て側で見る。 */
4822
+ function 四象限の座標として控える(raw: string): DslActor["at"] {
4823
+ const value = raw.trim();
4824
+ const inner = value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
4825
+ const parts = splitTopLevelCommas(inner).map((part) => stripQuotes(part.trim()));
4826
+ const 読む = (part: string | undefined): number | string | undefined => {
4827
+ if (part === undefined || part === "") return undefined;
4828
+ const n = Number(part);
4829
+ return Number.isFinite(n) ? n : part;
4830
+ };
4831
+ return { x: 読む(parts[0]), y: 読む(parts[1]), raw: value };
4832
+ }
4833
+
3301
4834
  /**
3302
4835
  * パーツでない箱に倍率を書いた時に知らせる (#1026)。
3303
4836
  *
3304
4837
  * 倍率はパーツにしか効かない。 黙って捨てると「書いたのに大きさが変わらない」 が手掛かり
3305
4838
  * なしで起きる。 3 つの書き方すべてで同じ知らせを出す (縦に並べた形だけ知らせて他が黙る、
3306
4839
  * という状態を作らない)。
4840
+ *
4841
+ * **案内に語彙を並べない** (#2330)。 語彙には `倍率` と `scale` が入っているので、
4842
+ * 並べると **いま弾いた名前を「使える項目」 として出す** ことになる。 読み手は同じ名前を
4843
+ * 書き直して同じ知らせを受ける (実測 = 案内の 39 項目のうち、弾かれるのはこの 2 件だけ)。
4844
+ *
4845
+ * 弾く理由はここが知っている (`isPart === false`) ので、**どこでなら書けるか** を出す。
4846
+ * 綴りの誤りを知らせる側 (`unknownKeys`) は語彙を並べたままにする = そちらは弾いた名前が
4847
+ * 語彙に無いため食い違わない。
3307
4848
  */
3308
4849
  function reportScaleOnNonPart(
3309
4850
  isPart: boolean,
@@ -3314,54 +4855,40 @@ function reportScaleOnNonPart(
3314
4855
  if (isPart || key === undefined) return;
3315
4856
  errors.push({
3316
4857
  line,
3317
- message: `項目名が読めません: "${key}"`,
3318
- hint: `使える項目 = ${[...ACTOR_ITEM_KEYS].join(", ")}`,
3319
- });
3320
- }
3321
-
3322
- /**
3323
- * 中括弧の形で読める日本語と、その英語名 (#1301)。
3324
- *
3325
- * **英語が中括弧で読める欄だけを載せる**。 `位置` / `大きさ` / `色` は英語側
3326
- * (`pos` / `size` / `color`) も中括弧では読めないため載せない = 英語で出来ないことを
3327
- * 日本語で出来るようにはしない。
3328
- *
3329
- * 載せる前は、同じ意味の語が縦書きでは通り中括弧では「項目名が読めません」 になっていた。
3330
- * 書き方によって日本語だけが落ちる状態を無くす。
3331
- */
3332
- export const INLINE_ACTOR_ALIASES: Record<string, string> = {
3333
- 種類: "kind",
3334
- 補足: "subtitle",
3335
- 値: "value",
3336
- 前の値: "previous",
3337
- 行: "rows",
3338
- 印: "marks",
3339
- };
3340
-
3341
- /** 中括弧に書かれた日本語の項目名を、同じ意味の英語名に寄せる (#1301) */
3342
- function 中括弧の別名を寄せる(opts: Record<string, string>): Record<string, string> {
3343
- let 触った = false;
3344
- const out: Record<string, string> = { ...opts };
3345
- for (const [日, 英] of Object.entries(INLINE_ACTOR_ALIASES)) {
3346
- if (!(日 in out)) continue;
3347
- 触った = true;
3348
- // 英語を併記した時は英語を優先する (縦書き形が後勝ちなのと違い、こちらは 1 行に同居する)
3349
- if (!(英 in out)) out[英] = out[日]!;
3350
- delete out[日];
3351
- }
3352
- return 触った ? out : opts;
4858
+ message: `項目名が読めません: "${key}"`,
4859
+ hint: 弾いた項目の案内(key),
4860
+ });
3353
4861
  }
3354
4862
 
3355
4863
  /**
3356
- * 中括弧の形で読める項目名。
4864
+ * 中括弧の形で読める英語の項目名 (#2344 で `INLINE_ACTOR_KEYS` から分けた)。
3357
4865
  *
3358
4866
  * ここに無い名前は、 パーツでない箱ではどこにも入らずに消える。 `ACTOR_ITEM_KEYS` (縦に
3359
4867
  * 並べた形) とは別に持つ = 中括弧の形は位置や大きさ (`pos` / `size` / `scale`) を未対応にしてあり、
3360
4868
  * 同じ集合にすると「知らせない」 側がずれる。
4869
+ *
4870
+ * **日本語の別名を混ぜない**。 別名は `ACTOR_ITEM_ALIASES` をこの集合で絞って導くため、
4871
+ * 混ぜると導く側と導かれる側が輪になる。 日本語も含む一覧は `INLINE_ACTOR_KEYS` が持つ。
3361
4872
  */
3362
- export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
4873
+ const INLINE_ACTOR_ENGLISH_KEYS: ReadonlySet<string> = new Set([
3363
4874
  "kind",
3364
4875
  "subtitle",
4876
+ "kindForm",
4877
+ "icon",
4878
+ "emptyRowsNote",
4879
+ "titleAlign",
4880
+ "rowRules",
4881
+ "rowStripe",
4882
+ "rowLayout",
4883
+ "rowDividerForm",
4884
+ "rowWell",
4885
+ "rowMarkForm",
4886
+ "nestIn",
4887
+ "figureCard",
4888
+ "stationNamePosition",
4889
+ "subtitlePlacement",
4890
+ "titleFontSize",
4891
+ "markGap",
3365
4892
  "eyebrow",
3366
4893
  "value",
3367
4894
  "previous",
@@ -3369,18 +4896,24 @@ export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
3369
4896
  "marks",
3370
4897
  "lane",
3371
4898
  "stack",
4899
+ "stage",
3372
4900
  "initial",
3373
4901
  "final",
3374
4902
  "tone",
3375
4903
  // 色の欄 (#1969)。 縦に並べた形と JSON は `color` を受けるのに、中括弧の形だけが知らない
3376
4904
  // 項目名として落としていた。 見本 (parts) では従来どおり状態の上書きとして読む
3377
4905
  "color",
3378
- // 体験の道筋の欄 (#1251)。 他の図種では組み立て側が知らせる
4906
+ // ユーザージャーニーの欄 (#1251)。 他の図種では組み立て側が知らせる
3379
4907
  "touchpoint",
3380
4908
  "opportunity",
4909
+ "opportunityPosition",
3381
4910
  // 工程の並びの欄 (#1251)
3382
4911
  "owner",
3383
4912
  "end",
4913
+ "startLabel",
4914
+ "milestone",
4915
+ "emphasis",
4916
+ "at",
3384
4917
  "posX",
3385
4918
  "posY",
3386
4919
  "posW",
@@ -3404,7 +4937,72 @@ export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
3404
4937
  // 同じ名前で 2 度知らせることになる
3405
4938
  "scale",
3406
4939
  "倍率",
3407
- // 英語が読める欄の日本語別名 (#1301)。 一覧は `INLINE_ACTOR_ALIASES` が持つ
4940
+ ]);
4941
+
4942
+ /**
4943
+ * 箱の項目名の、日本語と英語の対応 (#1301 / #2344)。
4944
+ *
4945
+ * **段を分けた形が読む日本語名をすべて載せる**。 中括弧で読めるかはここでは決めず、
4946
+ * `INLINE_ACTOR_ALIASES` が英語側の可否で絞る。
4947
+ *
4948
+ * 載せる場所を 1 つにしたのは、**中括弧の別名を別に手で並べていた間、英語の欄を
4949
+ * 中括弧に足した日に日本語名だけが取り残された**ため (`title` は #1381、`tone` は #1969、
4950
+ * `shape` は #1374 で足り、日本語名は 3 件とも置き去りになっていた)。
4951
+ *
4952
+ * `倍率` は箱の欄を持たない (部品にだけ効く、#1026)。 ここには載せず、
4953
+ * `PARTS_ONLY_ITEM_KEYS` と `reportScaleOnNonPart` が受け持つ。
4954
+ */
4955
+ export const ACTOR_ITEM_ALIASES: Record<string, string> = {
4956
+ 種類: "kind",
4957
+ 題: "title",
4958
+ 補足: "subtitle",
4959
+ 色: "tone",
4960
+ 行: "rows",
4961
+ 印: "marks",
4962
+ 大きさ: "size",
4963
+ 図形: "shape",
4964
+ 値: "value",
4965
+ 前の値: "previous",
4966
+ 位置: "pos",
4967
+ 出す条件: "visibleIf",
4968
+ 段階: "stage",
4969
+ 点の位置: "at",
4970
+ };
4971
+
4972
+ /**
4973
+ * 中括弧の形で読める日本語と、その英語名 (#1301 / #2344)。
4974
+ *
4975
+ * **英語が中括弧で読める欄だけに絞る** = 英語で出来ないことを日本語で出来るようにはしない。
4976
+ * `位置` (`pos`) と `大きさ` (`size`) はここで落ちる。
4977
+ *
4978
+ * **絞り込みは実装から導く**。 手で並べていた間、`題` / `色` / `図形` / `出す条件` の 4 件が
4979
+ * 英語側は中括弧で読めるのに日本語だけ落ちていた (実測)。
4980
+ */
4981
+ export const INLINE_ACTOR_ALIASES: Record<string, string> = Object.fromEntries(
4982
+ Object.entries(ACTOR_ITEM_ALIASES).filter(([, 英]) => INLINE_ACTOR_ENGLISH_KEYS.has(英)),
4983
+ );
4984
+
4985
+ /** 中括弧に書かれた日本語の項目名を、同じ意味の英語名に寄せる (#1301) */
4986
+ function 中括弧の別名を寄せる(opts: Record<string, string>): Record<string, string> {
4987
+ let 触った = false;
4988
+ const out: Record<string, string> = { ...opts };
4989
+ for (const [日, 英] of Object.entries(INLINE_ACTOR_ALIASES)) {
4990
+ if (!(日 in out)) continue;
4991
+ 触った = true;
4992
+ // 英語を併記した時は英語を優先する (縦書き形が後勝ちなのと違い、こちらは 1 行に同居する)
4993
+ if (!(英 in out)) out[英] = out[日]!;
4994
+ delete out[日];
4995
+ }
4996
+ return 触った ? out : opts;
4997
+ }
4998
+
4999
+ /**
5000
+ * 中括弧の形で読める項目名 (英語と、英語が読める欄の日本語別名)。
5001
+ *
5002
+ * 別名は `INLINE_ACTOR_ALIASES` が `ACTOR_ITEM_ALIASES` から導く (#1301 / #2344)。
5003
+ */
5004
+ export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
5005
+ ...INLINE_ACTOR_ENGLISH_KEYS,
3408
5006
  ...Object.keys(INLINE_ACTOR_ALIASES),
3409
5007
  ]);
3410
5008
  // `state` はパーツでだけ意味を持つ (`extractStateOverride` がパーツの時しか作らない)。
@@ -3447,7 +5045,7 @@ const EDGE_BIND_INLINE_READERS = {
3447
5045
 
3448
5046
  const FLOW_INLINE_READERS = {
3449
5047
  sub: (v: string | undefined) => v,
3450
- // クラス図の出どころ側の多重度 (#1771)。 `sub` (行き先の端) と対
5048
+ // UML の関係の出どころ側の多重度 (#1771 / #2783)。 `sub` (行き先の端) と対
3451
5049
  tailSub: (v: string | undefined) => v,
3452
5050
  guard: (v: string | undefined) => v,
3453
5051
  cardinality: (v: string | undefined) => v,
@@ -3468,11 +5066,24 @@ const FLOW_INLINE_READERS = {
3468
5066
  */
3469
5067
  labelPlate: (v: string | undefined) =>
3470
5068
  v === undefined ? undefined : v !== "false" && v !== "なし",
5069
+ labelCollision: (v: string | undefined) =>
5070
+ v !== undefined && (LABEL_COLLISION_VALUES as readonly string[]).includes(v)
5071
+ ? (v as "auto" | "author")
5072
+ : undefined,
5073
+ labelSize: (v: string | undefined) => v,
3471
5074
  // 矢印がどの辺から出るか (#1385)。 描画側は 4 方向を取り、書かなければ自動で選ぶ
3472
5075
  side: (v: string | undefined) =>
3473
5076
  v !== undefined && (EDGE_SIDE_VALUES as readonly string[]).includes(v)
3474
5077
  ? (v as "top" | "right" | "bottom" | "left")
3475
5078
  : undefined,
5079
+ fromSide: (v: string | undefined) =>
5080
+ v !== undefined && (EDGE_SIDE_VALUES as readonly string[]).includes(v)
5081
+ ? (v as "top" | "right" | "bottom" | "left")
5082
+ : undefined,
5083
+ toSide: (v: string | undefined) =>
5084
+ v !== undefined && (EDGE_SIDE_VALUES as readonly string[]).includes(v)
5085
+ ? (v as "top" | "right" | "bottom" | "left")
5086
+ : undefined,
3476
5087
  // 矢印の先の形 (#1462)。 書かなければ描画側の既定 (塗った三角) になる
3477
5088
  head: (v: string | undefined) =>
3478
5089
  v !== undefined && EDGE_HEAD_VALUES.includes(v) ? (v as EdgeHead) : undefined,
@@ -3485,7 +5096,7 @@ const FLOW_INLINE_READERS = {
3485
5096
  tailHeadFill: (v: string | undefined) =>
3486
5097
  v !== undefined && EDGE_HEAD_FILL_VALUES.includes(v) ? (v as EdgeHeadFill) : undefined,
3487
5098
  /*
3488
- * クラス図の関係の種類 (#1466)。 書くと **線と端の形と塗りと付く側** がまとめて決まる
5099
+ * record の UML 関係の種類 (#1466 / #2783)。書くと **線と端の形と塗りと付く側** がまとめて決まる
3489
5100
  * (`CLASS_RELATION_LOOK`)。
3490
5101
  *
3491
5102
  * 4 つを個別に書かせない = 組合せは 6 通りしか無く、1 つでも書き違えると読み手に別の意味で
@@ -3513,10 +5124,15 @@ const FLOW_INLINE_READERS = {
3513
5124
  */
3514
5125
  fromPartNode: (v: string | undefined) => v,
3515
5126
  toPartNode: (v: string | undefined) => v,
5127
+ waypoints: (v: string | undefined) => v,
3516
5128
  // 数と真偽の欄は `FLOW_INLINE_VALUE_KINDS` の表が読む (#1306)。 ここでは名前だけを持つ =
3517
5129
  // 読める欄の一覧 (`FLOW_INLINE_KEYS`) は本表から導くため、載せないと欄ごと消える
3518
5130
  labelOffsetX: null,
3519
5131
  labelOffsetY: null,
5132
+ posX1: null,
5133
+ posY1: null,
5134
+ posX2: null,
5135
+ posY2: null,
3520
5136
  overlay: null,
3521
5137
  } as const;
3522
5138
 
@@ -3553,7 +5169,7 @@ function reportUnknownInlineKeys(
3553
5169
  for (const field of splitInlineFields(inner)) {
3554
5170
  const idx = field.indexOf(":");
3555
5171
  if (idx < 0) continue;
3556
- const key = field.slice(0, idx).trim();
5172
+ const key = stripQuotes(field.slice(0, idx).trim());
3557
5173
  if (!key) continue;
3558
5174
  if (isPart) {
3559
5175
  if (!PART_INLINE_UNREADABLE_KEYS.has(key)) continue;
@@ -3567,10 +5183,23 @@ function reportUnknownInlineKeys(
3567
5183
  continue;
3568
5184
  }
3569
5185
  if (INLINE_ACTOR_KEYS.has(key)) continue;
5186
+ /*
5187
+ * **段を分けて書けば効く名前には、その行き先を添える** (#2344)。
5188
+ *
5189
+ * `位置` / `大きさ` は段を分けた形では読める (英語の `pos` / `size` も同じ)。
5190
+ * 使える項目の一覧だけを返すと、書いた人はその名前を諦めて別の書き方を探すことになる。
5191
+ * 部品の箱は同じ状況を #1996 で既にこの文言で知らせており、こちらだけが外れていた。
5192
+ *
5193
+ * 判定は段を分けた形が読む一覧 (`ACTOR_ITEM_KEYS`) で行う = 日本語と英語を分けない。
5194
+ * `size` を中括弧に書いた人も、同じ行き先を知りたい。
5195
+ */
5196
+ const 段を分ければ効く = ACTOR_ITEM_KEYS.has(key);
3570
5197
  errors.push({
3571
5198
  line,
3572
5199
  message: `項目名が読めません: "${key}"`,
3573
- hint: `使える項目 = ${[...INLINE_ACTOR_KEYS].join(", ")}`,
5200
+ hint: 段を分ければ効く
5201
+ ? `1 行の中括弧では読めません。名前の下に段を分けた形で書くと効きます`
5202
+ : `使える項目 = ${[...INLINE_ACTOR_KEYS].join(", ")}`,
3574
5203
  });
3575
5204
  }
3576
5205
  }
@@ -3598,7 +5227,7 @@ function 中括弧の知らない項目名を知らせる(
3598
5227
  for (const field of splitInlineFields(inner)) {
3599
5228
  const idx = field.indexOf(":");
3600
5229
  if (idx < 0) continue;
3601
- const key = field.slice(0, idx).trim();
5230
+ const key = stripQuotes(field.slice(0, idx).trim());
3602
5231
  if (!key || 使える.includes(key)) continue;
3603
5232
  // 接頭は値の欄名に続ける形 (`縦列 l1 の `) で末尾に空白を持つ。 日本語へ続ける時は空白を落とす
3604
5233
  errors.push({
@@ -3642,6 +5271,40 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
3642
5271
  if (!isPart && opts.tone !== undefined && resolveTone(opts.tone) === undefined) {
3643
5272
  report読めない色(opts.tone, line.no, errors, { 線種も受ける: false });
3644
5273
  }
5274
+ if (!isPart && opts.emphasis !== undefined && opts.emphasis !== "primary") {
5275
+ errors.push({
5276
+ line: line.no,
5277
+ message: `箱の emphasis が読めません: "${opts.emphasis}"`,
5278
+ hint: "使える値 = primary",
5279
+ });
5280
+ }
5281
+ if (
5282
+ !isPart && opts.opportunityPosition !== undefined &&
5283
+ !(JOURNEY_OPPORTUNITY_POSITION_VALUES as readonly string[]).includes(opts.opportunityPosition)
5284
+ ) {
5285
+ errors.push({
5286
+ line: line.no,
5287
+ message: `opportunityPosition が読めません: "${opts.opportunityPosition}"`,
5288
+ hint: `使える値 = ${JOURNEY_OPPORTUNITY_POSITION_VALUES.join(", ")}`,
5289
+ });
5290
+ }
5291
+ const 閉じた箱の値 = <T extends string>(
5292
+ key: string,
5293
+ values: readonly T[],
5294
+ ): T | undefined => {
5295
+ const value = opts[key];
5296
+ if (value === undefined || isPart) return undefined;
5297
+ if ((values as readonly string[]).includes(value)) return value as T;
5298
+ errors.push({ line: line.no, message: `箱の ${key} が読めません: "${value}"`, hint: `使える値 = ${values.join(", ")}` });
5299
+ return undefined;
5300
+ };
5301
+ let actorFigureCard: DslActor["figureCard"];
5302
+ if (!isPart && opts.figureCard !== undefined) {
5303
+ const value = JSONの欄を読む(opts.figureCard, "figureCard", line.no, errors);
5304
+ const unknown = 図表の知らない項目を知らせる(value, ["label", "note"] as const, "figureCard", line.no, errors);
5305
+ if (!unknown && objectか(value) && typeof value.label === "string" && (value.note === undefined || typeof value.note === "string")) actorFigureCard = value as NonNullable<DslActor["figureCard"]>;
5306
+ else if (!unknown && value !== undefined) errors.push({ line: line.no, message: "figureCard の値の形が読めません" });
5307
+ }
3645
5308
  // `color` は縦に並べた形と JSON と同じ振り分けを通す (#1969)。 色の名前は箱の色、`#` で
3646
5309
  // 始まる値は色番号になる。
3647
5310
  //
@@ -3662,13 +5325,43 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
3662
5325
  // 「書かなかった」 と同じ扱いにする (#1058)
3663
5326
  kindWritten: kindRaw !== "" && !isPart,
3664
5327
  subtitle: opts.subtitle,
5328
+ kindForm: 閉じた箱の値("kindForm", KIND_FORM_VALUES),
5329
+ icon: 閉じた箱の値("icon", CDL_ICON_VALUES),
5330
+ emptyRowsNote: isPart ? undefined : opts.emptyRowsNote,
5331
+ titleAlign: 閉じた箱の値("titleAlign", TITLE_ALIGN_VALUES),
5332
+ rowMarkForm: 閉じた箱の値("rowMarkForm", ROW_MARK_FORM_VALUES),
5333
+ rowDividerForm: 閉じた箱の値("rowDividerForm", ROW_DIVIDER_FORM_VALUES),
5334
+ rowLayout:
5335
+ isPart || opts.rowLayout === undefined
5336
+ ? undefined
5337
+ : 行の配置として読む(opts.rowLayout, line.no, errors),
5338
+ rowWell:
5339
+ isPart || opts.rowWell === undefined
5340
+ ? undefined
5341
+ : 行の受け皿として読む(opts.rowWell, line.no, errors),
5342
+ nestIn: isPart ? undefined : opts.nestIn,
5343
+ figureCard: actorFigureCard,
5344
+ stationNamePosition:
5345
+ opts.stationNamePosition === "bottom" ? opts.stationNamePosition : undefined,
5346
+ subtitlePlacement:
5347
+ opts.subtitlePlacement === "right" ? opts.subtitlePlacement : undefined,
3665
5348
  eyebrow: opts.eyebrow,
3666
5349
  // パーツでは状態の上書きとして意味を持つため、道筋の欄として横取りしない (#1251)
3667
5350
  touchpoint: isPart ? undefined : opts.touchpoint,
3668
5351
  opportunity: isPart ? undefined : opts.opportunity,
5352
+ opportunityPosition:
5353
+ isPart || opts.opportunityPosition === undefined ||
5354
+ !(JOURNEY_OPPORTUNITY_POSITION_VALUES as readonly string[]).includes(opts.opportunityPosition)
5355
+ ? undefined
5356
+ : opts.opportunityPosition as DslActor["opportunityPosition"],
3669
5357
  // 見本では状態の上書きとして意味を持つため横取りしない (#1251)
3670
5358
  owner: isPart ? undefined : opts.owner,
3671
5359
  end: isPart ? undefined : opts.end,
5360
+ startLabel: isPart ? undefined : opts.startLabel,
5361
+ emphasis: isPart || opts.emphasis !== "primary" ? undefined : "primary",
5362
+ at: isPart || opts.at === undefined ? undefined : 四象限の座標として控える(opts.at),
5363
+ atPos: isPart || opts.at === undefined ? undefined : { line: line.no },
5364
+ stage: isPart ? undefined : opts.stage,
3672
5365
  value: opts.value,
3673
5366
  previous: opts.previous,
3674
5367
  rows: opts.rows
@@ -3678,12 +5371,11 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
3678
5371
  .map((x) => stripQuotes(x.trim()))
3679
5372
  .filter(Boolean)
3680
5373
  : undefined,
3681
- // 行頭の印 (#1466)。 `rows` と同じ並びなので **空の要素を捨てない**
5374
+ // 行頭の印 (#1466)。 `rows` と同じ並びなので **空の要素を捨てない**。
5375
+ // 古い語の読み替えは縦に並べた形と同じ関数を通す (#2782) = 片方だけ通すと、
5376
+ // 中括弧で書いた `pk` が印を持たないまま通る
3682
5377
  marks: opts.marks
3683
- ? opts.marks
3684
- .replace(/^\[|\]$/g, "")
3685
- .split(/,(?![^[]*\])/)
3686
- .map((x) => stripQuotes(x.trim()))
5378
+ ? 行頭の印の並びとして読む(opts.marks, line.no, errors)
3687
5379
  : undefined,
3688
5380
  lane: opts.lane,
3689
5381
  // 箱の中に描く図形 (#1374)。 パーツでは状態の上書きとして意味を持つため横取りしない
@@ -3814,12 +5506,17 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3814
5506
  if (読み手 === null) continue;
3815
5507
  中括弧[k] = 読み手(opts[k]);
3816
5508
  }
3817
- if (opts.side !== undefined && 中括弧.side === undefined) {
3818
- errors.push({
3819
- line: line.no,
3820
- message: `矢印の side が読めません: "${opts.side}"`,
3821
- hint: `使える値 = ${EDGE_SIDE_VALUES.join(", ")}`,
3822
- });
5509
+ for (const field of ["side", "fromSide", "toSide"] as const) {
5510
+ if (opts[field] !== undefined && 中括弧[field] === undefined) {
5511
+ errors.push({
5512
+ line: line.no,
5513
+ message: `矢印の ${field} が読めません: "${opts[field]}"`,
5514
+ hint: `使える値 = ${EDGE_SIDE_VALUES.join(", ")}`,
5515
+ });
5516
+ }
5517
+ }
5518
+ if (opts.labelCollision !== undefined && 中括弧.labelCollision === undefined) {
5519
+ errors.push({ line: line.no, message: `矢印の labelCollision が読めません: "${opts.labelCollision}"`, hint: `使える値 = ${LABEL_COLLISION_VALUES.join(", ")}` });
3823
5520
  }
3824
5521
  // 読めない語を黙って捨てない (#1462)。 捨てると「書いたのに端の形が変わらない」 が
3825
5522
  // 手掛かりなしで起きる
@@ -3849,6 +5546,8 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3849
5546
  const guard = 中括弧.guard as string | undefined;
3850
5547
  const cardinality = 中括弧.cardinality as string | undefined;
3851
5548
  const side = 中括弧.side as "top" | "right" | "bottom" | "left" | undefined;
5549
+ const fromSide = 中括弧.fromSide as "top" | "right" | "bottom" | "left" | undefined;
5550
+ const toSide = 中括弧.toSide as "top" | "right" | "bottom" | "left" | undefined;
3852
5551
  const head = 中括弧.head as EdgeHead | undefined;
3853
5552
  const tailHead = 中括弧.tailHead as EdgeHead | undefined;
3854
5553
  const headFill = 中括弧.headFill as EdgeHeadFill | undefined;
@@ -3858,6 +5557,19 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3858
5557
  // 辺の役目と名前の下地 (cdl#618)
3859
5558
  const role = 中括弧.role as "main" | undefined;
3860
5559
  const labelPlate = 中括弧.labelPlate as boolean | undefined;
5560
+ const labelCollision = 中括弧.labelCollision as DslStep["labelCollision"];
5561
+ let labelSize: DslStep["labelSize"];
5562
+ if (中括弧.labelSize !== undefined) {
5563
+ const value = JSONの欄を読む(中括弧.labelSize as string, "labelSize", line.no, errors);
5564
+ if (objectか(value)) {
5565
+ let invalid = 図表の知らない項目を知らせる(value, ["width", "height", "fontSize"], "labelSize", line.no, errors);
5566
+ for (const key of ["width", "height", "fontSize"] as const) {
5567
+ if (value[key] !== undefined && !正の有限数か(value[key])) invalid = true;
5568
+ }
5569
+ if (!invalid) labelSize = value;
5570
+ else errors.push({ line: line.no, message: "labelSize の値の形が読めません" });
5571
+ } else if (value !== undefined) errors.push({ line: line.no, message: "labelSize は object で書きます" });
5572
+ }
3861
5573
  // 値に追随する 3 欄 (#1396)。 空は捨てずに知らせる = 描画側は空文字を既定値へ落とさず
3862
5574
  // そのまま置換に使うため、書き忘れが「線が消えた」 形で出る
3863
5575
  const widthBind = 追随する大きさとして読む(
@@ -3880,6 +5592,16 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3880
5592
  );
3881
5593
  const labelOffsetX = 数と真偽?.labelOffsetX;
3882
5594
  const labelOffsetY = 数と真偽?.labelOffsetY;
5595
+ const posX1 = 数と真偽?.posX1;
5596
+ const posY1 = 数と真偽?.posY1;
5597
+ const posX2 = 数と真偽?.posX2;
5598
+ const posY2 = 数と真偽?.posY2;
5599
+ const waypoints = 線の途中の点として読む(
5600
+ 中括弧.waypoints as string | undefined,
5601
+ [posX1, posY1, posX2, posY2],
5602
+ line.no,
5603
+ errors,
5604
+ );
3883
5605
  const overlay = 数と真偽?.overlay;
3884
5606
  // 空の名指し (`toPartNode: ""`) は書かなかったことにする = 要素 1 つの部品なら自動で繋ぐ
3885
5607
  const fromPartNode = (中括弧.fromPartNode as string | undefined) || undefined;
@@ -3942,6 +5664,8 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3942
5664
  guard,
3943
5665
  cardinality,
3944
5666
  side,
5667
+ fromSide,
5668
+ toSide,
3945
5669
  head,
3946
5670
  tailHead,
3947
5671
  headFill,
@@ -3955,6 +5679,13 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3955
5679
  dashOffsetBind,
3956
5680
  labelOffsetX,
3957
5681
  labelOffsetY,
5682
+ posX1,
5683
+ posY1,
5684
+ posX2,
5685
+ posY2,
5686
+ waypoints,
5687
+ labelCollision,
5688
+ labelSize,
3958
5689
  overlay,
3959
5690
  ...(fromPartNode !== undefined ? { fromPartNode } : {}),
3960
5691
  ...(toPartNode !== undefined ? { toPartNode } : {}),
@@ -3962,12 +5693,35 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3962
5693
  };
3963
5694
  }
3964
5695
 
3965
- function parseStateEntry(text: string, lineNo: number): DslState | null {
3966
- // `client_bal: 100` / `status: "idle"`
5696
+ /**
5697
+ * `client_bal: 100` / `status: "idle"` を 1 件の状態として読む。
5698
+ *
5699
+ * **2 つの断り方を分けて自分で報告する** (#2401)。 どちらも `null` にまとめると呼出側は
5700
+ * 区別できず、名前の規則を外れただけの行にも形の案内が返る。 書いた本文は案内どおりの形なので
5701
+ * **案内に従って書き直しても同じ案内が返り続ける** (実測 = `すすみ: 0` に
5702
+ * 「`name: initial` の形で書く」 が返った)。
5703
+ *
5704
+ * 名前の判定と案内は `value-syntax.ts` が持つ。 値の節 (`parseValueEntry`) と同じ文を使うことで、
5705
+ * 状態と値 × 記法と JSON の 4 経路で同じ理由が返る。
5706
+ *
5707
+ * 報告をここに置くと、呼出しを足した日に書き忘れても黙らない。 1 行にまとめた形の呼出しは
5708
+ * 実際に分岐を 1 つも持たず、規則外の名前の状態が知らせも無く消えていた。
5709
+ */
5710
+ function parseStateEntry(text: string, lineNo: number, errors: DslError[]): DslState | null {
3967
5711
  const m = text.match(/^([^:]+?)\s*:\s*(.+)$/);
3968
- if (!m) return null;
5712
+ if (!m) {
5713
+ errors.push({
5714
+ line: lineNo,
5715
+ message: `状態の行が読めません: "${text}"`,
5716
+ hint: "`name: initial` の形で書く",
5717
+ });
5718
+ return null;
5719
+ }
3969
5720
  const name = (m[1] ?? "").trim();
3970
- if (!isValueName(name)) return null;
5721
+ if (!isValueName(name)) {
5722
+ errors.push({ line: lineNo, ...valueNameIssue(name) });
5723
+ return null;
5724
+ }
3971
5725
  const raw = (m[2] ?? "").trim();
3972
5726
  const stripped = stripQuotes(raw);
3973
5727
  const asNum = Number(stripped);
@@ -3976,31 +5730,6 @@ function parseStateEntry(text: string, lineNo: number): DslState | null {
3976
5730
  return { name, initial, pos: { line: lineNo } };
3977
5731
  }
3978
5732
 
3979
- /**
3980
- * 字下げした行を 1 行も落とさずに集める。
3981
- *
3982
- * `collectIndentedList` は形が合わない行を黙って捨てるが、 `values` では捨てずに
3983
- * 読み手 (`parseValueEntry`) へ渡して書き間違いとして報告させる。
3984
- */
3985
- function collectIndentedRaw(
3986
- lines: Line[],
3987
- start: number,
3988
- parentIndent: number,
3989
- ): { items: Line[]; next: number } {
3990
- const items: Line[] = [];
3991
- let i = start;
3992
- while (i < lines.length) {
3993
- const ln = lines[i];
3994
- if (!ln || !ln.trimmed || ln.trimmed.startsWith("#")) {
3995
- i += 1;
3996
- continue;
3997
- }
3998
- if (ln.indent <= parentIndent) break;
3999
- items.push(ln);
4000
- i += 1;
4001
- }
4002
- return { items, next: i };
4003
- }
4004
5733
 
4005
5734
  /**
4006
5735
  * `waiting: "{inflow} - {done}"` を 1 件の値として読む。
@@ -4013,7 +5742,7 @@ function parseValueEntry(text: string, lineNo: number, errors: DslError[]): DslV
4013
5742
  if (!m) {
4014
5743
  errors.push({
4015
5744
  line: lineNo,
4016
- message: `invalid value entry: "${text}"`,
5745
+ message: `値の行が読めません: "${text}"`,
4017
5746
  hint: '`waiting: "{inflow} - {done}"` の形で書く',
4018
5747
  });
4019
5748
  return null;
@@ -4044,7 +5773,7 @@ function parseValueEntry(text: string, lineNo: number, errors: DslError[]): DslV
4044
5773
  if (expression === "") {
4045
5774
  errors.push({
4046
5775
  line: lineNo,
4047
- message: `empty expression for "${name}"`,
5776
+ message: `式が空です: "${name}"`,
4048
5777
  hint: '`"{a} + {b}"` のように式を書く',
4049
5778
  });
4050
5779
  return null;
@@ -4057,6 +5786,397 @@ function parseValueEntry(text: string, lineNo: number, errors: DslError[]): DslV
4057
5786
  return { name, expression, pos: { line: lineNo } };
4058
5787
  }
4059
5788
 
5789
+ /** JSON と同じ 1 行の配列 / object を、図表の複合欄で読む。 */
5790
+ function JSONの欄を読む(
5791
+ source: string | null,
5792
+ name: string,
5793
+ line: number,
5794
+ errors: DslError[],
5795
+ ): unknown {
5796
+ const raw = (source ?? "").trim();
5797
+ try {
5798
+ return JSON.parse(raw) as unknown;
5799
+ } catch {
5800
+ errors.push({
5801
+ line,
5802
+ message: `${name} の JSON の形が読めません`,
5803
+ hint: `${name}: [...] または ${name}: {...} の形で 1 行に書く`,
5804
+ });
5805
+ return undefined;
5806
+ }
5807
+ }
5808
+
5809
+ const objectか = (value: unknown): value is Record<string, unknown> =>
5810
+ value !== null && typeof value === "object" && !Array.isArray(value);
5811
+ const 図表の数か = (value: unknown): value is number | string =>
5812
+ (typeof value === "number" && Number.isFinite(value)) ||
5813
+ (typeof value === "string" && value.trim().length > 0);
5814
+
5815
+ function 図表の知らない項目を知らせる(
5816
+ value: unknown,
5817
+ 使える: readonly string[],
5818
+ 名: string,
5819
+ line: number,
5820
+ errors: DslError[],
5821
+ ): boolean {
5822
+ if (!objectか(value)) return false;
5823
+ const 知らない = Object.keys(value).filter((key) => !使える.includes(key));
5824
+ for (const key of 知らない) {
5825
+ errors.push({
5826
+ line,
5827
+ message: `${名} の項目名が読めません: "${key}"`,
5828
+ hint: `使える項目 = ${使える.join(", ")}`,
5829
+ });
5830
+ }
5831
+ return 知らない.length > 0;
5832
+ }
5833
+
5834
+ const 正の有限数か = (value: unknown): value is number =>
5835
+ typeof value === "number" && Number.isFinite(value) && value > 0;
5836
+ const 負でない有限数か = (value: unknown): value is number =>
5837
+ typeof value === "number" && Number.isFinite(value) && value >= 0;
5838
+
5839
+ const CDL_0132_OBJECT_FIELDS = [
5840
+ "terminalDecoration", "figureKindLabel", "figureInset", "journeySize", "chartBarSize", "chartLineSize",
5841
+ "chartPieSize", "chartWaffleSize", "chartSlopeLabelTemplate", "chartSlopeSize", "chartStackedLayout",
5842
+ "chartGaugeSize", "ganttSize", "quadrantSize", "legendOffset", "stageLayout",
5843
+ ] as const;
5844
+ type Cdl0132ObjectField = (typeof CDL_0132_OBJECT_FIELDS)[number];
5845
+
5846
+ const CDL_0132_KEYS: Record<Cdl0132ObjectField, readonly string[]> = {
5847
+ terminalDecoration: ["windowControls", "cursor", "rowOrdinal"],
5848
+ figureKindLabel: ["text", "x", "y", "fontSize", "fontWeight", "letterSpacing"],
5849
+ figureInset: ["top", "right", "bottom", "left", "radius"],
5850
+ journeySize: ["plotPadding", "pointPadding"],
5851
+ chartBarSize: ["tickFontSize", "valueFontSize", "labelFontSize", "plotPadding", "barWidth", "cornerRadius"],
5852
+ chartLineSize: ["valueLabelOffset", "tickFontSize", "periodFontSize", "pointRadius", "plotPadding", "tickGap", "pointPadding", "periodY"],
5853
+ chartPieSize: ["tableWidth", "tableLabelFontSize", "tableValueFontSize", "tableRowGap", "tablePaddingInline", "outerRadius", "centerX"],
5854
+ chartWaffleSize: ["gridLeft", "cellPitch"],
5855
+ chartSlopeLabelTemplate: ["left", "right"],
5856
+ chartSlopeSize: ["pointRadius", "periodFontSize", "labelFontSize", "leftAxisX", "axisGap", "labelAxisGap", "plotPadding", "pointPadding"],
5857
+ chartStackedLayout: ["legendColumns", "legendColumnGap", "segmentGap", "barThickness", "barWidth", "legendMarkSize", "legendFontSize", "periodFontSize", "rateFontSize", "legendOffsetY"],
5858
+ chartGaugeSize: ["arcThickness", "outerRadius", "plotTop", "valueFontSize"],
5859
+ ganttSize: ["taskLabelFontSize", "taskLabelAlign", "taskLabelX", "tickFontSize", "tickCount", "barThickness", "milestoneFontSize", "milestoneRadius", "dependencyStrokeWidth", "taskColumnWidth", "timelinePaddingRight"],
5860
+ quadrantSize: ["plotOffset", "plotSize", "pointRadius", "pointFontSize", "pointLabelGap", "quadrantFontSize", "axisFontSize"],
5861
+ legendOffset: ["x", "y"],
5862
+ stageLayout: ["columnWidths", "columnGap", "backDetourTopGap", "backDetourSideGap", "backDetourTargetInset"],
5863
+ };
5864
+
5865
+ function cdl0132の欄を読む(
5866
+ name: Cdl0132ObjectField,
5867
+ value: unknown,
5868
+ line: number,
5869
+ errors: DslError[],
5870
+ ): value is NonNullable<DslDocument[Cdl0132ObjectField]> {
5871
+ if (!objectか(value)) {
5872
+ errors.push({ line, message: `${name} は object で書きます` });
5873
+ return false;
5874
+ }
5875
+ let invalid = 図表の知らない項目を知らせる(value, CDL_0132_KEYS[name], name, line, errors);
5876
+ const positive = (key: string): void => {
5877
+ if (value[key] !== undefined && !正の有限数か(value[key])) invalid = true;
5878
+ };
5879
+ const nonNegative = (key: string): void => {
5880
+ if (value[key] !== undefined && !負でない有限数か(value[key])) invalid = true;
5881
+ };
5882
+ const nested = (key: string, keys: readonly string[], kind: "positive" | "nonNegative"): void => {
5883
+ const item = value[key];
5884
+ if (item === undefined) return;
5885
+ if (!objectか(item)) { invalid = true; return; }
5886
+ invalid = 図表の知らない項目を知らせる(item, keys, `${name}.${key}`, line, errors) || invalid;
5887
+ for (const nestedKey of keys) {
5888
+ if (item[nestedKey] === undefined) continue;
5889
+ if (kind === "positive" ? !正の有限数か(item[nestedKey]) : !負でない有限数か(item[nestedKey])) invalid = true;
5890
+ }
5891
+ };
5892
+ if (name === "terminalDecoration") {
5893
+ for (const key of CDL_0132_KEYS[name]) if (value[key] !== undefined && typeof value[key] !== "boolean") invalid = true;
5894
+ } else if (name === "figureKindLabel") {
5895
+ if (typeof value.text !== "string" || value.text.trim() === "") invalid = true;
5896
+ for (const key of ["x", "y"]) if (value[key] !== undefined && (typeof value[key] !== "number" || !Number.isFinite(value[key]))) invalid = true;
5897
+ for (const key of ["fontSize", "fontWeight"]) positive(key);
5898
+ nonNegative("letterSpacing");
5899
+ } else if (name === "figureInset") {
5900
+ for (const key of ["top", "right", "bottom", "left", "radius"]) nonNegative(key);
5901
+ } else if (name === "journeySize") {
5902
+ nested("plotPadding", ["left", "right"], "nonNegative");
5903
+ nested("pointPadding", ["left", "right"], "nonNegative");
5904
+ } else if (name === "chartBarSize") {
5905
+ for (const key of ["tickFontSize", "valueFontSize", "labelFontSize", "barWidth"]) positive(key);
5906
+ nested("plotPadding", ["top", "right", "bottom", "left"], "nonNegative");
5907
+ nonNegative("cornerRadius");
5908
+ } else if (name === "chartLineSize") {
5909
+ for (const key of ["tickFontSize", "periodFontSize", "pointRadius"]) positive(key);
5910
+ nested("valueLabelOffset", ["above", "below"], "nonNegative");
5911
+ nested("plotPadding", ["top", "right", "bottom", "left"], "nonNegative");
5912
+ nested("pointPadding", ["left", "right"], "nonNegative");
5913
+ nonNegative("tickGap");
5914
+ nonNegative("periodY");
5915
+ } else if (name === "chartPieSize") {
5916
+ for (const key of ["tableWidth", "tableLabelFontSize", "tableValueFontSize", "outerRadius"]) positive(key);
5917
+ for (const key of ["tableRowGap", "tablePaddingInline"]) nonNegative(key);
5918
+ nonNegative("centerX");
5919
+ } else if (name === "chartWaffleSize") {
5920
+ nonNegative("gridLeft");
5921
+ positive("cellPitch");
5922
+ } else if (name === "chartSlopeLabelTemplate") {
5923
+ for (const key of CDL_0132_KEYS[name]) if (value[key] !== undefined && typeof value[key] !== "string") invalid = true;
5924
+ } else if (name === "chartSlopeSize") {
5925
+ for (const key of ["pointRadius", "periodFontSize", "labelFontSize", "axisGap"]) positive(key);
5926
+ for (const key of ["leftAxisX", "labelAxisGap"]) nonNegative(key);
5927
+ nested("plotPadding", ["top", "bottom"], "nonNegative");
5928
+ nested("pointPadding", ["top", "bottom"], "nonNegative");
5929
+ } else if (name === "chartStackedLayout") {
5930
+ for (const key of ["barThickness", "barWidth", "legendMarkSize", "legendFontSize", "periodFontSize", "rateFontSize"]) positive(key);
5931
+ for (const key of ["legendColumnGap", "segmentGap"]) nonNegative(key);
5932
+ if (value.legendOffsetY !== undefined && (typeof value.legendOffsetY !== "number" || !Number.isFinite(value.legendOffsetY))) invalid = true;
5933
+ if (value.legendColumns !== undefined && (!Number.isInteger(value.legendColumns) || !正の有限数か(value.legendColumns))) invalid = true;
5934
+ } else if (name === "chartGaugeSize") {
5935
+ for (const key of ["arcThickness", "outerRadius", "valueFontSize"]) positive(key);
5936
+ nonNegative("plotTop");
5937
+ } else if (name === "ganttSize") {
5938
+ for (const key of ["taskLabelFontSize", "tickFontSize", "barThickness", "milestoneFontSize", "milestoneRadius", "dependencyStrokeWidth"]) positive(key);
5939
+ nonNegative("taskLabelX");
5940
+ nonNegative("taskColumnWidth");
5941
+ nonNegative("timelinePaddingRight");
5942
+ if (
5943
+ value.taskLabelAlign !== undefined &&
5944
+ !(GANTT_TASK_LABEL_ALIGN_VALUES as readonly unknown[]).includes(value.taskLabelAlign)
5945
+ ) invalid = true;
5946
+ if (value.tickCount !== undefined && (!Number.isInteger(value.tickCount) || typeof value.tickCount !== "number" || value.tickCount < 2)) invalid = true;
5947
+ } else if (name === "quadrantSize") {
5948
+ nested("plotOffset", ["x", "y"], "nonNegative");
5949
+ for (const key of ["plotSize", "pointRadius", "pointFontSize", "quadrantFontSize", "axisFontSize"]) positive(key);
5950
+ nonNegative("pointLabelGap");
5951
+ } else if (name === "legendOffset") {
5952
+ for (const key of ["x", "y"])
5953
+ if (value[key] !== undefined && (typeof value[key] !== "number" || !Number.isFinite(value[key]))) invalid = true;
5954
+ } else if (name === "stageLayout") {
5955
+ nested("columnWidths", Object.keys(objectか(value.columnWidths) ? value.columnWidths : {}), "nonNegative");
5956
+ for (const key of ["columnGap", "backDetourTopGap", "backDetourSideGap", "backDetourTargetInset"]) nonNegative(key);
5957
+ }
5958
+ if (invalid) errors.push({ line, message: `${name} の値の形が読めません` });
5959
+ return !invalid;
5960
+ }
5961
+
5962
+ function 木の寸法を読む(
5963
+ value: unknown,
5964
+ line: number,
5965
+ errors: DslError[],
5966
+ ): value is NonNullable<DslDocument["treeSize"]> {
5967
+ if (!objectか(value)) {
5968
+ errors.push({ line, message: "treeSize は object で書きます" });
5969
+ return false;
5970
+ }
5971
+ let invalid = 図表の知らない項目を知らせる(value, TREE_SIZE_KEYS, "treeSize", line, errors);
5972
+ if (
5973
+ value.nodeWidths !== undefined &&
5974
+ (!Array.isArray(value.nodeWidths) ||
5975
+ value.nodeWidths.length === 0 ||
5976
+ value.nodeWidths.some((width) => !正の有限数か(width)))
5977
+ )
5978
+ invalid = true;
5979
+ for (const key of ["nodeHeight", "titleFontSize", "subtitleFontSize"] as const) {
5980
+ if (value[key] !== undefined && !正の有限数か(value[key])) invalid = true;
5981
+ }
5982
+ for (const key of ["siblingGap", "levelGap"] as const) {
5983
+ if (value[key] !== undefined && !負でない有限数か(value[key])) invalid = true;
5984
+ }
5985
+ if (invalid) errors.push({ line, message: "treeSize の値の形が読めません" });
5986
+ return !invalid;
5987
+ }
5988
+
5989
+ function 放射の寸法を読む(
5990
+ value: unknown,
5991
+ line: number,
5992
+ errors: DslError[],
5993
+ ): value is NonNullable<DslDocument["mindSize"]> {
5994
+ if (!objectか(value)) {
5995
+ errors.push({ line, message: "mindSize は object で書きます" });
5996
+ return false;
5997
+ }
5998
+ let invalid = 図表の知らない項目を知らせる(value, MIND_SIZE_KEYS, "mindSize", line, errors);
5999
+ const contentOffset = value.contentOffset;
6000
+ if (contentOffset !== undefined) {
6001
+ if (!objectか(contentOffset)) invalid = true;
6002
+ else {
6003
+ invalid =
6004
+ 図表の知らない項目を知らせる(
6005
+ contentOffset,
6006
+ ["x", "y"],
6007
+ "mindSize.contentOffset",
6008
+ line,
6009
+ errors,
6010
+ ) || invalid;
6011
+ for (const key of ["x", "y"] as const) {
6012
+ if (typeof contentOffset[key] !== "number" || !Number.isFinite(contentOffset[key])) {
6013
+ invalid = true;
6014
+ }
6015
+ }
6016
+ }
6017
+ }
6018
+ for (const [key, allowed] of [
6019
+ ["root", ["width", "height", "fontSize"]],
6020
+ ["branch", ["width", "height", "fontSize"]],
6021
+ ["leaf", ["fontSize"]],
6022
+ ] as const) {
6023
+ const nested = value[key];
6024
+ if (nested === undefined) continue;
6025
+ if (!objectか(nested)) {
6026
+ invalid = true;
6027
+ continue;
6028
+ }
6029
+ invalid =
6030
+ 図表の知らない項目を知らせる(nested, allowed, `mindSize.${key}`, line, errors) || invalid;
6031
+ for (const nestedKey of allowed) {
6032
+ if (nested[nestedKey] !== undefined && !正の有限数か(nested[nestedKey])) invalid = true;
6033
+ }
6034
+ }
6035
+ for (const key of ["rootBranchGap", "branchLeafGap", "branchRowGap", "leafRowGap"] as const) {
6036
+ if (value[key] !== undefined && !負でない有限数か(value[key])) invalid = true;
6037
+ }
6038
+ for (const key of ["branchRowOffset", "leafRowOffset"] as const) {
6039
+ if (
6040
+ value[key] !== undefined &&
6041
+ (typeof value[key] !== "number" || !Number.isFinite(value[key]))
6042
+ )
6043
+ invalid = true;
6044
+ }
6045
+ if (invalid) errors.push({ line, message: "mindSize の値の形が読めません" });
6046
+ return !invalid;
6047
+ }
6048
+
6049
+ function chartLineSeriesの知らない項目を知らせる(
6050
+ value: unknown,
6051
+ line: number,
6052
+ errors: DslError[],
6053
+ ): boolean {
6054
+ if (!Array.isArray(value)) return false;
6055
+ let unknown = false;
6056
+ value.forEach((series, index) => {
6057
+ unknown =
6058
+ 図表の知らない項目を知らせる(
6059
+ series,
6060
+ ["label", "points", "tone", "dash", "marker", "valueIndexes"],
6061
+ `chartLineSeries[${index}]`,
6062
+ line,
6063
+ errors,
6064
+ ) || unknown;
6065
+ if (!objectか(series) || !Array.isArray(series.points)) return;
6066
+ series.points.forEach((point, pointIndex) => {
6067
+ unknown =
6068
+ 図表の知らない項目を知らせる(
6069
+ point,
6070
+ ["label", "value"],
6071
+ `chartLineSeries[${index}].points[${pointIndex}]`,
6072
+ line,
6073
+ errors,
6074
+ ) || unknown;
6075
+ });
6076
+ });
6077
+ return unknown;
6078
+ }
6079
+
6080
+ function chartStackedPeriodsの知らない項目を知らせる(
6081
+ value: unknown,
6082
+ line: number,
6083
+ errors: DslError[],
6084
+ ): boolean {
6085
+ if (!Array.isArray(value)) return false;
6086
+ let unknown = false;
6087
+ value.forEach((period, index) => {
6088
+ unknown =
6089
+ 図表の知らない項目を知らせる(
6090
+ period,
6091
+ ["label", "values"],
6092
+ `chartStackedPeriods[${index}]`,
6093
+ line,
6094
+ errors,
6095
+ ) || unknown;
6096
+ });
6097
+ return unknown;
6098
+ }
6099
+
6100
+ function chartGaugeValueの知らない項目を知らせる(
6101
+ value: unknown,
6102
+ line: number,
6103
+ errors: DslError[],
6104
+ ): boolean {
6105
+ return 図表の知らない項目を知らせる(
6106
+ value,
6107
+ ["max", "current", "target", "previous", "previousLabel"],
6108
+ "chartGaugeValue",
6109
+ line,
6110
+ errors,
6111
+ );
6112
+ }
6113
+
6114
+ function chartLineSeriesの形(
6115
+ value: unknown,
6116
+ ): value is NonNullable<DslDocument["chartLineSeries"]> {
6117
+ if (!Array.isArray(value) || value.length === 0) return false;
6118
+ return value.every((series) => {
6119
+ if (!objectか(series) || !Array.isArray(series.points) || series.points.length === 0) return false;
6120
+ if (series.label !== undefined && typeof series.label !== "string") return false;
6121
+ if (
6122
+ series.tone !== undefined &&
6123
+ (typeof series.tone !== "string" || resolveTone(series.tone) === undefined)
6124
+ )
6125
+ return false;
6126
+ if (
6127
+ series.dash !== undefined &&
6128
+ (typeof series.dash !== "string" ||
6129
+ !(CHART_LINE_DASH_VALUES as readonly string[]).includes(series.dash))
6130
+ )
6131
+ return false;
6132
+ if (
6133
+ series.marker !== undefined &&
6134
+ (typeof series.marker !== "string" ||
6135
+ !(CHART_LINE_MARKER_VALUES as readonly string[]).includes(series.marker))
6136
+ ) return false;
6137
+ if (
6138
+ series.valueIndexes !== undefined &&
6139
+ (!Array.isArray(series.valueIndexes) ||
6140
+ series.valueIndexes.some((index) => !Number.isInteger(index) || Number(index) < 0))
6141
+ ) return false;
6142
+ return series.points.every(
6143
+ (point) => objectか(point) && typeof point.label === "string" && 図表の数か(point.value),
6144
+ );
6145
+ });
6146
+ }
6147
+
6148
+ function chartStackedPeriodsの形(
6149
+ value: unknown,
6150
+ ): value is NonNullable<DslDocument["chartStackedPeriods"]> {
6151
+ return (
6152
+ Array.isArray(value) &&
6153
+ value.length > 0 &&
6154
+ value.every(
6155
+ (period) =>
6156
+ objectか(period) &&
6157
+ typeof period.label === "string" &&
6158
+ Array.isArray(period.values) &&
6159
+ period.values.every(図表の数か),
6160
+ )
6161
+ );
6162
+ }
6163
+
6164
+ function chartGaugeValueの形(
6165
+ value: unknown,
6166
+ ): value is NonNullable<DslDocument["chartGaugeValue"]> {
6167
+ if (!objectか(value)) return false;
6168
+ const finite = (entry: unknown): entry is number =>
6169
+ typeof entry === "number" && Number.isFinite(entry);
6170
+ return (
6171
+ finite(value.max) &&
6172
+ value.max > 0 &&
6173
+ 図表の数か(value.current) &&
6174
+ (value.target === undefined || 図表の数か(value.target)) &&
6175
+ (value.previous === undefined || 図表の数か(value.previous)) &&
6176
+ (value.previousLabel === undefined || typeof value.previousLabel === "string")
6177
+ );
6178
+ }
6179
+
4060
6180
  function splitTopLevelCommas(s: string): string[] {
4061
6181
  // brace 内を考慮 ... 今回は単純 split (動作する範囲)
4062
6182
  return s
@@ -4080,8 +6200,8 @@ function ensureAnimate(a: DslAnimate | undefined, lineNo: number): DslAnimate {
4080
6200
  export const PHASE_ITEM_WORDS = [
4081
6201
  "focus",
4082
6202
  "badge",
4083
- "body",
4084
- "description",
6203
+ // 説明の語は `PHASE_BODY_KEYS` から導く (#2621)。 ここに書き写すと JSON の入口とずれる
6204
+ ...PHASE_BODY_KEYS,
4085
6205
  "tween",
4086
6206
  "set",
4087
6207
  "draw",
@@ -4097,8 +6217,8 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4097
6217
  if (!m) {
4098
6218
  errors.push({
4099
6219
  line: head.no,
4100
- message: `invalid step header: "${head.trimmed}"`,
4101
- hint: 'use `- step: "name" 1.5s`',
6220
+ message: `段の見出しが読めません: "${head.trimmed}"`,
6221
+ hint: '`- step: "name" 1.5s` の形で書く',
4102
6222
  });
4103
6223
  return null;
4104
6224
  }
@@ -4108,8 +6228,8 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4108
6228
  if (!headParse) {
4109
6229
  errors.push({
4110
6230
  line: head.no,
4111
- message: `invalid step value: "${headRest}"`,
4112
- hint: 'use `"name" 1.5s` (duration in s)',
6231
+ message: `段の値が読めません: "${headRest}"`,
6232
+ hint: '`"name" 1.5s` の形で書く (長さは秒)',
4113
6233
  });
4114
6234
  return null;
4115
6235
  }
@@ -4131,6 +6251,14 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4131
6251
  // 拾った上で、知らない名前は下で誤りとして知らせる
4132
6252
  const propMatch = t.match(/^([^\s:]+)\s*:\s*(.*)$/);
4133
6253
  if (!propMatch) {
6254
+ // **項目名として読めなかった行も捨てない** (#2403)。 すぐ下で「知らない項目名」 は
6255
+ // 知らせているのに、ここだけが黙っていた = コロンを書き落とすと、その行が
6256
+ // 知らせにも図にも現れない (実測 = 段の中に `focus` と書くと誤り 0 件で何も起きない)
6257
+ errors.push({
6258
+ line: ln.no,
6259
+ message: `段の行が項目名として読めません: "${t}"`,
6260
+ hint: `\`項目名: 値\` の形で書く (使える項目 = ${段の項目の英語.join(", ")})`,
6261
+ });
4134
6262
  i += 1;
4135
6263
  continue;
4136
6264
  }
@@ -4174,7 +6302,7 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4174
6302
  errors.push({
4175
6303
  line: ln.no,
4176
6304
  message: `draw に書ける項目は語と割合の 2 つまでです: "${stripQuotes(value).trim()}"`,
4177
- hint: "use `draw: line 0.4`",
6305
+ hint: "`draw: line 0.4` の形で書く",
4178
6306
  });
4179
6307
  } else {
4180
6308
  phase.draw = 語;
@@ -4197,39 +6325,28 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4197
6325
  i += 1;
4198
6326
  continue;
4199
6327
  }
4200
- if (key === "description" || key === "body") {
6328
+ if ((PHASE_BODY_KEYS as readonly string[]).includes(key)) {
4201
6329
  phase.body = stripQuotes(value);
4202
6330
  i += 1;
4203
6331
  continue;
4204
6332
  }
4205
6333
  if (key === "tween") {
4206
- // inline (tween: client_bal 100 -> 90) or block
6334
+ // 1 行にまとめた形 (tween: client_bal 100 -> 90) と縦に並べた形の両方を受ける。
6335
+ // 読めない行の文は読み手が持つ (#2403)
4207
6336
  if (value) {
4208
- const tw = parseTweenLine(value, ln.no);
6337
+ const tw = parseTweenLine(value, ln.no, errors);
4209
6338
  if (tw) phase.tweens!.push(tw);
4210
- else
4211
- errors.push({
4212
- line: ln.no,
4213
- message: `invalid tween: "${value}"`,
4214
- hint: "use `tween: name 100 -> 90`",
4215
- });
4216
6339
  i += 1;
4217
6340
  continue;
4218
6341
  }
4219
- // block ... 後続の同 indent + 1 以上の行を取り込む
6342
+ // 縦に並べた形 ... 後続の同 indent + 1 以上の行を取り込む
4220
6343
  const baseIndent = ln.indent;
4221
6344
  let j = i + 1;
4222
6345
  while (j < block.length) {
4223
6346
  const nx = block[j]!;
4224
6347
  if (nx.indent <= baseIndent) break;
4225
- const tw = parseTweenLine(nx.trimmed, nx.no);
6348
+ const tw = parseTweenLine(nx.trimmed, nx.no, errors);
4226
6349
  if (tw) phase.tweens!.push(tw);
4227
- else
4228
- errors.push({
4229
- line: nx.no,
4230
- message: `invalid tween entry: "${nx.trimmed}"`,
4231
- hint: "use `name: 100 -> 90`",
4232
- });
4233
6350
  j += 1;
4234
6351
  }
4235
6352
  i = j;
@@ -4237,7 +6354,7 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4237
6354
  }
4238
6355
  if (key === "set") {
4239
6356
  if (value) {
4240
- const st = parseSetLine(value, ln.no);
6357
+ const st = parseSetLine(value, ln.no, errors);
4241
6358
  if (st) phase.sets!.push(st);
4242
6359
  i += 1;
4243
6360
  continue;
@@ -4247,7 +6364,7 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4247
6364
  while (j < block.length) {
4248
6365
  const nx = block[j]!;
4249
6366
  if (nx.indent <= baseIndent) break;
4250
- const st = parseSetLine(nx.trimmed, nx.no);
6367
+ const st = parseSetLine(nx.trimmed, nx.no, errors);
4251
6368
  if (st) phase.sets!.push(st);
4252
6369
  j += 1;
4253
6370
  }
@@ -4311,23 +6428,47 @@ function 段の項目のヒント(書いた名前: string): string {
4311
6428
  *
4312
6429
  * いまは語と図種が同じ綴りだが、**同じものとして扱わない**。 語は書き手が書く名前で、
4313
6430
  * 図種は `type:` が取る値。 片方だけ別名を足したくなった時に、対応が表に残っている形にする。
6431
+ *
6432
+ * **数を描く図は形まで指す** (#2657)。 9 つの型を `chart` 1 つに畳んだので、型だけを
6433
+ * 指すと `draw: pie` を棒の図に書いても咎められなくなる = 語ごとの相手を型から形へ
6434
+ * 下ろして、畳む前と同じ細かさで判定する。
4314
6435
  */
4315
- export const DRAW_TARGETS: ReadonlyMap<string, PresetType> = new Map<string, PresetType>([
4316
- ["line", "line"],
4317
- ["bar", "bar"],
4318
- ["pie", "pie"],
4319
- ["journey", "journey"],
4320
- ["mind", "mind"],
4321
- ["tree", "tree"],
4322
- ["gantt", "gantt"],
4323
- ["funnel", "funnel"],
4324
- ["slope", "slope"],
4325
- ["gauge", "gauge"],
4326
- ["radial", "radial"],
4327
- ["stacked", "stacked"],
4328
- ["waffle", "waffle"],
6436
+ export const DRAW_TARGETS: ReadonlyMap<string, { type: PresetType; shape?: DslShape }> = new Map<
6437
+ string,
6438
+ { type: PresetType; shape?: DslShape }
6439
+ >([
6440
+ ["line", { type: "chart", shape: "line" }],
6441
+ ["bar", { type: "chart", shape: "bar" }],
6442
+ ["pie", { type: "chart", shape: "pie" }],
6443
+ ["journey", { type: "journey" }],
6444
+ ["mind", { type: "mind" }],
6445
+ ["tree", { type: "tree" }],
6446
+ ["gantt", { type: "gantt" }],
6447
+ ["funnel", { type: "funnel" }],
6448
+ ["slope", { type: "chart", shape: "slope" }],
6449
+ ["gauge", { type: "chart", shape: "gauge" }],
6450
+ ["radial", { type: "chart", shape: "radial" }],
6451
+ ["stacked", { type: "chart", shape: "stacked" }],
6452
+ ["waffle", { type: "chart", shape: "waffle" }],
4329
6453
  ]);
4330
6454
 
6455
+ /**
6456
+ * 段に書いた描く語が、その図に効くか (#2657)。
6457
+ *
6458
+ * 型が合うだけでは足りない = 数を描く図は 1 つの型に 9 つの形が入るので、形まで見ないと
6459
+ * `draw: pie` を棒の図に書いた形が通る。 形を持たない語は型の一致だけで決まる。
6460
+ */
6461
+ export function 描く語がその図を指すか(
6462
+ 語: string,
6463
+ type: PresetType,
6464
+ shape: DslShape | undefined,
6465
+ ): boolean {
6466
+ const 相手 = DRAW_TARGETS.get(語);
6467
+ if (相手 === undefined) return false;
6468
+ if (相手.type !== type) return false;
6469
+ return 相手.shape === undefined || 相手.shape === shape;
6470
+ }
6471
+
4331
6472
  /** `draw:` に書ける語。 表から導く (#1314) */
4332
6473
  export const DRAW_WORDS: ReadonlySet<string> = new Set(DRAW_TARGETS.keys());
4333
6474
 
@@ -4453,13 +6594,33 @@ function parseFocusList(s: string): string[] {
4453
6594
  return out;
4454
6595
  }
4455
6596
 
4456
- function parseTweenLine(s: string, lineNo: number): DslTween | null {
4457
- // `client_bal 100 -> 90` / `client_bal: 100 -> 90`
6597
+ /**
6598
+ * `client_bal 100 -> 90` / `client_bal: 100 -> 90` を 1 件の変化として読む。
6599
+ *
6600
+ * **2 つの断り方を分けて自分で報告する** (#2403、 #2401 と同じ形)。 どちらも `null` に
6601
+ * まとめると、名前の規則を外れただけの行にも形の案内が返り、案内に従っても直らない
6602
+ * (実測 = `すすみ 0 -> 1` に「`tween: name 100 -> 90` の形で書く」 が返った)。
6603
+ *
6604
+ * **形の案内には 2 つの書き方を両方載せる**。 呼出側は 1 行にまとめた形と縦に並べた形の
6605
+ * 2 つあり、文を呼出側へ戻すと「呼出しを足した日に書き忘れる」 形が戻る。 文を 1 か所に
6606
+ * 置いたまま、どちらで書いた人にも直し方が伝わるようにする。
6607
+ */
6608
+ function parseTweenLine(s: string, lineNo: number, errors: DslError[]): DslTween | null {
4458
6609
  const cleaned = s.replace(/^-\s*/, "").trim();
4459
6610
  const m = cleaned.match(/^([^:\s]+)\s*[:\s]\s*(-?\d+(?:\.\d+)?)\s*->\s*(-?\d+(?:\.\d+)?)$/);
4460
- if (!m) return null;
6611
+ if (!m) {
6612
+ errors.push({
6613
+ line: lineNo,
6614
+ message: `変化の行が読めません: "${cleaned}"`,
6615
+ hint: "`tween: name 100 -> 90` の形で書く (縦に並べる時は `name: 100 -> 90`)",
6616
+ });
6617
+ return null;
6618
+ }
4461
6619
  const state = m[1] ?? "";
4462
- if (!isValueName(state)) return null;
6620
+ if (!isValueName(state)) {
6621
+ errors.push({ line: lineNo, ...valueNameIssue(state) });
6622
+ return null;
6623
+ }
4463
6624
  return {
4464
6625
  state,
4465
6626
  from: parseFloat(m[2] ?? "0"),
@@ -4468,13 +6629,32 @@ function parseTweenLine(s: string, lineNo: number): DslTween | null {
4468
6629
  };
4469
6630
  }
4470
6631
 
4471
- function parseSetLine(s: string, lineNo: number): DslSet | null {
4472
- // `status: "loading"` / `status loading`
6632
+ /**
6633
+ * `status: "loading"` / `status loading` を 1 件の書き換えとして読む。
6634
+ *
6635
+ * **2 つの断り方を分けて自分で報告する** (#2403)。 直す前は断り方を分けないどころか、
6636
+ * 呼出側が `null` を握り潰していた = 4 通りの書き損じ (1 行 / 縦 × 名前が規則外 / 区切りが無い)
6637
+ * すべてで知らせが 0 件になり、段に書き換えも入らなかった。
6638
+ *
6639
+ * 隣に並ぶ `tween` は知らせを出しており、**同じ段の中で片方だけが黙っていた**。
6640
+ * 文の置き場所を読み手 1 か所にすると、呼出しを足した日に書き忘れても黙らない。
6641
+ */
6642
+ function parseSetLine(s: string, lineNo: number, errors: DslError[]): DslSet | null {
4473
6643
  const cleaned = s.replace(/^-\s*/, "").trim();
4474
6644
  const m = cleaned.match(/^([^:\s]+)\s*[:\s]\s*(.+)$/);
4475
- if (!m) return null;
6645
+ if (!m) {
6646
+ errors.push({
6647
+ line: lineNo,
6648
+ message: `値の書き換えが読めません: "${cleaned}"`,
6649
+ hint: '`set: name "done"` の形で書く (縦に並べる時は `name: "done"`)',
6650
+ });
6651
+ return null;
6652
+ }
4476
6653
  const state = m[1] ?? "";
4477
- if (!isValueName(state)) return null;
6654
+ if (!isValueName(state)) {
6655
+ errors.push({ line: lineNo, ...valueNameIssue(state) });
6656
+ return null;
6657
+ }
4478
6658
  const raw = (m[2] ?? "").trim();
4479
6659
  const stripped = stripQuotes(raw);
4480
6660
  const asNum = Number(stripped);