@cardenelabs/dragon 0.12.0 → 0.14.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.
@@ -20,6 +20,7 @@
20
20
 
21
21
  import {
22
22
  DRAW_WORDS,
23
+ EDGE_SIDE_VALUES,
23
24
  NODE_KIND_VALID,
24
25
  PRESET_TYPES,
25
26
  STYLE_VALID,
@@ -27,9 +28,16 @@ import {
27
28
  resolveTone,
28
29
  splitColorValue,
29
30
  書ける色名,
31
+ 図形の表,
32
+ 部品の表,
33
+ 部品の組の表,
34
+ つまみの表,
35
+ EVENT_KINDS,
36
+ type 図形の定義,
30
37
  } from "./v05/parser";
31
38
  import type { CompileToCdlOpts } from "./compile";
32
39
  import type { CdlDiagram, NodeKind, Tone, EdgeStyle } from "@cardenelabs/cdl";
40
+ import { extractIdentifiers, parseFormula } from "@cardenelabs/cdl";
33
41
  import type {
34
42
  DslDocument,
35
43
  DslActor,
@@ -39,6 +47,12 @@ import type {
39
47
  DslState,
40
48
  PresetType,
41
49
  LayoutPos,
50
+ DslReadout,
51
+ DslInput,
52
+ DslFormula,
53
+ DslEventBinding,
54
+ DslScrollTrigger,
55
+ DslDynShape,
42
56
  } from "./types";
43
57
  import { checkValueExpression, isValueName, valueNameIssue } from "./value-syntax";
44
58
  import { compileToCdl } from "./compile";
@@ -72,6 +86,39 @@ export interface DragonJson {
72
86
  actors: (string | JsonActor)[];
73
87
  /** flow step 配列 (必須): { from, to, label, ... } */
74
88
  flow: JsonStep[];
89
+ /**
90
+ * 値を見せる部品 (optional、 #1374)。 記法の最上位 `readouts:` と同じ。
91
+ *
92
+ * 箱ではないので縦列に載らない。 図全体に 1 つの並びとして持つ。
93
+ */
94
+ readouts?: DslReadout[];
95
+ /**
96
+ * 読む人が動かすつまみ (optional、 #1389)。 記法の最上位 `inputs:` と同じ。
97
+ *
98
+ * 部品と同じく箱ではないので縦列に載らない。 図全体に 1 つの並びとして持つ。
99
+ */
100
+ inputs?: DslInput[];
101
+ /**
102
+ * つまみの値から決まる値 (optional、 #1391)。 記法の最上位 `formulas:` と同じ。
103
+ *
104
+ * 形は `{ 名前: "式" }`。 `values:` と経路が別で、式は名前を中括弧で囲わない。
105
+ */
106
+ formulas?: Record<string, string>;
107
+ /**
108
+ * 押下などの出来事で動く仕掛け (optional、 #1393)。 記法の最上位 `events:` と同じ。
109
+ *
110
+ * 相手は名前で指す (`box` / `lane` / `arrow` / `diagram` のどれか 1 つ)。
111
+ */
112
+ events?: {
113
+ on: string;
114
+ handler: string;
115
+ box?: string;
116
+ lane?: string;
117
+ arrow?: string;
118
+ diagram?: boolean;
119
+ }[];
120
+ /** 巻き上げに応じて進む値 (optional、 #1393)。 記法の最上位 `scrolls:` と同じ */
121
+ scrolls?: Record<string, { start?: number; end?: number; scrub?: number; label?: string }>;
75
122
  /**
76
123
  * 状態の初期値 (optional)。 記法の `states:` と同じ (#1181)。
77
124
  *
@@ -129,6 +176,31 @@ export interface DragonJson {
129
176
 
130
177
  export interface JsonActor {
131
178
  name: string;
179
+ /**
180
+ * 箱の中に描く図形 (optional、 #1374)。 記法の `shape:` と同じ。
181
+ *
182
+ * 水位や角度を状態で動かす。 形は描画側の型が縛る。
183
+ */
184
+ shape?: DslDynShape;
185
+ /**
186
+ * その箱を出すかどうかの条件 (optional、 #1381)。 記法の `visibleIf:` と同じ。
187
+ */
188
+ visibleIf?: string;
189
+ /**
190
+ * 箱に出す題 (optional、 #1381)。 記法の `title:` と同じ。
191
+ */
192
+ title?: string;
193
+ /**
194
+ * 値に追随する 5 欄 (optional、 #1392)。 記法の `wBind:` / `hBind:` / `opacity:` /
195
+ * `renderOffsetX:` / `renderOffsetY:` と同じ。
196
+ *
197
+ * 大きさは文字列だけ (数を書いても追随のしようが無い)、濃さとずらしは数も受ける。
198
+ */
199
+ wBind?: string;
200
+ hBind?: string;
201
+ opacity?: number | string;
202
+ renderOffsetX?: number | string;
203
+ renderOffsetY?: number | string;
132
204
  /**
133
205
  * CAR-1657 unified syntax = 既存 NodeKind (28 個) に加えて parts identifier (arc-gauge 等) を
134
206
  * accept する。 未知 kind 値は parts 候補として partId に格納、 compile 側 partsCatalog で解決。
@@ -236,6 +308,8 @@ export interface JsonStep {
236
308
  to: string;
237
309
  label: string;
238
310
  sub?: string;
311
+ /** 矢印がどの辺から出るか (#1385)。 記法の `side:` と同じ */
312
+ side?: "top" | "right" | "bottom" | "left";
239
313
  /**
240
314
  * 矢印の色 (#1304)。 記法の `(成功)` と同じく別名 (`成功` / `neutral` 等) も受ける。
241
315
  *
@@ -245,6 +319,13 @@ export interface JsonStep {
245
319
  style?: EdgeStyle;
246
320
  guard?: string;
247
321
  cardinality?: string;
322
+ /**
323
+ * 矢印を値に追随させる 3 欄 (optional、 #1396)。 記法の `widthBind:` / `strokeBind:` /
324
+ * `dashOffsetBind:` と同じ。 描画側は文字列だけを取る。
325
+ */
326
+ widthBind?: string;
327
+ strokeBind?: string;
328
+ dashOffsetBind?: string;
248
329
  labelOffsetX?: number;
249
330
  labelOffsetY?: number;
250
331
  /** true で説明文を矢印の線の上に重ねる。 分岐図の条件ラベル用。 */
@@ -347,6 +428,15 @@ export const ACCEPTED_KEYS = {
347
428
  "viewport",
348
429
  "lanes",
349
430
  "groups",
431
+ // 値を見せる部品 (#1374)
432
+ "readouts",
433
+ // 読む人が動かすつまみ (#1389)
434
+ "inputs",
435
+ // つまみの値から決まる値 (#1391)
436
+ "formulas",
437
+ // 押下などの出来事で動く仕掛けと、巻き上げに応じて進む値 (#1393)
438
+ "events",
439
+ "scrolls",
350
440
  ],
351
441
  actor: [
352
442
  "name",
@@ -373,16 +463,34 @@ export const ACCEPTED_KEYS = {
373
463
  "scale",
374
464
  "state",
375
465
  "pos",
466
+ // 箱の中に描く図形 (#1374)
467
+ "shape",
468
+ // その箱を出すかどうかの条件 (#1381)
469
+ "visibleIf",
470
+ // 箱に出す題 (#1381)
471
+ "title",
472
+ // 値に追随する 5 欄 (#1392)
473
+ "wBind",
474
+ "hBind",
475
+ "opacity",
476
+ "renderOffsetX",
477
+ "renderOffsetY",
376
478
  ],
377
479
  step: [
378
480
  "from",
379
481
  "to",
380
482
  "label",
381
483
  "sub",
484
+ // 矢印がどの辺から出るか (#1385)
485
+ "side",
382
486
  "tone",
383
487
  "style",
384
488
  "guard",
385
489
  "cardinality",
490
+ // 値に追随する 3 欄 (#1396)
491
+ "widthBind",
492
+ "strokeBind",
493
+ "dashOffsetBind",
386
494
  "labelOffsetX",
387
495
  "labelOffsetY",
388
496
  "overlay",
@@ -431,9 +539,12 @@ export type 欄の型 =
431
539
  | "文字列の並び"
432
540
  | "数"
433
541
  | "必須の数"
542
+ // 値に追随する欄 (#1392)。 数を直に書く形と、状態の名前を書く形の両方を受ける
543
+ | "数か文字列"
434
544
  | "真偽"
435
545
  | "色"
436
546
  | "線種"
547
+ | "辺"
437
548
  | "色か色番号"
438
549
  | "描くもの"
439
550
  | "必須の図種"
@@ -456,6 +567,15 @@ export const 欄の型表 = {
456
567
  viewport: "object",
457
568
  lanes: "object",
458
569
  groups: "object",
570
+ // 値を見せる部品 (#1374)。 中身は下の検査が種類ごとに見る
571
+ readouts: "並び",
572
+ // 読む人が動かすつまみ (#1389)。 部品と同じく中身は下の検査が種類ごとに見る
573
+ inputs: "並び",
574
+ // つまみの値から決まる値 (#1391)。 中身は下の検査が式ごとに見る
575
+ formulas: "object",
576
+ // 押下と巻き上げ (#1393)。 中身は下の検査が 1 件ずつ見る
577
+ events: "並び",
578
+ scrolls: "object",
459
579
  },
460
580
  actor: {
461
581
  name: "必須の非空文字列",
@@ -482,16 +602,34 @@ export const 欄の型表 = {
482
602
  scale: "数",
483
603
  state: "object",
484
604
  pos: "object",
605
+ // 箱の中に描く図形 (#1374)。 中身は下の検査が種類ごとに見る
606
+ shape: "object",
607
+ // その箱を出すかどうかの条件 (#1381)
608
+ visibleIf: "文字列",
609
+ // 箱に出す題 (#1381)
610
+ title: "文字列",
611
+ // 値に追随する 5 欄 (#1392)。 大きさは文字列だけ、濃さとずらしは数も受ける
612
+ wBind: "非空の文字列",
613
+ hBind: "非空の文字列",
614
+ opacity: "数か文字列",
615
+ renderOffsetX: "数か文字列",
616
+ renderOffsetY: "数か文字列",
485
617
  },
486
618
  step: {
487
619
  from: "必須の文字列",
488
620
  to: "必須の文字列",
489
621
  label: "必須の文字列",
490
622
  sub: "文字列",
623
+ // 矢印がどの辺から出るか (#1385)
624
+ side: "辺",
491
625
  tone: "色",
492
626
  style: "線種",
493
627
  guard: "文字列",
494
628
  cardinality: "文字列",
629
+ // 値に追随する 3 欄 (#1396)。 描画側は文字列だけを取る
630
+ widthBind: "非空の文字列",
631
+ strokeBind: "非空の文字列",
632
+ dashOffsetBind: "非空の文字列",
495
633
  labelOffsetX: "数",
496
634
  labelOffsetY: "数",
497
635
  overlay: "真偽",
@@ -573,7 +711,10 @@ function JSONの色名か(v: string): boolean {
573
711
  * `見本に効かない欄` (#1308) と対になる。 2 つの表で「どちらの箱にしか効かないか」 を
574
712
  * 両方向から宣言する = 片方だけ増えると鏡の関係が崩れる。
575
713
  */
576
- export const 見本にしか効かない欄 = ["state", "scale"] as const satisfies readonly (typeof ACCEPTED_KEYS.actor)[number][];
714
+ export const 見本にしか効かない欄 = [
715
+ "state",
716
+ "scale",
717
+ ] as const satisfies readonly (typeof ACCEPTED_KEYS.actor)[number][];
577
718
 
578
719
  /**
579
720
  * 普通の箱にしか効かない欄 (#1308)。
@@ -598,6 +739,18 @@ export const 見本に効かない欄 = [
598
739
  "end",
599
740
  "touchpoint",
600
741
  "opportunity",
742
+ // 箱の中に描く図形 (#1374)。 見本は自分の形を持つため、外から図形を差し替えられない
743
+ "shape",
744
+ // その箱を出すかどうかの条件 (#1381)。 見本は自分の出方を持つ
745
+ "visibleIf",
746
+ // 箱に出す題 (#1381)。 見本は自分の題を持つ
747
+ "title",
748
+ // 値に追随する 5 欄 (#1392)。 見本は自分の大きさと出方を持つ
749
+ "wBind",
750
+ "hBind",
751
+ "opacity",
752
+ "renderOffsetX",
753
+ "renderOffsetY",
601
754
  ] as const satisfies readonly (typeof ACCEPTED_KEYS.actor)[number][];
602
755
 
603
756
  /**
@@ -629,7 +782,7 @@ function 値を検査(
629
782
  return;
630
783
  case "非空の文字列":
631
784
  if (v === undefined) return;
632
- if (typeof v !== "string" || v.length === 0) 型違い("a non-empty string if present");
785
+ if (typeof v !== "string" || v.trim().length === 0) 型違い("a non-empty string if present");
633
786
  return;
634
787
  case "文字列":
635
788
  if (v === undefined) return;
@@ -666,6 +819,20 @@ function 値を検査(
666
819
  型違い("a finite number", typeof v === "number" ? `got ${String(v)}` : undefined);
667
820
  }
668
821
  return;
822
+ case "数か文字列":
823
+ if (v === undefined) return;
824
+ // 空文字は受けない。 描画側は 0 として読むため、書き忘れが「箱が消える」 形で出る
825
+ if (typeof v === "string") {
826
+ if (v.trim().length === 0) 型違い("a non-empty string if present");
827
+ return;
828
+ }
829
+ if (typeof v !== "number" || !Number.isFinite(v)) {
830
+ 型違い(
831
+ "a finite number or a non-empty string if present",
832
+ typeof v === "number" ? `got ${String(v)}` : undefined,
833
+ );
834
+ }
835
+ return;
669
836
  case "真偽":
670
837
  if (v === undefined) return;
671
838
  if (typeof v !== "boolean") 型違い("true or false if present");
@@ -695,6 +862,16 @@ function 値を検査(
695
862
  });
696
863
  }
697
864
  return;
865
+ case "辺":
866
+ if (v === undefined) return;
867
+ if (typeof v !== "string" || !(EDGE_SIDE_VALUES as readonly string[]).includes(v)) {
868
+ errors.push({
869
+ path,
870
+ message: `${名前} must be one of: ${EDGE_SIDE_VALUES.join(", ")}`,
871
+ hint: typeof v === "string" ? `got "${v}"` : `got ${typeof v}`,
872
+ });
873
+ }
874
+ return;
698
875
  case "描くもの":
699
876
  if (v === undefined) return;
700
877
  // 受ける語は記法と同じ一覧を見る (`DRAW_WORDS`)。 写すと語が増えた時に片方だけ古くなる
@@ -912,6 +1089,458 @@ function validateViewport(v: unknown, errors: JsonDslError[]): void {
912
1089
  * #1304 まで外側の形が違う入力 (`lanes: 5`) は走査ごと飛ばされ、誤りが 1 件も返らなかった。
913
1090
  * 形が違うものを黙って捨てると、書いた縦列が 1 つも効かない図が知らせなしで出る。
914
1091
  */
1092
+ /**
1093
+ * 図形と部品の中身を、記法と **同じ表** で検査する (#1374)。
1094
+ *
1095
+ * 表を 2 つ持つと片方だけ直してずれる。 記法側 (`v05/parser.ts`) が持つ表をそのまま引く。
1096
+ *
1097
+ * 記法は値が全て文字列で届くため読み替えが要るが、JSON は型のまま届く。 ここでは
1098
+ * 「知らない種類」 「知らない欄」 「足りない必須の欄」 「欄の型違い」 の 4 つを見る。
1099
+ */
1100
+ function 表で中身を検査する(
1101
+ o: Record<string, unknown>,
1102
+ 表: Record<string, 図形の定義>,
1103
+ path: string,
1104
+ 何: string,
1105
+ errors: JsonDslError[],
1106
+ ): void {
1107
+ // JSON は公開 schema の enum と同じ正規名だけを受ける。 ここだけ小文字化すると、
1108
+ // schema が拒む種類を validator が通した上、種類別の追加検査も回避できてしまう。
1109
+ const kind = typeof o.kind === "string" ? o.kind : "";
1110
+ // 通常の object を表に使うため、継承した名前を own kind として扱わない。
1111
+ const 定義 = Object.hasOwn(表, kind) ? 表[kind] : undefined;
1112
+ if (定義 === undefined) {
1113
+ errors.push({
1114
+ path: `${path}.kind`,
1115
+ // **文字列でない値をそのまま文にしない**。 object を混ぜると `[object Object]` になり、
1116
+ // 何を書いたのかが読み手に届かない
1117
+ message: `unknown ${何} kind ${typeof o.kind === "string" ? `"${o.kind}"` : JSON.stringify(o.kind ?? null)}`,
1118
+ hint: `使える種類 = ${Object.keys(表).join(", ")}`,
1119
+ });
1120
+ return;
1121
+ }
1122
+ for (const [欄, 値] of Object.entries(o)) {
1123
+ if (欄 === "kind" || 欄 === "id") continue;
1124
+ const 形 = 定義.欄[欄];
1125
+ if (形 === undefined) {
1126
+ errors.push({
1127
+ path: `${path}.${欄}`,
1128
+ message: `unknown key "${欄}"`,
1129
+ hint: `使える項目 = ${Object.keys(定義.欄).join(", ")}`,
1130
+ });
1131
+ continue;
1132
+ }
1133
+ const 型が合う =
1134
+ 形 === "数"
1135
+ ? typeof 値 === "number" && Number.isFinite(値)
1136
+ : 形 === "数か文字列"
1137
+ ? (typeof 値 === "number" && Number.isFinite(値)) || typeof 値 === "string"
1138
+ : 形 === "文字列の並び"
1139
+ ? Array.isArray(値) && 値.every((x) => typeof x === "string")
1140
+ : 形 === "数の並び"
1141
+ ? Array.isArray(値) && 値.every((x) => typeof x === "number" && Number.isFinite(x))
1142
+ : 形 === "真偽"
1143
+ ? typeof 値 === "boolean"
1144
+ : 形 === "組の並び"
1145
+ ? Array.isArray(値) &&
1146
+ 値.length > 0 &&
1147
+ 値.every(
1148
+ (x) =>
1149
+ typeof x === "object" &&
1150
+ x !== null &&
1151
+ !Array.isArray(x) &&
1152
+ Object.keys(x as object).length > 0 &&
1153
+ Object.values(x as object).every(
1154
+ (y) =>
1155
+ typeof y === "string" || (typeof y === "number" && Number.isFinite(y)),
1156
+ ),
1157
+ )
1158
+ : 形 === "向き"
1159
+ ? typeof 値 === "string" && ["up", "down", "left", "right"].includes(値)
1160
+ : typeof 値 === "string";
1161
+ if (!型が合う) {
1162
+ errors.push({
1163
+ path: `${path}.${欄}`,
1164
+ message: `${欄} must be ${形}`,
1165
+ hint: `got ${Array.isArray(値) ? "array" : 値 === null ? "null" : typeof 値}`,
1166
+ });
1167
+ }
1168
+ }
1169
+ for (const 欄 of 定義.必須) {
1170
+ if (o[欄] === undefined) {
1171
+ errors.push({
1172
+ path: `${path}.${欄}`,
1173
+ message: `${欄} is required for ${何} kind "${kind}"`,
1174
+ hint: `必須の項目 = ${定義.必須.join(", ")}`,
1175
+ });
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ /**
1181
+ * 値を見せる部品の並びを検査する (#1374)。
1182
+ *
1183
+ * **外側の形もここで見る**。 `欄の型表` は「並び」 とだけ宣言し、中身の検査は専用の検査に
1184
+ * 委ねる作りなので (`checkFieldType` の `case "並び"`)、ここで見ないと `readouts: 1` が
1185
+ * 素通りする。
1186
+ */
1187
+ function validateReadouts(v: unknown, errors: JsonDslError[]): void {
1188
+ if (v === undefined) return;
1189
+ if (!Array.isArray(v)) {
1190
+ errors.push({
1191
+ path: "$.readouts",
1192
+ message: "readouts must be an array of readout objects",
1193
+ hint: `got ${v === null ? "null" : typeof v}`,
1194
+ });
1195
+ return;
1196
+ }
1197
+ v.forEach((r, i) => {
1198
+ const path = `$.readouts[${i}]`;
1199
+ if (!r || typeof r !== "object" || Array.isArray(r)) {
1200
+ errors.push({ path, message: "readout must be a plain object", hint: `got ${typeof r}` });
1201
+ return;
1202
+ }
1203
+ const o = r as Record<string, unknown>;
1204
+ if (typeof o.id !== "string" || o.id === "") {
1205
+ errors.push({ path: `${path}.id`, message: "id is required", hint: "空でない文字列で書く" });
1206
+ }
1207
+ 表で中身を検査する(o, 部品の表, path, "readout", errors);
1208
+
1209
+ /*
1210
+ * 組の並びを取る欄の中身を、記法と同じ表で見る (#1385)。
1211
+ *
1212
+ * **種類を手で並べない**。 元は `status-dot` と `status-timeline` を直書きしていたが、
1213
+ * 表は描画側の型定義から生成しており、種類が増えるたびに書き足す形になる。
1214
+ */
1215
+ const 組の定義 = 部品の組の表[String(o.kind)];
1216
+ for (const [欄名, 定義] of Object.entries(組の定義 ?? {})) {
1217
+ if (!Array.isArray(o[欄名])) continue;
1218
+ (o[欄名] as unknown[]).forEach((entry, j) => {
1219
+ const entryPath = `${path}.${欄名}[${j}]`;
1220
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
1221
+ const item = entry as Record<string, unknown>;
1222
+ for (const key of Object.keys(item)) {
1223
+ if (key in 定義.欄) continue;
1224
+ errors.push({
1225
+ path: `${entryPath}.${key}`,
1226
+ message: `${key} is not allowed for readout kind "${String(o.kind)}"`,
1227
+ hint: `使える項目 = ${Object.keys(定義.欄).join(", ")}`,
1228
+ });
1229
+ }
1230
+ for (const key of 定義.必須) {
1231
+ if (item[key] !== undefined) continue;
1232
+ errors.push({
1233
+ path: `${entryPath}.${key}`,
1234
+ message: `${key} is required for readout kind "${String(o.kind)}"`,
1235
+ });
1236
+ }
1237
+ for (const [key, 形] of Object.entries(定義.欄)) {
1238
+ const 値 = item[key];
1239
+ if (値 === undefined) continue;
1240
+ const 合う =
1241
+ 形 === "数" ? typeof 値 === "number" && Number.isFinite(値) : typeof 値 === "string";
1242
+ if (合う) continue;
1243
+ errors.push({
1244
+ path: `${entryPath}.${key}`,
1245
+ message: `${key} must be ${形}`,
1246
+ hint: `got ${Array.isArray(値) ? "array" : 値 === null ? "null" : typeof 値}`,
1247
+ });
1248
+ }
1249
+ });
1250
+ }
1251
+ });
1252
+ }
1253
+
1254
+ /**
1255
+ * 読む人が動かすつまみの並びを検査する (#1389)。
1256
+ *
1257
+ * **外側の形もここで見る**。 `欄の型表` は「並び」 とだけ宣言し、中身の検査は専用の検査に
1258
+ * 委ねる作りなので (`checkFieldType` の `case "並び"`)、ここで見ないと `inputs: 1` が
1259
+ * 素通りする。
1260
+ *
1261
+ * 種類ごとの欄は記法と同じ表 (`つまみの表`) で見る。 表は描画側の型定義から生成しており、
1262
+ * 種類が増えても書き足す場所が増えない。
1263
+ */
1264
+ function validateInputs(v: unknown, errors: JsonDslError[]): void {
1265
+ if (v === undefined) return;
1266
+ if (!Array.isArray(v)) {
1267
+ errors.push({
1268
+ path: "$.inputs",
1269
+ message: "inputs must be an array of input objects",
1270
+ hint: `got ${v === null ? "null" : typeof v}`,
1271
+ });
1272
+ return;
1273
+ }
1274
+ v.forEach((r, i) => {
1275
+ const path = `$.inputs[${i}]`;
1276
+ if (!r || typeof r !== "object" || Array.isArray(r)) {
1277
+ errors.push({ path, message: "input must be a plain object", hint: `got ${typeof r}` });
1278
+ return;
1279
+ }
1280
+ const o = r as Record<string, unknown>;
1281
+ if (typeof o.id !== "string" || o.id === "") {
1282
+ errors.push({ path: `${path}.id`, message: "id is required", hint: "空でない文字列で書く" });
1283
+ }
1284
+ 表で中身を検査する(o, つまみの表, path, "input", errors);
1285
+ });
1286
+ }
1287
+
1288
+ /**
1289
+ * つまみの値から決まる値を検査する (#1391)。
1290
+ *
1291
+ * **外側の形もここで見る**。 `欄の型表` は「object」 とだけ宣言するため、
1292
+ * ここで見ないと `formulas: { a: 1 }` が素通りする。
1293
+ *
1294
+ * 式そのものは描画側の parser に通す = 自前で書き方を決めると、通ったのに描画側が
1295
+ * 解けない式を受けてしまう。
1296
+ */
1297
+ function validateFormulas(v: unknown, inputs: unknown, errors: JsonDslError[]): void {
1298
+ if (v === undefined) return;
1299
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
1300
+ errors.push({
1301
+ path: "$.formulas",
1302
+ message: "formulas must be an object of name to expression",
1303
+ hint: `got ${v === null ? "null" : Array.isArray(v) ? "array" : typeof v}`,
1304
+ });
1305
+ return;
1306
+ }
1307
+ const つまみ = new Map<string, string>();
1308
+ if (Array.isArray(inputs)) {
1309
+ for (const input of inputs) {
1310
+ if (input && typeof input === "object" && !Array.isArray(input)) {
1311
+ const candidate = input as { id?: unknown; kind?: unknown };
1312
+ if (typeof candidate.id === "string" && typeof candidate.kind === "string") {
1313
+ つまみ.set(candidate.id, candidate.kind);
1314
+ }
1315
+ }
1316
+ }
1317
+ }
1318
+ const 先に書かれた式 = new Set<string>();
1319
+ for (const [名前, 式] of Object.entries(v as Record<string, unknown>)) {
1320
+ const path = `$.formulas.${名前}`;
1321
+ if (!isValueName(名前)) {
1322
+ errors.push({ path, ...valueNameIssue(名前) });
1323
+ continue;
1324
+ }
1325
+ if (typeof 式 !== "string" || 式.trim() === "") {
1326
+ errors.push({
1327
+ path,
1328
+ message: `${名前} must be a non-empty expression string`,
1329
+ hint: `got ${Array.isArray(式) ? "array" : 式 === null ? "null" : typeof 式}`,
1330
+ });
1331
+ continue;
1332
+ }
1333
+ try {
1334
+ const names = extractIdentifiers(parseFormula(式));
1335
+ if (つまみ.has(名前) || 先に書かれた式.has(名前)) {
1336
+ errors.push({
1337
+ path,
1338
+ message: `${名前} collides with an input or an earlier formula`,
1339
+ hint: "inputs と formulas では重ならない名前を使う",
1340
+ });
1341
+ continue;
1342
+ }
1343
+ let valid = true;
1344
+ for (const name of names) {
1345
+ if (先に書かれた式.has(name)) continue;
1346
+ const kind = つまみ.get(name);
1347
+ if (["slider", "number", "stepper", "timeline", "toggle"].includes(kind ?? "")) continue;
1348
+ valid = false;
1349
+ errors.push({
1350
+ path,
1351
+ message: `${名前} references an unavailable formula identifier`,
1352
+ hint: `${name} は数値/真偽の input にするか、この式より前の formula に書く`,
1353
+ });
1354
+ }
1355
+ if (valid) 先に書かれた式.add(名前);
1356
+ } catch (e) {
1357
+ errors.push({
1358
+ path,
1359
+ message: `${名前} is not a readable expression`,
1360
+ hint: (e as Error).message,
1361
+ });
1362
+ }
1363
+ }
1364
+ }
1365
+
1366
+ /**
1367
+ * 押下などの出来事で動く仕掛けを検査する (#1393)。
1368
+ *
1369
+ * 相手の指し方は 4 つあり、**ちょうど 1 つだけ書く**。 2 つ書くとどちらを指したのか
1370
+ * 決まらず、0 なら相手がいない。 記法側の読み取りと同じ規則にする。
1371
+ */
1372
+ function validateEvents(v: unknown, errors: JsonDslError[]): void {
1373
+ if (v === undefined) return;
1374
+ if (!Array.isArray(v)) {
1375
+ errors.push({
1376
+ path: "$.events",
1377
+ message: "events must be an array of event objects",
1378
+ hint: `got ${v === null ? "null" : typeof v}`,
1379
+ });
1380
+ return;
1381
+ }
1382
+ const 相手の鍵 = ["box", "lane", "arrow", "diagram"];
1383
+ v.forEach((e, i) => {
1384
+ const path = `$.events[${i}]`;
1385
+ if (!e || typeof e !== "object" || Array.isArray(e)) {
1386
+ errors.push({ path, message: "event must be a plain object", hint: `got ${typeof e}` });
1387
+ return;
1388
+ }
1389
+ const o = e as Record<string, unknown>;
1390
+ for (const k of Object.keys(o)) {
1391
+ if (k === "on" || k === "handler" || 相手の鍵.includes(k)) continue;
1392
+ errors.push({
1393
+ path: `${path}.${k}`,
1394
+ message: `unknown key "${k}"`,
1395
+ hint: `使える項目 = on, handler, ${相手の鍵.join(", ")}`,
1396
+ });
1397
+ }
1398
+ if (typeof o.on !== "string" || !(EVENT_KINDS as readonly string[]).includes(o.on)) {
1399
+ errors.push({
1400
+ path: `${path}.on`,
1401
+ message: "on must be a known event kind",
1402
+ hint: `使える種類 = ${EVENT_KINDS.join(", ")}`,
1403
+ });
1404
+ }
1405
+ if (typeof o.handler !== "string" || o.handler.trim() === "") {
1406
+ errors.push({
1407
+ path: `${path}.handler`,
1408
+ message: "handler is required",
1409
+ hint: "空でない文字列で書く",
1410
+ });
1411
+ }
1412
+ const 書いた = 相手の鍵.filter((k) => o[k] !== undefined);
1413
+ if (書いた.length !== 1) {
1414
+ errors.push({
1415
+ path,
1416
+ message:
1417
+ 書いた.length === 0 ? "event target is required" : "event target must be written once",
1418
+ hint: `${相手の鍵.join(" / ")} のどれか 1 つだけを書く`,
1419
+ });
1420
+ return;
1421
+ }
1422
+ const 鍵 = 書いた[0]!;
1423
+ if (鍵 === "diagram") {
1424
+ if (o.diagram !== true) {
1425
+ errors.push({
1426
+ path: `${path}.diagram`,
1427
+ message: "diagram must be true",
1428
+ hint: "図全体を指す時だけ書く",
1429
+ });
1430
+ }
1431
+ return;
1432
+ }
1433
+ if (typeof o[鍵] !== "string" || (o[鍵] as string).trim() === "") {
1434
+ errors.push({ path: `${path}.${鍵}`, message: `${鍵} must be a non-empty string` });
1435
+ return;
1436
+ }
1437
+ if (鍵 === "arrow") {
1438
+ const 両端 = (o.arrow as string).split("->");
1439
+ if (両端.length !== 2 || 両端.some((x) => x.trim() === "")) {
1440
+ errors.push({
1441
+ path: `${path}.arrow`,
1442
+ message: "arrow must be `A -> B`",
1443
+ hint: "矢印の両端の名前を書く",
1444
+ });
1445
+ }
1446
+ }
1447
+ });
1448
+ }
1449
+
1450
+ /**
1451
+ * 巻き上げに応じて進む値を検査する (#1393)。
1452
+ *
1453
+ * 形は `{ 名前: { start, end, scrub, label } }`。 名前の規則は状態と揃える。
1454
+ */
1455
+ function validateScrolls(
1456
+ v: unknown,
1457
+ inputs: unknown,
1458
+ formulas: unknown,
1459
+ errors: JsonDslError[],
1460
+ ): void {
1461
+ if (v === undefined) return;
1462
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
1463
+ errors.push({
1464
+ path: "$.scrolls",
1465
+ message: "scrolls must be an object of name to spec",
1466
+ hint: `got ${v === null ? "null" : Array.isArray(v) ? "array" : typeof v}`,
1467
+ });
1468
+ return;
1469
+ }
1470
+ const 既に値を作る名前 = new Set<string>();
1471
+ if (Array.isArray(inputs)) {
1472
+ for (const input of inputs) {
1473
+ if (!input || typeof input !== "object" || Array.isArray(input)) continue;
1474
+ const id = (input as { id?: unknown }).id;
1475
+ if (typeof id === "string") 既に値を作る名前.add(id);
1476
+ }
1477
+ }
1478
+ if (formulas && typeof formulas === "object" && !Array.isArray(formulas)) {
1479
+ for (const id of Object.keys(formulas)) 既に値を作る名前.add(id);
1480
+ }
1481
+ for (const [名前, spec] of Object.entries(v as Record<string, unknown>)) {
1482
+ const path = `$.scrolls.${名前}`;
1483
+ if (!isValueName(名前)) {
1484
+ errors.push({ path, ...valueNameIssue(名前) });
1485
+ continue;
1486
+ }
1487
+ if (既に値を作る名前.has(名前)) {
1488
+ errors.push({
1489
+ path,
1490
+ message: `${名前} collides with an input or formula`,
1491
+ hint: "inputs / formulas / scrolls では重ならない名前を使う",
1492
+ });
1493
+ }
1494
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
1495
+ errors.push({ path, message: `${名前} must be a plain object`, hint: `got ${typeof spec}` });
1496
+ continue;
1497
+ }
1498
+ for (const [k, x] of Object.entries(spec as Record<string, unknown>)) {
1499
+ if (k === "label") {
1500
+ if (typeof x !== "string") {
1501
+ errors.push({ path: `${path}.label`, message: "label must be a string" });
1502
+ }
1503
+ continue;
1504
+ }
1505
+ if (k !== "start" && k !== "end" && k !== "scrub") {
1506
+ errors.push({
1507
+ path: `${path}.${k}`,
1508
+ message: `unknown key "${k}"`,
1509
+ hint: "使える項目 = start, end, scrub, label",
1510
+ });
1511
+ continue;
1512
+ }
1513
+ if (typeof x !== "number" || !Number.isFinite(x)) {
1514
+ errors.push({ path: `${path}.${k}`, message: `${k} must be a finite number` });
1515
+ } else if (x < 0 || x > 1) {
1516
+ errors.push({
1517
+ path: `${path}.${k}`,
1518
+ message: `${k} must be between 0 and 1`,
1519
+ hint: "0 と 1 を含む範囲で書く",
1520
+ });
1521
+ }
1522
+ }
1523
+ }
1524
+ }
1525
+
1526
+ /**
1527
+ * 箱の中に描く図形を検査する (#1374)。
1528
+ *
1529
+ * `validateReadouts` と同じ理由で外側の形もここで見る。
1530
+ */
1531
+ function validateActorShape(v: unknown, path: string, errors: JsonDslError[]): void {
1532
+ if (v === undefined) return;
1533
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
1534
+ errors.push({
1535
+ path,
1536
+ message: "shape must be a plain object",
1537
+ hint: `got ${v === null ? "null" : Array.isArray(v) ? "array" : typeof v}`,
1538
+ });
1539
+ return;
1540
+ }
1541
+ 表で中身を検査する(v as Record<string, unknown>, 図形の表, path, "shape", errors);
1542
+ }
1543
+
915
1544
  function validateIdMap(
916
1545
  v: unknown,
917
1546
  欄: "lanes" | "groups",
@@ -1230,6 +1859,15 @@ function validateJson(
1230
1859
  // 知らない項目を先に見る (#1295)。 綴り違いは「書いた項目が効かない」 形で表に出るため、
1231
1860
  // 個々の型の誤りより先に伝える方が直しやすい
1232
1861
  checkUnknownKeys(j, "root", "$", errors);
1862
+ // 値を見せる部品の中身を、記法と同じ表で見る (#1374)
1863
+ validateReadouts(j.readouts, errors);
1864
+ // 読む人が動かすつまみの中身も、記法と同じ表で見る (#1389)
1865
+ validateInputs(j.inputs, errors);
1866
+ // 式は描画側の parser に通す (#1391)
1867
+ validateFormulas(j.formulas, j.inputs, errors);
1868
+ // 押下と巻き上げ (#1393)
1869
+ validateEvents(j.events, errors);
1870
+ validateScrolls(j.scrolls, j.inputs, j.formulas, errors);
1233
1871
 
1234
1872
  // 値そのものの型は表が見る (#1304)。 図表の箱の上の小見出し (#1247) の空文字は
1235
1873
  // 「書かなかった」 と同じ扱いにするため通す (記法側の `eyebrow:` と揃える。 落とすのは `jsonToDoc`)
@@ -1249,6 +1887,8 @@ function validateJson(
1249
1887
  // 値そのものの型は表が見る (#1304)。 `kind` は見本 (parts) の名前も受けるため
1250
1888
  // 非空の文字列までしか縛らない (CAR-1657 の unified syntax)
1251
1889
  表で検査(ao, "actor", `$.actors[${i}]`, "actor", errors);
1890
+ // 箱の中に描く図形の中身を、記法と同じ表で見る (#1374)
1891
+ validateActorShape(ao.shape, `$.actors[${i}].shape`, errors);
1252
1892
  // 見本 (parts) にしか効かない項目は、見本でない箱に書かれたら誤りにする (#1294)。
1253
1893
  // 記法側は読めない項目名として行番号付きで知らせるため、黙って捨てると入口で扱いが変わる。
1254
1894
  const 見本か = 見本の名前か(ao.kind);
@@ -1526,6 +2166,16 @@ export function jsonToDoc(json: DragonJson): DslDocument {
1526
2166
  stack: a.stack,
1527
2167
  initial: a.initial,
1528
2168
  final: a.final,
2169
+ // 箱の中に描く図形 (#1374)。 見本では状態の上書きが効くため、通常の箱にだけ渡す
2170
+ shape: isPart ? undefined : a.shape,
2171
+ visibleIf: isPart ? undefined : a.visibleIf,
2172
+ title: isPart ? undefined : a.title,
2173
+ // 値に追随する 5 欄 (#1392)。 記法と同じく見本には渡さない
2174
+ wBind: isPart ? undefined : a.wBind,
2175
+ hBind: isPart ? undefined : a.hBind,
2176
+ opacity: isPart ? undefined : a.opacity,
2177
+ renderOffsetX: isPart ? undefined : a.renderOffsetX,
2178
+ renderOffsetY: isPart ? undefined : a.renderOffsetY,
1529
2179
  // 普通の箱にしか効かない欄は見本では落とす。 落とす欄の一覧は `見本に効かない欄` が
1530
2180
  // 唯一の出どころで、検査 (#1308) も同じ表を見る = 「検査は通すが組み立てが捨てる」
1531
2181
  // 状態が作れない
@@ -1570,12 +2220,17 @@ export function jsonToDoc(json: DragonJson): DslDocument {
1570
2220
  to: s.to,
1571
2221
  label: s.label,
1572
2222
  sub: s.sub,
2223
+ side: s.side as "top" | "right" | "bottom" | "left" | undefined,
1573
2224
  // 箱と同じ読み替えを通す (#1304)。 通さないと `tone: "成功"` が色名として解決されないまま
1574
2225
  // 図に届き、同じ値が箱では色になり矢印では色にならない
1575
2226
  tone: resolveTone(s.tone),
1576
2227
  style: s.style,
1577
2228
  guard: s.guard,
1578
2229
  cardinality: s.cardinality,
2230
+ // 値に追随する 3 欄 (#1396)
2231
+ widthBind: s.widthBind,
2232
+ strokeBind: s.strokeBind,
2233
+ dashOffsetBind: s.dashOffsetBind,
1579
2234
  labelOffsetX: s.labelOffsetX,
1580
2235
  labelOffsetY: s.labelOffsetY,
1581
2236
  overlay: s.overlay,
@@ -1585,6 +2240,39 @@ export function jsonToDoc(json: DragonJson): DslDocument {
1585
2240
  }));
1586
2241
  // 状態は段が無くても図に載る (#1162 で組み立ての出口が載せる)。 **段の有無で分けない** =
1587
2242
  // 分けると `states` だけを書いた JSON で値が 1 つも届かない (記法側で起きていた形、 #1181)
2243
+ // 値を見せる部品はそのまま渡す (#1374)。 形は描画側の型が縛る
2244
+ const readouts: DslReadout[] | undefined = json.readouts ? [...json.readouts] : undefined;
2245
+ // つまみもそのまま渡す (#1389)。 形は描画側の型が縛る
2246
+ const inputs: DslInput[] | undefined = json.inputs ? [...json.inputs] : undefined;
2247
+ /*
2248
+ * 押下と巻き上げも記法側と同じ形へ写す (#1393)。
2249
+ *
2250
+ * 相手は名前のまま持ち、識別子への読み替えは組み立てが行う = 2 つの入口で同じ経路を通る。
2251
+ */
2252
+ const events: DslEventBinding[] | undefined = json.events?.map((e) => ({
2253
+ event: e.on as DslEventBinding["event"],
2254
+ target:
2255
+ e.diagram === true
2256
+ ? ({ kind: "diagram" } as const)
2257
+ : e.arrow !== undefined
2258
+ ? ({
2259
+ kind: "edge" as const,
2260
+ from: e.arrow.split("->")[0]?.trim() ?? "",
2261
+ to: e.arrow.split("->")[1]?.trim() ?? "",
2262
+ } as const)
2263
+ : e.lane !== undefined
2264
+ ? ({ kind: "lane" as const, name: e.lane } as const)
2265
+ : ({ kind: "node" as const, name: e.box ?? "" } as const),
2266
+ handlerId: e.handler.trim(),
2267
+ pos: p0,
2268
+ }));
2269
+ const scrolls: DslScrollTrigger[] | undefined = json.scrolls
2270
+ ? Object.entries(json.scrolls).map(([id, spec]) => ({ id, ...spec }))
2271
+ : undefined;
2272
+ // 式は `{ 名前: "式" }` から並びへ写す (#1391)。 記法側と同じ形にして組み立てを 1 本にする
2273
+ const formulas: DslFormula[] | undefined = json.formulas
2274
+ ? Object.entries(json.formulas).map(([id, expression]) => ({ id, expression, pos: p0 }))
2275
+ : undefined;
1588
2276
  const states: DslState[] = Object.entries(json.states ?? {}).map(([name, initial]) => ({
1589
2277
  name,
1590
2278
  initial,
@@ -1658,6 +2346,11 @@ export function jsonToDoc(json: DragonJson): DslDocument {
1658
2346
  ]),
1659
2347
  )
1660
2348
  : undefined,
2349
+ readouts,
2350
+ inputs,
2351
+ formulas,
2352
+ events,
2353
+ scrolls,
1661
2354
  pos: p0,
1662
2355
  };
1663
2356
  }