@cardenelabs/dragon 0.28.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 (66) hide show
  1. package/README.md +391 -16
  2. package/dist/index.cjs +13479 -7403
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +2360 -208
  5. package/dist/index.d.ts +2360 -208
  6. package/dist/index.js +13462 -7405
  7. package/dist/index.js.map +1 -1
  8. package/package.json +12 -13
  9. package/src/compile/actor-ids.ts +297 -0
  10. package/src/compile/actor-option-notice.ts +379 -0
  11. package/src/compile/actors.ts +103 -0
  12. package/src/compile/c4.ts +74 -0
  13. package/src/compile/chain.ts +93 -0
  14. package/src/compile/chart-fields.ts +193 -0
  15. package/src/compile/deep-rewrite.ts +30 -0
  16. package/src/compile/derived-source.ts +18 -0
  17. package/src/compile/direction.ts +102 -0
  18. package/src/compile/edge-option-notice.ts +112 -0
  19. package/src/compile/er-relation.ts +200 -0
  20. package/src/compile/flow.ts +54 -0
  21. package/src/compile/flowchart.ts +344 -0
  22. package/src/compile/funnel.ts +87 -0
  23. package/src/compile/gantt.ts +337 -0
  24. package/src/compile/generic.ts +696 -0
  25. package/src/compile/hierarchy.ts +92 -0
  26. package/src/compile/journey.ts +93 -0
  27. package/src/compile/kinds.ts +148 -0
  28. package/src/compile/lanes.ts +138 -0
  29. package/src/compile/metro.ts +271 -0
  30. package/src/compile/mind.ts +393 -0
  31. package/src/compile/node-title.ts +24 -0
  32. package/src/compile/notice.ts +141 -0
  33. package/src/compile/order.ts +62 -0
  34. package/src/compile/parts-lookup.ts +17 -0
  35. package/src/compile/parts.ts +2709 -0
  36. package/src/compile/placement-apply.ts +689 -0
  37. package/src/compile/placement.ts +15 -0
  38. package/src/compile/quadrant.ts +265 -0
  39. package/src/compile/record.ts +75 -0
  40. package/src/compile/row-marks.ts +128 -0
  41. package/src/compile/rows.ts +40 -0
  42. package/src/compile/sequence.ts +305 -0
  43. package/src/compile/slug.ts +37 -0
  44. package/src/compile/stages.ts +247 -0
  45. package/src/compile/subtitle.ts +35 -0
  46. package/src/compile/swimlane-marks.ts +87 -0
  47. package/src/compile/swimlane.ts +82 -0
  48. package/src/compile/timeline.ts +213 -0
  49. package/src/compile/topology.ts +44 -0
  50. package/src/compile/tree.ts +69 -0
  51. package/src/compile/value-chart.ts +337 -0
  52. package/src/compile/values.ts +432 -0
  53. package/src/compile/word-state.ts +82 -0
  54. package/src/compile.ts +1806 -6971
  55. package/src/focus.ts +2 -2
  56. package/src/index.ts +42 -9
  57. package/src/json-line-map.ts +30 -0
  58. package/src/json-parser.ts +1760 -168
  59. package/src/keywords.ts +251 -17
  60. package/src/near-name.ts +42 -0
  61. package/src/notation-lint.ts +41 -64
  62. package/src/relative-pos.ts +71 -2
  63. package/src/schema.ts +1 -1
  64. package/src/schemas/diagram.json +680 -23
  65. package/src/types.ts +337 -36
  66. package/src/v05/parser.ts +2553 -354
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";
50
- import { parseRelativePos, orderByDependency } from "../relative-pos";
93
+ import type { DslOrder } from "../keywords";
94
+ import type { DslDiagramShape, DslShape } from "../keywords";
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` には含まれない */
@@ -383,14 +695,12 @@ const INFRA_KIND_ALIAS: Record<string, NodeKind> = {
383
695
  };
384
696
 
385
697
  /**
386
- * 受け付ける箱の種類。 描画できる種類 (cdl の `NODE_KINDS`) に、 記法だけが持つ種類を足す。
698
+ * 記法が受理する種類の全体。 これに載っていない種類は見本 (パーツ) の候補になる。
387
699
  *
388
- * 以前は手書きの 31 種だった。 描画できる 90 種のうち 78 種が記法から書けず、 部品名として
389
- * 扱われて「そんな部品は無い」 と警告が出るだけだった。 描画側を出所に加えることで
700
+ * 描画できる種類 (cdl の `NODE_KINDS`) に、 記法だけが持つ種類と基盤の別名を足す。
701
+ * 手書きの一覧だった頃は、 描画側に種類が増えても記法から書けず、 部品名として扱われて
702
+ * 「そんな部品は無い」 と警告が出るだけだった。 描画側を出所に加えることで
390
703
  * 「描画できるものは書ける」 が成立する。
391
- */
392
- /**
393
- * 記法が受理する種類の全体。 これに載っていない種類は見本 (パーツ) の候補になる。
394
704
  *
395
705
  * 画面側が「本文が見本を使っているか」 を判定するのに使う (#1022)。 手書きの一覧を
396
706
  * 別に持つと、種類が増えた時にそちらだけ取り残されて余分な読み込みが起きる。
@@ -461,6 +771,20 @@ type Line = {
461
771
  no: number;
462
772
  };
463
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
+
464
788
  export function parseTextDslV05(src: string): V05ParseResult {
465
789
  const errors: DslError[] = [];
466
790
  const lines = tokenize(src);
@@ -473,9 +797,79 @@ export function parseTextDslV05(src: string): V05ParseResult {
473
797
  let relations: RelationFocus | null = null;
474
798
  let direction: DslDirection | null = null;
475
799
  let directionLine = 0;
476
- 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;
477
870
  let axes: DslAxes | undefined = undefined;
478
871
  let axesLine = 0;
872
+ let regions: DslRegions | undefined = undefined;
479
873
  let actors: DslActor[] = [];
480
874
  const flow: DslStep[] = [];
481
875
  let animate: DslAnimate | undefined = undefined;
@@ -491,6 +885,11 @@ export function parseTextDslV05(src: string): V05ParseResult {
491
885
  let scrollsList: DslScrollTrigger[] | undefined = undefined;
492
886
  let scrollLines = new Map<string, number>();
493
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"];
494
893
 
495
894
  let i = 0;
496
895
  while (i < lines.length) {
@@ -499,12 +898,18 @@ export function parseTextDslV05(src: string): V05ParseResult {
499
898
  i += 1;
500
899
  continue;
501
900
  }
502
- 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;
503
908
  if (!head || !isTopLevelKey(head.key)) {
504
909
  errors.push({
505
910
  line: line.no,
506
- message: `unknown top-level key: "${line.trimmed}"`,
507
- hint: `expected one of: ${TOP_LEVEL_KEYS.join(", ")}`,
911
+ message: `最上位の項目名が読めません: "${最上位の項目名(line.trimmed)}"`,
912
+ hint: `使える項目 = ${TOP_LEVEL_KEYS.join(", ")}`,
508
913
  });
509
914
  i += 1;
510
915
  continue;
@@ -512,7 +917,11 @@ export function parseTextDslV05(src: string): V05ParseResult {
512
917
  if (head.key === "title") {
513
918
  title = head.value ?? null;
514
919
  if (!title) {
515
- 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
+ });
516
925
  }
517
926
  i += 1;
518
927
  continue;
@@ -544,7 +953,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
544
953
  /*
545
954
  * 箱に触れると関係する線だけを光らせるか (#1757)。
546
955
  *
547
- * クラス図と ER 図は順番を持たない図で、段を追う見せ方は読み手の問い
956
+ * UML や表の関係を描く record は順番を持たない図で、段を追う見せ方は読み手の問い
548
957
  * (この箱はどこと繋がっているか) に答えない。 `hover` と書くと、箱に触れた時に
549
958
  * その箱 ・ 繋がる線 ・ 相手の箱だけが光る (`cdl#806`)。
550
959
  */
@@ -589,29 +998,134 @@ export function parseTextDslV05(src: string): V05ParseResult {
589
998
  i += 1;
590
999
  continue;
591
1000
  }
592
- 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") {
593
1026
  /*
594
- * 図の配色 (#1553)。
1027
+ * 図の意匠 (#1553 / #2790)。
595
1028
  *
596
1029
  * 読めない語はその場で知らせる。 黙って既定に落とすと、書き手には「書いたのに効かない」
597
1030
  * としか見えず、書き間違いか未対応かを分けられない。
598
1031
  */
599
1032
  const v = (head.value ?? "").trim();
1033
+ const 書いた語 = 書いた頭?.key ?? head.key;
600
1034
  if (v.length > 0) {
601
- const 解けた = resolvePalette(v);
1035
+ const 解けた = resolveTheme(v);
602
1036
  if (解けた !== null) {
603
- palette = 解けた;
1037
+ if (書いた語 === "theme") {
1038
+ theme = 解けた;
1039
+ themeLine = line.no;
1040
+ } else {
1041
+ palette = 解けた;
1042
+ paletteLine = line.no;
1043
+ }
604
1044
  } else {
605
1045
  errors.push({
606
1046
  line: line.no,
607
- message: `palette が読めません (書いた値: ${v})`,
608
- hint: `使える語 = ${PALETTES.join(" / ")} / 生成り / 青磁`,
1047
+ message: `${書いた語} が読めません (書いた値: ${v}。 使える名前 = ${THEMES.join(" / ")})`,
1048
+ hint: `使える語 = ${Object.keys(THEME_ALIAS).join(" / ")}`,
609
1049
  });
610
1050
  }
611
1051
  }
612
1052
  i += 1;
613
1053
  continue;
614
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
+ }
615
1129
  if (head.key === "eyebrow") {
616
1130
  // 空で書いた形 (`eyebrow:`) は「書かなかった」 と同じにする。 空文字を残すと
617
1131
  // 描画側が中身のない帯を出す
@@ -621,13 +1135,392 @@ export function parseTextDslV05(src: string): V05ParseResult {
621
1135
  i += 1;
622
1136
  continue;
623
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
+ }
624
1503
  if (head.key === "type") {
625
1504
  const v = (head.value ?? "").trim().toLowerCase();
626
- 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)) {
627
1520
  errors.push({
628
1521
  line: line.no,
629
- message: `unknown type: "${v}"`,
630
- hint: `expected: ${Array.from(PRESET_TYPES).join(", ")}`,
1522
+ message: `図種が読めません: "${v}"`,
1523
+ hint: `使える図種 = ${[...PRESET_TYPES, ...TYPE_ALIASES.keys()].join(", ")}`,
631
1524
  });
632
1525
  } else {
633
1526
  type = v as PresetType;
@@ -637,15 +1530,23 @@ export function parseTextDslV05(src: string): V05ParseResult {
637
1530
  }
638
1531
  if (head.key === "actors") {
639
1532
  // 1 行で書いた形と、 続く字下げ行に項目を並べた形の両方を受け付ける
640
- 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
+ }
641
1542
  actors = [];
642
1543
  for (const entry of items) {
643
1544
  const base = parseActor(entry[0]!, errors);
644
1545
  if (base === null) {
645
1546
  errors.push({
646
1547
  line: entry[0]!.no,
647
- message: `invalid actor entry: "${entry[0]!.trimmed}"`,
648
- hint: "use `- Client` or `- Client: storage`",
1548
+ message: `登場人物の行が読めません: "${entry[0]!.trimmed}"`,
1549
+ hint: "`- Client` または `- Client: storage` の形で書く",
649
1550
  });
650
1551
  continue;
651
1552
  }
@@ -666,8 +1567,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
666
1567
  } else {
667
1568
  errors.push({
668
1569
  line: it.no,
669
- message: `invalid flow entry: "${it.trimmed}"`,
670
- hint: 'use `- A -> B: "label"` or `- A -> B: "label" (success)`',
1570
+ message: `矢印の行が読めません: "${it.trimmed}"`,
1571
+ hint: '`- A -> B: "label"` または `- A -> B: "label" (success)` の形で書く',
671
1572
  });
672
1573
  }
673
1574
  }
@@ -681,7 +1582,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
681
1582
  const inner = inlineMatch.slice(1, -1).trim();
682
1583
  animate = ensureAnimate(animate, line.no);
683
1584
  for (const pair of splitTopLevelCommas(inner)) {
684
- const st = parseStateEntry(pair, line.no);
1585
+ const st = parseStateEntry(pair, line.no, errors);
685
1586
  if (st) animate.states.push(st);
686
1587
  }
687
1588
  i += 1;
@@ -690,14 +1591,10 @@ export function parseTextDslV05(src: string): V05ParseResult {
690
1591
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
691
1592
  animate = ensureAnimate(animate, line.no);
692
1593
  for (const it of items) {
693
- const st = parseStateEntry(it.trimmed.replace(/^-\s*/, ""), it.no);
1594
+ // 断り方ごとの文は読み手が持つ (#2401)。 ここで `null` をまとめて叱ると、
1595
+ // 名前の規則を外れただけの行に形の案内が返る
1596
+ const st = parseStateEntry(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
694
1597
  if (st) animate.states.push(st);
695
- else
696
- errors.push({
697
- line: it.no,
698
- message: `invalid state entry: "${it.trimmed}"`,
699
- hint: "use `name: initial`",
700
- });
701
1598
  }
702
1599
  i = next;
703
1600
  continue;
@@ -707,8 +1604,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
707
1604
  // 入る (`min({a}, {b})`) ため、 `states` が使う素朴な `,` 分割では式が壊れる。
708
1605
  //
709
1606
  // **`{` で始まるかを見てはいけない** (#1169)。 `values: a: "{b} + 1"` は `{` で始まらない
710
- // ため判定を通り抜け、 その後 `collectIndentedRaw` が次行以降しか見ないので **値が
711
- // 1 件も読まれずに黙って消える**。 書き間違いを黙って捨てないという `collectIndentedRaw`
1607
+ // ため判定を通り抜け、 その後 `collectIndentedList` が次行以降しか見ないので **値が
1608
+ // 1 件も読まれずに黙って消える**。 書き間違いを黙って捨てないという `collectIndentedList`
712
1609
  // を自前で持った理由と矛盾する。
713
1610
  //
714
1611
  // 同じ行に何か書いてあれば形を問わず弾く = 1 行形は全て受けないという規則そのもの。
@@ -722,9 +1619,9 @@ export function parseTextDslV05(src: string): V05ParseResult {
722
1619
  i += 1;
723
1620
  continue;
724
1621
  }
725
- // `collectIndentedList` は `:` を含まない行を黙って捨てる。 捨てられると
726
- // 書き間違えた行が「書かなかった」 と同じになり、 値が 1 つ消えたことに気付けない
727
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1622
+ // 字下げした行を 1 行も落とさずに集める。 捨てられると書き間違えた行が
1623
+ // 「書かなかった」 と同じになり、 値が 1 つ消えたことに気付けない
1624
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
728
1625
  for (const it of items) {
729
1626
  const v = parseValueEntry(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
730
1627
  if (v) values.push(v);
@@ -734,8 +1631,16 @@ export function parseTextDslV05(src: string): V05ParseResult {
734
1631
  }
735
1632
  if (head.key === "animation") {
736
1633
  // animation: は step を list で並べる
737
- const { items: stepBlocks, next } = collectAnimationSteps(lines, i + 1, line.indent);
1634
+ const { items: stepBlocks, 届かない, next } = collectAnimationSteps(lines, i + 1, line.indent);
738
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
+ }
739
1644
  for (const block of stepBlocks) {
740
1645
  const ph = parsePhase(block, errors);
741
1646
  if (ph) animate.phases.push(ph);
@@ -765,7 +1670,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
765
1670
  // block: viewport:\n width: 1400\n height: 900\n ...
766
1671
  // **`collectIndentedList` を使わない** (#1968)。 あちらは `:` を含まない行を黙って捨てるため、
767
1672
  // 書き間違えた行が「書かなかった」 と同じになる (`axes:` と同じ理由)
768
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1673
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
769
1674
  const opts: Record<string, string> = {};
770
1675
  // 知らせは **値を書いた行** を指す (#1306)。 `viewport:` の行を指すと、欄が縦に並ぶ形で
771
1676
  // どの行を直せばよいか分からない
@@ -826,7 +1731,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
826
1731
  }
827
1732
  // **`collectIndentedList` を使わない** (Round 1 の指摘)。 あちらは `:` を含まない行を
828
1733
  // 黙って捨てるため、書き間違えた行が「書かなかった」 と同じになる
829
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1734
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
830
1735
  axesLine = line.no;
831
1736
  const 組み立て: DslAxes = {};
832
1737
  for (const it of items) {
@@ -834,8 +1739,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
834
1739
  if (!m) {
835
1740
  errors.push({
836
1741
  line: it.no,
837
- message: `invalid axes entry: "${it.trimmed}"`,
838
- hint: 'use `x: { left: "...", right: "..." }` or `y: { bottom: "...", top: "..." }`',
1742
+ message: `軸の行が読めません: "${it.trimmed}"`,
1743
+ hint: '`x: { left: "...", right: "..." }` または `y: { bottom: "...", top: "..." }` の形で書く',
839
1744
  });
840
1745
  continue;
841
1746
  }
@@ -847,7 +1752,12 @@ export function parseTextDslV05(src: string): V05ParseResult {
847
1752
  it.no,
848
1753
  errors,
849
1754
  );
850
- 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 };
851
1761
  else 組み立て.y = { bottom: opts.bottom, top: opts.top };
852
1762
  }
853
1763
  // 1 本も読めなかった形は「書かなかった」 と同じにする。 空の軸を渡すと、
@@ -856,9 +1766,55 @@ export function parseTextDslV05(src: string): V05ParseResult {
856
1766
  i = next;
857
1767
  continue;
858
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
+ }
859
1814
  if (head.key === "bands") {
860
1815
  /*
861
- * 動いている間の帯 (#1466)。 `- DB: 1..2` の形で、段の番号の区間を書く。
1816
+ * 動いている間の帯 (#1466)。 `- DB: 1..2` の形で、言づての番号の区間を書く。
1817
+ * 番号は板に載る言づての行で 0 から数える (#2404 で実測、 段ではない)。
862
1818
  *
863
1819
  * 面ごとに 2 行以上書ける = 途中で手が空く面はそこで切れる。 書かない図は
864
1820
  * 組み立て器が「最初に関わった段から最後まで」 の 1 本にする。
@@ -872,11 +1828,11 @@ export function parseTextDslV05(src: string): V05ParseResult {
872
1828
  errors.push({
873
1829
  line: it.no,
874
1830
  message: `帯の書き方が読めません: "${it.trimmed}"`,
875
- hint: "use `- DB: 1..2` (面の名前と、段の番号の区間)",
1831
+ hint: "`- DB: 1..2` の形で書く (面の名前と、言づての番号の区間。 番号は 0 から数える)",
876
1832
  });
877
1833
  continue;
878
1834
  }
879
- 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 } });
880
1836
  }
881
1837
  i = next;
882
1838
  continue;
@@ -901,6 +1857,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
901
1857
  id,
902
1858
  ...表で読む(LANE_VALUE_KINDS, opts, `縦列 ${id} の `, it.no, errors),
903
1859
  label: opts.label,
1860
+ subtitle: opts.subtitle,
904
1861
  // 位置のずらし (#1971)
905
1862
  layoutPos: ずらしにまとめる(
906
1863
  表で読む(OFFSET_VALUE_KINDS, opts, `縦列 ${id} の `, it.no, errors),
@@ -910,8 +1867,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
910
1867
  } else {
911
1868
  errors.push({
912
1869
  line: it.no,
913
- message: `invalid lane entry: "${it.trimmed}"`,
914
- hint: 'use `id: { x: 0, width: 320, label: "..." }`',
1870
+ message: `縦列の行が読めません: "${it.trimmed}"`,
1871
+ hint: '`id: { x: 0, width: 320, label: "..." }` の形で書く',
915
1872
  });
916
1873
  }
917
1874
  }
@@ -932,7 +1889,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
932
1889
  }
933
1890
  // `collectIndentedList` は `:` の無い行を落とす。 部品を綴り違えた行も知らせるため、
934
1891
  // 字下げした行を全て読み手へ渡す。
935
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1892
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
936
1893
  readoutsList = [];
937
1894
  for (const it of items) {
938
1895
  const m = 名前と中括弧に割る(it.trimmed);
@@ -942,8 +1899,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
942
1899
  } else {
943
1900
  errors.push({
944
1901
  line: it.no,
945
- message: `invalid readout entry: "${it.trimmed}"`,
946
- hint: "use `id: { kind: percent-ring, source: total, max: 500 }`",
1902
+ message: `読み取り値の行が読めません: "${it.trimmed}"`,
1903
+ hint: "`id: { kind: percent-ring, source: total, max: 500 }` の形で書く",
947
1904
  });
948
1905
  }
949
1906
  }
@@ -964,7 +1921,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
964
1921
  }
965
1922
  // `collectIndentedList` は `:` の無い行を落とす。 つまみを綴り違えた行も知らせるため、
966
1923
  // 字下げした行を全て読み手へ渡す。
967
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1924
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
968
1925
  inputsList = [];
969
1926
  for (const it of items) {
970
1927
  const m = 名前と中括弧に割る(it.trimmed);
@@ -974,8 +1931,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
974
1931
  } else {
975
1932
  errors.push({
976
1933
  line: it.no,
977
- message: `invalid input entry: "${it.trimmed}"`,
978
- 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 }` の形で書く",
979
1936
  });
980
1937
  }
981
1938
  }
@@ -997,7 +1954,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
997
1954
  continue;
998
1955
  }
999
1956
  // 字下げした行を全て読み手へ渡す = 綴りを誤った行も知らせるため
1000
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1957
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
1001
1958
  formulasList = [];
1002
1959
  for (const it of items) {
1003
1960
  const f = 式として読む(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
@@ -1017,7 +1974,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
1017
1974
  i += 1;
1018
1975
  continue;
1019
1976
  }
1020
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1977
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
1021
1978
  eventsList = [];
1022
1979
  for (const it of items) {
1023
1980
  const e = 出来事として読む(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
@@ -1037,7 +1994,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
1037
1994
  i += 1;
1038
1995
  continue;
1039
1996
  }
1040
- const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
1997
+ const { items, next } = collectIndentedList(lines, i + 1, line.indent);
1041
1998
  scrollsList = [];
1042
1999
  scrollLines = new Map();
1043
2000
  for (const it of items) {
@@ -1059,8 +2016,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
1059
2016
  } else {
1060
2017
  errors.push({
1061
2018
  line: it.no,
1062
- message: `invalid scroll entry: "${it.trimmed}"`,
1063
- 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 }` の形で書く",
1064
2021
  });
1065
2022
  }
1066
2023
  }
@@ -1091,8 +2048,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
1091
2048
  } else {
1092
2049
  errors.push({
1093
2050
  line: it.no,
1094
- message: `invalid group entry: "${it.trimmed}"`,
1095
- hint: 'use `id: { label: "...", lanes: [a, b] }`',
2051
+ message: `組の行が読めません: "${it.trimmed}"`,
2052
+ hint: '`id: { label: "...", lanes: [a, b] }` の形で書く',
1096
2053
  });
1097
2054
  }
1098
2055
  }
@@ -1103,12 +2060,21 @@ export function parseTextDslV05(src: string): V05ParseResult {
1103
2060
  }
1104
2061
 
1105
2062
  if (!title)
1106
- 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
+ });
1107
2068
  if (!type)
1108
2069
  errors.push({
1109
2070
  line: 1,
1110
- message: "type is required",
1111
- 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(", ")})`,
1112
2078
  });
1113
2079
 
1114
2080
  // つまみ・式・巻き上げは同じ名前空間で値を作る。 重なると後から作る値が効かない。
@@ -1125,7 +2091,25 @@ export function parseTextDslV05(src: string): V05ParseResult {
1125
2091
  });
1126
2092
  }
1127
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
+
1128
2111
  if (errors.length > 0) return { ok: false, errors };
2112
+ const 選んだ意匠 = theme ?? palette;
1129
2113
 
1130
2114
  return {
1131
2115
  ok: true,
@@ -1136,8 +2120,80 @@ export function parseTextDslV05(src: string): V05ParseResult {
1136
2120
  ...(reveal !== null ? { reveal } : {}),
1137
2121
  ...(relations !== null ? { relations } : {}),
1138
2122
  ...(direction !== null ? { direction, directionPos: { line: directionLine } } : {}),
1139
- ...(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
+ : {}),
1140
2195
  ...(axes !== undefined ? { axes, axesPos: { line: axesLine } } : {}),
2196
+ ...(regions !== undefined ? { regions } : {}),
1141
2197
  actors,
1142
2198
  flow,
1143
2199
  animate,
@@ -1156,26 +2212,141 @@ export function parseTextDslV05(src: string): V05ParseResult {
1156
2212
  };
1157
2213
  }
1158
2214
 
1159
- function tokenize(src: string): Line[] {
1160
- const out: Line[] = [];
1161
- const raw = src.split("\n");
1162
- for (let i = 0; i < raw.length; i += 1) {
1163
- const r = raw[i] ?? "";
1164
- const trimmed = r.trim();
1165
- const indent = r.length - r.trimStart().length;
1166
- out.push({ raw: r, trimmed, indent, no: i + 1 });
2215
+ function tokenize(src: string): Line[] {
2216
+ const out: Line[] = [];
2217
+ const raw = src.split("\n");
2218
+ for (let i = 0; i < raw.length; i += 1) {
2219
+ const r = raw[i] ?? "";
2220
+ const trimmed = r.trim();
2221
+ const indent = r.length - r.trimStart().length;
2222
+ out.push({ raw: r, trimmed, indent, no: i + 1 });
2223
+ }
2224
+ return out;
2225
+ }
2226
+
2227
+ type TopHeader = { key: string; value: string | null };
2228
+
2229
+ function matchTopHeader(trimmed: string): TopHeader | null {
2230
+ // 形式: `key:` or `key: value`
2231
+ const m = trimmed.match(/^([^\s:]+)\s*:\s*(.*)$/u);
2232
+ if (!m) return null;
2233
+ const value = (m[2] ?? "").trim();
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;
1167
2290
  }
1168
- return out;
1169
- }
1170
2291
 
1171
- type TopHeader = { key: string; value: string | null };
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
+ }
1172
2310
 
1173
- function matchTopHeader(trimmed: string): TopHeader | null {
1174
- // 形式: `key:` or `key: value`
1175
- const m = trimmed.match(/^([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
1176
- if (!m) return null;
1177
- const value = (m[2] ?? "").trim();
1178
- return { key: (m[1] ?? "").toLowerCase(), value: value.length ? stripQuotes(value) : null };
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;
1179
2350
  }
1180
2351
 
1181
2352
  /**
@@ -1472,6 +2643,7 @@ function 表で読む<T extends Record<string, 値の形>>(
1472
2643
  export const VIEWPORT_VALUE_KINDS = {
1473
2644
  width: "数",
1474
2645
  height: "数",
2646
+ contentOffsetX: "数",
1475
2647
  scale: "数",
1476
2648
  laneWidth: "数",
1477
2649
  gap: "数",
@@ -1510,13 +2682,14 @@ export const LANE_VALUE_KINDS = {
1510
2682
 
1511
2683
  /**
1512
2684
  * 縦列の中括弧に書ける項目 (#1968)。 数と真偽の欄は `LANE_VALUE_KINDS` と `OFFSET_VALUE_KINDS` から
1513
- * 導き、表に載せない文字列の `label` だけを足す。
2685
+ * 導き、表に載せない文字列の `label` と `subtitle` だけを足す。
1514
2686
  */
1515
2687
  export const LANE_INLINE_KEYS: readonly (keyof DslLane | keyof typeof OFFSET_VALUE_KINDS)[] = [
1516
2688
  ...(Object.keys(LANE_VALUE_KINDS) as (keyof typeof LANE_VALUE_KINDS)[]),
1517
2689
  // 位置のずらし (#1971)。 JSON の縦列の `pos` と同じ `layoutPos` に入る
1518
2690
  ...(Object.keys(OFFSET_VALUE_KINDS) as (keyof typeof OFFSET_VALUE_KINDS)[]),
1519
2691
  "label",
2692
+ "subtitle",
1520
2693
  ];
1521
2694
 
1522
2695
  /** 組の中括弧に書ける項目 (#1968)。 `groups:` の読み手が拾う欄と揃える */
@@ -1526,12 +2699,18 @@ export const GROUP_INLINE_KEYS = ["label", "lanes"] as const satisfies readonly
1526
2699
  >[];
1527
2700
 
1528
2701
  /** 軸の名前の中括弧に書ける項目 (#1968)。 横軸は左右、縦軸は下上 */
1529
- export const AXIS_X_KEYS = ["left", "right"] as const satisfies readonly (keyof NonNullable<
1530
- DslAxes["x"]
1531
- >)[];
1532
- export const AXIS_Y_KEYS = ["bottom", "top"] as const satisfies readonly (keyof NonNullable<
1533
- DslAxes["y"]
1534
- >)[];
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
+ )[];
1535
2714
 
1536
2715
  /**
1537
2716
  * 箱の中に描く図形 (`shape:`) と、値を見せる部品 (`readouts:`) の欄 (#1374)。
@@ -1553,7 +2732,7 @@ export const AXIS_Y_KEYS = ["bottom", "top"] as const satisfies readonly (keyof
1553
2732
  // `parser.ts` に置いたままだと 生成した表 → parser → 生成した表 の輪ができる
1554
2733
  export type { 欄の形, 図形の定義 } from "./parser-types";
1555
2734
 
1556
- /** 描ける図形と、その欄 (`CdlDynShape` の全 5 種を覆う) */
2735
+ /** 描ける図形と、その欄 (`CdlDynShape` の全種を覆う) */
1557
2736
  export const 図形の表: Record<string, 図形の定義> = {
1558
2737
  rect: {
1559
2738
  必須: ["source", "fillMax"],
@@ -1608,9 +2787,9 @@ export const 図形の表: Record<string, 図形の定義> = {
1608
2787
  /**
1609
2788
  * 記法が受ける部品と、その欄。
1610
2789
  *
1611
- * **手で書かず、描画側の型定義から生成する** (#1385)。 描画側は 107 種を持ち欄は 510 個あり、
1612
- * 手で写すと写し間違いと描画側の変更への drift が残る (`rules/quality.md § 導出可能記述は
1613
- * 人手で書かない`)。
2790
+ * **手で書かず、描画側の型定義から生成する** (#1385)。 描画側が持つ種類も、種類ごとの欄も
2791
+ * 多く、手で写すと写し間違いと描画側の変更への drift が残る (`rules/quality.md § 導出可能記述は
2792
+ * 人手で書かない`)。 いくつあるかは `readout-table.generated.ts` を数えれば出る。
1614
2793
  *
1615
2794
  * 作り直す = `node packages/dragon/scripts/gen-readout-table.mjs`
1616
2795
  * ずれの検知 = `packages/dragon/test/readout-table-generated.test.ts`
@@ -1621,8 +2800,9 @@ import { 部品の表, 部品の組の表 } from "./readout-table.generated";
1621
2800
  /**
1622
2801
  * 記法が受けるつまみと、その欄 (#1389)。
1623
2802
  *
1624
- * **部品の表と同じく、描画側の型定義から生成する**。 14 種それぞれ欄が違い、手で写すと
2803
+ * **部品の表と同じく、描画側の型定義から生成する**。 種類ごとに欄が違い、手で写すと
1625
2804
  * 描画側が種類を足した時に drift が残る (`rules/quality.md § 導出可能記述は人手で書かない`)。
2805
+ * いくつあるかは `input-table.generated.ts` を数えれば出る。
1626
2806
  *
1627
2807
  * 作り直す = `node packages/dragon/scripts/gen-input-table.mjs`
1628
2808
  * ずれの検知 = `packages/dragon/test/input-table-generated.test.ts`
@@ -1733,7 +2913,11 @@ function 選択肢の並びとして読む(
1733
2913
  ): (string | { value: string; label: string })[] | undefined {
1734
2914
  const 中身 = 値.trim();
1735
2915
  if (!中身.startsWith("[") || !中身.endsWith("]")) {
1736
- errors.push({ line, message: `${名} の並びが読めません: "${値}"`, hint: '`["green", { value: "red", label: "失敗" }]` の形で書く' });
2916
+ errors.push({
2917
+ line,
2918
+ message: `${名} の並びが読めません: "${値}"`,
2919
+ hint: '`["green", { value: "red", label: "失敗" }]` の形で書く',
2920
+ });
1737
2921
  return undefined;
1738
2922
  }
1739
2923
  const 出: (string | { value: string; label: string })[] = [];
@@ -1746,19 +2930,35 @@ function 選択肢の並びとして読む(
1746
2930
  return;
1747
2931
  }
1748
2932
  if (!要素.endsWith("}")) {
1749
- errors.push({ line, message: `${名}[${i}] の組が読めません: "${要素}"`, hint: '`{ value: "red", label: "失敗" }` の形で書く' });
2933
+ errors.push({
2934
+ line,
2935
+ message: `${名}[${i}] の組が読めません: "${要素}"`,
2936
+ hint: '`{ value: "red", label: "失敗" }` の形で書く',
2937
+ });
1750
2938
  return;
1751
2939
  }
1752
2940
  const 組 = parseInlineMapping(要素.slice(1, -1));
1753
2941
  for (const k of Object.keys(組)) {
1754
2942
  if ((選択肢の組の項目 as readonly string[]).includes(k)) continue;
1755
- errors.push({ line, message: `${名}[${i}] の項目名が読めません: "${k}"`, hint: `使える項目 = ${選択肢の組の項目.join(", ")}` });
2943
+ errors.push({
2944
+ line,
2945
+ message: `${名}[${i}] の項目名が読めません: "${k}"`,
2946
+ hint: `使える項目 = ${選択肢の組の項目.join(", ")}`,
2947
+ });
1756
2948
  }
1757
2949
  for (const k of 選択肢の組の項目) {
1758
2950
  if (組[k] === undefined) {
1759
- errors.push({ line, message: `${名}[${i}].${k} は必ず書きます`, hint: `必須の項目 = ${選択肢の組の項目.join(", ")}` });
2951
+ errors.push({
2952
+ line,
2953
+ message: `${名}[${i}].${k} は必ず書きます`,
2954
+ hint: `必須の項目 = ${選択肢の組の項目.join(", ")}`,
2955
+ });
1760
2956
  } else if (組[k] === "") {
1761
- 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
+ });
1762
2962
  }
1763
2963
  }
1764
2964
  if (組.value && 組.label) 出.push({ value: 組.value, label: 組.label });
@@ -2060,7 +3260,7 @@ function 部品として読む(
2060
3260
  const 読めた = 表に従って読む(定義, opts, `部品 ${id} の `, line, errors);
2061
3261
  部品の組を検査する(kind, 読めた, line, errors);
2062
3262
  for (const 欄 of 定義.必須) if (読めた[欄] === undefined) return undefined;
2063
- return { id, kind, ...読めた } as DslReadout;
3263
+ return { id, kind, ...読めた, pos: { line } } as DslReadout;
2064
3264
  }
2065
3265
 
2066
3266
  /**
@@ -2081,8 +3281,26 @@ export const EVENT_KINDS = [
2081
3281
  "blur",
2082
3282
  ] as const;
2083
3283
 
2084
- /** 出来事の相手を指す書き方。 ちょうど 1 つだけ書く */
2085
- const EVENT_TARGET_KEYS = ["box", "lane", "arrow", "diagram"] as const;
3284
+ /**
3285
+ * 出来事の相手を指す書き方。 ちょうど 1 つだけ書く。
3286
+ *
3287
+ * **ここが唯一の置き場所** (#2256)。 同じ 4 語が JSON の記法にも書かれていた間、
3288
+ * 1:1 を名乗りながら受ける項目が両側で別々に決まっており、片方に 5 つ目を足すと
3289
+ * 記法では通るのに JSON では弾かれる状態が黙って生まれる形だった。
3290
+ * 公開 schema の項目の鍵とは `event-target-keys-parity.test.ts` が突き合わせる。
3291
+ */
3292
+ export const EVENT_TARGET_KEYS = ["box", "lane", "arrow", "diagram"] as const;
3293
+
3294
+ /** 出来事に必ず書く項目。 相手の指し方と合わせて schema の項目の鍵になる */
3295
+ export const EVENT_REQUIRED_KEYS = ["on", "handler"] as const;
3296
+
3297
+ /**
3298
+ * 出来事が受ける項目の全体 (#2258)。
3299
+ *
3300
+ * **判定と知らせが同じ集合を読む**。 知らせが先頭 2 語だけを手で並べていた間、
3301
+ * 必ず書く項目を増やしても知らせは古い一覧を教え続ける形だった。
3302
+ */
3303
+ const EVENT_ITEM_KEYS: readonly string[] = [...EVENT_REQUIRED_KEYS, ...EVENT_TARGET_KEYS];
2086
3304
 
2087
3305
  /**
2088
3306
  * 押下などの出来事で動く仕掛けを 1 件読む (#1393)。
@@ -2109,12 +3327,11 @@ function 出来事として読む(
2109
3327
  }
2110
3328
  const opts = parseInlineMapping(t.slice(1, -1));
2111
3329
  for (const k of Object.keys(opts)) {
2112
- if (k === "on" || k === "handler" || (EVENT_TARGET_KEYS as readonly string[]).includes(k))
2113
- continue;
3330
+ if (EVENT_ITEM_KEYS.includes(k)) continue;
2114
3331
  errors.push({
2115
3332
  line,
2116
3333
  message: `出来事の項目名が読めません: "${k}"`,
2117
- hint: `使える項目 = on, handler, ${EVENT_TARGET_KEYS.join(", ")}`,
3334
+ hint: `使える項目 = ${EVENT_ITEM_KEYS.join(", ")}`,
2118
3335
  });
2119
3336
  return undefined;
2120
3337
  }
@@ -2299,7 +3516,12 @@ function 式として読む(行: string, line: number, errors: DslError[]): DslF
2299
3516
  });
2300
3517
  return undefined;
2301
3518
  }
2302
- return { id: 名前, expression: 式, ...(名札 !== undefined ? { label: 名札 } : {}), pos: { line } };
3519
+ return {
3520
+ id: 名前,
3521
+ expression: 式,
3522
+ ...(名札 !== undefined ? { label: 名札 } : {}),
3523
+ pos: { line },
3524
+ };
2303
3525
  }
2304
3526
 
2305
3527
  /** 組の中の項目名の形。 `parseInlineMapping` が項目名として受ける字と同じ */
@@ -2332,7 +3554,9 @@ function 式の組を読む(
2332
3554
  ): { expression: string; label?: string } | undefined {
2333
3555
  const 使える = ["expression", "label"];
2334
3556
  const 読めた: Record<string, string> = {};
2335
- 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 !== "")) {
2336
3560
  const m = 欄.match(組の項目名);
2337
3561
  if (!m) {
2338
3562
  errors.push({
@@ -2403,19 +3627,40 @@ export const ACTOR_INLINE_VALUE_KINDS = {
2403
3627
  posY: "数",
2404
3628
  posW: "数",
2405
3629
  posH: "数",
3630
+ titleFontSize: "数",
3631
+ markGap: "数",
3632
+ milestone: "真偽",
3633
+ rowRules: "真偽",
3634
+ rowStripe: "真偽",
2406
3635
  } as const satisfies Record<string, 値の形>;
2407
3636
 
2408
- /** 縦に並べる形で箱に書ける、数と真偽の欄 (#1306)。 `posW` / `posH` は `大きさ:` が受ける */
3637
+ /**
3638
+ * 縦に並べる形で箱に書ける、数と真偽の欄 (#1306)。 `posW` / `posH` は `大きさ:` が受ける。
3639
+ *
3640
+ * 始まりと終わりの印 (`initial` / `final`) は #2346 で足した。 中括弧の表にはあったのに
3641
+ * こちらに無く、**段を分けた側には書き方が 1 つも無かった** = 書くと綴りの誤りとして落ちる
3642
+ */
2409
3643
  export const ACTOR_BLOCK_VALUE_KINDS = {
2410
3644
  stack: "数",
2411
3645
  posX: "数",
2412
3646
  posY: "数",
3647
+ initial: "真偽",
3648
+ final: "真偽",
3649
+ titleFontSize: "数",
3650
+ markGap: "数",
3651
+ milestone: "真偽",
3652
+ rowRules: "真偽",
3653
+ rowStripe: "真偽",
2413
3654
  } as const satisfies Record<string, 値の形>;
2414
3655
 
2415
3656
  /** 矢印の中括弧に書ける、数と真偽の欄 (#1306) */
2416
3657
  export const FLOW_INLINE_VALUE_KINDS = {
2417
3658
  labelOffsetX: "数",
2418
3659
  labelOffsetY: "数",
3660
+ posX1: "数",
3661
+ posY1: "数",
3662
+ posX2: "数",
3663
+ posY2: "数",
2419
3664
  overlay: "真偽",
2420
3665
  } as const satisfies Record<string, 値の形>;
2421
3666
 
@@ -2504,10 +3749,10 @@ function parseInlineMapping(inner: string): Record<string, string> {
2504
3749
  // 読める名前を広げても、**知っている名前しか使われない**。 パーツの状態の上書きに
2505
3750
  // 流れるのは `ACTOR_RESERVED_FIELDS` に無い名前だけで、日本語の項目名 (`位置` / `大きさ`
2506
3751
  // 等) はそこに載せてあるため、これまでどおり落ちる
2507
- const m = p.match(/^\s*([^\s:,{}[\]"']+)\s*:\s*(.+?)\s*$/);
3752
+ const m = p.match(/^\s*(?:"([^"]+)"|'([^']+)'|([^\s:,{}[\]"']+))\s*:\s*(.+?)\s*$/);
2508
3753
  if (m) {
2509
- const key = m[1]!;
2510
- out[key] = stripQuotes(m[2]!.trim());
3754
+ const key = m[1] ?? m[2] ?? m[3]!;
3755
+ out[key] = stripQuotes(m[4]!.trim());
2511
3756
  }
2512
3757
  }
2513
3758
  return out;
@@ -2525,7 +3770,7 @@ function writtenScaleFields(inner: string): Map<string, string> {
2525
3770
  for (const field of splitInlineFields(inner)) {
2526
3771
  const idx = field.indexOf(":");
2527
3772
  if (idx < 0) continue;
2528
- const key = field.slice(0, idx).trim();
3773
+ const key = stripQuotes(field.slice(0, idx).trim());
2529
3774
  if (!SCALE_KEYS.has(key)) continue;
2530
3775
  // 同じ名前を 2 度書いたら後の値を採る
2531
3776
  out.set(key, stripQuotes(field.slice(idx + 1).trim()));
@@ -2597,6 +3842,81 @@ function splitInlineFields(inner: string): string[] {
2597
3842
  return 見た.閉じた ? 見た.parts : 引用符を見て割る(false).parts;
2598
3843
  }
2599
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
+ */
2600
3920
  function collectIndentedList(
2601
3921
  lines: Line[],
2602
3922
  start: number,
@@ -2606,36 +3926,18 @@ function collectIndentedList(
2606
3926
  let i = start;
2607
3927
  while (i < lines.length) {
2608
3928
  const ln = lines[i]!;
2609
- if (!ln.trimmed) {
3929
+ if (!ln.trimmed || ln.trimmed.startsWith("#")) {
2610
3930
  i += 1;
2611
3931
  continue;
2612
3932
  }
2613
3933
  if (ln.indent <= parentIndent) break;
2614
- if (ln.trimmed.startsWith("- ")) {
2615
- items.push({ ...ln, trimmed: ln.trimmed.slice(2).trim() });
2616
- } else if (ln.trimmed.includes(":")) {
2617
- // YAML 風 inline (key: value) は state 用 block で許容
2618
- items.push(ln);
2619
- }
3934
+ // 先頭の `- ` を外して渡す。 外した形と、 YAML 風の `key: value` を読み手が同じに読む
3935
+ items.push(ln.trimmed.startsWith("- ") ? { ...ln, trimmed: ln.trimmed.slice(2).trim() } : ln);
2620
3936
  i += 1;
2621
3937
  }
2622
3938
  return { items, next: i };
2623
3939
  }
2624
3940
 
2625
- /**
2626
- * 登場人物を 1 件ずつ集める。 続く字下げ行は同じ 1 件にまとめる。
2627
- *
2628
- * 項目が少なければ 1 行で書け、 多ければ縦に並べられる。 縦に並べた方が、 何を指定できるかが
2629
- * 見える。
2630
- *
2631
- * ```
2632
- * - Client
2633
- * - API: service
2634
- * - Web:
2635
- * kind: service
2636
- * 色: 失敗
2637
- * ```
2638
- */
2639
3941
  /**
2640
3942
  * 色の指定を振り分ける。
2641
3943
  *
@@ -2739,7 +4041,7 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2739
4041
  /** 倍率を名前ごとに最後に書いた行。 別名の優先順と行番号を取り違えないために保持する。 */
2740
4042
  const scaleLines = new Map<string, number>();
2741
4043
  /**
2742
- * 縦に並べて書かれた体験の道筋の欄 (#1251)。
4044
+ * 縦に並べて書かれたユーザージャーニーの欄 (#1251)。
2743
4045
  *
2744
4046
  * パーツでは状態の上書きとして意味を持つため、どちらに入れるかは block を読み終わってから
2745
4047
  * 決める。 `kind:` の行が後ろに書かれることもあり、読んだ時点ではパーツか分からない。
@@ -2799,6 +4101,111 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2799
4101
  case "補足":
2800
4102
  out.subtitle = stripQuotes(raw);
2801
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
+ }
2802
4209
  case "value":
2803
4210
  case "値":
2804
4211
  out.value = stripQuotes(raw);
@@ -2846,20 +4253,20 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2846
4253
  .filter(Boolean);
2847
4254
  break;
2848
4255
  /*
2849
- * 行頭の印 (#1466)。 `rows` と同じ並びで、空文字はその行に印を付けない。
4256
+ * 行頭の印 (#1466)。 `rows` と同じ並びで書く。
2850
4257
  *
2851
- * **語の意味は図の種類が決める**。 ER は `pk` / `fk` / `opt`、状態遷移は
2852
- * `entry` / `exit` / `do` / `internal`。 印の 2 軸 (形 × 塗り) は共通だが、その軸が
2853
- * 何を指すかは種類ごとに違う = 1 つの語彙に畳むと、どの図でも意味が合わない語が残る。
4258
+ * **語は dragon の 3 つ** (`鍵` / `外` / `条件`)。 印の 3 軸 (下線 / 形 / 塗り) に
4259
+ * 1 対 1 で当たる (`compile/row-marks.ts` が表を持つ)。
4260
+ *
4261
+ * 古い語 (`pk` / `fk` / `opt` / `entry` / `exit` / `do` / `internal`) はここで
4262
+ * 3 語へ読み替える (#2782)。 **読み替えは読み取りの入口 1 か所に閉じる** =
4263
+ * ここを通った後は古い語がどこにも残らないので、組み立て側は 3 語だけを扱う。
2854
4264
  *
2855
4265
  * 空の要素を捨てない (`filter(Boolean)` を掛けない) = 並びが `rows` とずれる。
2856
4266
  */
2857
4267
  case "marks":
2858
4268
  case "印":
2859
- out.marks = raw
2860
- .replace(/^\[|\]$/g, "")
2861
- .split(/,(?![^[]*\])/)
2862
- .map((x) => stripQuotes(x.trim()));
4269
+ out.marks = 行頭の印の並びとして読む(raw, ln.no, errors);
2863
4270
  break;
2864
4271
  case "位置":
2865
4272
  case "pos": {
@@ -2907,6 +4314,11 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2907
4314
  case "posY":
2908
4315
  out.posY = 数として読む(raw, "箱の posY", ln.no, errors);
2909
4316
  break;
4317
+ case "at":
4318
+ case "点の位置":
4319
+ out.at = 四象限の座標として控える(raw);
4320
+ out.atPos = { line: ln.no };
4321
+ break;
2910
4322
  // 位置のずらし (#1971)。 中括弧の形と同じ表で読み、片方だけ書いた時は残りを 0 とする
2911
4323
  case "offsetX":
2912
4324
  case "offsetY": {
@@ -2939,12 +4351,15 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2939
4351
  }
2940
4352
  case "touchpoint":
2941
4353
  case "opportunity":
4354
+ case "opportunityPosition":
2942
4355
  case "owner":
2943
4356
  case "end":
4357
+ case "stage":
4358
+ case "段階":
2944
4359
  // **どちらに入れるかは block を読み終わるまで決まらない** (#1251 Round 1 の指摘)。
2945
4360
  // パーツかどうかは `kind:` の行で決まり、それが後ろに書かれることもある。
2946
4361
  // 倍率 (`scaleWritten`) と読めない項目名 (`unknownKeys`) が同じ理由で後回しにしている
2947
- 図種ごとの欄.set(key, stripQuotes(raw));
4362
+ 図種ごとの欄.set(key === "段階" ? "stage" : key, stripQuotes(raw));
2948
4363
  break;
2949
4364
  case "lane":
2950
4365
  out.lane = stripQuotes(raw);
@@ -2967,7 +4382,7 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2967
4382
  out.scale = s.scale;
2968
4383
  out.scaleKeys = [...new Set([...(actor.scaleKeys ?? []), ...s.keys])];
2969
4384
  }
2970
- // 体験の道筋の欄は、パーツなら状態の上書き、そうでなければ道筋の欄として入れる
4385
+ // ユーザージャーニーの欄は、パーツなら状態の上書き、そうでなければ道筋の欄として入れる
2971
4386
  for (const [key, v] of 図種ごとの欄) {
2972
4387
  if (out.partId !== undefined) {
2973
4388
  state[key] = coerceStateValue(v);
@@ -2976,8 +4391,20 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
2976
4391
  }
2977
4392
  if (key === "touchpoint") out.touchpoint = v;
2978
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
+ }
2979
4405
  else if (key === "owner") out.owner = v;
2980
- else out.end = v;
4406
+ else if (key === "end") out.end = v;
4407
+ else out.stage = v;
2981
4408
  }
2982
4409
  // 状態も倍率も parts でだけ意味を持つ。 パーツなら知らせずに返す
2983
4410
  if (out.partId !== undefined) {
@@ -3017,7 +4444,7 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
3017
4444
  errors.push({
3018
4445
  line: u.line,
3019
4446
  message: `項目名が読めません: "${u.key}"`,
3020
- hint: `使える項目 = ${[...ACTOR_ITEM_KEYS].join(", ")}`,
4447
+ hint: 弾いた項目の案内(u.key),
3021
4448
  });
3022
4449
  }
3023
4450
  return out;
@@ -3033,6 +4460,22 @@ export const ACTOR_ITEM_KEYS: ReadonlySet<string> = new Set([
3033
4460
  "kind",
3034
4461
  "種類",
3035
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",
3036
4479
  "補足",
3037
4480
  "value",
3038
4481
  "値",
@@ -3072,14 +4515,61 @@ export const ACTOR_ITEM_KEYS: ReadonlySet<string> = new Set([
3072
4515
  "scale",
3073
4516
  "lane",
3074
4517
  "stack",
3075
- // 体験の道筋の欄 (#1251)
4518
+ "stage",
4519
+ "段階",
4520
+ // ユーザージャーニーの欄 (#1251)
3076
4521
  "touchpoint",
3077
4522
  "opportunity",
4523
+ "opportunityPosition",
3078
4524
  // 工程の並びの欄 (#1251)
3079
4525
  "owner",
3080
4526
  "end",
4527
+ "startLabel",
4528
+ "milestone",
4529
+ "emphasis",
4530
+ "at",
4531
+ "点の位置",
4532
+ // 箱の目次と、始まりと終わりの印 (#2346)。 中括弧にしか書き方が無かった 3 件
4533
+ "eyebrow",
4534
+ "initial",
4535
+ "final",
3081
4536
  ]);
3082
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
+
3083
4573
  /**
3084
4574
  * 相対で書かれた位置が解けるかを確かめる。
3085
4575
  *
@@ -3091,63 +4581,82 @@ export const ACTOR_ITEM_KEYS: ReadonlySet<string> = new Set([
3091
4581
  */
3092
4582
  function validateRelativePositions(actors: DslActor[], errors: DslError[]): void {
3093
4583
  const named = new Set(actors.map((a) => a.name));
3094
- const broken = new Set<string>();
3095
-
3096
- for (const a of actors) {
3097
- const rel = a.posRel;
3098
- if (!rel) continue;
3099
- if (rel.anchor === a.name) {
3100
- errors.push({
3101
- line: a.pos.line,
3102
- message: `位置の基準が自分自身です: "${a.name}"`,
3103
- hint: "別の登場人物の名前を書く",
3104
- });
3105
- broken.add(a.name);
3106
- continue;
3107
- }
3108
- if (!named.has(rel.anchor)) {
3109
- errors.push({
3110
- line: a.pos.line,
3111
- message: `位置の基準が見つかりません: "${rel.anchor}"`,
3112
- hint:
3113
- named.size > 0
3114
- ? `actors: に書かれている名前 = ${[...named].join(", ")}`
3115
- : "actors: に基準にする登場人物を書く",
3116
- });
3117
- broken.add(a.name);
3118
- }
3119
- }
4584
+ // 解けない書き方の判定は `relative-pos.ts` が持つ。 JSON の入口も同じ判定を呼ぶ (#2039)
4585
+ const problems = findRelativeProblems(actors.map((a) => ({ name: a.name, rel: a.posRel })));
3120
4586
 
3121
- const { cyclic } = orderByDependency(
3122
- actors.map((a) => ({ name: a.name, rel: broken.has(a.name) ? undefined : a.posRel })),
3123
- );
3124
- for (const name of cyclic) {
3125
- const a = actors.find((x) => x.name === name);
4587
+ for (const p of problems) {
4588
+ const a = actors.find((x) => x.name === p.name);
3126
4589
  errors.push({
3127
4590
  line: a?.pos.line ?? 1,
3128
- message: `位置の基準が互いを指しています: "${name}"`,
3129
- hint: "どれか 1 つは座標 (`位置: 300,200`) か自動配置にする",
4591
+ ...相対の誤り(p, named),
3130
4592
  });
3131
- broken.add(name);
3132
4593
  }
3133
4594
 
4595
+ const broken = new Set(problems.map((p) => p.name));
3134
4596
  for (const a of actors) {
3135
4597
  if (broken.has(a.name)) a.posRel = undefined;
3136
4598
  }
3137
4599
  }
3138
4600
 
4601
+ /**
4602
+ * 解けない相対の指定に対する、記法側の誤りの文 (#2039)。
4603
+ *
4604
+ * 判定は `relative-pos.ts` が持ち、文はここが持つ。 JSON 側は欄の場所を添えた別の文を作る。
4605
+ */
4606
+ function 相対の誤り(
4607
+ p: RelativeProblem,
4608
+ named: ReadonlySet<string>,
4609
+ ): { message: string; hint: string } {
4610
+ switch (p.kind) {
4611
+ case "self":
4612
+ return {
4613
+ message: `位置の基準が自分自身です: "${p.name}"`,
4614
+ hint: "別の登場人物の名前を書く",
4615
+ };
4616
+ case "missing-anchor":
4617
+ return {
4618
+ message: `位置の基準が見つかりません: "${p.anchor ?? ""}"`,
4619
+ hint:
4620
+ named.size > 0
4621
+ ? `actors: に書かれている名前 = ${[...named].join(", ")}`
4622
+ : "actors: に基準にする登場人物を書く",
4623
+ };
4624
+ case "cyclic":
4625
+ return {
4626
+ message: `位置の基準が互いを指しています: "${p.name}"`,
4627
+ hint: "どれか 1 つは座標 (`位置: 300,200`) か自動配置にする",
4628
+ };
4629
+ }
4630
+ }
4631
+
4632
+ /**
4633
+ * 登場人物を 1 件ずつ集める。 続く字下げ行は同じ 1 件にまとめる。
4634
+ *
4635
+ * 項目が少なければ 1 行で書け、 多ければ縦に並べられる。 縦に並べた方が、 何を指定できるかが
4636
+ * 見える。
4637
+ *
4638
+ * ```
4639
+ * - Client
4640
+ * - API: service
4641
+ * - Web:
4642
+ * kind: service
4643
+ * 色: 失敗
4644
+ * ```
4645
+ */
3139
4646
  function collectActorEntries(
3140
4647
  lines: Line[],
3141
4648
  start: number,
3142
4649
  parentIndent: number,
3143
- ): { items: Line[][]; next: number } {
4650
+ ): { items: Line[][]; 届かない: Line[]; next: number } {
3144
4651
  const items: Line[][] = [];
4652
+ /** 1 件の頭にも続きにもならなかった行 (#2400)。 呼出側が知らせにする */
4653
+ const 届かない: Line[] = [];
3145
4654
  let cur: Line[] | null = null;
3146
4655
  let headIndent = -1;
3147
4656
  let i = start;
3148
4657
  while (i < lines.length) {
3149
4658
  const ln = lines[i]!;
3150
- if (!ln.trimmed) {
4659
+ if (!ln.trimmed || ln.trimmed.startsWith("#")) {
3151
4660
  i += 1;
3152
4661
  continue;
3153
4662
  }
@@ -3159,25 +4668,34 @@ function collectActorEntries(
3159
4668
  } else if (cur && ln.indent > headIndent) {
3160
4669
  // 頭より深い字下げは、 直前の 1 件の続き
3161
4670
  cur.push(ln);
4671
+ } else {
4672
+ // 続きにも頭にもならない行を **捨てない** (#2400)。 呼出側が「読めません」 として知らせる。
4673
+ // 捨てると `- ` を書き落とした 1 行が知らせにも図にも現れない (実測 = 箱が 1 つ消えた)
4674
+ //
4675
+ // **ここで 1 件の頭にはしない**。 `- Web:` と同じ深さに書いた `補足: "..."` が
4676
+ // 登場人物 `補足` になり、書き間違いが別の意味に化ける (#1367 の検査が守っている形)
4677
+ 届かない.push(ln);
3162
4678
  }
3163
4679
  i += 1;
3164
4680
  }
3165
4681
  if (cur) items.push(cur);
3166
- return { items, next: i };
4682
+ return { items, 届かない, next: i };
3167
4683
  }
3168
4684
 
3169
4685
  function collectAnimationSteps(
3170
4686
  lines: Line[],
3171
4687
  start: number,
3172
4688
  parentIndent: number,
3173
- ): { items: Line[][]; next: number } {
4689
+ ): { items: Line[][]; 届かない: Line[]; next: number } {
3174
4690
  // 各 `- step: "..."` 開始を 1 block の頭として識別、 後続の同 indent 以下を block 本文として吸収
3175
4691
  const out: Line[][] = [];
4692
+ /** どの段にも属さなかった行 (#2400)。 呼出側が知らせにする */
4693
+ const 届かない: Line[] = [];
3176
4694
  let i = start;
3177
4695
  let cur: Line[] | null = null;
3178
4696
  while (i < lines.length) {
3179
4697
  const ln = lines[i]!;
3180
- if (!ln.trimmed) {
4698
+ if (!ln.trimmed || ln.trimmed.startsWith("#")) {
3181
4699
  i += 1;
3182
4700
  continue;
3183
4701
  }
@@ -3191,11 +4709,15 @@ function collectAnimationSteps(
3191
4709
  } else if (cur) {
3192
4710
  // 続く property line (focus / tween / set / badge / description)
3193
4711
  cur.push(ln);
4712
+ } else {
4713
+ // 最初の段が始まる前の行を **捨てない** (#2400)。 呼出側が「読めません」 として知らせる。
4714
+ // 捨てると `- ` を書き落とした段が丸ごと黙って消える
4715
+ 届かない.push(ln);
3194
4716
  }
3195
4717
  i += 1;
3196
4718
  }
3197
4719
  if (cur) out.push(cur);
3198
- return { items: out, next: i };
4720
+ return { items: out, 届かない, next: i };
3199
4721
  }
3200
4722
 
3201
4723
  /**
@@ -3207,6 +4729,18 @@ function collectAnimationSteps(
3207
4729
  export const ACTOR_RESERVED_FIELDS: ReadonlySet<string> = new Set([
3208
4730
  "kind",
3209
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",
3210
4744
  "eyebrow",
3211
4745
  "value",
3212
4746
  "previous",
@@ -3214,10 +4748,15 @@ export const ACTOR_RESERVED_FIELDS: ReadonlySet<string> = new Set([
3214
4748
  "marks",
3215
4749
  "lane",
3216
4750
  "stack",
4751
+ "stage",
3217
4752
  "initial",
3218
4753
  "final",
4754
+ "startLabel",
4755
+ "milestone",
4756
+ "emphasis",
4757
+ "at",
3219
4758
  "state",
3220
- // 体験の道筋の欄 (`touchpoint` / `opportunity`) はここに載せない (#1251 Round 1 の指摘)。
4759
+ // ユーザージャーニーの欄 (`touchpoint` / `opportunity`) はここに載せない (#1251 Round 1 の指摘)。
3221
4760
  // 載せるとパーツで同じ名前の状態を書いた時に横取りされる = 既に動いている見本が静かに変わる。
3222
4761
  // パーツでない箱でだけ道筋の欄として読む (`parseActor` / `applyContinuationLines` が分岐する)
3223
4762
  // canvas pivot 新 spec = 絶対座標 4 field (dragon canvas pivot spec §layout-role-conversion)
@@ -3279,12 +4818,33 @@ function coerceStateValue(raw: string): number | string | boolean {
3279
4818
  return stripped;
3280
4819
  }
3281
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
+
3282
4834
  /**
3283
4835
  * パーツでない箱に倍率を書いた時に知らせる (#1026)。
3284
4836
  *
3285
4837
  * 倍率はパーツにしか効かない。 黙って捨てると「書いたのに大きさが変わらない」 が手掛かり
3286
4838
  * なしで起きる。 3 つの書き方すべてで同じ知らせを出す (縦に並べた形だけ知らせて他が黙る、
3287
4839
  * という状態を作らない)。
4840
+ *
4841
+ * **案内に語彙を並べない** (#2330)。 語彙には `倍率` と `scale` が入っているので、
4842
+ * 並べると **いま弾いた名前を「使える項目」 として出す** ことになる。 読み手は同じ名前を
4843
+ * 書き直して同じ知らせを受ける (実測 = 案内の 39 項目のうち、弾かれるのはこの 2 件だけ)。
4844
+ *
4845
+ * 弾く理由はここが知っている (`isPart === false`) ので、**どこでなら書けるか** を出す。
4846
+ * 綴りの誤りを知らせる側 (`unknownKeys`) は語彙を並べたままにする = そちらは弾いた名前が
4847
+ * 語彙に無いため食い違わない。
3288
4848
  */
3289
4849
  function reportScaleOnNonPart(
3290
4850
  isPart: boolean,
@@ -3296,53 +4856,39 @@ function reportScaleOnNonPart(
3296
4856
  errors.push({
3297
4857
  line,
3298
4858
  message: `項目名が読めません: "${key}"`,
3299
- hint: `使える項目 = ${[...ACTOR_ITEM_KEYS].join(", ")}`,
4859
+ hint: 弾いた項目の案内(key),
3300
4860
  });
3301
4861
  }
3302
4862
 
3303
4863
  /**
3304
- * 中括弧の形で読める項目名。
3305
- *
3306
- * ここに無い名前は、 パーツでない箱ではどこにも入らずに消える。 `ACTOR_ITEM_KEYS` (縦に
3307
- * 並べた形) とは別に持つ = 中括弧の形は位置や大きさを未対応にしてあり、 同じ集合にすると
3308
- * 「知らせない」 側がずれる。
3309
- */
3310
- /**
3311
- * 中括弧の形で読める日本語と、その英語名 (#1301)。
3312
- *
3313
- * **英語が中括弧で読める欄だけを載せる**。 `位置` / `大きさ` / `色` は英語側
3314
- * (`pos` / `size` / `color`) も中括弧では読めないため載せない = 英語で出来ないことを
3315
- * 日本語で出来るようにはしない。
3316
- *
3317
- * 載せる前は、同じ意味の語が縦書きでは通り中括弧では「項目名が読めません」 になっていた。
3318
- * 書き方によって日本語だけが落ちる状態を無くす。
3319
- */
3320
- export const INLINE_ACTOR_ALIASES: Record<string, string> = {
3321
- 種類: "kind",
3322
- 補足: "subtitle",
3323
- 値: "value",
3324
- 前の値: "previous",
3325
- 行: "rows",
3326
- 印: "marks",
3327
- };
3328
-
3329
- /** 中括弧に書かれた日本語の項目名を、同じ意味の英語名に寄せる (#1301) */
3330
- function 中括弧の別名を寄せる(opts: Record<string, string>): Record<string, string> {
3331
- let 触った = false;
3332
- const out: Record<string, string> = { ...opts };
3333
- for (const [日, 英] of Object.entries(INLINE_ACTOR_ALIASES)) {
3334
- if (!(日 in out)) continue;
3335
- 触った = true;
3336
- // 英語を併記した時は英語を優先する (縦書き形が後勝ちなのと違い、こちらは 1 行に同居する)
3337
- if (!(英 in out)) out[英] = out[日]!;
3338
- delete out[日];
3339
- }
3340
- return 触った ? out : opts;
3341
- }
3342
-
3343
- export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
4864
+ * 中括弧の形で読める英語の項目名 (#2344 で `INLINE_ACTOR_KEYS` から分けた)。
4865
+ *
4866
+ * ここに無い名前は、 パーツでない箱ではどこにも入らずに消える。 `ACTOR_ITEM_KEYS` (縦に
4867
+ * 並べた形) とは別に持つ = 中括弧の形は位置や大きさ (`pos` / `size` / `scale`) を未対応にしてあり、
4868
+ * 同じ集合にすると「知らせない」 側がずれる。
4869
+ *
4870
+ * **日本語の別名を混ぜない**。 別名は `ACTOR_ITEM_ALIASES` をこの集合で絞って導くため、
4871
+ * 混ぜると導く側と導かれる側が輪になる。 日本語も含む一覧は `INLINE_ACTOR_KEYS` が持つ。
4872
+ */
4873
+ const INLINE_ACTOR_ENGLISH_KEYS: ReadonlySet<string> = new Set([
3344
4874
  "kind",
3345
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",
3346
4892
  "eyebrow",
3347
4893
  "value",
3348
4894
  "previous",
@@ -3350,18 +4896,24 @@ export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
3350
4896
  "marks",
3351
4897
  "lane",
3352
4898
  "stack",
4899
+ "stage",
3353
4900
  "initial",
3354
4901
  "final",
3355
4902
  "tone",
3356
4903
  // 色の欄 (#1969)。 縦に並べた形と JSON は `color` を受けるのに、中括弧の形だけが知らない
3357
4904
  // 項目名として落としていた。 見本 (parts) では従来どおり状態の上書きとして読む
3358
4905
  "color",
3359
- // 体験の道筋の欄 (#1251)。 他の図種では組み立て側が知らせる
4906
+ // ユーザージャーニーの欄 (#1251)。 他の図種では組み立て側が知らせる
3360
4907
  "touchpoint",
3361
4908
  "opportunity",
4909
+ "opportunityPosition",
3362
4910
  // 工程の並びの欄 (#1251)
3363
4911
  "owner",
3364
4912
  "end",
4913
+ "startLabel",
4914
+ "milestone",
4915
+ "emphasis",
4916
+ "at",
3365
4917
  "posX",
3366
4918
  "posY",
3367
4919
  "posW",
@@ -3385,7 +4937,72 @@ export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
3385
4937
  // 同じ名前で 2 度知らせることになる
3386
4938
  "scale",
3387
4939
  "倍率",
3388
- // 英語が読める欄の日本語別名 (#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,
3389
5006
  ...Object.keys(INLINE_ACTOR_ALIASES),
3390
5007
  ]);
3391
5008
  // `state` はパーツでだけ意味を持つ (`extractStateOverride` がパーツの時しか作らない)。
@@ -3428,7 +5045,7 @@ const EDGE_BIND_INLINE_READERS = {
3428
5045
 
3429
5046
  const FLOW_INLINE_READERS = {
3430
5047
  sub: (v: string | undefined) => v,
3431
- // クラス図の出どころ側の多重度 (#1771)。 `sub` (行き先の端) と対
5048
+ // UML の関係の出どころ側の多重度 (#1771 / #2783)。 `sub` (行き先の端) と対
3432
5049
  tailSub: (v: string | undefined) => v,
3433
5050
  guard: (v: string | undefined) => v,
3434
5051
  cardinality: (v: string | undefined) => v,
@@ -3449,11 +5066,24 @@ const FLOW_INLINE_READERS = {
3449
5066
  */
3450
5067
  labelPlate: (v: string | undefined) =>
3451
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,
3452
5074
  // 矢印がどの辺から出るか (#1385)。 描画側は 4 方向を取り、書かなければ自動で選ぶ
3453
5075
  side: (v: string | undefined) =>
3454
5076
  v !== undefined && (EDGE_SIDE_VALUES as readonly string[]).includes(v)
3455
5077
  ? (v as "top" | "right" | "bottom" | "left")
3456
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,
3457
5087
  // 矢印の先の形 (#1462)。 書かなければ描画側の既定 (塗った三角) になる
3458
5088
  head: (v: string | undefined) =>
3459
5089
  v !== undefined && EDGE_HEAD_VALUES.includes(v) ? (v as EdgeHead) : undefined,
@@ -3466,7 +5096,7 @@ const FLOW_INLINE_READERS = {
3466
5096
  tailHeadFill: (v: string | undefined) =>
3467
5097
  v !== undefined && EDGE_HEAD_FILL_VALUES.includes(v) ? (v as EdgeHeadFill) : undefined,
3468
5098
  /*
3469
- * クラス図の関係の種類 (#1466)。 書くと **線と端の形と塗りと付く側** がまとめて決まる
5099
+ * record の UML 関係の種類 (#1466 / #2783)。書くと **線と端の形と塗りと付く側** がまとめて決まる
3470
5100
  * (`CLASS_RELATION_LOOK`)。
3471
5101
  *
3472
5102
  * 4 つを個別に書かせない = 組合せは 6 通りしか無く、1 つでも書き違えると読み手に別の意味で
@@ -3494,10 +5124,15 @@ const FLOW_INLINE_READERS = {
3494
5124
  */
3495
5125
  fromPartNode: (v: string | undefined) => v,
3496
5126
  toPartNode: (v: string | undefined) => v,
5127
+ waypoints: (v: string | undefined) => v,
3497
5128
  // 数と真偽の欄は `FLOW_INLINE_VALUE_KINDS` の表が読む (#1306)。 ここでは名前だけを持つ =
3498
5129
  // 読める欄の一覧 (`FLOW_INLINE_KEYS`) は本表から導くため、載せないと欄ごと消える
3499
5130
  labelOffsetX: null,
3500
5131
  labelOffsetY: null,
5132
+ posX1: null,
5133
+ posY1: null,
5134
+ posX2: null,
5135
+ posY2: null,
3501
5136
  overlay: null,
3502
5137
  } as const;
3503
5138
 
@@ -3534,7 +5169,7 @@ function reportUnknownInlineKeys(
3534
5169
  for (const field of splitInlineFields(inner)) {
3535
5170
  const idx = field.indexOf(":");
3536
5171
  if (idx < 0) continue;
3537
- const key = field.slice(0, idx).trim();
5172
+ const key = stripQuotes(field.slice(0, idx).trim());
3538
5173
  if (!key) continue;
3539
5174
  if (isPart) {
3540
5175
  if (!PART_INLINE_UNREADABLE_KEYS.has(key)) continue;
@@ -3548,10 +5183,23 @@ function reportUnknownInlineKeys(
3548
5183
  continue;
3549
5184
  }
3550
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);
3551
5197
  errors.push({
3552
5198
  line,
3553
5199
  message: `項目名が読めません: "${key}"`,
3554
- hint: `使える項目 = ${[...INLINE_ACTOR_KEYS].join(", ")}`,
5200
+ hint: 段を分ければ効く
5201
+ ? `1 行の中括弧では読めません。名前の下に段を分けた形で書くと効きます`
5202
+ : `使える項目 = ${[...INLINE_ACTOR_KEYS].join(", ")}`,
3555
5203
  });
3556
5204
  }
3557
5205
  }
@@ -3579,7 +5227,7 @@ function 中括弧の知らない項目名を知らせる(
3579
5227
  for (const field of splitInlineFields(inner)) {
3580
5228
  const idx = field.indexOf(":");
3581
5229
  if (idx < 0) continue;
3582
- const key = field.slice(0, idx).trim();
5230
+ const key = stripQuotes(field.slice(0, idx).trim());
3583
5231
  if (!key || 使える.includes(key)) continue;
3584
5232
  // 接頭は値の欄名に続ける形 (`縦列 l1 の `) で末尾に空白を持つ。 日本語へ続ける時は空白を落とす
3585
5233
  errors.push({
@@ -3623,6 +5271,40 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
3623
5271
  if (!isPart && opts.tone !== undefined && resolveTone(opts.tone) === undefined) {
3624
5272
  report読めない色(opts.tone, line.no, errors, { 線種も受ける: false });
3625
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
+ }
3626
5308
  // `color` は縦に並べた形と JSON と同じ振り分けを通す (#1969)。 色の名前は箱の色、`#` で
3627
5309
  // 始まる値は色番号になる。
3628
5310
  //
@@ -3643,13 +5325,43 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
3643
5325
  // 「書かなかった」 と同じ扱いにする (#1058)
3644
5326
  kindWritten: kindRaw !== "" && !isPart,
3645
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,
3646
5348
  eyebrow: opts.eyebrow,
3647
5349
  // パーツでは状態の上書きとして意味を持つため、道筋の欄として横取りしない (#1251)
3648
5350
  touchpoint: isPart ? undefined : opts.touchpoint,
3649
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"],
3650
5357
  // 見本では状態の上書きとして意味を持つため横取りしない (#1251)
3651
5358
  owner: isPart ? undefined : opts.owner,
3652
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,
3653
5365
  value: opts.value,
3654
5366
  previous: opts.previous,
3655
5367
  rows: opts.rows
@@ -3659,12 +5371,11 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
3659
5371
  .map((x) => stripQuotes(x.trim()))
3660
5372
  .filter(Boolean)
3661
5373
  : undefined,
3662
- // 行頭の印 (#1466)。 `rows` と同じ並びなので **空の要素を捨てない**
5374
+ // 行頭の印 (#1466)。 `rows` と同じ並びなので **空の要素を捨てない**。
5375
+ // 古い語の読み替えは縦に並べた形と同じ関数を通す (#2782) = 片方だけ通すと、
5376
+ // 中括弧で書いた `pk` が印を持たないまま通る
3663
5377
  marks: opts.marks
3664
- ? opts.marks
3665
- .replace(/^\[|\]$/g, "")
3666
- .split(/,(?![^[]*\])/)
3667
- .map((x) => stripQuotes(x.trim()))
5378
+ ? 行頭の印の並びとして読む(opts.marks, line.no, errors)
3668
5379
  : undefined,
3669
5380
  lane: opts.lane,
3670
5381
  // 箱の中に描く図形 (#1374)。 パーツでは状態の上書きとして意味を持つため横取りしない
@@ -3795,12 +5506,17 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3795
5506
  if (読み手 === null) continue;
3796
5507
  中括弧[k] = 読み手(opts[k]);
3797
5508
  }
3798
- if (opts.side !== undefined && 中括弧.side === undefined) {
3799
- errors.push({
3800
- line: line.no,
3801
- message: `矢印の side が読めません: "${opts.side}"`,
3802
- hint: `使える値 = ${EDGE_SIDE_VALUES.join(", ")}`,
3803
- });
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(", ")}` });
3804
5520
  }
3805
5521
  // 読めない語を黙って捨てない (#1462)。 捨てると「書いたのに端の形が変わらない」 が
3806
5522
  // 手掛かりなしで起きる
@@ -3830,6 +5546,8 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3830
5546
  const guard = 中括弧.guard as string | undefined;
3831
5547
  const cardinality = 中括弧.cardinality as string | undefined;
3832
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;
3833
5551
  const head = 中括弧.head as EdgeHead | undefined;
3834
5552
  const tailHead = 中括弧.tailHead as EdgeHead | undefined;
3835
5553
  const headFill = 中括弧.headFill as EdgeHeadFill | undefined;
@@ -3839,6 +5557,19 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3839
5557
  // 辺の役目と名前の下地 (cdl#618)
3840
5558
  const role = 中括弧.role as "main" | undefined;
3841
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
+ }
3842
5573
  // 値に追随する 3 欄 (#1396)。 空は捨てずに知らせる = 描画側は空文字を既定値へ落とさず
3843
5574
  // そのまま置換に使うため、書き忘れが「線が消えた」 形で出る
3844
5575
  const widthBind = 追随する大きさとして読む(
@@ -3861,6 +5592,16 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3861
5592
  );
3862
5593
  const labelOffsetX = 数と真偽?.labelOffsetX;
3863
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
+ );
3864
5605
  const overlay = 数と真偽?.overlay;
3865
5606
  // 空の名指し (`toPartNode: ""`) は書かなかったことにする = 要素 1 つの部品なら自動で繋ぐ
3866
5607
  const fromPartNode = (中括弧.fromPartNode as string | undefined) || undefined;
@@ -3923,6 +5664,8 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3923
5664
  guard,
3924
5665
  cardinality,
3925
5666
  side,
5667
+ fromSide,
5668
+ toSide,
3926
5669
  head,
3927
5670
  tailHead,
3928
5671
  headFill,
@@ -3936,6 +5679,13 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3936
5679
  dashOffsetBind,
3937
5680
  labelOffsetX,
3938
5681
  labelOffsetY,
5682
+ posX1,
5683
+ posY1,
5684
+ posX2,
5685
+ posY2,
5686
+ waypoints,
5687
+ labelCollision,
5688
+ labelSize,
3939
5689
  overlay,
3940
5690
  ...(fromPartNode !== undefined ? { fromPartNode } : {}),
3941
5691
  ...(toPartNode !== undefined ? { toPartNode } : {}),
@@ -3943,12 +5693,35 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
3943
5693
  };
3944
5694
  }
3945
5695
 
3946
- function parseStateEntry(text: string, lineNo: number): DslState | null {
3947
- // `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 {
3948
5711
  const m = text.match(/^([^:]+?)\s*:\s*(.+)$/);
3949
- 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
+ }
3950
5720
  const name = (m[1] ?? "").trim();
3951
- if (!isValueName(name)) return null;
5721
+ if (!isValueName(name)) {
5722
+ errors.push({ line: lineNo, ...valueNameIssue(name) });
5723
+ return null;
5724
+ }
3952
5725
  const raw = (m[2] ?? "").trim();
3953
5726
  const stripped = stripQuotes(raw);
3954
5727
  const asNum = Number(stripped);
@@ -3957,31 +5730,6 @@ function parseStateEntry(text: string, lineNo: number): DslState | null {
3957
5730
  return { name, initial, pos: { line: lineNo } };
3958
5731
  }
3959
5732
 
3960
- /**
3961
- * 字下げした行を 1 行も落とさずに集める。
3962
- *
3963
- * `collectIndentedList` は形が合わない行を黙って捨てるが、 `values` では捨てずに
3964
- * 読み手 (`parseValueEntry`) へ渡して書き間違いとして報告させる。
3965
- */
3966
- function collectIndentedRaw(
3967
- lines: Line[],
3968
- start: number,
3969
- parentIndent: number,
3970
- ): { items: Line[]; next: number } {
3971
- const items: Line[] = [];
3972
- let i = start;
3973
- while (i < lines.length) {
3974
- const ln = lines[i];
3975
- if (!ln || !ln.trimmed || ln.trimmed.startsWith("#")) {
3976
- i += 1;
3977
- continue;
3978
- }
3979
- if (ln.indent <= parentIndent) break;
3980
- items.push(ln);
3981
- i += 1;
3982
- }
3983
- return { items, next: i };
3984
- }
3985
5733
 
3986
5734
  /**
3987
5735
  * `waiting: "{inflow} - {done}"` を 1 件の値として読む。
@@ -3994,7 +5742,7 @@ function parseValueEntry(text: string, lineNo: number, errors: DslError[]): DslV
3994
5742
  if (!m) {
3995
5743
  errors.push({
3996
5744
  line: lineNo,
3997
- message: `invalid value entry: "${text}"`,
5745
+ message: `値の行が読めません: "${text}"`,
3998
5746
  hint: '`waiting: "{inflow} - {done}"` の形で書く',
3999
5747
  });
4000
5748
  return null;
@@ -4025,7 +5773,7 @@ function parseValueEntry(text: string, lineNo: number, errors: DslError[]): DslV
4025
5773
  if (expression === "") {
4026
5774
  errors.push({
4027
5775
  line: lineNo,
4028
- message: `empty expression for "${name}"`,
5776
+ message: `式が空です: "${name}"`,
4029
5777
  hint: '`"{a} + {b}"` のように式を書く',
4030
5778
  });
4031
5779
  return null;
@@ -4038,6 +5786,397 @@ function parseValueEntry(text: string, lineNo: number, errors: DslError[]): DslV
4038
5786
  return { name, expression, pos: { line: lineNo } };
4039
5787
  }
4040
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
+
4041
6180
  function splitTopLevelCommas(s: string): string[] {
4042
6181
  // brace 内を考慮 ... 今回は単純 split (動作する範囲)
4043
6182
  return s
@@ -4061,8 +6200,8 @@ function ensureAnimate(a: DslAnimate | undefined, lineNo: number): DslAnimate {
4061
6200
  export const PHASE_ITEM_WORDS = [
4062
6201
  "focus",
4063
6202
  "badge",
4064
- "body",
4065
- "description",
6203
+ // 説明の語は `PHASE_BODY_KEYS` から導く (#2621)。 ここに書き写すと JSON の入口とずれる
6204
+ ...PHASE_BODY_KEYS,
4066
6205
  "tween",
4067
6206
  "set",
4068
6207
  "draw",
@@ -4078,8 +6217,8 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4078
6217
  if (!m) {
4079
6218
  errors.push({
4080
6219
  line: head.no,
4081
- message: `invalid step header: "${head.trimmed}"`,
4082
- hint: 'use `- step: "name" 1.5s`',
6220
+ message: `段の見出しが読めません: "${head.trimmed}"`,
6221
+ hint: '`- step: "name" 1.5s` の形で書く',
4083
6222
  });
4084
6223
  return null;
4085
6224
  }
@@ -4089,8 +6228,8 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4089
6228
  if (!headParse) {
4090
6229
  errors.push({
4091
6230
  line: head.no,
4092
- message: `invalid step value: "${headRest}"`,
4093
- hint: 'use `"name" 1.5s` (duration in s)',
6231
+ message: `段の値が読めません: "${headRest}"`,
6232
+ hint: '`"name" 1.5s` の形で書く (長さは秒)',
4094
6233
  });
4095
6234
  return null;
4096
6235
  }
@@ -4112,6 +6251,14 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4112
6251
  // 拾った上で、知らない名前は下で誤りとして知らせる
4113
6252
  const propMatch = t.match(/^([^\s:]+)\s*:\s*(.*)$/);
4114
6253
  if (!propMatch) {
6254
+ // **項目名として読めなかった行も捨てない** (#2403)。 すぐ下で「知らない項目名」 は
6255
+ // 知らせているのに、ここだけが黙っていた = コロンを書き落とすと、その行が
6256
+ // 知らせにも図にも現れない (実測 = 段の中に `focus` と書くと誤り 0 件で何も起きない)
6257
+ errors.push({
6258
+ line: ln.no,
6259
+ message: `段の行が項目名として読めません: "${t}"`,
6260
+ hint: `\`項目名: 値\` の形で書く (使える項目 = ${段の項目の英語.join(", ")})`,
6261
+ });
4115
6262
  i += 1;
4116
6263
  continue;
4117
6264
  }
@@ -4155,7 +6302,7 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4155
6302
  errors.push({
4156
6303
  line: ln.no,
4157
6304
  message: `draw に書ける項目は語と割合の 2 つまでです: "${stripQuotes(value).trim()}"`,
4158
- hint: "use `draw: line 0.4`",
6305
+ hint: "`draw: line 0.4` の形で書く",
4159
6306
  });
4160
6307
  } else {
4161
6308
  phase.draw = 語;
@@ -4178,39 +6325,28 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4178
6325
  i += 1;
4179
6326
  continue;
4180
6327
  }
4181
- if (key === "description" || key === "body") {
6328
+ if ((PHASE_BODY_KEYS as readonly string[]).includes(key)) {
4182
6329
  phase.body = stripQuotes(value);
4183
6330
  i += 1;
4184
6331
  continue;
4185
6332
  }
4186
6333
  if (key === "tween") {
4187
- // inline (tween: client_bal 100 -> 90) or block
6334
+ // 1 行にまとめた形 (tween: client_bal 100 -> 90) と縦に並べた形の両方を受ける。
6335
+ // 読めない行の文は読み手が持つ (#2403)
4188
6336
  if (value) {
4189
- const tw = parseTweenLine(value, ln.no);
6337
+ const tw = parseTweenLine(value, ln.no, errors);
4190
6338
  if (tw) phase.tweens!.push(tw);
4191
- else
4192
- errors.push({
4193
- line: ln.no,
4194
- message: `invalid tween: "${value}"`,
4195
- hint: "use `tween: name 100 -> 90`",
4196
- });
4197
6339
  i += 1;
4198
6340
  continue;
4199
6341
  }
4200
- // block ... 後続の同 indent + 1 以上の行を取り込む
6342
+ // 縦に並べた形 ... 後続の同 indent + 1 以上の行を取り込む
4201
6343
  const baseIndent = ln.indent;
4202
6344
  let j = i + 1;
4203
6345
  while (j < block.length) {
4204
6346
  const nx = block[j]!;
4205
6347
  if (nx.indent <= baseIndent) break;
4206
- const tw = parseTweenLine(nx.trimmed, nx.no);
6348
+ const tw = parseTweenLine(nx.trimmed, nx.no, errors);
4207
6349
  if (tw) phase.tweens!.push(tw);
4208
- else
4209
- errors.push({
4210
- line: nx.no,
4211
- message: `invalid tween entry: "${nx.trimmed}"`,
4212
- hint: "use `name: 100 -> 90`",
4213
- });
4214
6350
  j += 1;
4215
6351
  }
4216
6352
  i = j;
@@ -4218,7 +6354,7 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4218
6354
  }
4219
6355
  if (key === "set") {
4220
6356
  if (value) {
4221
- const st = parseSetLine(value, ln.no);
6357
+ const st = parseSetLine(value, ln.no, errors);
4222
6358
  if (st) phase.sets!.push(st);
4223
6359
  i += 1;
4224
6360
  continue;
@@ -4228,7 +6364,7 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
4228
6364
  while (j < block.length) {
4229
6365
  const nx = block[j]!;
4230
6366
  if (nx.indent <= baseIndent) break;
4231
- const st = parseSetLine(nx.trimmed, nx.no);
6367
+ const st = parseSetLine(nx.trimmed, nx.no, errors);
4232
6368
  if (st) phase.sets!.push(st);
4233
6369
  j += 1;
4234
6370
  }
@@ -4292,23 +6428,47 @@ function 段の項目のヒント(書いた名前: string): string {
4292
6428
  *
4293
6429
  * いまは語と図種が同じ綴りだが、**同じものとして扱わない**。 語は書き手が書く名前で、
4294
6430
  * 図種は `type:` が取る値。 片方だけ別名を足したくなった時に、対応が表に残っている形にする。
6431
+ *
6432
+ * **数を描く図は形まで指す** (#2657)。 9 つの型を `chart` 1 つに畳んだので、型だけを
6433
+ * 指すと `draw: pie` を棒の図に書いても咎められなくなる = 語ごとの相手を型から形へ
6434
+ * 下ろして、畳む前と同じ細かさで判定する。
4295
6435
  */
4296
- export const DRAW_TARGETS: ReadonlyMap<string, PresetType> = new Map<string, PresetType>([
4297
- ["line", "line"],
4298
- ["bar", "bar"],
4299
- ["pie", "pie"],
4300
- ["journey", "journey"],
4301
- ["mind", "mind"],
4302
- ["tree", "tree"],
4303
- ["gantt", "gantt"],
4304
- ["funnel", "funnel"],
4305
- ["slope", "slope"],
4306
- ["gauge", "gauge"],
4307
- ["radial", "radial"],
4308
- ["stacked", "stacked"],
4309
- ["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" }],
4310
6453
  ]);
4311
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
+
4312
6472
  /** `draw:` に書ける語。 表から導く (#1314) */
4313
6473
  export const DRAW_WORDS: ReadonlySet<string> = new Set(DRAW_TARGETS.keys());
4314
6474
 
@@ -4434,13 +6594,33 @@ function parseFocusList(s: string): string[] {
4434
6594
  return out;
4435
6595
  }
4436
6596
 
4437
- function parseTweenLine(s: string, lineNo: number): DslTween | null {
4438
- // `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 {
4439
6609
  const cleaned = s.replace(/^-\s*/, "").trim();
4440
6610
  const m = cleaned.match(/^([^:\s]+)\s*[:\s]\s*(-?\d+(?:\.\d+)?)\s*->\s*(-?\d+(?:\.\d+)?)$/);
4441
- 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
+ }
4442
6619
  const state = m[1] ?? "";
4443
- if (!isValueName(state)) return null;
6620
+ if (!isValueName(state)) {
6621
+ errors.push({ line: lineNo, ...valueNameIssue(state) });
6622
+ return null;
6623
+ }
4444
6624
  return {
4445
6625
  state,
4446
6626
  from: parseFloat(m[2] ?? "0"),
@@ -4449,13 +6629,32 @@ function parseTweenLine(s: string, lineNo: number): DslTween | null {
4449
6629
  };
4450
6630
  }
4451
6631
 
4452
- function parseSetLine(s: string, lineNo: number): DslSet | null {
4453
- // `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 {
4454
6643
  const cleaned = s.replace(/^-\s*/, "").trim();
4455
6644
  const m = cleaned.match(/^([^:\s]+)\s*[:\s]\s*(.+)$/);
4456
- 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
+ }
4457
6653
  const state = m[1] ?? "";
4458
- if (!isValueName(state)) return null;
6654
+ if (!isValueName(state)) {
6655
+ errors.push({ line: lineNo, ...valueNameIssue(state) });
6656
+ return null;
6657
+ }
4459
6658
  const raw = (m[2] ?? "").trim();
4460
6659
  const stripped = stripQuotes(raw);
4461
6660
  const asNum = Number(stripped);