@cardenelabs/dragon 0.13.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.
@@ -31,10 +31,13 @@ import {
31
31
  図形の表,
32
32
  部品の表,
33
33
  部品の組の表,
34
+ つまみの表,
35
+ EVENT_KINDS,
34
36
  type 図形の定義,
35
37
  } from "./v05/parser";
36
38
  import type { CompileToCdlOpts } from "./compile";
37
39
  import type { CdlDiagram, NodeKind, Tone, EdgeStyle } from "@cardenelabs/cdl";
40
+ import { extractIdentifiers, parseFormula } from "@cardenelabs/cdl";
38
41
  import type {
39
42
  DslDocument,
40
43
  DslActor,
@@ -45,6 +48,10 @@ import type {
45
48
  PresetType,
46
49
  LayoutPos,
47
50
  DslReadout,
51
+ DslInput,
52
+ DslFormula,
53
+ DslEventBinding,
54
+ DslScrollTrigger,
48
55
  DslDynShape,
49
56
  } from "./types";
50
57
  import { checkValueExpression, isValueName, valueNameIssue } from "./value-syntax";
@@ -85,6 +92,33 @@ export interface DragonJson {
85
92
  * 箱ではないので縦列に載らない。 図全体に 1 つの並びとして持つ。
86
93
  */
87
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 }>;
88
122
  /**
89
123
  * 状態の初期値 (optional)。 記法の `states:` と同じ (#1181)。
90
124
  *
@@ -156,6 +190,17 @@ export interface JsonActor {
156
190
  * 箱に出す題 (optional、 #1381)。 記法の `title:` と同じ。
157
191
  */
158
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;
159
204
  /**
160
205
  * CAR-1657 unified syntax = 既存 NodeKind (28 個) に加えて parts identifier (arc-gauge 等) を
161
206
  * accept する。 未知 kind 値は parts 候補として partId に格納、 compile 側 partsCatalog で解決。
@@ -274,6 +319,13 @@ export interface JsonStep {
274
319
  style?: EdgeStyle;
275
320
  guard?: string;
276
321
  cardinality?: string;
322
+ /**
323
+ * 矢印を値に追随させる 3 欄 (optional、 #1396)。 記法の `widthBind:` / `strokeBind:` /
324
+ * `dashOffsetBind:` と同じ。 描画側は文字列だけを取る。
325
+ */
326
+ widthBind?: string;
327
+ strokeBind?: string;
328
+ dashOffsetBind?: string;
277
329
  labelOffsetX?: number;
278
330
  labelOffsetY?: number;
279
331
  /** true で説明文を矢印の線の上に重ねる。 分岐図の条件ラベル用。 */
@@ -378,6 +430,13 @@ export const ACCEPTED_KEYS = {
378
430
  "groups",
379
431
  // 値を見せる部品 (#1374)
380
432
  "readouts",
433
+ // 読む人が動かすつまみ (#1389)
434
+ "inputs",
435
+ // つまみの値から決まる値 (#1391)
436
+ "formulas",
437
+ // 押下などの出来事で動く仕掛けと、巻き上げに応じて進む値 (#1393)
438
+ "events",
439
+ "scrolls",
381
440
  ],
382
441
  actor: [
383
442
  "name",
@@ -410,6 +469,12 @@ export const ACCEPTED_KEYS = {
410
469
  "visibleIf",
411
470
  // 箱に出す題 (#1381)
412
471
  "title",
472
+ // 値に追随する 5 欄 (#1392)
473
+ "wBind",
474
+ "hBind",
475
+ "opacity",
476
+ "renderOffsetX",
477
+ "renderOffsetY",
413
478
  ],
414
479
  step: [
415
480
  "from",
@@ -422,6 +487,10 @@ export const ACCEPTED_KEYS = {
422
487
  "style",
423
488
  "guard",
424
489
  "cardinality",
490
+ // 値に追随する 3 欄 (#1396)
491
+ "widthBind",
492
+ "strokeBind",
493
+ "dashOffsetBind",
425
494
  "labelOffsetX",
426
495
  "labelOffsetY",
427
496
  "overlay",
@@ -470,6 +539,8 @@ export type 欄の型 =
470
539
  | "文字列の並び"
471
540
  | "数"
472
541
  | "必須の数"
542
+ // 値に追随する欄 (#1392)。 数を直に書く形と、状態の名前を書く形の両方を受ける
543
+ | "数か文字列"
473
544
  | "真偽"
474
545
  | "色"
475
546
  | "線種"
@@ -498,6 +569,13 @@ export const 欄の型表 = {
498
569
  groups: "object",
499
570
  // 値を見せる部品 (#1374)。 中身は下の検査が種類ごとに見る
500
571
  readouts: "並び",
572
+ // 読む人が動かすつまみ (#1389)。 部品と同じく中身は下の検査が種類ごとに見る
573
+ inputs: "並び",
574
+ // つまみの値から決まる値 (#1391)。 中身は下の検査が式ごとに見る
575
+ formulas: "object",
576
+ // 押下と巻き上げ (#1393)。 中身は下の検査が 1 件ずつ見る
577
+ events: "並び",
578
+ scrolls: "object",
501
579
  },
502
580
  actor: {
503
581
  name: "必須の非空文字列",
@@ -530,6 +608,12 @@ export const 欄の型表 = {
530
608
  visibleIf: "文字列",
531
609
  // 箱に出す題 (#1381)
532
610
  title: "文字列",
611
+ // 値に追随する 5 欄 (#1392)。 大きさは文字列だけ、濃さとずらしは数も受ける
612
+ wBind: "非空の文字列",
613
+ hBind: "非空の文字列",
614
+ opacity: "数か文字列",
615
+ renderOffsetX: "数か文字列",
616
+ renderOffsetY: "数か文字列",
533
617
  },
534
618
  step: {
535
619
  from: "必須の文字列",
@@ -542,6 +626,10 @@ export const 欄の型表 = {
542
626
  style: "線種",
543
627
  guard: "文字列",
544
628
  cardinality: "文字列",
629
+ // 値に追随する 3 欄 (#1396)。 描画側は文字列だけを取る
630
+ widthBind: "非空の文字列",
631
+ strokeBind: "非空の文字列",
632
+ dashOffsetBind: "非空の文字列",
545
633
  labelOffsetX: "数",
546
634
  labelOffsetY: "数",
547
635
  overlay: "真偽",
@@ -657,6 +745,12 @@ export const 見本に効かない欄 = [
657
745
  "visibleIf",
658
746
  // 箱に出す題 (#1381)。 見本は自分の題を持つ
659
747
  "title",
748
+ // 値に追随する 5 欄 (#1392)。 見本は自分の大きさと出方を持つ
749
+ "wBind",
750
+ "hBind",
751
+ "opacity",
752
+ "renderOffsetX",
753
+ "renderOffsetY",
660
754
  ] as const satisfies readonly (typeof ACCEPTED_KEYS.actor)[number][];
661
755
 
662
756
  /**
@@ -688,7 +782,7 @@ function 値を検査(
688
782
  return;
689
783
  case "非空の文字列":
690
784
  if (v === undefined) return;
691
- 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");
692
786
  return;
693
787
  case "文字列":
694
788
  if (v === undefined) return;
@@ -725,6 +819,20 @@ function 値を検査(
725
819
  型違い("a finite number", typeof v === "number" ? `got ${String(v)}` : undefined);
726
820
  }
727
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;
728
836
  case "真偽":
729
837
  if (v === undefined) return;
730
838
  if (typeof v !== "boolean") 型違い("true or false if present");
@@ -999,7 +1107,8 @@ function 表で中身を検査する(
999
1107
  // JSON は公開 schema の enum と同じ正規名だけを受ける。 ここだけ小文字化すると、
1000
1108
  // schema が拒む種類を validator が通した上、種類別の追加検査も回避できてしまう。
1001
1109
  const kind = typeof o.kind === "string" ? o.kind : "";
1002
- const 定義 = 表[kind];
1110
+ // 通常の object を表に使うため、継承した名前を own kind として扱わない。
1111
+ const 定義 = Object.hasOwn(表, kind) ? 表[kind] : undefined;
1003
1112
  if (定義 === undefined) {
1004
1113
  errors.push({
1005
1114
  path: `${path}.kind`,
@@ -1028,25 +1137,27 @@ function 表で中身を検査する(
1028
1137
  ? (typeof 値 === "number" && Number.isFinite(値)) || typeof 値 === "string"
1029
1138
  : 形 === "文字列の並び"
1030
1139
  ? Array.isArray(値) && 値.every((x) => typeof x === "string")
1031
- : 形 === "真偽"
1032
- ? typeof === "boolean"
1033
- : 形 === "組の並び"
1034
- ? Array.isArray() &&
1035
- 値.length > 0 &&
1036
- 値.every(
1037
- (x) =>
1038
- typeof x === "object" &&
1039
- x !== null &&
1040
- !Array.isArray(x) &&
1041
- Object.keys(x as object).length > 0 &&
1042
- Object.values(x as object).every(
1043
- (y) =>
1044
- typeof y === "string" || (typeof y === "number" && Number.isFinite(y)),
1045
- ),
1046
- )
1047
- : 形 === "向き"
1048
- ? typeof 値 === "string" && ["up", "down", "left", "right"].includes(値)
1049
- : typeof === "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";
1050
1161
  if (!型が合う) {
1051
1162
  errors.push({
1052
1163
  path: `${path}.${欄}`,
@@ -1140,6 +1251,278 @@ function validateReadouts(v: unknown, errors: JsonDslError[]): void {
1140
1251
  });
1141
1252
  }
1142
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
+
1143
1526
  /**
1144
1527
  * 箱の中に描く図形を検査する (#1374)。
1145
1528
  *
@@ -1478,6 +1861,13 @@ function validateJson(
1478
1861
  checkUnknownKeys(j, "root", "$", errors);
1479
1862
  // 値を見せる部品の中身を、記法と同じ表で見る (#1374)
1480
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);
1481
1871
 
1482
1872
  // 値そのものの型は表が見る (#1304)。 図表の箱の上の小見出し (#1247) の空文字は
1483
1873
  // 「書かなかった」 と同じ扱いにするため通す (記法側の `eyebrow:` と揃える。 落とすのは `jsonToDoc`)
@@ -1780,6 +2170,12 @@ export function jsonToDoc(json: DragonJson): DslDocument {
1780
2170
  shape: isPart ? undefined : a.shape,
1781
2171
  visibleIf: isPart ? undefined : a.visibleIf,
1782
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,
1783
2179
  // 普通の箱にしか効かない欄は見本では落とす。 落とす欄の一覧は `見本に効かない欄` が
1784
2180
  // 唯一の出どころで、検査 (#1308) も同じ表を見る = 「検査は通すが組み立てが捨てる」
1785
2181
  // 状態が作れない
@@ -1831,6 +2227,10 @@ export function jsonToDoc(json: DragonJson): DslDocument {
1831
2227
  style: s.style,
1832
2228
  guard: s.guard,
1833
2229
  cardinality: s.cardinality,
2230
+ // 値に追随する 3 欄 (#1396)
2231
+ widthBind: s.widthBind,
2232
+ strokeBind: s.strokeBind,
2233
+ dashOffsetBind: s.dashOffsetBind,
1834
2234
  labelOffsetX: s.labelOffsetX,
1835
2235
  labelOffsetY: s.labelOffsetY,
1836
2236
  overlay: s.overlay,
@@ -1842,6 +2242,37 @@ export function jsonToDoc(json: DragonJson): DslDocument {
1842
2242
  // 分けると `states` だけを書いた JSON で値が 1 つも届かない (記法側で起きていた形、 #1181)
1843
2243
  // 値を見せる部品はそのまま渡す (#1374)。 形は描画側の型が縛る
1844
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;
1845
2276
  const states: DslState[] = Object.entries(json.states ?? {}).map(([name, initial]) => ({
1846
2277
  name,
1847
2278
  initial,
@@ -1916,6 +2347,10 @@ export function jsonToDoc(json: DragonJson): DslDocument {
1916
2347
  )
1917
2348
  : undefined,
1918
2349
  readouts,
2350
+ inputs,
2351
+ formulas,
2352
+ events,
2353
+ scrolls,
1919
2354
  pos: p0,
1920
2355
  };
1921
2356
  }