@cardenelabs/dragon 0.13.0 → 0.15.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.
package/src/v05/parser.ts CHANGED
@@ -42,7 +42,7 @@
42
42
  */
43
43
 
44
44
  import type { NodeKind, Tone, EdgeStyle } from "@cardenelabs/cdl";
45
- import { TONES, NODE_KINDS } from "@cardenelabs/cdl";
45
+ import { TONES, NODE_KINDS, parseFormula } from "@cardenelabs/cdl";
46
46
  import { TONE_ALIAS, NODE_KIND_ALIAS } from "../keywords";
47
47
  import { parseRelativePos, orderByDependency } from "../relative-pos";
48
48
  import {
@@ -56,8 +56,13 @@ import type {
56
56
  DslAxes,
57
57
  DslDocument,
58
58
  DslActor,
59
+ DslNodeKind,
59
60
  DslDynShape,
60
61
  DslReadout,
62
+ DslInput,
63
+ DslFormula,
64
+ DslEventBinding,
65
+ DslScrollTrigger,
61
66
  DslActorNodeOverride,
62
67
  DslStep,
63
68
  DslAnimate,
@@ -102,6 +107,14 @@ export const TOP_LEVEL_KEYS = [
102
107
  "axes",
103
108
  // 値を見せる部品 (#1374)
104
109
  "readouts",
110
+ // 読む人が動かすつまみ (#1389)
111
+ "inputs",
112
+ // つまみの値から決まる値 (#1391)
113
+ "formulas",
114
+ // 押下などの出来事で動く仕掛け (#1393)
115
+ "events",
116
+ // 巻き上げに応じて進む値 (#1393)
117
+ "scrolls",
105
118
  ] as const;
106
119
 
107
120
  /**
@@ -170,6 +183,54 @@ function 名前と中括弧に割る(行: string): [string, string] | undefined
170
183
  return undefined;
171
184
  }
172
185
 
186
+ /**
187
+ * 行の末尾にある中括弧の塊を切り出す (#1396)。
188
+ *
189
+ * **`{[^}]*}` では切れない**。 欄の値に中括弧が入る形 (`{ widthBind: "{flow}" }`) では
190
+ * 内側の `}` で止まってしまい、塊ごと読み落として **本文の一部として扱われる**
191
+ * (実測 = 矢印の説明文が `"x" { widthBind: "{flow}" }` のまま図に載った)。
192
+ *
193
+ * 箱の側は #1381 で同じ理由から深さを数える形に直してある。 矢印だけが古い形で
194
+ * 残っていた。
195
+ *
196
+ * 引用符の内側は数えない = `sub: "a } b"` の `}` で閉じたことにしない。
197
+ * 切り出せない形は `undefined` を返し、呼び手が従来どおり本文として扱う。
198
+ */
199
+ function 末尾の中括弧を切り出す(rest: string): { 前: string; 中身: string } | undefined {
200
+ const t = rest.trimEnd();
201
+ if (!t.endsWith("}")) return undefined;
202
+ let 深さ = 0;
203
+ let 引用: string | null = null;
204
+ let 直前: string | null = null;
205
+ let 始 = -1;
206
+ for (let i = 0; i < t.length; i += 1) {
207
+ const c = t[i]!;
208
+ if (引用 !== null) {
209
+ if (引用 === '"' && c === "\\" && i + 1 < t.length) {
210
+ i += 1;
211
+ continue;
212
+ }
213
+ if (c === 引用) 引用 = null;
214
+ continue;
215
+ }
216
+ if ((c === '"' || c === "'") && (直前 === null || ":,{[".includes(直前))) {
217
+ 引用 = c;
218
+ 直前 = c;
219
+ continue;
220
+ }
221
+ if (c === "{") {
222
+ if (深さ === 0) 始 = i;
223
+ 深さ += 1;
224
+ } else if (c === "}") {
225
+ 深さ -= 1;
226
+ // 閉じた位置が末尾なら、そこが探していた塊
227
+ if (深さ === 0 && i === t.length - 1) return { 前: t.slice(0, 始), 中身: t.slice(始 + 1, i) };
228
+ }
229
+ if (!/\s/u.test(c)) 直前 = c;
230
+ }
231
+ return undefined;
232
+ }
233
+
173
234
  function isTopLevelKey(key: string): key is (typeof TOP_LEVEL_KEYS)[number] {
174
235
  return (TOP_LEVEL_KEYS as readonly string[]).includes(key);
175
236
  }
@@ -202,10 +263,13 @@ const NODE_KIND_DEFAULT: NodeKind = "actor";
202
263
  * 記法だけが持つ種類。 描画側には無いが、 図種ごとの組み立てで意味を持つ。
203
264
  *
204
265
  * `contract` / `eoa` / `multisig` / `proxy` / `library` / `interface` は Solidity 図の
205
- * 役割分けに、 `entity` / `state` は ER 図と状態遷移図に使う。 組み立ての段階で描画できる
206
- * 種類に置き換わるため、 そのまま描画側に渡ることはない。
266
+ * 役割分けに、 `entity` / `state` は ER 図と状態遷移図に使う。
267
+ *
268
+ * **描画側へ渡す前に必ず読み替える** (`compile.ts` の `描ける種別`)。 読み替えを通さずに渡すと
269
+ * 図の組み立てが落ちる = 描画側は知らない種類の大きさを引けない (#1420 で実測、
270
+ * `Cannot read properties of undefined (reading 'h')`)。
207
271
  */
208
- const DSL_ONLY_KINDS = [
272
+ export const DSL_ONLY_KINDS = [
209
273
  "entity",
210
274
  "state",
211
275
  "contract",
@@ -216,6 +280,9 @@ const DSL_ONLY_KINDS = [
216
280
  "interface",
217
281
  ] as const;
218
282
 
283
+ /** 記法だけが持つ種類。 描画側の `NodeKind` には含まれない */
284
+ export type DslOnlyKind = (typeof DSL_ONLY_KINDS)[number];
285
+
219
286
  /**
220
287
  * AWS などの固有名を、 同じ役割を表す汎用の種類に読み替える表。
221
288
  *
@@ -333,6 +400,11 @@ export function parseTextDslV05(src: string): V05ParseResult {
333
400
  let viewport: DslViewport | undefined = undefined;
334
401
  let lanesMap: Record<string, DslLane> | undefined = undefined;
335
402
  let readoutsList: DslReadout[] | undefined = undefined;
403
+ let inputsList: DslInput[] | undefined = undefined;
404
+ let formulasList: DslFormula[] | undefined = undefined;
405
+ let eventsList: DslEventBinding[] | undefined = undefined;
406
+ let scrollsList: DslScrollTrigger[] | undefined = undefined;
407
+ let scrollLines = new Map<string, number>();
336
408
  let groupsMap: Record<string, DslGroup> | undefined = undefined;
337
409
 
338
410
  let i = 0;
@@ -626,6 +698,123 @@ export function parseTextDslV05(src: string): V05ParseResult {
626
698
  i = next;
627
699
  continue;
628
700
  }
701
+ if (head.key === "inputs") {
702
+ // inputs:\n value: { kind: slider, min: 0, max: 100, defaultValue: 50, label: "Value" }
703
+ // 1 行にまとめた形は受けない。 黙って空の並びにすると、書いたつまみが全て消えた図になる。
704
+ if (head.value !== null && head.value.trim() !== "") {
705
+ errors.push({
706
+ line: line.no,
707
+ message: "inputs は 1 行にまとめて書けない",
708
+ hint: "次の行から字下げして `value: { kind: slider, min: 0, max: 100, defaultValue: 50 }` の形で並べる",
709
+ });
710
+ i += 1;
711
+ continue;
712
+ }
713
+ // `collectIndentedList` は `:` の無い行を落とす。 つまみを綴り違えた行も知らせるため、
714
+ // 字下げした行を全て読み手へ渡す。
715
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
716
+ inputsList = [];
717
+ for (const it of items) {
718
+ const m = 名前と中括弧に割る(it.trimmed);
719
+ if (m) {
720
+ const 読めた = つまみとして読む(m[0], m[1], it.no, errors);
721
+ if (読めた) inputsList.push(読めた);
722
+ } else {
723
+ errors.push({
724
+ line: it.no,
725
+ message: `invalid input entry: "${it.trimmed}"`,
726
+ hint: "use `id: { kind: slider, min: 0, max: 100, defaultValue: 50 }`",
727
+ });
728
+ }
729
+ }
730
+ i = next;
731
+ continue;
732
+ }
733
+ if (head.key === "formulas") {
734
+ // formulas:\n doubled: "input * 2"
735
+ //
736
+ // 1 行にまとめた形は受けない (`values:` と同じ理由)。 式に `,` が入るため、
737
+ // 素朴な `,` 分割では式が壊れる。
738
+ if (head.value !== null && head.value.trim() !== "") {
739
+ errors.push({
740
+ line: line.no,
741
+ message: "formulas は 1 行にまとめて書けない",
742
+ hint: '式に `,` が入るため。 次の行から字下げして `doubled: "input * 2"` の形で並べる',
743
+ });
744
+ i += 1;
745
+ continue;
746
+ }
747
+ // 字下げした行を全て読み手へ渡す = 綴りを誤った行も知らせるため
748
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
749
+ formulasList = [];
750
+ for (const it of items) {
751
+ const f = 式として読む(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
752
+ if (f) formulasList.push(f);
753
+ }
754
+ i = next;
755
+ continue;
756
+ }
757
+ if (head.key === "events") {
758
+ // events:\n - { on: click, box: Button, handler: toggle-active }
759
+ if (head.value !== null && head.value.trim() !== "") {
760
+ errors.push({
761
+ line: line.no,
762
+ message: "events は 1 行にまとめて書けない",
763
+ hint: "次の行から字下げして `- { on: click, box: Button, handler: toggle }` の形で並べる",
764
+ });
765
+ i += 1;
766
+ continue;
767
+ }
768
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
769
+ eventsList = [];
770
+ for (const it of items) {
771
+ const e = 出来事として読む(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
772
+ if (e) eventsList.push(e);
773
+ }
774
+ i = next;
775
+ continue;
776
+ }
777
+ if (head.key === "scrolls") {
778
+ // scrolls:\n intro: { start: 0.9, end: 0.1, scrub: 1, label: "..." }
779
+ if (head.value !== null && head.value.trim() !== "") {
780
+ errors.push({
781
+ line: line.no,
782
+ message: "scrolls は 1 行にまとめて書けない",
783
+ hint: "次の行から字下げして `intro: { start: 0.9, end: 0.1 }` の形で並べる",
784
+ });
785
+ i += 1;
786
+ continue;
787
+ }
788
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
789
+ scrollsList = [];
790
+ scrollLines = new Map();
791
+ for (const it of items) {
792
+ const m = 名前と中括弧に割る(it.trimmed);
793
+ if (m) {
794
+ const 読めた = 巻き上げとして読む(m[0], m[1], it.no, errors);
795
+ if (読めた) {
796
+ if (scrollLines.has(読めた.id)) {
797
+ errors.push({
798
+ line: it.no,
799
+ message: `巻き上げの名前 "${読めた.id}" が重複しています`,
800
+ hint: "scrolls の名前は 1 度だけ書く",
801
+ });
802
+ } else {
803
+ scrollsList.push(読めた);
804
+ scrollLines.set(読めた.id, it.no);
805
+ }
806
+ }
807
+ } else {
808
+ errors.push({
809
+ line: it.no,
810
+ message: `invalid scroll entry: "${it.trimmed}"`,
811
+ hint: "use `id: { start: 0.9, end: 0.1, scrub: 1 }`",
812
+ });
813
+ }
814
+ }
815
+ i = next;
816
+ continue;
817
+ }
629
818
  if (head.key === "groups") {
630
819
  // groups:\n aws: { label: "AWS", lanes: [ecs, rds] }
631
820
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
@@ -669,6 +858,20 @@ export function parseTextDslV05(src: string): V05ParseResult {
669
858
  hint: "add `type: sequence|flow|swimlane|er|state|topology|solidity|gantt|class|pie|c4|mind`",
670
859
  });
671
860
 
861
+ // つまみ・式・巻き上げは同じ名前空間で値を作る。 重なると後から作る値が効かない。
862
+ const 既に値を作る名前 = new Set([
863
+ ...(inputsList ?? []).map((input) => input.id),
864
+ ...(formulasList ?? []).map((formula) => formula.id),
865
+ ]);
866
+ for (const scroll of scrollsList ?? []) {
867
+ if (!既に値を作る名前.has(scroll.id)) continue;
868
+ errors.push({
869
+ line: scrollLines.get(scroll.id) ?? 1,
870
+ message: `巻き上げの名前 "${scroll.id}" が inputs または formulas と重なっています`,
871
+ hint: "inputs / formulas / scrolls では重ならない名前を使う",
872
+ });
873
+ }
874
+
672
875
  if (errors.length > 0) return { ok: false, errors };
673
876
 
674
877
  return {
@@ -685,6 +888,10 @@ export function parseTextDslV05(src: string): V05ParseResult {
685
888
  viewport,
686
889
  lanes: lanesMap,
687
890
  readouts: readoutsList,
891
+ inputs: inputsList,
892
+ formulas: formulasList,
893
+ events: eventsList,
894
+ scrolls: scrollsList,
688
895
  groups: groupsMap,
689
896
  pos: { line: 1 },
690
897
  },
@@ -882,15 +1089,16 @@ function classifyValues(values: string[], line: number, errors: DslError[]): Act
882
1089
  }
883
1090
 
884
1091
  /**
885
- * 書かれた種類名を、 描画できる種類に解決する。
1092
+ * 書かれた種類名を、 記法が扱う種類に解決する。
886
1093
  *
887
- * 固有名 (`lambda` / `rds` 等) は読み替え表を通す。 それ以外はそのまま返す。
1094
+ * 固有名 (`lambda` / `rds` 等) は読み替え表を通す。 記法だけが持つ種類はそのまま返し、
1095
+ * 描画側へ渡す時に `compile.ts` の `描ける種別` が読み替える。
888
1096
  */
889
- export function resolveNodeKind(raw: string): NodeKind {
1097
+ export function resolveNodeKind(raw: string): DslNodeKind {
890
1098
  if (raw === "") return NODE_KIND_DEFAULT;
891
1099
  // `Object.hasOwn` で引く。 素の添字だと `toString` 等の既定の持ち物が引けてしまい、
892
1100
  // 種類として関数が返る。 呼ぶ前に受理集合で弾いてはいるが、 表を引く側でも閉じておく。
893
- return Object.hasOwn(INFRA_KIND_ALIAS, raw) ? INFRA_KIND_ALIAS[raw]! : (raw as NodeKind);
1101
+ return Object.hasOwn(INFRA_KIND_ALIAS, raw) ? INFRA_KIND_ALIAS[raw]! : (raw as DslNodeKind);
894
1102
  }
895
1103
 
896
1104
  function numberOrUndef(s: string | undefined): number | undefined {
@@ -1109,6 +1317,18 @@ export const 図形の表: Record<string, 図形の定義> = {
1109
1317
  */
1110
1318
  export { 部品の表, 部品の組の表 } from "./readout-table.generated";
1111
1319
  import { 部品の表, 部品の組の表 } from "./readout-table.generated";
1320
+
1321
+ /**
1322
+ * 記法が受けるつまみと、その欄 (#1389)。
1323
+ *
1324
+ * **部品の表と同じく、描画側の型定義から生成する**。 14 種それぞれ欄が違い、手で写すと
1325
+ * 描画側が種類を足した時に drift が残る (`rules/quality.md § 導出可能記述は人手で書かない`)。
1326
+ *
1327
+ * 作り直す = `node packages/dragon/scripts/gen-input-table.mjs`
1328
+ * ずれの検知 = `packages/dragon/test/input-table-generated.test.ts`
1329
+ */
1330
+ export { つまみの表 } from "./input-table.generated";
1331
+ import { つまみの表 } from "./input-table.generated";
1112
1332
  import type { 図形の定義 } from "./parser-types";
1113
1333
 
1114
1334
  /** `[a, b]` の形を文字列の並びに読む */
@@ -1266,6 +1486,25 @@ function 表に従って読む(
1266
1486
  out[欄] = n !== undefined ? n : 値;
1267
1487
  } else if (形 === "文字列の並び") {
1268
1488
  out[欄] = 並びとして読む(値);
1489
+ } else if (形 === "数の並び") {
1490
+ /*
1491
+ * 数の並びは、1 つでも数として読めなければ欄ごと落とす (#1389)。
1492
+ *
1493
+ * 読めた分だけ渡すと並びの長さが変わり、番号で指す欄 (`defaultSpeedIdx`) が
1494
+ * 別の要素を指す。 書き間違いが「別の値が選ばれている図」 になって出るため、
1495
+ * 数え落としを黙って通さない。
1496
+ */
1497
+ const 生 = 並びとして読む(値);
1498
+ const 数 = 生.map((x) => numberOrUndef(x));
1499
+ if (数.some((n) => n === undefined)) {
1500
+ errors.push({
1501
+ line,
1502
+ message: `${接頭}${欄} に数でない値があります: "${値}"`,
1503
+ hint: "`[0.5, 1, 2, 4]` の形で数だけを並べる",
1504
+ });
1505
+ } else {
1506
+ out[欄] = 数;
1507
+ }
1269
1508
  } else if (形 === "組の並び") {
1270
1509
  const 組 = 組の並びとして読む(値);
1271
1510
  if (組 !== undefined) out[欄] = 組;
@@ -1336,6 +1575,86 @@ function 出す条件として読む(
1336
1575
  return 値;
1337
1576
  }
1338
1577
 
1578
+ /**
1579
+ * 値に追随する欄を読む (#1392)。 数として読めれば数、読めなければ文字列のまま渡す。
1580
+ *
1581
+ * `箱の中に描く図形` の `source` と同じ読み方に揃える (`表に従って読む` の「数か文字列」)。
1582
+ * 状態の名前 (`"{barW}"`) を書く形と、数を直に書く形の両方を受ける。
1583
+ *
1584
+ * **空は捨てずに知らせる**。 描画側は空文字を「書かなかった」 と同じには扱わず、
1585
+ * `opacity: ""` は 0 として読まれて箱が消える。 書いた人はたいてい値を書き忘れただけ。
1586
+ */
1587
+ function 値に追随する欄として読む(
1588
+ raw: string | undefined,
1589
+ 接頭: string,
1590
+ line: number,
1591
+ errors: DslError[],
1592
+ ): number | string | undefined {
1593
+ if (raw === undefined) return undefined;
1594
+ const 値 = raw.trim();
1595
+ if (値 === "") {
1596
+ errors.push({
1597
+ line,
1598
+ message: `${接頭}が空です`,
1599
+ hint: '`{状態の名前}` か数を書く (例 `opacity: "{fade}"` / `opacity: 0.5`)',
1600
+ });
1601
+ return undefined;
1602
+ }
1603
+ const n = numberOrUndef(値);
1604
+ return n !== undefined ? n : 値;
1605
+ }
1606
+
1607
+ /**
1608
+ * 箱の幅と高さを値に追随させる欄を読む (#1392)。
1609
+ *
1610
+ * 描画側は文字列だけを受ける (`wBind?: string`)。 数を直に書いても追随のしようが無いので、
1611
+ * 数か文字列として読む欄とは分ける。
1612
+ */
1613
+ function 追随する大きさとして読む(
1614
+ raw: string | undefined,
1615
+ 接頭: string,
1616
+ line: number,
1617
+ errors: DslError[],
1618
+ ): string | undefined {
1619
+ if (raw === undefined) return undefined;
1620
+ const 値 = raw.trim();
1621
+ if (値 === "") {
1622
+ errors.push({
1623
+ line,
1624
+ message: `${接頭}が空です`,
1625
+ hint: '`{状態の名前}` を書く (例 `wBind: "{barW}"`)',
1626
+ });
1627
+ return undefined;
1628
+ }
1629
+ return 値;
1630
+ }
1631
+
1632
+ /**
1633
+ * 値に追随する箱の欄 (#1392)。
1634
+ *
1635
+ * `satisfies` で `DslActor` から欄を導く = 綴りを誤ると型検査が落ちる。
1636
+ */
1637
+ const 値に追随する箱の欄 = [
1638
+ "wBind",
1639
+ "hBind",
1640
+ "opacity",
1641
+ "renderOffsetX",
1642
+ "renderOffsetY",
1643
+ ] as const satisfies readonly (keyof DslActor)[];
1644
+
1645
+ /**
1646
+ * 縦に並べた時、値が空でも読み取りへ渡す欄 (#1392)。
1647
+ *
1648
+ * 他の欄は空を「書かなかった」 として落とすが、この 5 欄は描画側が空文字を数として
1649
+ * 読むため、落とすと書き忘れが「箱が消えた」 形で出る。
1650
+ */
1651
+ const 空を知らせる箱の欄: ReadonlySet<string> = new Set(値に追随する箱の欄);
1652
+
1653
+ /** Object.prototype の持ち物を、表にある種類として扱わない。 */
1654
+ function 表から定義を引く(表: Record<string, 図形の定義>, kind: string): 図形の定義 | undefined {
1655
+ return Object.hasOwn(表, kind) ? 表[kind] : undefined;
1656
+ }
1657
+
1339
1658
  /**
1340
1659
  * 箱の中に描く図形を読む (#1374)。 読めなければ `undefined` を返す。
1341
1660
  *
@@ -1346,7 +1665,7 @@ function 図形として読む(raw: string, line: number, errors: DslError[]): D
1346
1665
  const 中身 = raw.trim().replace(/^\{|\}$/g, "");
1347
1666
  const opts = parseInlineMapping(中身);
1348
1667
  const kind = (opts.kind ?? "").toLowerCase();
1349
- const 定義 = 図形の表[kind];
1668
+ const 定義 = 表から定義を引く(図形の表, kind);
1350
1669
  if (定義 === undefined) {
1351
1670
  errors.push({
1352
1671
  line,
@@ -1371,7 +1690,7 @@ function 部品として読む(
1371
1690
  ): DslReadout | undefined {
1372
1691
  const opts = parseInlineMapping(raw);
1373
1692
  const kind = (opts.kind ?? "").toLowerCase();
1374
- const 定義 = 部品の表[kind];
1693
+ const 定義 = 表から定義を引く(部品の表, kind);
1375
1694
  if (定義 === undefined) {
1376
1695
  errors.push({
1377
1696
  line,
@@ -1386,6 +1705,260 @@ function 部品として読む(
1386
1705
  return { id, kind, ...読めた } as DslReadout;
1387
1706
  }
1388
1707
 
1708
+ /**
1709
+ * 出来事の種類 (#1393)。 描画側の `CdlEventKind` と同じ語を並べる。
1710
+ *
1711
+ * **描画側から導けない**。 型は書き出されるが値の一覧は実行時に無いため、ここに書く。
1712
+ * 知らない語を書いた時の知らせがこの一覧をそのまま出すので、増えたら 1 行足す。
1713
+ */
1714
+ export const EVENT_KINDS = [
1715
+ "click",
1716
+ "hover",
1717
+ "double-click",
1718
+ "long-press",
1719
+ "drag",
1720
+ "drop",
1721
+ "keydown",
1722
+ "focus",
1723
+ "blur",
1724
+ ] as const;
1725
+
1726
+ /** 出来事の相手を指す書き方。 ちょうど 1 つだけ書く */
1727
+ const EVENT_TARGET_KEYS = ["box", "lane", "arrow", "diagram"] as const;
1728
+
1729
+ /**
1730
+ * 押下などの出来事で動く仕掛けを 1 件読む (#1393)。
1731
+ *
1732
+ * 形は `{ on: click, box: Button, handler: toggle }`。 相手の指し方は 4 つあり、
1733
+ * **ちょうど 1 つだけ書く** = 2 つ書くとどちらを指したのか決まらず、0 なら相手がいない。
1734
+ *
1735
+ * 相手は名前で書く。 識別子は記法で書けないため、名前から識別子への読み替えは
1736
+ * 組み立てが行う (`focus:` と同じ扱い)。
1737
+ */
1738
+ function 出来事として読む(
1739
+ raw: string,
1740
+ line: number,
1741
+ errors: DslError[],
1742
+ ): DslEventBinding | undefined {
1743
+ const t = raw.trim();
1744
+ if (!t.startsWith("{") || !t.endsWith("}")) {
1745
+ errors.push({
1746
+ line,
1747
+ message: `出来事の行が読めません: "${t}"`,
1748
+ hint: "`- { on: click, box: Button, handler: toggle }` の形で書く",
1749
+ });
1750
+ return undefined;
1751
+ }
1752
+ const opts = parseInlineMapping(t.slice(1, -1));
1753
+ for (const k of Object.keys(opts)) {
1754
+ if (k === "on" || k === "handler" || (EVENT_TARGET_KEYS as readonly string[]).includes(k))
1755
+ continue;
1756
+ errors.push({
1757
+ line,
1758
+ message: `出来事の項目名が読めません: "${k}"`,
1759
+ hint: `使える項目 = on, handler, ${EVENT_TARGET_KEYS.join(", ")}`,
1760
+ });
1761
+ return undefined;
1762
+ }
1763
+ const on = opts.on ?? "";
1764
+ if (!(EVENT_KINDS as readonly string[]).includes(on)) {
1765
+ errors.push({
1766
+ line,
1767
+ message: `出来事の種類が読めません: "${on}"`,
1768
+ hint: `使える種類 = ${EVENT_KINDS.join(", ")}`,
1769
+ });
1770
+ return undefined;
1771
+ }
1772
+ const handlerId = (opts.handler ?? "").trim();
1773
+ if (handlerId === "") {
1774
+ errors.push({
1775
+ line,
1776
+ message: "出来事の handler が空です",
1777
+ hint: "`handler: toggle-active` のように、呼び出す仕掛けの名前を書く",
1778
+ });
1779
+ return undefined;
1780
+ }
1781
+ const 書いた相手 = EVENT_TARGET_KEYS.filter((k) => opts[k] !== undefined);
1782
+ if (書いた相手.length !== 1) {
1783
+ errors.push({
1784
+ line,
1785
+ message:
1786
+ 書いた相手.length === 0
1787
+ ? "出来事の相手が書かれていません"
1788
+ : `出来事の相手を 2 つ以上書いています: ${書いた相手.join(", ")}`,
1789
+ hint: `${EVENT_TARGET_KEYS.join(" / ")} のどれか 1 つだけを書く`,
1790
+ });
1791
+ return undefined;
1792
+ }
1793
+ const 鍵 = 書いた相手[0]!;
1794
+ const 値 = (opts[鍵] ?? "").trim();
1795
+ let target: DslEventBinding["target"];
1796
+ if (鍵 === "diagram") {
1797
+ if (値 !== "true") {
1798
+ errors.push({
1799
+ line,
1800
+ message: `出来事の diagram が読めません: "${値}"`,
1801
+ hint: "図全体を指す時は `diagram: true` と書く",
1802
+ });
1803
+ return undefined;
1804
+ }
1805
+ target = { kind: "diagram" };
1806
+ } else if (鍵 === "arrow") {
1807
+ const m = 値.split("->");
1808
+ if (m.length !== 2 || m[0]!.trim() === "" || m[1]!.trim() === "") {
1809
+ errors.push({
1810
+ line,
1811
+ message: `出来事の矢印が読めません: "${値}"`,
1812
+ hint: "`arrow: A -> B` の形で、矢印の両端の名前を書く",
1813
+ });
1814
+ return undefined;
1815
+ }
1816
+ target = { kind: "edge", from: stripQuotes(m[0]!.trim()), to: stripQuotes(m[1]!.trim()) };
1817
+ } else {
1818
+ if (値 === "") {
1819
+ errors.push({
1820
+ line,
1821
+ message: `出来事の ${鍵} が空です`,
1822
+ hint: "指す相手の名前を書く",
1823
+ });
1824
+ return undefined;
1825
+ }
1826
+ target = { kind: 鍵 === "box" ? "node" : "lane", name: stripQuotes(値) };
1827
+ }
1828
+ return { event: on as DslEventBinding["event"], target, handlerId, pos: { line } };
1829
+ }
1830
+
1831
+ /** 巻き上げに応じて進む値の欄 (#1393)。 描画側の `CdlScrollTrigger` を覆う */
1832
+ const SCROLL_VALUE_KINDS = {
1833
+ start: "数",
1834
+ end: "数",
1835
+ scrub: "数",
1836
+ } as const satisfies Record<string, 値の形>;
1837
+
1838
+ /**
1839
+ * 巻き上げに応じて進む値を 1 件読む (#1393)。
1840
+ *
1841
+ * 形は `intro: { start: 0.9, end: 0.1, scrub: 1, label: "..." }`。
1842
+ * 数の欄は表が読み、説明文はそのまま渡す。
1843
+ */
1844
+ function 巻き上げとして読む(
1845
+ id: string,
1846
+ raw: string,
1847
+ line: number,
1848
+ errors: DslError[],
1849
+ ): DslScrollTrigger | undefined {
1850
+ if (!isValueName(id)) {
1851
+ errors.push({ line, ...valueNameIssue(id) });
1852
+ return undefined;
1853
+ }
1854
+ const opts = parseInlineMapping(raw);
1855
+ const 使える = [...Object.keys(SCROLL_VALUE_KINDS), "label"];
1856
+ for (const k of Object.keys(opts)) {
1857
+ if (使える.includes(k)) continue;
1858
+ errors.push({
1859
+ line,
1860
+ message: `巻き上げ ${id} の項目名が読めません: "${k}"`,
1861
+ hint: `使える項目 = ${使える.join(", ")}`,
1862
+ });
1863
+ return undefined;
1864
+ }
1865
+ const 数 = 表で読む(SCROLL_VALUE_KINDS, opts, `巻き上げ ${id} の `, line, errors);
1866
+ for (const 欄 of ["start", "end", "scrub"] as const) {
1867
+ const 値 = 数[欄];
1868
+ if (値 === undefined || (値 >= 0 && 値 <= 1)) continue;
1869
+ errors.push({
1870
+ line,
1871
+ message: `巻き上げ ${id} の ${欄} は 0 から 1 の間で書きます: "${値}"`,
1872
+ hint: "0 は画面の上端または段階的な追随、1 は下端または連続追随",
1873
+ });
1874
+ }
1875
+ return {
1876
+ id,
1877
+ ...(数.start !== undefined ? { start: 数.start } : {}),
1878
+ ...(数.end !== undefined ? { end: 数.end } : {}),
1879
+ ...(数.scrub !== undefined ? { scrub: 数.scrub } : {}),
1880
+ ...(opts.label !== undefined ? { label: opts.label } : {}),
1881
+ };
1882
+ }
1883
+
1884
+ /**
1885
+ * つまみの値から決まる値を読む (#1391)。 読めなければ `undefined` を返す。
1886
+ *
1887
+ * 形は `名前: "式"` の 1 行。 `values:` と同じ形で、**違うのは解かれる仕組み**。
1888
+ * あちらは段が動かす状態を読み、こちらはつまみが握る値を読む。
1889
+ *
1890
+ * 名前を中括弧で囲うかは自由 (実測 = 描画側の parser は `"{a} + 1"` と `"a + 1"` を
1891
+ * 同じ名前として読む)。 `values:` から式を書き写しても、そのまま通る。
1892
+ *
1893
+ * **式は描画側の parser に通す**。 自前で書き方を決めると、通ったのに描画側が
1894
+ * 解けない式を受けてしまう。 描画側が投げた誤りの本文をそのまま知らせに載せる。
1895
+ */
1896
+ function 式として読む(行: string, line: number, errors: DslError[]): DslFormula | undefined {
1897
+ const c = 行.indexOf(":");
1898
+ if (c < 0) {
1899
+ errors.push({
1900
+ line,
1901
+ message: `式の行が読めません: "${行}"`,
1902
+ hint: '`名前: "式"` の形で書く (例 `doubled: "input * 2"`)',
1903
+ });
1904
+ return undefined;
1905
+ }
1906
+ const 名前 = 行.slice(0, c).trim();
1907
+ const 式 = stripQuotes(行.slice(c + 1).trim());
1908
+ if (!isValueName(名前)) {
1909
+ // 名前の規則は状態と揃える = 式の名前も `{名前}` で箱の文字に差し込める
1910
+ errors.push({ line, ...valueNameIssue(名前) });
1911
+ return undefined;
1912
+ }
1913
+ if (式 === "") {
1914
+ errors.push({
1915
+ line,
1916
+ message: `式 "${名前}" が空です`,
1917
+ hint: '`doubled: "input * 2"` のように式を書く',
1918
+ });
1919
+ return undefined;
1920
+ }
1921
+ try {
1922
+ parseFormula(式);
1923
+ } catch (e) {
1924
+ errors.push({
1925
+ line,
1926
+ message: `式 "${名前}" を読めません: ${(e as Error).message}`,
1927
+ hint: "使えるのは四則と括弧、比較、三項 (`a > 1 ? 2 : 3`)、`Math.min` などの関数",
1928
+ });
1929
+ return undefined;
1930
+ }
1931
+ return { id: 名前, expression: 式, pos: { line } };
1932
+ }
1933
+
1934
+ /**
1935
+ * 読む人が動かすつまみを読む (#1389)。 読めなければ `undefined` を返す。
1936
+ *
1937
+ * 部品 (`readouts:`) と同じ経路で読む。 違いは組の並びを取る欄が無いことだけで、
1938
+ * 知らない欄と足りない必須欄の知らせ方は同じ。
1939
+ */
1940
+ function つまみとして読む(
1941
+ id: string,
1942
+ raw: string,
1943
+ line: number,
1944
+ errors: DslError[],
1945
+ ): DslInput | undefined {
1946
+ const opts = parseInlineMapping(raw);
1947
+ const kind = (opts.kind ?? "").toLowerCase();
1948
+ const 定義 = 表から定義を引く(つまみの表, kind);
1949
+ if (定義 === undefined) {
1950
+ errors.push({
1951
+ line,
1952
+ message: `つまみの種類が読めません: "${opts.kind ?? ""}"`,
1953
+ hint: `使える種類 = ${Object.keys(つまみの表).join(", ")}`,
1954
+ });
1955
+ return undefined;
1956
+ }
1957
+ const 読めた = 表に従って読む(定義, opts, `つまみ ${id} の `, line, errors);
1958
+ for (const 欄 of 定義.必須) if (読めた[欄] === undefined) return undefined;
1959
+ return { id, kind, ...読めた } as DslInput;
1960
+ }
1961
+
1389
1962
  /** 箱の中の要素の欄 (#1306)。 `DslActorNodeOverride` の全欄を覆う */
1390
1963
  export const ACTOR_NODE_VALUE_KINDS = {
1391
1964
  posX: "数",
@@ -1747,6 +2320,12 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1747
2320
  const 図種ごとの欄 = new Map<string, string>();
1748
2321
  // パーツでなければどこにも入らない項目。 パーツかどうかは block を読み終わるまで決まらない
1749
2322
  const unknownKeys: Array<{ key: string; line: number }> = [];
2323
+ /**
2324
+ * 値に追随する 5 欄に書かれた字 (#1392)。
2325
+ *
2326
+ * 見本かどうかで読み方が変わるため、行を読む時点では振り分けない。
2327
+ */
2328
+ const 追随する欄の生値 = new Map<string, { 値: string; line: number }>();
1750
2329
 
1751
2330
  for (const ln of rest) {
1752
2331
  const idx = ln.trimmed.indexOf(":");
@@ -1762,7 +2341,9 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1762
2341
  unknownKeys.push({ key, line: ln.no });
1763
2342
  continue;
1764
2343
  }
1765
- if (!raw) continue;
2344
+ // 従来欄の空値は書かなかった扱いのままにする。 値に追随する 5 欄は、
2345
+ // 空文字を描画側が数として解釈するため、個別の読み取りへ渡して知らせる。
2346
+ if (!raw && !空を知らせる箱の欄.has(key)) continue;
1766
2347
 
1767
2348
  if (COLOR_KEYS.has(key)) {
1768
2349
  const { tone, hex } = splitColorValue(raw);
@@ -1807,6 +2388,23 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1807
2388
  case "題":
1808
2389
  out.title = stripQuotes(raw);
1809
2390
  break;
2391
+ /*
2392
+ * 値に追随する 5 欄 (#1392)。 日本語の別名は置かない = 描画側の欄名がそのまま
2393
+ * 状態の名前と並ぶ場所なので、英字 1 種に絞って書き方の揺れを作らない。
2394
+ *
2395
+ * **ここでは読まずに書かれた字だけを控える**。 見本 (parts) では同じ名前が状態の
2396
+ * 上書きになり、中括弧の形は生の字を `coerceStateValue` に通す。 ここで先に読むと
2397
+ * `wBind: 120` が中括弧では数、縦に並べると文字列になって書き方で割れる。
2398
+ * 見本かどうかは `kind` で決まり、それが後ろの行に書かれることがあるため、
2399
+ * 全行を読み終えてから振り分ける。
2400
+ */
2401
+ case "wBind":
2402
+ case "hBind":
2403
+ case "opacity":
2404
+ case "renderOffsetX":
2405
+ case "renderOffsetY":
2406
+ 追随する欄の生値.set(key, { 値: stripQuotes(raw), line: ln.no });
2407
+ break;
1810
2408
  case "rows":
1811
2409
  case "行":
1812
2410
  out.rows = raw
@@ -1924,9 +2522,31 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1924
2522
  }
1925
2523
  // 状態も倍率も parts でだけ意味を持つ。 パーツなら知らせずに返す
1926
2524
  if (out.partId !== undefined) {
2525
+ /*
2526
+ * 見本では 5 欄も他の名前と同じく状態の上書きになる。
2527
+ *
2528
+ * **中括弧の形と同じ `coerceStateValue` を通す**。 専用の読み取りを通した値を移すと、
2529
+ * `wBind: 120` が中括弧では数、縦に並べると文字列になって書き方で割れる (Round 2 の指摘)。
2530
+ */
2531
+ for (const [欄, { 値 }] of 追随する欄の生値) {
2532
+ state[欄] = coerceStateValue(値);
2533
+ touchedState = true;
2534
+ }
1927
2535
  if (touchedState) out.stateOverride = state;
1928
2536
  return out;
1929
2537
  }
2538
+ /*
2539
+ * 普通の箱では専用の欄として読む (#1392)。
2540
+ *
2541
+ * 見本かどうかが決まってから読むため、空の知らせも見本でない箱にだけ出る。
2542
+ */
2543
+ for (const [欄, { 値, line }] of 追随する欄の生値) {
2544
+ if (欄 === "wBind" || 欄 === "hBind") {
2545
+ out[欄] = 追随する大きさとして読む(値, `箱の ${欄} `, line, errors);
2546
+ } else if (欄 === "opacity" || 欄 === "renderOffsetX" || 欄 === "renderOffsetY") {
2547
+ out[欄] = 値に追随する欄として読む(値, `箱の ${欄} `, line, errors);
2548
+ }
2549
+ }
1930
2550
  // パーツでない箱に書かれた見知らぬ項目は、 どこにも入らずに消える。 黙って捨てると
1931
2551
  // 「書いたのに図が変わらない」 が手掛かりなしで起きるので、 綴りの誤りとして知らせる
1932
2552
  for (const u of unknownKeys) {
@@ -1963,6 +2583,12 @@ export const ACTOR_ITEM_KEYS: ReadonlySet<string> = new Set([
1963
2583
  // 箱に出す題 (#1381)
1964
2584
  "title",
1965
2585
  "題",
2586
+ // 値に追随する 5 欄 (#1392)
2587
+ "wBind",
2588
+ "hBind",
2589
+ "opacity",
2590
+ "renderOffsetX",
2591
+ "renderOffsetY",
1966
2592
  "位置",
1967
2593
  "pos",
1968
2594
  "posX",
@@ -2307,6 +2933,12 @@ export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
2307
2933
  "visibleIf",
2308
2934
  // 箱に出す題 (#1381)。 名前と切り離して書ける
2309
2935
  "title",
2936
+ // 値に追随する 5 欄 (#1392)
2937
+ "wBind",
2938
+ "hBind",
2939
+ "opacity",
2940
+ "renderOffsetX",
2941
+ "renderOffsetY",
2310
2942
  // 倍率は別経路 (`reportScaleOnNonPart`) が知らせる。 ここでも読める扱いにしないと
2311
2943
  // 同じ名前で 2 度知らせることになる
2312
2944
  "scale",
@@ -2326,6 +2958,12 @@ export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
2326
2958
  * 持つ一覧が実装と drift する = 欄を足しても誰も気付けない。 表を唯一の出どころにして、
2327
2959
  * `FLOW_INLINE_KEYS` から一覧を導けるようにする。
2328
2960
  */
2961
+ const EDGE_BIND_INLINE_READERS = {
2962
+ widthBind: (v: string | undefined) => v,
2963
+ strokeBind: (v: string | undefined) => v,
2964
+ dashOffsetBind: (v: string | undefined) => v,
2965
+ } as const;
2966
+
2329
2967
  const FLOW_INLINE_READERS = {
2330
2968
  sub: (v: string | undefined) => v,
2331
2969
  guard: (v: string | undefined) => v,
@@ -2335,6 +2973,13 @@ const FLOW_INLINE_READERS = {
2335
2973
  v !== undefined && (EDGE_SIDE_VALUES as readonly string[]).includes(v)
2336
2974
  ? (v as "top" | "right" | "bottom" | "left")
2337
2975
  : undefined,
2976
+ /*
2977
+ * 矢印を値に追随させる 3 欄 (#1396)。 箱の `wBind` (#1392) と同じく文字列だけを取る。
2978
+ *
2979
+ * ここでは字をそのまま通し、空かどうかは呼出側が知らせる (表の読み手は行番号を
2980
+ * 持たないため、知らせを出せる場所で見る)。
2981
+ */
2982
+ ...EDGE_BIND_INLINE_READERS,
2338
2983
  // 数と真偽の欄は `FLOW_INLINE_VALUE_KINDS` の表が読む (#1306)。 ここでは名前だけを持つ =
2339
2984
  // 読める欄の一覧 (`FLOW_INLINE_KEYS`) は本表から導くため、載せないと欄ごと消える
2340
2985
  labelOffsetX: null,
@@ -2452,6 +3097,23 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
2452
3097
  visibleIf: isPart ? undefined : 出す条件として読む(opts.visibleIf, line.no, errors),
2453
3098
  // 箱に出す題 (#1381)。 空文字も意味を持つ (題を出さない箱) ため undefined と分ける
2454
3099
  title: isPart ? undefined : opts.title,
3100
+ // 値に追随する 5 欄 (#1392)。 図形や出す条件と同じく、パーツでは状態の上書きとして
3101
+ // 意味を持つため横取りしない
3102
+ wBind: isPart
3103
+ ? undefined
3104
+ : 追随する大きさとして読む(opts.wBind, "箱の wBind ", line.no, errors),
3105
+ hBind: isPart
3106
+ ? undefined
3107
+ : 追随する大きさとして読む(opts.hBind, "箱の hBind ", line.no, errors),
3108
+ opacity: isPart
3109
+ ? undefined
3110
+ : 値に追随する欄として読む(opts.opacity, "箱の opacity ", line.no, errors),
3111
+ renderOffsetX: isPart
3112
+ ? undefined
3113
+ : 値に追随する欄として読む(opts.renderOffsetX, "箱の renderOffsetX ", line.no, errors),
3114
+ renderOffsetY: isPart
3115
+ ? undefined
3116
+ : 値に追随する欄として読む(opts.renderOffsetY, "箱の renderOffsetY ", line.no, errors),
2455
3117
  ...表で読む(ACTOR_INLINE_VALUE_KINDS, opts, "箱の ", line.no, errors),
2456
3118
  // parts では `tone` を状態の上書きとして従来から使えるため、 色として横取りしない
2457
3119
  tone: isPart ? undefined : resolveTone(opts.tone),
@@ -2530,10 +3192,20 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
2530
3192
  // 欄は `FLOW_INLINE_READERS` の表から読む。 個別に並べると一覧が実装と drift する
2531
3193
  const 中括弧: Partial<Record<keyof typeof FLOW_INLINE_READERS, unknown>> = {};
2532
3194
  // inline option (`{ ... }`) を末尾から抽出
2533
- const mapMatch = rest.match(/\s*\{([^}]*)\}\s*$/);
3195
+ const = 末尾の中括弧を切り出す(rest);
2534
3196
  let 数と真偽: 読んだ結果<typeof FLOW_INLINE_VALUE_KINDS> | undefined;
2535
- if (mapMatch) {
2536
- const opts = parseInlineMapping(mapMatch[1]!);
3197
+ if () {
3198
+ const opts = parseInlineMapping(塊.中身);
3199
+ // `parseInlineMapping` は値が 1 文字もない `widthBind:` を拾わない。 3 欄は空を
3200
+ // 「書かなかった」扱いにせず知らせる契約なので、書かれた値を空も含めて上書きする。
3201
+ // 同じ欄を複数回書いた時は通常の mapping と同じく後勝ちにする。
3202
+ for (const field of splitInlineFields(塊.中身)) {
3203
+ const idx = field.indexOf(":");
3204
+ if (idx < 0) continue;
3205
+ const key = field.slice(0, idx).trim();
3206
+ if (!Object.hasOwn(EDGE_BIND_INLINE_READERS, key)) continue;
3207
+ opts[key] = stripQuotes(field.slice(idx + 1).trim());
3208
+ }
2537
3209
  // 文字列の欄はそのまま入れ、数と真偽の欄は表が読んで読めない値を知らせる (#1306)
2538
3210
  for (const k of FLOW_INLINE_KEYS) {
2539
3211
  const 読み手 = FLOW_INLINE_READERS[k];
@@ -2548,12 +3220,32 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
2548
3220
  });
2549
3221
  }
2550
3222
  数と真偽 = 表で読む(FLOW_INLINE_VALUE_KINDS, opts, "矢印の ", line.no, errors);
2551
- rest = rest.slice(0, mapMatch.index ?? 0).trim();
3223
+ rest = 塊.前.trim();
2552
3224
  }
2553
3225
  const sub = 中括弧.sub as string | undefined;
2554
3226
  const guard = 中括弧.guard as string | undefined;
2555
3227
  const cardinality = 中括弧.cardinality as string | undefined;
2556
3228
  const side = 中括弧.side as "top" | "right" | "bottom" | "left" | undefined;
3229
+ // 値に追随する 3 欄 (#1396)。 空は捨てずに知らせる = 描画側は空文字を既定値へ落とさず
3230
+ // そのまま置換に使うため、書き忘れが「線が消えた」 形で出る
3231
+ const widthBind = 追随する大きさとして読む(
3232
+ 中括弧.widthBind as string | undefined,
3233
+ "矢印の widthBind ",
3234
+ line.no,
3235
+ errors,
3236
+ );
3237
+ const strokeBind = 追随する大きさとして読む(
3238
+ 中括弧.strokeBind as string | undefined,
3239
+ "矢印の strokeBind ",
3240
+ line.no,
3241
+ errors,
3242
+ );
3243
+ const dashOffsetBind = 追随する大きさとして読む(
3244
+ 中括弧.dashOffsetBind as string | undefined,
3245
+ "矢印の dashOffsetBind ",
3246
+ line.no,
3247
+ errors,
3248
+ );
2557
3249
  const labelOffsetX = 数と真偽?.labelOffsetX;
2558
3250
  const labelOffsetY = 数と真偽?.labelOffsetY;
2559
3251
  const overlay = 数と真偽?.overlay;
@@ -2614,6 +3306,9 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
2614
3306
  guard,
2615
3307
  cardinality,
2616
3308
  side,
3309
+ widthBind,
3310
+ strokeBind,
3311
+ dashOffsetBind,
2617
3312
  labelOffsetX,
2618
3313
  labelOffsetY,
2619
3314
  overlay,