@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.
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 {
@@ -58,6 +58,10 @@ import type {
58
58
  DslActor,
59
59
  DslDynShape,
60
60
  DslReadout,
61
+ DslInput,
62
+ DslFormula,
63
+ DslEventBinding,
64
+ DslScrollTrigger,
61
65
  DslActorNodeOverride,
62
66
  DslStep,
63
67
  DslAnimate,
@@ -102,6 +106,14 @@ export const TOP_LEVEL_KEYS = [
102
106
  "axes",
103
107
  // 値を見せる部品 (#1374)
104
108
  "readouts",
109
+ // 読む人が動かすつまみ (#1389)
110
+ "inputs",
111
+ // つまみの値から決まる値 (#1391)
112
+ "formulas",
113
+ // 押下などの出来事で動く仕掛け (#1393)
114
+ "events",
115
+ // 巻き上げに応じて進む値 (#1393)
116
+ "scrolls",
105
117
  ] as const;
106
118
 
107
119
  /**
@@ -170,6 +182,54 @@ function 名前と中括弧に割る(行: string): [string, string] | undefined
170
182
  return undefined;
171
183
  }
172
184
 
185
+ /**
186
+ * 行の末尾にある中括弧の塊を切り出す (#1396)。
187
+ *
188
+ * **`{[^}]*}` では切れない**。 欄の値に中括弧が入る形 (`{ widthBind: "{flow}" }`) では
189
+ * 内側の `}` で止まってしまい、塊ごと読み落として **本文の一部として扱われる**
190
+ * (実測 = 矢印の説明文が `"x" { widthBind: "{flow}" }` のまま図に載った)。
191
+ *
192
+ * 箱の側は #1381 で同じ理由から深さを数える形に直してある。 矢印だけが古い形で
193
+ * 残っていた。
194
+ *
195
+ * 引用符の内側は数えない = `sub: "a } b"` の `}` で閉じたことにしない。
196
+ * 切り出せない形は `undefined` を返し、呼び手が従来どおり本文として扱う。
197
+ */
198
+ function 末尾の中括弧を切り出す(rest: string): { 前: string; 中身: string } | undefined {
199
+ const t = rest.trimEnd();
200
+ if (!t.endsWith("}")) return undefined;
201
+ let 深さ = 0;
202
+ let 引用: string | null = null;
203
+ let 直前: string | null = null;
204
+ let 始 = -1;
205
+ for (let i = 0; i < t.length; i += 1) {
206
+ const c = t[i]!;
207
+ if (引用 !== null) {
208
+ if (引用 === '"' && c === "\\" && i + 1 < t.length) {
209
+ i += 1;
210
+ continue;
211
+ }
212
+ if (c === 引用) 引用 = null;
213
+ continue;
214
+ }
215
+ if ((c === '"' || c === "'") && (直前 === null || ":,{[".includes(直前))) {
216
+ 引用 = c;
217
+ 直前 = c;
218
+ continue;
219
+ }
220
+ if (c === "{") {
221
+ if (深さ === 0) 始 = i;
222
+ 深さ += 1;
223
+ } else if (c === "}") {
224
+ 深さ -= 1;
225
+ // 閉じた位置が末尾なら、そこが探していた塊
226
+ if (深さ === 0 && i === t.length - 1) return { 前: t.slice(0, 始), 中身: t.slice(始 + 1, i) };
227
+ }
228
+ if (!/\s/u.test(c)) 直前 = c;
229
+ }
230
+ return undefined;
231
+ }
232
+
173
233
  function isTopLevelKey(key: string): key is (typeof TOP_LEVEL_KEYS)[number] {
174
234
  return (TOP_LEVEL_KEYS as readonly string[]).includes(key);
175
235
  }
@@ -333,6 +393,11 @@ export function parseTextDslV05(src: string): V05ParseResult {
333
393
  let viewport: DslViewport | undefined = undefined;
334
394
  let lanesMap: Record<string, DslLane> | undefined = undefined;
335
395
  let readoutsList: DslReadout[] | undefined = undefined;
396
+ let inputsList: DslInput[] | undefined = undefined;
397
+ let formulasList: DslFormula[] | undefined = undefined;
398
+ let eventsList: DslEventBinding[] | undefined = undefined;
399
+ let scrollsList: DslScrollTrigger[] | undefined = undefined;
400
+ let scrollLines = new Map<string, number>();
336
401
  let groupsMap: Record<string, DslGroup> | undefined = undefined;
337
402
 
338
403
  let i = 0;
@@ -626,6 +691,123 @@ export function parseTextDslV05(src: string): V05ParseResult {
626
691
  i = next;
627
692
  continue;
628
693
  }
694
+ if (head.key === "inputs") {
695
+ // inputs:\n value: { kind: slider, min: 0, max: 100, defaultValue: 50, label: "Value" }
696
+ // 1 行にまとめた形は受けない。 黙って空の並びにすると、書いたつまみが全て消えた図になる。
697
+ if (head.value !== null && head.value.trim() !== "") {
698
+ errors.push({
699
+ line: line.no,
700
+ message: "inputs は 1 行にまとめて書けない",
701
+ hint: "次の行から字下げして `value: { kind: slider, min: 0, max: 100, defaultValue: 50 }` の形で並べる",
702
+ });
703
+ i += 1;
704
+ continue;
705
+ }
706
+ // `collectIndentedList` は `:` の無い行を落とす。 つまみを綴り違えた行も知らせるため、
707
+ // 字下げした行を全て読み手へ渡す。
708
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
709
+ inputsList = [];
710
+ for (const it of items) {
711
+ const m = 名前と中括弧に割る(it.trimmed);
712
+ if (m) {
713
+ const 読めた = つまみとして読む(m[0], m[1], it.no, errors);
714
+ if (読めた) inputsList.push(読めた);
715
+ } else {
716
+ errors.push({
717
+ line: it.no,
718
+ message: `invalid input entry: "${it.trimmed}"`,
719
+ hint: "use `id: { kind: slider, min: 0, max: 100, defaultValue: 50 }`",
720
+ });
721
+ }
722
+ }
723
+ i = next;
724
+ continue;
725
+ }
726
+ if (head.key === "formulas") {
727
+ // formulas:\n doubled: "input * 2"
728
+ //
729
+ // 1 行にまとめた形は受けない (`values:` と同じ理由)。 式に `,` が入るため、
730
+ // 素朴な `,` 分割では式が壊れる。
731
+ if (head.value !== null && head.value.trim() !== "") {
732
+ errors.push({
733
+ line: line.no,
734
+ message: "formulas は 1 行にまとめて書けない",
735
+ hint: '式に `,` が入るため。 次の行から字下げして `doubled: "input * 2"` の形で並べる',
736
+ });
737
+ i += 1;
738
+ continue;
739
+ }
740
+ // 字下げした行を全て読み手へ渡す = 綴りを誤った行も知らせるため
741
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
742
+ formulasList = [];
743
+ for (const it of items) {
744
+ const f = 式として読む(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
745
+ if (f) formulasList.push(f);
746
+ }
747
+ i = next;
748
+ continue;
749
+ }
750
+ if (head.key === "events") {
751
+ // events:\n - { on: click, box: Button, handler: toggle-active }
752
+ if (head.value !== null && head.value.trim() !== "") {
753
+ errors.push({
754
+ line: line.no,
755
+ message: "events は 1 行にまとめて書けない",
756
+ hint: "次の行から字下げして `- { on: click, box: Button, handler: toggle }` の形で並べる",
757
+ });
758
+ i += 1;
759
+ continue;
760
+ }
761
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
762
+ eventsList = [];
763
+ for (const it of items) {
764
+ const e = 出来事として読む(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
765
+ if (e) eventsList.push(e);
766
+ }
767
+ i = next;
768
+ continue;
769
+ }
770
+ if (head.key === "scrolls") {
771
+ // scrolls:\n intro: { start: 0.9, end: 0.1, scrub: 1, label: "..." }
772
+ if (head.value !== null && head.value.trim() !== "") {
773
+ errors.push({
774
+ line: line.no,
775
+ message: "scrolls は 1 行にまとめて書けない",
776
+ hint: "次の行から字下げして `intro: { start: 0.9, end: 0.1 }` の形で並べる",
777
+ });
778
+ i += 1;
779
+ continue;
780
+ }
781
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
782
+ scrollsList = [];
783
+ scrollLines = new Map();
784
+ for (const it of items) {
785
+ const m = 名前と中括弧に割る(it.trimmed);
786
+ if (m) {
787
+ const 読めた = 巻き上げとして読む(m[0], m[1], it.no, errors);
788
+ if (読めた) {
789
+ if (scrollLines.has(読めた.id)) {
790
+ errors.push({
791
+ line: it.no,
792
+ message: `巻き上げの名前 "${読めた.id}" が重複しています`,
793
+ hint: "scrolls の名前は 1 度だけ書く",
794
+ });
795
+ } else {
796
+ scrollsList.push(読めた);
797
+ scrollLines.set(読めた.id, it.no);
798
+ }
799
+ }
800
+ } else {
801
+ errors.push({
802
+ line: it.no,
803
+ message: `invalid scroll entry: "${it.trimmed}"`,
804
+ hint: "use `id: { start: 0.9, end: 0.1, scrub: 1 }`",
805
+ });
806
+ }
807
+ }
808
+ i = next;
809
+ continue;
810
+ }
629
811
  if (head.key === "groups") {
630
812
  // groups:\n aws: { label: "AWS", lanes: [ecs, rds] }
631
813
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
@@ -669,6 +851,20 @@ export function parseTextDslV05(src: string): V05ParseResult {
669
851
  hint: "add `type: sequence|flow|swimlane|er|state|topology|solidity|gantt|class|pie|c4|mind`",
670
852
  });
671
853
 
854
+ // つまみ・式・巻き上げは同じ名前空間で値を作る。 重なると後から作る値が効かない。
855
+ const 既に値を作る名前 = new Set([
856
+ ...(inputsList ?? []).map((input) => input.id),
857
+ ...(formulasList ?? []).map((formula) => formula.id),
858
+ ]);
859
+ for (const scroll of scrollsList ?? []) {
860
+ if (!既に値を作る名前.has(scroll.id)) continue;
861
+ errors.push({
862
+ line: scrollLines.get(scroll.id) ?? 1,
863
+ message: `巻き上げの名前 "${scroll.id}" が inputs または formulas と重なっています`,
864
+ hint: "inputs / formulas / scrolls では重ならない名前を使う",
865
+ });
866
+ }
867
+
672
868
  if (errors.length > 0) return { ok: false, errors };
673
869
 
674
870
  return {
@@ -685,6 +881,10 @@ export function parseTextDslV05(src: string): V05ParseResult {
685
881
  viewport,
686
882
  lanes: lanesMap,
687
883
  readouts: readoutsList,
884
+ inputs: inputsList,
885
+ formulas: formulasList,
886
+ events: eventsList,
887
+ scrolls: scrollsList,
688
888
  groups: groupsMap,
689
889
  pos: { line: 1 },
690
890
  },
@@ -1109,6 +1309,18 @@ export const 図形の表: Record<string, 図形の定義> = {
1109
1309
  */
1110
1310
  export { 部品の表, 部品の組の表 } from "./readout-table.generated";
1111
1311
  import { 部品の表, 部品の組の表 } from "./readout-table.generated";
1312
+
1313
+ /**
1314
+ * 記法が受けるつまみと、その欄 (#1389)。
1315
+ *
1316
+ * **部品の表と同じく、描画側の型定義から生成する**。 14 種それぞれ欄が違い、手で写すと
1317
+ * 描画側が種類を足した時に drift が残る (`rules/quality.md § 導出可能記述は人手で書かない`)。
1318
+ *
1319
+ * 作り直す = `node packages/dragon/scripts/gen-input-table.mjs`
1320
+ * ずれの検知 = `packages/dragon/test/input-table-generated.test.ts`
1321
+ */
1322
+ export { つまみの表 } from "./input-table.generated";
1323
+ import { つまみの表 } from "./input-table.generated";
1112
1324
  import type { 図形の定義 } from "./parser-types";
1113
1325
 
1114
1326
  /** `[a, b]` の形を文字列の並びに読む */
@@ -1266,6 +1478,25 @@ function 表に従って読む(
1266
1478
  out[欄] = n !== undefined ? n : 値;
1267
1479
  } else if (形 === "文字列の並び") {
1268
1480
  out[欄] = 並びとして読む(値);
1481
+ } else if (形 === "数の並び") {
1482
+ /*
1483
+ * 数の並びは、1 つでも数として読めなければ欄ごと落とす (#1389)。
1484
+ *
1485
+ * 読めた分だけ渡すと並びの長さが変わり、番号で指す欄 (`defaultSpeedIdx`) が
1486
+ * 別の要素を指す。 書き間違いが「別の値が選ばれている図」 になって出るため、
1487
+ * 数え落としを黙って通さない。
1488
+ */
1489
+ const 生 = 並びとして読む(値);
1490
+ const 数 = 生.map((x) => numberOrUndef(x));
1491
+ if (数.some((n) => n === undefined)) {
1492
+ errors.push({
1493
+ line,
1494
+ message: `${接頭}${欄} に数でない値があります: "${値}"`,
1495
+ hint: "`[0.5, 1, 2, 4]` の形で数だけを並べる",
1496
+ });
1497
+ } else {
1498
+ out[欄] = 数;
1499
+ }
1269
1500
  } else if (形 === "組の並び") {
1270
1501
  const 組 = 組の並びとして読む(値);
1271
1502
  if (組 !== undefined) out[欄] = 組;
@@ -1336,6 +1567,86 @@ function 出す条件として読む(
1336
1567
  return 値;
1337
1568
  }
1338
1569
 
1570
+ /**
1571
+ * 値に追随する欄を読む (#1392)。 数として読めれば数、読めなければ文字列のまま渡す。
1572
+ *
1573
+ * `箱の中に描く図形` の `source` と同じ読み方に揃える (`表に従って読む` の「数か文字列」)。
1574
+ * 状態の名前 (`"{barW}"`) を書く形と、数を直に書く形の両方を受ける。
1575
+ *
1576
+ * **空は捨てずに知らせる**。 描画側は空文字を「書かなかった」 と同じには扱わず、
1577
+ * `opacity: ""` は 0 として読まれて箱が消える。 書いた人はたいてい値を書き忘れただけ。
1578
+ */
1579
+ function 値に追随する欄として読む(
1580
+ raw: string | undefined,
1581
+ 接頭: string,
1582
+ line: number,
1583
+ errors: DslError[],
1584
+ ): number | string | undefined {
1585
+ if (raw === undefined) return undefined;
1586
+ const 値 = raw.trim();
1587
+ if (値 === "") {
1588
+ errors.push({
1589
+ line,
1590
+ message: `${接頭}が空です`,
1591
+ hint: '`{状態の名前}` か数を書く (例 `opacity: "{fade}"` / `opacity: 0.5`)',
1592
+ });
1593
+ return undefined;
1594
+ }
1595
+ const n = numberOrUndef(値);
1596
+ return n !== undefined ? n : 値;
1597
+ }
1598
+
1599
+ /**
1600
+ * 箱の幅と高さを値に追随させる欄を読む (#1392)。
1601
+ *
1602
+ * 描画側は文字列だけを受ける (`wBind?: string`)。 数を直に書いても追随のしようが無いので、
1603
+ * 数か文字列として読む欄とは分ける。
1604
+ */
1605
+ function 追随する大きさとして読む(
1606
+ raw: string | undefined,
1607
+ 接頭: string,
1608
+ line: number,
1609
+ errors: DslError[],
1610
+ ): string | undefined {
1611
+ if (raw === undefined) return undefined;
1612
+ const 値 = raw.trim();
1613
+ if (値 === "") {
1614
+ errors.push({
1615
+ line,
1616
+ message: `${接頭}が空です`,
1617
+ hint: '`{状態の名前}` を書く (例 `wBind: "{barW}"`)',
1618
+ });
1619
+ return undefined;
1620
+ }
1621
+ return 値;
1622
+ }
1623
+
1624
+ /**
1625
+ * 値に追随する箱の欄 (#1392)。
1626
+ *
1627
+ * `satisfies` で `DslActor` から欄を導く = 綴りを誤ると型検査が落ちる。
1628
+ */
1629
+ const 値に追随する箱の欄 = [
1630
+ "wBind",
1631
+ "hBind",
1632
+ "opacity",
1633
+ "renderOffsetX",
1634
+ "renderOffsetY",
1635
+ ] as const satisfies readonly (keyof DslActor)[];
1636
+
1637
+ /**
1638
+ * 縦に並べた時、値が空でも読み取りへ渡す欄 (#1392)。
1639
+ *
1640
+ * 他の欄は空を「書かなかった」 として落とすが、この 5 欄は描画側が空文字を数として
1641
+ * 読むため、落とすと書き忘れが「箱が消えた」 形で出る。
1642
+ */
1643
+ const 空を知らせる箱の欄: ReadonlySet<string> = new Set(値に追随する箱の欄);
1644
+
1645
+ /** Object.prototype の持ち物を、表にある種類として扱わない。 */
1646
+ function 表から定義を引く(表: Record<string, 図形の定義>, kind: string): 図形の定義 | undefined {
1647
+ return Object.hasOwn(表, kind) ? 表[kind] : undefined;
1648
+ }
1649
+
1339
1650
  /**
1340
1651
  * 箱の中に描く図形を読む (#1374)。 読めなければ `undefined` を返す。
1341
1652
  *
@@ -1346,7 +1657,7 @@ function 図形として読む(raw: string, line: number, errors: DslError[]): D
1346
1657
  const 中身 = raw.trim().replace(/^\{|\}$/g, "");
1347
1658
  const opts = parseInlineMapping(中身);
1348
1659
  const kind = (opts.kind ?? "").toLowerCase();
1349
- const 定義 = 図形の表[kind];
1660
+ const 定義 = 表から定義を引く(図形の表, kind);
1350
1661
  if (定義 === undefined) {
1351
1662
  errors.push({
1352
1663
  line,
@@ -1371,7 +1682,7 @@ function 部品として読む(
1371
1682
  ): DslReadout | undefined {
1372
1683
  const opts = parseInlineMapping(raw);
1373
1684
  const kind = (opts.kind ?? "").toLowerCase();
1374
- const 定義 = 部品の表[kind];
1685
+ const 定義 = 表から定義を引く(部品の表, kind);
1375
1686
  if (定義 === undefined) {
1376
1687
  errors.push({
1377
1688
  line,
@@ -1386,6 +1697,260 @@ function 部品として読む(
1386
1697
  return { id, kind, ...読めた } as DslReadout;
1387
1698
  }
1388
1699
 
1700
+ /**
1701
+ * 出来事の種類 (#1393)。 描画側の `CdlEventKind` と同じ語を並べる。
1702
+ *
1703
+ * **描画側から導けない**。 型は書き出されるが値の一覧は実行時に無いため、ここに書く。
1704
+ * 知らない語を書いた時の知らせがこの一覧をそのまま出すので、増えたら 1 行足す。
1705
+ */
1706
+ export const EVENT_KINDS = [
1707
+ "click",
1708
+ "hover",
1709
+ "double-click",
1710
+ "long-press",
1711
+ "drag",
1712
+ "drop",
1713
+ "keydown",
1714
+ "focus",
1715
+ "blur",
1716
+ ] as const;
1717
+
1718
+ /** 出来事の相手を指す書き方。 ちょうど 1 つだけ書く */
1719
+ const EVENT_TARGET_KEYS = ["box", "lane", "arrow", "diagram"] as const;
1720
+
1721
+ /**
1722
+ * 押下などの出来事で動く仕掛けを 1 件読む (#1393)。
1723
+ *
1724
+ * 形は `{ on: click, box: Button, handler: toggle }`。 相手の指し方は 4 つあり、
1725
+ * **ちょうど 1 つだけ書く** = 2 つ書くとどちらを指したのか決まらず、0 なら相手がいない。
1726
+ *
1727
+ * 相手は名前で書く。 識別子は記法で書けないため、名前から識別子への読み替えは
1728
+ * 組み立てが行う (`focus:` と同じ扱い)。
1729
+ */
1730
+ function 出来事として読む(
1731
+ raw: string,
1732
+ line: number,
1733
+ errors: DslError[],
1734
+ ): DslEventBinding | undefined {
1735
+ const t = raw.trim();
1736
+ if (!t.startsWith("{") || !t.endsWith("}")) {
1737
+ errors.push({
1738
+ line,
1739
+ message: `出来事の行が読めません: "${t}"`,
1740
+ hint: "`- { on: click, box: Button, handler: toggle }` の形で書く",
1741
+ });
1742
+ return undefined;
1743
+ }
1744
+ const opts = parseInlineMapping(t.slice(1, -1));
1745
+ for (const k of Object.keys(opts)) {
1746
+ if (k === "on" || k === "handler" || (EVENT_TARGET_KEYS as readonly string[]).includes(k))
1747
+ continue;
1748
+ errors.push({
1749
+ line,
1750
+ message: `出来事の項目名が読めません: "${k}"`,
1751
+ hint: `使える項目 = on, handler, ${EVENT_TARGET_KEYS.join(", ")}`,
1752
+ });
1753
+ return undefined;
1754
+ }
1755
+ const on = opts.on ?? "";
1756
+ if (!(EVENT_KINDS as readonly string[]).includes(on)) {
1757
+ errors.push({
1758
+ line,
1759
+ message: `出来事の種類が読めません: "${on}"`,
1760
+ hint: `使える種類 = ${EVENT_KINDS.join(", ")}`,
1761
+ });
1762
+ return undefined;
1763
+ }
1764
+ const handlerId = (opts.handler ?? "").trim();
1765
+ if (handlerId === "") {
1766
+ errors.push({
1767
+ line,
1768
+ message: "出来事の handler が空です",
1769
+ hint: "`handler: toggle-active` のように、呼び出す仕掛けの名前を書く",
1770
+ });
1771
+ return undefined;
1772
+ }
1773
+ const 書いた相手 = EVENT_TARGET_KEYS.filter((k) => opts[k] !== undefined);
1774
+ if (書いた相手.length !== 1) {
1775
+ errors.push({
1776
+ line,
1777
+ message:
1778
+ 書いた相手.length === 0
1779
+ ? "出来事の相手が書かれていません"
1780
+ : `出来事の相手を 2 つ以上書いています: ${書いた相手.join(", ")}`,
1781
+ hint: `${EVENT_TARGET_KEYS.join(" / ")} のどれか 1 つだけを書く`,
1782
+ });
1783
+ return undefined;
1784
+ }
1785
+ const 鍵 = 書いた相手[0]!;
1786
+ const 値 = (opts[鍵] ?? "").trim();
1787
+ let target: DslEventBinding["target"];
1788
+ if (鍵 === "diagram") {
1789
+ if (値 !== "true") {
1790
+ errors.push({
1791
+ line,
1792
+ message: `出来事の diagram が読めません: "${値}"`,
1793
+ hint: "図全体を指す時は `diagram: true` と書く",
1794
+ });
1795
+ return undefined;
1796
+ }
1797
+ target = { kind: "diagram" };
1798
+ } else if (鍵 === "arrow") {
1799
+ const m = 値.split("->");
1800
+ if (m.length !== 2 || m[0]!.trim() === "" || m[1]!.trim() === "") {
1801
+ errors.push({
1802
+ line,
1803
+ message: `出来事の矢印が読めません: "${値}"`,
1804
+ hint: "`arrow: A -> B` の形で、矢印の両端の名前を書く",
1805
+ });
1806
+ return undefined;
1807
+ }
1808
+ target = { kind: "edge", from: stripQuotes(m[0]!.trim()), to: stripQuotes(m[1]!.trim()) };
1809
+ } else {
1810
+ if (値 === "") {
1811
+ errors.push({
1812
+ line,
1813
+ message: `出来事の ${鍵} が空です`,
1814
+ hint: "指す相手の名前を書く",
1815
+ });
1816
+ return undefined;
1817
+ }
1818
+ target = { kind: 鍵 === "box" ? "node" : "lane", name: stripQuotes(値) };
1819
+ }
1820
+ return { event: on as DslEventBinding["event"], target, handlerId, pos: { line } };
1821
+ }
1822
+
1823
+ /** 巻き上げに応じて進む値の欄 (#1393)。 描画側の `CdlScrollTrigger` を覆う */
1824
+ const SCROLL_VALUE_KINDS = {
1825
+ start: "数",
1826
+ end: "数",
1827
+ scrub: "数",
1828
+ } as const satisfies Record<string, 値の形>;
1829
+
1830
+ /**
1831
+ * 巻き上げに応じて進む値を 1 件読む (#1393)。
1832
+ *
1833
+ * 形は `intro: { start: 0.9, end: 0.1, scrub: 1, label: "..." }`。
1834
+ * 数の欄は表が読み、説明文はそのまま渡す。
1835
+ */
1836
+ function 巻き上げとして読む(
1837
+ id: string,
1838
+ raw: string,
1839
+ line: number,
1840
+ errors: DslError[],
1841
+ ): DslScrollTrigger | undefined {
1842
+ if (!isValueName(id)) {
1843
+ errors.push({ line, ...valueNameIssue(id) });
1844
+ return undefined;
1845
+ }
1846
+ const opts = parseInlineMapping(raw);
1847
+ const 使える = [...Object.keys(SCROLL_VALUE_KINDS), "label"];
1848
+ for (const k of Object.keys(opts)) {
1849
+ if (使える.includes(k)) continue;
1850
+ errors.push({
1851
+ line,
1852
+ message: `巻き上げ ${id} の項目名が読めません: "${k}"`,
1853
+ hint: `使える項目 = ${使える.join(", ")}`,
1854
+ });
1855
+ return undefined;
1856
+ }
1857
+ const 数 = 表で読む(SCROLL_VALUE_KINDS, opts, `巻き上げ ${id} の `, line, errors);
1858
+ for (const 欄 of ["start", "end", "scrub"] as const) {
1859
+ const 値 = 数[欄];
1860
+ if (値 === undefined || (値 >= 0 && 値 <= 1)) continue;
1861
+ errors.push({
1862
+ line,
1863
+ message: `巻き上げ ${id} の ${欄} は 0 から 1 の間で書きます: "${値}"`,
1864
+ hint: "0 は画面の上端または段階的な追随、1 は下端または連続追随",
1865
+ });
1866
+ }
1867
+ return {
1868
+ id,
1869
+ ...(数.start !== undefined ? { start: 数.start } : {}),
1870
+ ...(数.end !== undefined ? { end: 数.end } : {}),
1871
+ ...(数.scrub !== undefined ? { scrub: 数.scrub } : {}),
1872
+ ...(opts.label !== undefined ? { label: opts.label } : {}),
1873
+ };
1874
+ }
1875
+
1876
+ /**
1877
+ * つまみの値から決まる値を読む (#1391)。 読めなければ `undefined` を返す。
1878
+ *
1879
+ * 形は `名前: "式"` の 1 行。 `values:` と同じ形で、**違うのは解かれる仕組み**。
1880
+ * あちらは段が動かす状態を読み、こちらはつまみが握る値を読む。
1881
+ *
1882
+ * 名前を中括弧で囲うかは自由 (実測 = 描画側の parser は `"{a} + 1"` と `"a + 1"` を
1883
+ * 同じ名前として読む)。 `values:` から式を書き写しても、そのまま通る。
1884
+ *
1885
+ * **式は描画側の parser に通す**。 自前で書き方を決めると、通ったのに描画側が
1886
+ * 解けない式を受けてしまう。 描画側が投げた誤りの本文をそのまま知らせに載せる。
1887
+ */
1888
+ function 式として読む(行: string, line: number, errors: DslError[]): DslFormula | undefined {
1889
+ const c = 行.indexOf(":");
1890
+ if (c < 0) {
1891
+ errors.push({
1892
+ line,
1893
+ message: `式の行が読めません: "${行}"`,
1894
+ hint: '`名前: "式"` の形で書く (例 `doubled: "input * 2"`)',
1895
+ });
1896
+ return undefined;
1897
+ }
1898
+ const 名前 = 行.slice(0, c).trim();
1899
+ const 式 = stripQuotes(行.slice(c + 1).trim());
1900
+ if (!isValueName(名前)) {
1901
+ // 名前の規則は状態と揃える = 式の名前も `{名前}` で箱の文字に差し込める
1902
+ errors.push({ line, ...valueNameIssue(名前) });
1903
+ return undefined;
1904
+ }
1905
+ if (式 === "") {
1906
+ errors.push({
1907
+ line,
1908
+ message: `式 "${名前}" が空です`,
1909
+ hint: '`doubled: "input * 2"` のように式を書く',
1910
+ });
1911
+ return undefined;
1912
+ }
1913
+ try {
1914
+ parseFormula(式);
1915
+ } catch (e) {
1916
+ errors.push({
1917
+ line,
1918
+ message: `式 "${名前}" を読めません: ${(e as Error).message}`,
1919
+ hint: "使えるのは四則と括弧、比較、三項 (`a > 1 ? 2 : 3`)、`Math.min` などの関数",
1920
+ });
1921
+ return undefined;
1922
+ }
1923
+ return { id: 名前, expression: 式, pos: { line } };
1924
+ }
1925
+
1926
+ /**
1927
+ * 読む人が動かすつまみを読む (#1389)。 読めなければ `undefined` を返す。
1928
+ *
1929
+ * 部品 (`readouts:`) と同じ経路で読む。 違いは組の並びを取る欄が無いことだけで、
1930
+ * 知らない欄と足りない必須欄の知らせ方は同じ。
1931
+ */
1932
+ function つまみとして読む(
1933
+ id: string,
1934
+ raw: string,
1935
+ line: number,
1936
+ errors: DslError[],
1937
+ ): DslInput | undefined {
1938
+ const opts = parseInlineMapping(raw);
1939
+ const kind = (opts.kind ?? "").toLowerCase();
1940
+ const 定義 = 表から定義を引く(つまみの表, kind);
1941
+ if (定義 === undefined) {
1942
+ errors.push({
1943
+ line,
1944
+ message: `つまみの種類が読めません: "${opts.kind ?? ""}"`,
1945
+ hint: `使える種類 = ${Object.keys(つまみの表).join(", ")}`,
1946
+ });
1947
+ return undefined;
1948
+ }
1949
+ const 読めた = 表に従って読む(定義, opts, `つまみ ${id} の `, line, errors);
1950
+ for (const 欄 of 定義.必須) if (読めた[欄] === undefined) return undefined;
1951
+ return { id, kind, ...読めた } as DslInput;
1952
+ }
1953
+
1389
1954
  /** 箱の中の要素の欄 (#1306)。 `DslActorNodeOverride` の全欄を覆う */
1390
1955
  export const ACTOR_NODE_VALUE_KINDS = {
1391
1956
  posX: "数",
@@ -1747,6 +2312,12 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1747
2312
  const 図種ごとの欄 = new Map<string, string>();
1748
2313
  // パーツでなければどこにも入らない項目。 パーツかどうかは block を読み終わるまで決まらない
1749
2314
  const unknownKeys: Array<{ key: string; line: number }> = [];
2315
+ /**
2316
+ * 値に追随する 5 欄に書かれた字 (#1392)。
2317
+ *
2318
+ * 見本かどうかで読み方が変わるため、行を読む時点では振り分けない。
2319
+ */
2320
+ const 追随する欄の生値 = new Map<string, { 値: string; line: number }>();
1750
2321
 
1751
2322
  for (const ln of rest) {
1752
2323
  const idx = ln.trimmed.indexOf(":");
@@ -1762,7 +2333,9 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1762
2333
  unknownKeys.push({ key, line: ln.no });
1763
2334
  continue;
1764
2335
  }
1765
- if (!raw) continue;
2336
+ // 従来欄の空値は書かなかった扱いのままにする。 値に追随する 5 欄は、
2337
+ // 空文字を描画側が数として解釈するため、個別の読み取りへ渡して知らせる。
2338
+ if (!raw && !空を知らせる箱の欄.has(key)) continue;
1766
2339
 
1767
2340
  if (COLOR_KEYS.has(key)) {
1768
2341
  const { tone, hex } = splitColorValue(raw);
@@ -1807,6 +2380,23 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1807
2380
  case "題":
1808
2381
  out.title = stripQuotes(raw);
1809
2382
  break;
2383
+ /*
2384
+ * 値に追随する 5 欄 (#1392)。 日本語の別名は置かない = 描画側の欄名がそのまま
2385
+ * 状態の名前と並ぶ場所なので、英字 1 種に絞って書き方の揺れを作らない。
2386
+ *
2387
+ * **ここでは読まずに書かれた字だけを控える**。 見本 (parts) では同じ名前が状態の
2388
+ * 上書きになり、中括弧の形は生の字を `coerceStateValue` に通す。 ここで先に読むと
2389
+ * `wBind: 120` が中括弧では数、縦に並べると文字列になって書き方で割れる。
2390
+ * 見本かどうかは `kind` で決まり、それが後ろの行に書かれることがあるため、
2391
+ * 全行を読み終えてから振り分ける。
2392
+ */
2393
+ case "wBind":
2394
+ case "hBind":
2395
+ case "opacity":
2396
+ case "renderOffsetX":
2397
+ case "renderOffsetY":
2398
+ 追随する欄の生値.set(key, { 値: stripQuotes(raw), line: ln.no });
2399
+ break;
1810
2400
  case "rows":
1811
2401
  case "行":
1812
2402
  out.rows = raw
@@ -1924,9 +2514,31 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1924
2514
  }
1925
2515
  // 状態も倍率も parts でだけ意味を持つ。 パーツなら知らせずに返す
1926
2516
  if (out.partId !== undefined) {
2517
+ /*
2518
+ * 見本では 5 欄も他の名前と同じく状態の上書きになる。
2519
+ *
2520
+ * **中括弧の形と同じ `coerceStateValue` を通す**。 専用の読み取りを通した値を移すと、
2521
+ * `wBind: 120` が中括弧では数、縦に並べると文字列になって書き方で割れる (Round 2 の指摘)。
2522
+ */
2523
+ for (const [欄, { 値 }] of 追随する欄の生値) {
2524
+ state[欄] = coerceStateValue(値);
2525
+ touchedState = true;
2526
+ }
1927
2527
  if (touchedState) out.stateOverride = state;
1928
2528
  return out;
1929
2529
  }
2530
+ /*
2531
+ * 普通の箱では専用の欄として読む (#1392)。
2532
+ *
2533
+ * 見本かどうかが決まってから読むため、空の知らせも見本でない箱にだけ出る。
2534
+ */
2535
+ for (const [欄, { 値, line }] of 追随する欄の生値) {
2536
+ if (欄 === "wBind" || 欄 === "hBind") {
2537
+ out[欄] = 追随する大きさとして読む(値, `箱の ${欄} `, line, errors);
2538
+ } else if (欄 === "opacity" || 欄 === "renderOffsetX" || 欄 === "renderOffsetY") {
2539
+ out[欄] = 値に追随する欄として読む(値, `箱の ${欄} `, line, errors);
2540
+ }
2541
+ }
1930
2542
  // パーツでない箱に書かれた見知らぬ項目は、 どこにも入らずに消える。 黙って捨てると
1931
2543
  // 「書いたのに図が変わらない」 が手掛かりなしで起きるので、 綴りの誤りとして知らせる
1932
2544
  for (const u of unknownKeys) {
@@ -1963,6 +2575,12 @@ export const ACTOR_ITEM_KEYS: ReadonlySet<string> = new Set([
1963
2575
  // 箱に出す題 (#1381)
1964
2576
  "title",
1965
2577
  "題",
2578
+ // 値に追随する 5 欄 (#1392)
2579
+ "wBind",
2580
+ "hBind",
2581
+ "opacity",
2582
+ "renderOffsetX",
2583
+ "renderOffsetY",
1966
2584
  "位置",
1967
2585
  "pos",
1968
2586
  "posX",
@@ -2307,6 +2925,12 @@ export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
2307
2925
  "visibleIf",
2308
2926
  // 箱に出す題 (#1381)。 名前と切り離して書ける
2309
2927
  "title",
2928
+ // 値に追随する 5 欄 (#1392)
2929
+ "wBind",
2930
+ "hBind",
2931
+ "opacity",
2932
+ "renderOffsetX",
2933
+ "renderOffsetY",
2310
2934
  // 倍率は別経路 (`reportScaleOnNonPart`) が知らせる。 ここでも読める扱いにしないと
2311
2935
  // 同じ名前で 2 度知らせることになる
2312
2936
  "scale",
@@ -2326,6 +2950,12 @@ export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
2326
2950
  * 持つ一覧が実装と drift する = 欄を足しても誰も気付けない。 表を唯一の出どころにして、
2327
2951
  * `FLOW_INLINE_KEYS` から一覧を導けるようにする。
2328
2952
  */
2953
+ const EDGE_BIND_INLINE_READERS = {
2954
+ widthBind: (v: string | undefined) => v,
2955
+ strokeBind: (v: string | undefined) => v,
2956
+ dashOffsetBind: (v: string | undefined) => v,
2957
+ } as const;
2958
+
2329
2959
  const FLOW_INLINE_READERS = {
2330
2960
  sub: (v: string | undefined) => v,
2331
2961
  guard: (v: string | undefined) => v,
@@ -2335,6 +2965,13 @@ const FLOW_INLINE_READERS = {
2335
2965
  v !== undefined && (EDGE_SIDE_VALUES as readonly string[]).includes(v)
2336
2966
  ? (v as "top" | "right" | "bottom" | "left")
2337
2967
  : undefined,
2968
+ /*
2969
+ * 矢印を値に追随させる 3 欄 (#1396)。 箱の `wBind` (#1392) と同じく文字列だけを取る。
2970
+ *
2971
+ * ここでは字をそのまま通し、空かどうかは呼出側が知らせる (表の読み手は行番号を
2972
+ * 持たないため、知らせを出せる場所で見る)。
2973
+ */
2974
+ ...EDGE_BIND_INLINE_READERS,
2338
2975
  // 数と真偽の欄は `FLOW_INLINE_VALUE_KINDS` の表が読む (#1306)。 ここでは名前だけを持つ =
2339
2976
  // 読める欄の一覧 (`FLOW_INLINE_KEYS`) は本表から導くため、載せないと欄ごと消える
2340
2977
  labelOffsetX: null,
@@ -2452,6 +3089,23 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
2452
3089
  visibleIf: isPart ? undefined : 出す条件として読む(opts.visibleIf, line.no, errors),
2453
3090
  // 箱に出す題 (#1381)。 空文字も意味を持つ (題を出さない箱) ため undefined と分ける
2454
3091
  title: isPart ? undefined : opts.title,
3092
+ // 値に追随する 5 欄 (#1392)。 図形や出す条件と同じく、パーツでは状態の上書きとして
3093
+ // 意味を持つため横取りしない
3094
+ wBind: isPart
3095
+ ? undefined
3096
+ : 追随する大きさとして読む(opts.wBind, "箱の wBind ", line.no, errors),
3097
+ hBind: isPart
3098
+ ? undefined
3099
+ : 追随する大きさとして読む(opts.hBind, "箱の hBind ", line.no, errors),
3100
+ opacity: isPart
3101
+ ? undefined
3102
+ : 値に追随する欄として読む(opts.opacity, "箱の opacity ", line.no, errors),
3103
+ renderOffsetX: isPart
3104
+ ? undefined
3105
+ : 値に追随する欄として読む(opts.renderOffsetX, "箱の renderOffsetX ", line.no, errors),
3106
+ renderOffsetY: isPart
3107
+ ? undefined
3108
+ : 値に追随する欄として読む(opts.renderOffsetY, "箱の renderOffsetY ", line.no, errors),
2455
3109
  ...表で読む(ACTOR_INLINE_VALUE_KINDS, opts, "箱の ", line.no, errors),
2456
3110
  // parts では `tone` を状態の上書きとして従来から使えるため、 色として横取りしない
2457
3111
  tone: isPart ? undefined : resolveTone(opts.tone),
@@ -2530,10 +3184,20 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
2530
3184
  // 欄は `FLOW_INLINE_READERS` の表から読む。 個別に並べると一覧が実装と drift する
2531
3185
  const 中括弧: Partial<Record<keyof typeof FLOW_INLINE_READERS, unknown>> = {};
2532
3186
  // inline option (`{ ... }`) を末尾から抽出
2533
- const mapMatch = rest.match(/\s*\{([^}]*)\}\s*$/);
3187
+ const = 末尾の中括弧を切り出す(rest);
2534
3188
  let 数と真偽: 読んだ結果<typeof FLOW_INLINE_VALUE_KINDS> | undefined;
2535
- if (mapMatch) {
2536
- const opts = parseInlineMapping(mapMatch[1]!);
3189
+ if () {
3190
+ const opts = parseInlineMapping(塊.中身);
3191
+ // `parseInlineMapping` は値が 1 文字もない `widthBind:` を拾わない。 3 欄は空を
3192
+ // 「書かなかった」扱いにせず知らせる契約なので、書かれた値を空も含めて上書きする。
3193
+ // 同じ欄を複数回書いた時は通常の mapping と同じく後勝ちにする。
3194
+ for (const field of splitInlineFields(塊.中身)) {
3195
+ const idx = field.indexOf(":");
3196
+ if (idx < 0) continue;
3197
+ const key = field.slice(0, idx).trim();
3198
+ if (!Object.hasOwn(EDGE_BIND_INLINE_READERS, key)) continue;
3199
+ opts[key] = stripQuotes(field.slice(idx + 1).trim());
3200
+ }
2537
3201
  // 文字列の欄はそのまま入れ、数と真偽の欄は表が読んで読めない値を知らせる (#1306)
2538
3202
  for (const k of FLOW_INLINE_KEYS) {
2539
3203
  const 読み手 = FLOW_INLINE_READERS[k];
@@ -2548,12 +3212,32 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
2548
3212
  });
2549
3213
  }
2550
3214
  数と真偽 = 表で読む(FLOW_INLINE_VALUE_KINDS, opts, "矢印の ", line.no, errors);
2551
- rest = rest.slice(0, mapMatch.index ?? 0).trim();
3215
+ rest = 塊.前.trim();
2552
3216
  }
2553
3217
  const sub = 中括弧.sub as string | undefined;
2554
3218
  const guard = 中括弧.guard as string | undefined;
2555
3219
  const cardinality = 中括弧.cardinality as string | undefined;
2556
3220
  const side = 中括弧.side as "top" | "right" | "bottom" | "left" | undefined;
3221
+ // 値に追随する 3 欄 (#1396)。 空は捨てずに知らせる = 描画側は空文字を既定値へ落とさず
3222
+ // そのまま置換に使うため、書き忘れが「線が消えた」 形で出る
3223
+ const widthBind = 追随する大きさとして読む(
3224
+ 中括弧.widthBind as string | undefined,
3225
+ "矢印の widthBind ",
3226
+ line.no,
3227
+ errors,
3228
+ );
3229
+ const strokeBind = 追随する大きさとして読む(
3230
+ 中括弧.strokeBind as string | undefined,
3231
+ "矢印の strokeBind ",
3232
+ line.no,
3233
+ errors,
3234
+ );
3235
+ const dashOffsetBind = 追随する大きさとして読む(
3236
+ 中括弧.dashOffsetBind as string | undefined,
3237
+ "矢印の dashOffsetBind ",
3238
+ line.no,
3239
+ errors,
3240
+ );
2557
3241
  const labelOffsetX = 数と真偽?.labelOffsetX;
2558
3242
  const labelOffsetY = 数と真偽?.labelOffsetY;
2559
3243
  const overlay = 数と真偽?.overlay;
@@ -2614,6 +3298,9 @@ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | nu
2614
3298
  guard,
2615
3299
  cardinality,
2616
3300
  side,
3301
+ widthBind,
3302
+ strokeBind,
3303
+ dashOffsetBind,
2617
3304
  labelOffsetX,
2618
3305
  labelOffsetY,
2619
3306
  overlay,