@cardenelabs/cdl 0.5.0 → 0.6.1

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.
@@ -11,7 +11,6 @@ import { ChartPieNode } from "../kinds/chart-pie";
11
11
  import { ChartBarNode } from "../kinds/chart-bar";
12
12
  import { GanttNode } from "../kinds/gantt";
13
13
  import { MindMapNode } from "../kinds/mind-map";
14
- import { MindRadialNode } from "../kinds/mind-radial";
15
14
  import { FunnelNode } from "../kinds/funnel";
16
15
  import { QuadrantNode } from "../kinds/quadrant";
17
16
  import { TreeNode as TreeHierarchyNode } from "../kinds/tree";
@@ -80,7 +79,7 @@ import {
80
79
  ShapeCustomerServiceNode,
81
80
  } from "../kinds/shape-people";
82
81
  import type { LaidNode } from "../types";
83
- import { interpolate, resolveNumericAttr } from "./utils";
82
+ import { hasUnresolvedRef, interpolate, resolveNumericAttr } from "./utils";
84
83
 
85
84
  export function CdlNodeView({
86
85
  node,
@@ -110,7 +109,9 @@ export function CdlNodeView({
110
109
  // (unresolved = `{missingSig}` そのままなら typo と判断、 fail-safe に hidden 側倒す)
111
110
  if (node.visibleIf) {
112
111
  const resolved = interpolate(node.visibleIf, stateValues).trim().toLowerCase();
113
- const unresolved = /^\{\w+(?:[.\[][^}]*)?\}$/.test(resolved) || /\{\w+(?:[.\[][^}]*)?\}/.test(resolved);
112
+ // 判定は interpolate と同じ式を使う (`hasUnresolvedRef`) 別に書くと字種がずれ、
113
+ // 置換されずに残った名前を未解決と見なせず fail-safe が素通りする
114
+ const unresolved = hasUnresolvedRef(resolved);
114
115
  const falsy = resolved === "" || resolved === "0" || resolved === "false" || resolved === "null" || resolved === "undefined" || resolved === "nan";
115
116
  if (falsy || unresolved) {
116
117
  return <g data-cdl-node={node.id} data-cdl-hidden="true" style={{ display: "none" }} />;
@@ -148,26 +149,27 @@ export function CdlNodeView({
148
149
  return <EventNode node={resolvedNode} active={active} progress={progress} />;
149
150
  case "card":
150
151
  return <CardNode node={resolvedNode} active={active} />;
152
+ // 図表 7 種は payload の数と語が `{signal}` を取れるので stateValues を渡す
153
+ // (`render/payload-binding.ts` が描画直前に解決する)。 動かさない 2 種
154
+ // (mind-map / tree-hierarchy) は構造だけを持つため渡さない。
151
155
  case "chart-line":
152
- return <ChartLineNode node={resolvedNode} active={active} />;
156
+ return <ChartLineNode node={resolvedNode} active={active} stateValues={stateValues} />;
153
157
  case "chart-pie":
154
- return <ChartPieNode node={resolvedNode} active={active} />;
158
+ return <ChartPieNode node={resolvedNode} active={active} stateValues={stateValues} />;
155
159
  case "chart-bar":
156
- return <ChartBarNode node={resolvedNode} active={active} />;
160
+ return <ChartBarNode node={resolvedNode} active={active} stateValues={stateValues} />;
157
161
  case "gantt-timeline":
158
- return <GanttNode node={resolvedNode} active={active} />;
162
+ return <GanttNode node={resolvedNode} active={active} stateValues={stateValues} />;
159
163
  case "mind-map":
160
164
  return <MindMapNode node={resolvedNode} active={active} />;
161
- case "mind-radial":
162
- return <MindRadialNode node={resolvedNode} active={active} />;
163
165
  case "funnel-stages":
164
- return <FunnelNode node={resolvedNode} active={active} />;
166
+ return <FunnelNode node={resolvedNode} active={active} stateValues={stateValues} />;
165
167
  case "quadrant-matrix":
166
- return <QuadrantNode node={resolvedNode} active={active} />;
168
+ return <QuadrantNode node={resolvedNode} active={active} stateValues={stateValues} />;
167
169
  case "tree-hierarchy":
168
170
  return <TreeHierarchyNode node={resolvedNode} active={active} />;
169
171
  case "journey-map":
170
- return <UserJourneyNode node={resolvedNode} active={active} />;
172
+ return <UserJourneyNode node={resolvedNode} active={active} stateValues={stateValues} />;
171
173
  case "shape-file":
172
174
  return <ShapeFileNode node={resolvedNode} active={active} />;
173
175
  case "shape-folder":
@@ -0,0 +1,196 @@
1
+ import type {
2
+ BoundEnum,
3
+ BoundNumber,
4
+ ChartDatumPayload,
5
+ FunnelStagePayload,
6
+ GanttTaskPayload,
7
+ JourneyEmotion,
8
+ JourneyStepPayload,
9
+ QuadrantItemPayload,
10
+ QuadrantKey,
11
+ QuadrantPayload,
12
+ } from "../types";
13
+ import { interpolate } from "./utils";
14
+
15
+ /**
16
+ * 図表 payload が状態を読むための解決層 (dragon#1161 段 3)。
17
+ *
18
+ * `chart-*` / `funnel-stages` / `gantt-timeline` / `quadrant-matrix` / `journey-map` の 5 系統は
19
+ * 配列 payload を受け取って一度描くだけで、 状態を読む経路が無かった。 各 kind は描画の直前に
20
+ * ここの resolver を 1 回呼び、 以降は今までどおり数と語を扱う。
21
+ *
22
+ * 解決できない欄の扱いは 1 つに揃えてある。 **項目を落とさず、 既定値で描いて印を付ける**。
23
+ * 落とすと配列の長さが変わって図の形が別物になり、 どこが壊れたのかが読み取れなくなる
24
+ * (spec § 4.2 が輪について同じ判断をしている = 1 箇所の壊れで図が消えると原因が分からない)。
25
+ * 印は各 kind が `data-cdl-unresolved="true"` として DOM に出す。
26
+ *
27
+ * 動かさない 2 種 (`mind-map` / `tree-hierarchy`) はここに resolver を持たない。
28
+ * 構造だけを持ち動かせる数が無く、 動かすとしたら項目の増減になるため (Issue の実装しない条件)。
29
+ */
30
+
31
+ /** 解決の結果。 `ok` が false なら既定値に落ちたことを表す */
32
+ export type BindingResult<T> = { value: T; ok: boolean };
33
+
34
+ /** 解決できなかった欄を持つ項目に付く印。 kind 側が DOM 属性に写す */
35
+ export type UnresolvedMark = { unresolved?: true };
36
+
37
+ export type ResolvedChartDatum = Omit<ChartDatumPayload, "value"> & { value: number } & UnresolvedMark;
38
+ export type ResolvedFunnelStage = Omit<FunnelStagePayload, "count"> & { count: number } & UnresolvedMark;
39
+ export type ResolvedGanttTask = Omit<GanttTaskPayload, "startIdx" | "endIdx"> & {
40
+ startIdx: number;
41
+ endIdx: number;
42
+ } & UnresolvedMark;
43
+ export type ResolvedQuadrantItem = Omit<QuadrantItemPayload, "quadrant"> & {
44
+ quadrant: QuadrantKey;
45
+ } & UnresolvedMark;
46
+ export type ResolvedJourneyStep = Omit<JourneyStepPayload, "emotion"> & {
47
+ emotion: JourneyEmotion;
48
+ } & UnresolvedMark;
49
+ export type ResolvedQuadrantData = Omit<QuadrantPayload, "items"> & { items: ResolvedQuadrantItem[] };
50
+
51
+ export const QUADRANT_KEYS: readonly QuadrantKey[] = [
52
+ "topLeft",
53
+ "topRight",
54
+ "bottomLeft",
55
+ "bottomRight",
56
+ ];
57
+
58
+ export const JOURNEY_EMOTIONS: readonly JourneyEmotion[] = [
59
+ "delighted",
60
+ "happy",
61
+ "neutral",
62
+ "frustrated",
63
+ "angry",
64
+ ];
65
+
66
+ /**
67
+ * 解決できない項目を置く象限。 4 つは対称なので既定値は本来どれでもよいが、 右上は
68
+ * 「重要かつ容易」 として読まれる場所なので、 壊れた項目をそこへ置くと図が嘘をつく。
69
+ * 最も読み違えにくい左下に固定する。
70
+ */
71
+ const QUADRANT_FALLBACK: QuadrantKey = "bottomLeft";
72
+
73
+ /** 解決できない段の気持ち。 5 段階の真ん中に置く */
74
+ const EMOTION_FALLBACK: JourneyEmotion = "neutral";
75
+
76
+ /**
77
+ * 数の欄を解決する。 数ならそのまま、 文字列なら `{signal}` を状態で埋めてから数として読む。
78
+ *
79
+ * 空文字を弾いてから数に直すのは `Number("")` が 0 を返すため。 弾かないと、 参照先が消えた欄が
80
+ * 「0 と書いてある欄」 と区別できなくなる。 直接書かれた数でも有限でなければ既定値に落とす
81
+ * (NaN を SVG に流すと path ごと描かれなくなり、 図が黙って消える)。
82
+ */
83
+ export function resolveBoundNumber(
84
+ raw: BoundNumber,
85
+ values: Record<string, string>,
86
+ fallback: number,
87
+ ): BindingResult<number> {
88
+ if (typeof raw === "number") {
89
+ return Number.isFinite(raw) ? { value: raw, ok: true } : { value: fallback, ok: false };
90
+ }
91
+ const resolved = interpolate(raw, values).trim();
92
+ if (resolved === "") return { value: fallback, ok: false };
93
+ const parsed = Number(resolved);
94
+ return Number.isFinite(parsed) ? { value: parsed, ok: true } : { value: fallback, ok: false };
95
+ }
96
+
97
+ /**
98
+ * 語の欄を解決する。 `{signal}` を埋めた結果が決まった語のどれかなら採り、 違えば既定値に落とす。
99
+ *
100
+ * 数の欄と違い、 binding が無くても必ずここを通す。 型を `{signal}` も取れるように広げた分だけ
101
+ * 書き間違いが素通りするようになり、 素通りした語はどの分類にも一致せず項目が黙って消えるため。
102
+ * 既定値 + 印にすれば、 消えずに「解決できていない」 と読める。
103
+ */
104
+ export function resolveBoundEnum<T extends string>(
105
+ raw: BoundEnum<T>,
106
+ values: Record<string, string>,
107
+ allowed: readonly T[],
108
+ fallback: T,
109
+ ): BindingResult<T> {
110
+ const resolved = interpolate(raw, values).trim();
111
+ if ((allowed as readonly string[]).includes(resolved)) return { value: resolved as T, ok: true };
112
+ return { value: fallback, ok: false };
113
+ }
114
+
115
+ /**
116
+ * その欄が解決を要るかどうか。
117
+ *
118
+ * 状態を読む形 (文字列) だけでなく、 **有限でない数** も対象にする。 型だけで判定すると
119
+ * `NaN` / `Infinity` が近道を素通りし、 `resolveBoundNumber` の有限性の判定に一度も届かない
120
+ * (実測 = 印も付かず `NaN` がそのまま SVG へ流れ、 図が黙って消える)。
121
+ */
122
+ function needsResolve(raw: BoundNumber): boolean {
123
+ return typeof raw !== "number" || !Number.isFinite(raw);
124
+ }
125
+
126
+ export function resolveChartData(
127
+ data: ChartDatumPayload[],
128
+ values: Record<string, string>,
129
+ ): ResolvedChartDatum[] {
130
+ // 全ての値が有限な数なら配列を組み直さない。 状態を読まない図が毎 frame で項目数ぶんの
131
+ // 割当てを払わないようにするための近道で、 同じ配列をそのまま返すので挙動も変わらない。
132
+ if (!data.some((d) => needsResolve(d.value))) return data as ResolvedChartDatum[];
133
+ return data.map((d) => {
134
+ const v = resolveBoundNumber(d.value, values, 0);
135
+ return v.ok ? { ...d, value: v.value } : { ...d, value: v.value, unresolved: true as const };
136
+ });
137
+ }
138
+
139
+ export function resolveFunnelData(
140
+ stages: FunnelStagePayload[],
141
+ values: Record<string, string>,
142
+ ): ResolvedFunnelStage[] {
143
+ if (!stages.some((s) => needsResolve(s.count))) return stages as ResolvedFunnelStage[];
144
+ return stages.map((s) => {
145
+ const c = resolveBoundNumber(s.count, values, 0);
146
+ return c.ok ? { ...s, count: c.value } : { ...s, count: c.value, unresolved: true as const };
147
+ });
148
+ }
149
+
150
+ export function resolveGanttData(
151
+ tasks: GanttTaskPayload[],
152
+ values: Record<string, string>,
153
+ ): ResolvedGanttTask[] {
154
+ if (!tasks.some((t) => needsResolve(t.startIdx) || needsResolve(t.endIdx))) return tasks as ResolvedGanttTask[];
155
+ return tasks.map((t) => {
156
+ const start = resolveBoundNumber(t.startIdx, values, 0);
157
+ // 終わりの既定値は始まりに合わせて 1 目盛りぶんの帯にする。 0 に落とすと、 横軸の長さが
158
+ // その帯の終わりから決まる (maxIdx) ため目盛りが 1 本まで縮み、 始まりが 0 でない帯は
159
+ // 描画領域の外へ押し出されて消える。
160
+ const end = resolveBoundNumber(t.endIdx, values, start.value);
161
+ const next = { ...t, startIdx: start.value, endIdx: end.value };
162
+ return start.ok && end.ok ? next : { ...next, unresolved: true as const };
163
+ });
164
+ }
165
+
166
+ export function resolveQuadrantData(
167
+ data: QuadrantPayload,
168
+ values: Record<string, string>,
169
+ ): ResolvedQuadrantData {
170
+ const items = data.items;
171
+ if (items.every((i) => (QUADRANT_KEYS as readonly string[]).includes(i.quadrant))) {
172
+ return data as ResolvedQuadrantData;
173
+ }
174
+ return {
175
+ ...data,
176
+ items: items.map((i) => {
177
+ const q = resolveBoundEnum(i.quadrant, values, QUADRANT_KEYS, QUADRANT_FALLBACK);
178
+ return q.ok
179
+ ? { ...i, quadrant: q.value }
180
+ : { ...i, quadrant: q.value, unresolved: true as const };
181
+ }),
182
+ };
183
+ }
184
+
185
+ export function resolveJourneyData(
186
+ steps: JourneyStepPayload[],
187
+ values: Record<string, string>,
188
+ ): ResolvedJourneyStep[] {
189
+ if (steps.every((s) => (JOURNEY_EMOTIONS as readonly string[]).includes(s.emotion))) {
190
+ return steps as ResolvedJourneyStep[];
191
+ }
192
+ return steps.map((s) => {
193
+ const e = resolveBoundEnum(s.emotion, values, JOURNEY_EMOTIONS, EMOTION_FALLBACK);
194
+ return e.ok ? { ...s, emotion: e.value } : { ...s, emotion: e.value, unresolved: true as const };
195
+ });
196
+ }
@@ -1,10 +1,49 @@
1
1
  import { lerp } from "../anim";
2
2
  import type { LaidDiagram } from "../types";
3
+ import { withDerivedValues } from "./derived-values";
4
+ import { TEMPLATE_LEFTOVER_SRC, TEMPLATE_REF_SRC } from "../template-name";
5
+
6
+ const TEMPLATE_REF_RE = new RegExp(TEMPLATE_REF_SRC, "g");
7
+ // 残っている参照の判定用。 `g` を付けないので `lastIndex` を持たず、 使い回しても結果が
8
+ // 変わらない (描画経路は node ごと / frame ごとに通るため、 呼出のたびに式を作らない)
9
+ const TEMPLATE_LEFTOVER_TEST_RE = new RegExp(TEMPLATE_LEFTOVER_SRC);
10
+
11
+ /**
12
+ * 置換しきれなかった `{名前}` が残っているか。
13
+ *
14
+ * `interpolate` は値が無い名前を `{名前}` のまま残すため、 これが残っている = その文字列は
15
+ * 解決できていない。 読み手はここを見て fail-safe 側に倒す。
16
+ */
17
+ export function hasUnresolvedRef(text: string): boolean {
18
+ return TEMPLATE_LEFTOVER_TEST_RE.test(text);
19
+ }
20
+
21
+ /**
22
+ * 文字列が読んでいる `{名前}` の名前を全て返す (accessor は落として本体だけ)。
23
+ *
24
+ * 検証側が「その名前が定義されているか」 を見るのに使う。 置換する側と同じ式を使うので、
25
+ * 置換されうる名前が検証を素通りすることが無い。
26
+ */
27
+ export function templateRefIds(text: string): string[] {
28
+ // 置換できる形より広い方で拾う。 `{sig.unknown}` も `sig` を読もうとしているので、
29
+ // 検証側は「その名前が定義されているか」 を見る必要がある
30
+ const re = new RegExp(TEMPLATE_LEFTOVER_SRC, "g");
31
+ const out: string[] = [];
32
+ let m: RegExpExecArray | null;
33
+ while ((m = re.exec(text)) !== null) {
34
+ if (m[1]) out.push(m[1]);
35
+ }
36
+ return out;
37
+ }
3
38
 
4
39
  /**
5
40
  * 全 state の現時点 value を計算 (現在 phase + これまでの phase の最終 tween 値を集約)。
6
41
  * - set = 即時切替 (phase 到達で value 上書き、 lerp なし)
7
42
  * - tween = 数値補間 (現 phase 中は progress で lerp、 過去 phase は to 固定)
43
+ *
44
+ * 段の値を出した後に `laid.derived` (他の値から自動で決まる値) を参照順で解いて足す。
45
+ * 毎 frame ここを通るので、 掛け算 / 比較のような端点 2 点では表せない関係も途中の値が
46
+ * 正しくなる。 同じ名前が `states` にもある場合は解けた値が勝ち、 解けなければ初期値が残る。
8
47
  */
9
48
  export function computeStateValues(
10
49
  laid: LaidDiagram,
@@ -32,7 +71,7 @@ export function computeStateValues(
32
71
  }
33
72
  const out: Record<string, string> = {};
34
73
  for (const [k, v] of Object.entries(values)) out[k] = String(v);
35
- return out;
74
+ return withDerivedValues(out, laid.derived);
36
75
  }
37
76
 
38
77
  /**
@@ -41,10 +80,15 @@ export function computeStateValues(
41
80
  * `{sig[0]}` = 要素 0 の値、 `{sig.length}` = 要素数、
42
81
  * `{sig.sum}` / `{sig.max}` / `{sig.min}` / `{sig.avg}` = 数値集約 (非数値 element は 0 扱い)。
43
82
  * array 記法対象外 signal は従来通り `{sig}` で単純置換。
83
+ *
84
+ * 名前に使える文字は `template-name.ts` が決める (engine 全体で 1 つ)。 ここだけで
85
+ * 定義すると、 式を書ける側との間で「読めるが書けない」 「書けるが読めない」 名前ができる。
44
86
  */
45
87
  export function interpolate(template: string, values: Record<string, string>): string {
46
- return template.replace(/\{(\w+)(\.length|\.sum|\.max|\.min|\.avg|\[\d+\])?\}/g, (_, id: string, accessor?: string) => {
47
- const raw = values[id];
88
+ return template.replace(TEMPLATE_REF_RE, (_, id: string, accessor?: string) => {
89
+ // 自分が持つ名前だけを見る。 継承を辿ると `{constructor}` が「値がある」 と読まれ、
90
+ // prototype の中身が図に出る (実測)
91
+ const raw = Object.hasOwn(values, id) ? values[id] : undefined;
48
92
  if (raw === undefined) return `{${id}${accessor ?? ""}}`;
49
93
  if (!accessor) return raw;
50
94
  const arr = parseArraySignal(raw);
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `{名前}` の名前とは何か。 **engine 全体で唯一の定義**。
3
+ *
4
+ * 使えるのは英数字と `_` (`\w`)。 呼び出し側の記法もこの範囲で名前を書く
5
+ * (`dragon` の `states` / `values` はどちらも `[a-zA-Z_][a-zA-Z0-9_]*` に限っている)。
6
+ *
7
+ * **読む側 (`render/utils.ts` の `interpolate`) と書ける側 (`formula/parser.ts` の `{...}`)
8
+ * の両方がここを使う**。 片方だけ広げると、 定義はできるのにどの箱からも読めない名前ができる。
9
+ * 実測した食い違いが 2 回ある。
10
+ *
11
+ * - 読み手が `\w` で拾うのに書ける側が字種を問わなかった時、 その名前は置換されず
12
+ * `visibleIf` の fail-safe (未解決なら隠す) も素通りした
13
+ * - 書ける側が `.` を許した時、 `{v.done}` は式では読めるのに読み手が拾えなかった
14
+ * (`.` は accessor `{sig.sum}` の始まりなので名前には使えない)
15
+ *
16
+ * どこにも依存しない file に置くのは、 読む側と書ける側が互いを import する輪を作らないため。
17
+ */
18
+ export const TEMPLATE_NAME_CHARS = "\\w+";
19
+
20
+ /**
21
+ * **置換できる**参照を拾う式。 名前 + 決まった accessor (`.sum` / `[0]` 等)。
22
+ *
23
+ * 置換する側 (`interpolate`) が使う。 知らない accessor を置換対象にすると、 何に
24
+ * 置き換えればよいか決まらない。
25
+ */
26
+ export const TEMPLATE_REF_SRC =
27
+ `\\{(${TEMPLATE_NAME_CHARS})(\\.length|\\.sum|\\.max|\\.min|\\.avg|\\[\\d+\\])?\\}`;
28
+
29
+ /**
30
+ * **残っている**参照を拾う式。 accessor は中身を問わない。
31
+ *
32
+ * 置換できる形より広い。 `{sig.unknown}` のように知らない accessor が付いた参照は
33
+ * 置換されずに残るので、 「解決できていない」 側として数える必要がある
34
+ * (狭い方で判定していた間、 この形で `visibleIf` の fail-safe が効かず箱が出た)。
35
+ */
36
+ export const TEMPLATE_LEFTOVER_SRC = `\\{(${TEMPLATE_NAME_CHARS})([.[][^}]*)?\\}`;
37
+
38
+ const NAME_ONLY_RE = new RegExp(`^${TEMPLATE_NAME_CHARS}$`);
39
+
40
+ /** その文字列が `{名前}` の名前として使えるか */
41
+ export function isValidTemplateName(name: string): boolean {
42
+ return NAME_ONLY_RE.test(name);
43
+ }
package/src/types.ts CHANGED
@@ -4,7 +4,7 @@ export const TONES = ["accent", "teal", "success", "error", "warning", "info"] a
4
4
  export type Tone = (typeof TONES)[number];
5
5
 
6
6
  /**
7
- * cdl SVG に付与される data-cdl-role attribute の SSOT 型 (CAR-730 5 → 10 role に拡張)。
7
+ * cdl SVG に付与される data-cdl-role attribute の SSOT 型 (現在 12 role)。
8
8
  * consumer app (dragon 等) は `[data-cdl-role="<role>"]` selector で CSS を宛てる。 role を追加する時は本 union と該当 render 箇所 (kinds/*.tsx、
9
9
  * render/edges.tsx、 render/stage.tsx) を同時に更新する。
10
10
  *
@@ -28,7 +28,6 @@ export type Tone = (typeof TONES)[number];
28
28
  * - "lane-container" ... lane.contain=true の boundary rect
29
29
  * - "lane-lifeline" ... lane.lifeline=true の縦点線 line
30
30
  * - "lane-label" ... lane 見出し text
31
- * - "mind-radial-halo" ... 放射状の図の中心に敷く光の輪 (意図して淡い飾り)
32
31
  */
33
32
  export type CdlRole =
34
33
  | "node-body"
@@ -42,8 +41,7 @@ export type CdlRole =
42
41
  | "edge-label"
43
42
  | "lane-container"
44
43
  | "lane-lifeline"
45
- | "lane-label"
46
- | "mind-radial-halo";
44
+ | "lane-label";
47
45
 
48
46
  /** 描画できる箱の種類。 検査・記法一覧・型はこの 1 つの列を出所にする。 */
49
47
  export const NODE_KINDS = [
@@ -62,8 +60,8 @@ export const NODE_KINDS = [
62
60
  "signer", "oracle", "merkle-tree", "decision",
63
61
  // Chart 系 (3) ... 1 node に datum 配列を持ち、 kind 内で全 datum を SVG 描画
64
62
  "chart-pie", "chart-line", "chart-bar",
65
- // Timeline / Radial 系 (3、 CAR-994 Phase B) ... 1 node に payload 配列、 SVG geometry render
66
- "gantt-timeline", "mind-map", "mind-radial",
63
+ // Timeline / Mind 系 (2、 CAR-994 Phase B) ... 1 node に payload 配列、 SVG geometry render
64
+ "gantt-timeline", "mind-map",
67
65
  // Analytic 系 (4、 CAR-994 Phase C) ... 1 node に payload 配列、 SVG geometry render
68
66
  "funnel-stages", "quadrant-matrix", "tree-hierarchy", "journey-map",
69
67
  // Shape-driven basement 8 (CAR-1099) ... 要素形状自体が意味を持つ SVG path node、
@@ -272,8 +270,8 @@ export type CdlNode = {
272
270
  */
273
271
  ganttData?: GanttTaskPayload[];
274
272
  /**
275
- * mind-map / mind-radial 専用の branch payload。 tree 構造を 1 node に格納し、
276
- * kinds/mind-map.tsx / mind-radial.tsx が中心 + 放射で SVG 描画する。
273
+ * mind-map 専用の branch payload。 tree 構造を 1 node に格納し、
274
+ * kinds/mind-map.tsx が中心 + 放射で SVG 描画する。
277
275
  */
278
276
  mindData?: MindBranchPayload;
279
277
  /** funnel-stages 専用の stage 配列 payload、 逆三角形 stage 描画に使う */
@@ -286,10 +284,33 @@ export type CdlNode = {
286
284
  journeyData?: JourneyStepPayload[];
287
285
  };
288
286
 
287
+ /**
288
+ * 図表 payload の数値欄。 数を直接書くか、 `{signal}` を書いて状態から読む。
289
+ *
290
+ * 文字列を許すのは、 図表が状態を読む唯一の経路だから (`render/payload-binding.ts` が
291
+ * 描画直前に stateValues で解決する)。 **解決できない欄でも項目は落とさず**、 既定値で
292
+ * 描いて `data-cdl-unresolved="true"` を付ける (落とすと配列の長さが変わって図の形が
293
+ * 別物になる)。
294
+ *
295
+ * 既定値は欄ごとに違う。 数の欄は 0 だが、 `gantt-timeline` の終端は自分の始端
296
+ * (0 にすると帯が軸の左端まで伸びて別の図に見える)、 `quadrant-matrix` は左下、
297
+ * `journey-map` は「普通」。 いずれも `render/payload-binding.ts` が持つ。
298
+ */
299
+ export type BoundNumber = number | string;
300
+
301
+ /**
302
+ * 図表 payload の語の欄。 決まった語を直接書くか、 `{signal}` を書いて状態から読む。
303
+ *
304
+ * `(string & {})` は「決まった語の候補を出しつつ任意の文字列も許す」 ための書き方。
305
+ * 素の `string` と union すると候補が消えるため、 補完を残す目的で挟んでいる。
306
+ */
307
+ export type BoundEnum<T extends string> = T | (string & {});
308
+
289
309
  /** Chart 系 kind の 1 datum payload。 label + value + optional tone (color) */
290
310
  export type ChartDatumPayload = {
291
311
  label: string;
292
- value: number;
312
+ /** 棒 / 折れ線 / 扇の大きさ。 `{signal}` で状態に追随する */
313
+ value: BoundNumber;
293
314
  tone?: Tone;
294
315
  };
295
316
 
@@ -297,10 +318,10 @@ export type ChartDatumPayload = {
297
318
  export type GanttTaskPayload = {
298
319
  id: string;
299
320
  title: string;
300
- /** 開始 index (0-based 相対位置) */
301
- startIdx: number;
302
- /** 終了 index (0-based 相対位置、 startIdx と同じなら 1 cell 幅) */
303
- endIdx: number;
321
+ /** 開始 index (0-based 相対位置) `{signal}` で状態に追随する */
322
+ startIdx: BoundNumber;
323
+ /** 終了 index (0-based 相対位置、 startIdx と同じなら 1 cell 幅) `{signal}` 可 */
324
+ endIdx: BoundNumber;
304
325
  /** 開始 label (例 "Q1" / "2026-01") */
305
326
  startLabel: string;
306
327
  /** 終了 label (例 "Q2" / "2026-03") */
@@ -310,7 +331,7 @@ export type GanttTaskPayload = {
310
331
  tone?: Tone;
311
332
  };
312
333
 
313
- /** mind-map / mind-radial kind の branch tree payload。 rootId + branch 配列 */
334
+ /** mind-map kind の branch tree payload。 rootId + branch 配列 */
314
335
  export type MindBranchPayload = {
315
336
  rootId: string;
316
337
  rootTitle: string;
@@ -330,7 +351,8 @@ export type MindBranchNode = {
330
351
  export type FunnelStagePayload = {
331
352
  id: string;
332
353
  title: string;
333
- count: number;
354
+ /** 段の人数 / 件数。 `{signal}` で状態に追随する */
355
+ count: BoundNumber;
334
356
  subtitle?: string;
335
357
  };
336
358
 
@@ -347,11 +369,15 @@ export type QuadrantPayload = {
347
369
  items: QuadrantItemPayload[];
348
370
  };
349
371
 
372
+ /** quadrant-matrix の象限 4 種 */
373
+ export type QuadrantKey = "topLeft" | "topRight" | "bottomLeft" | "bottomRight";
374
+
350
375
  /** quadrant-matrix の 1 item payload */
351
376
  export type QuadrantItemPayload = {
352
377
  id: string;
353
378
  title: string;
354
- quadrant: "topLeft" | "topRight" | "bottomLeft" | "bottomRight";
379
+ /** どの象限に居るか。 `{signal}` を書くと項目が象限を移る */
380
+ quadrant: BoundEnum<QuadrantKey>;
355
381
  subtitle?: string;
356
382
  };
357
383
 
@@ -364,12 +390,15 @@ export type TreeNodePayload = {
364
390
  eyebrow?: string;
365
391
  };
366
392
 
393
+ /** journey-map の気持ち 5 段階 */
394
+ export type JourneyEmotion = "delighted" | "happy" | "neutral" | "frustrated" | "angry";
395
+
367
396
  /** journey-map kind の 1 step payload */
368
397
  export type JourneyStepPayload = {
369
398
  id: string;
370
399
  title: string;
371
- /** emotion 5 段階 (delighted / happy / neutral / frustrated / angry) */
372
- emotion: "delighted" | "happy" | "neutral" | "frustrated" | "angry";
400
+ /** emotion 5 段階 (delighted / happy / neutral / frustrated / angry) `{signal}` で動く */
401
+ emotion: BoundEnum<JourneyEmotion>;
373
402
  touchpoint?: string;
374
403
  opportunity?: string;
375
404
  };
@@ -2204,6 +2233,13 @@ export type CdlDiagram = {
2204
2233
  * 省略時は空 array 相当で下位互換。
2205
2234
  */
2206
2235
  readouts?: CdlReadout[];
2236
+ /**
2237
+ * 他の値から自動で決まる値。 段の補間中も毎 frame 決まり直す (`computeStateValues`)。
2238
+ *
2239
+ * 入力欄の式 (`formulas`) とは経路が別。 あちらは入力欄の signal を読む reactive graph に
2240
+ * 載り、 こちらは段が動かす状態を読む。 省略時は空 array 相当で下位互換。
2241
+ */
2242
+ derived?: CdlDerivedValue[];
2207
2243
  lanes: CdlLane[];
2208
2244
  nodes: CdlNode[];
2209
2245
  edges: CdlEdge[];
@@ -2213,6 +2249,22 @@ export type CdlDiagram = {
2213
2249
  viewport?: CdlViewport;
2214
2250
  };
2215
2251
 
2252
+ /**
2253
+ * 他の値から自動で決まる値の宣言。
2254
+ *
2255
+ * 式は `{名前}` で他の値 (`states` / 他の `derived`) を読み、 四則 / 括弧 / 比較 /
2256
+ * `min` / `max` が書ける。 比較の結果は真 = 1 / 偽 = 0 の数として載る。
2257
+ *
2258
+ * 書いた順は結果に影響しない (参照から順序を解く)。 解けない値 (輪 / 存在しない名前 /
2259
+ * 0 除算 / 数でない値) はその値だけ止まり、 残りは動く。
2260
+ */
2261
+ export type CdlDerivedValue = {
2262
+ /** 値の名前。 図からは `{名前}` で読む */
2263
+ id: string;
2264
+ /** 式。 例 `"{inflow} - {done}"` */
2265
+ expression: string;
2266
+ };
2267
+
2216
2268
  /** Canvas 全体仕様 (v0.5+ syntax `viewport: { width, height, scale, gap, laneGap, nodeGap, labelMargin }`)。
2217
2269
  * 未指定時は layout が node 配置から自動算出する従来挙動。
2218
2270
  *
@@ -2356,6 +2408,8 @@ export type LaidDiagram = {
2356
2408
  edges: LaidEdge[];
2357
2409
  states: CdlState[];
2358
2410
  phases: CdlPhase[];
2411
+ /** 他の値から自動で決まる値 (`CdlDiagram.derived` をそのまま引き継ぐ) */
2412
+ derived?: CdlDerivedValue[];
2359
2413
  /**
2360
2414
  * キャンバス全要素の bounding box list。
2361
2415
  * engine が「ここに何があるか」 を全部把握、 衝突検証 / debug 表示 / validation で使う。
package/src/validate.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { CdlDiagram } from "./types";
2
2
  import { TONES as TONE_NAMES, NODE_KINDS } from "./types";
3
+ import { templateRefIds } from "./render/utils";
4
+ import { derivedRefIds } from "./render/derived-values";
3
5
 
4
6
  const TONES = new Set<string>(TONE_NAMES);
5
7
  const KINDS = new Set<string>(NODE_KINDS);
@@ -79,7 +81,14 @@ export function validate(diag: CdlDiagram): void {
79
81
  const laneIds = new Set(diag.lanes.map((l) => l.id));
80
82
  const nodeIds = new Set(diag.nodes.map((n) => n.id));
81
83
  const edgeIds = new Set(diag.edges.map((e) => e.id));
84
+ // 段が動かせる値。 `tween` / `set` の宛先はこちらでなければならない
82
85
  const stateIds = new Set(diag.states.map((s) => s.id));
86
+ // `{名前}` で読める値。 他の値から自動で決まる値 (`derived`) も読めるので足す。
87
+ //
88
+ // 2 つを 1 つに畳んではいけない。 畳むと `tween` の宛先に derived を書けてしまい、
89
+ // 「式で決まる値を段が動かす」 という成立しない指定が素通りする (derived は毎 frame
90
+ // 計算し直されるので、 段が入れた値は次の frame で消える)。
91
+ const readableIds = new Set([...stateIds, ...(diag.derived ?? []).map((d) => d.id)]);
83
92
 
84
93
  // 「もしかして」 サジェスト (typo 検出): edit distance 1 で最も近い候補 1 件
85
94
  const suggest = (target: string, candidates: Set<string>): string => {
@@ -151,16 +160,15 @@ export function validate(diag: CdlDiagram): void {
151
160
  // ───────────────────────────────────────────────────────────
152
161
  // 6. state 参照 (node.value / node.rows の {stateId}) → 未定義は warning
153
162
  // ───────────────────────────────────────────────────────────
154
- const refRe = /\{(\w+)\}/g;
155
163
  for (const n of diag.nodes) {
156
164
  const checks: string[] = [];
157
165
  if (n.value) checks.push(n.value);
158
166
  if (n.rows) checks.push(...n.rows);
159
167
  for (const text of checks) {
160
- let m: RegExpExecArray | null;
161
- while ((m = refRe.exec(text))) {
162
- const id = m[1];
163
- if (id && !stateIds.has(id)) {
168
+ // 名前の切り出しは描画側と同じ式を使う (`templateRefIds`)。 別に書くと字種がずれ、
169
+ // 置換されうる名前が検証を素通りする
170
+ for (const id of templateRefIds(text)) {
171
+ if (!readableIds.has(id)) {
164
172
  warnings.push(`node "${n.id}" が未定義 state "${id}" を参照`);
165
173
  }
166
174
  }
@@ -194,10 +202,30 @@ export function validate(diag: CdlDiagram): void {
194
202
  for (const n of diag.nodes) {
195
203
  const texts = [n.value, ...(n.rows ?? [])].filter(Boolean) as string[];
196
204
  for (const text of texts) {
197
- let m: RegExpExecArray | null;
198
- const re = /\{(\w+)\}/g;
199
- while ((m = re.exec(text))) {
200
- if (m[1]) stateUsed.add(m[1]);
205
+ // 名前の切り出しは描画側と同じ式を使う。 別に書くと字種がずれ、 実際は読まれている
206
+ // 状態が「どこでも参照されていない」 と報告される
207
+ for (const id of templateRefIds(text)) stateUsed.add(id);
208
+ }
209
+ }
210
+ // 他の値から決まる値の式も状態を読むので数える。 ただし **その値自体が読まれている時だけ**。
211
+ //
212
+ // 式を無条件に使用として数えると、 誰も読まない値の連なりに隠れて未使用の状態が
213
+ // 見えなくなる (`waiting = {inflow} - 1` を誰も読まなくても `inflow` が使用済になる)。
214
+ // 読まれている名前から式を辿って広げる形にすれば、 連なりごと使われていない場合に
215
+ // 先頭も末尾も unused として出る。
216
+ const derivedById = new Map((diag.derived ?? []).map((dv) => [dv.id, dv]));
217
+ const queue = [...stateUsed];
218
+ while (queue.length > 0) {
219
+ const id = queue.pop()!;
220
+ const dv = derivedById.get(id);
221
+ if (!dv) continue;
222
+ // 式の参照は評価側と同じ抽出を使う (`derivedRefIds`)。 `templateRefIds` は `{名前}` しか
223
+ // 拾わないため、 素の識別子で書かれた参照を取りこぼす
224
+ for (const ref of derivedRefIds(dv.expression)) {
225
+ // 既に数えた名前は辿らない = 式に輪があっても止まる
226
+ if (!stateUsed.has(ref)) {
227
+ stateUsed.add(ref);
228
+ queue.push(ref);
201
229
  }
202
230
  }
203
231
  }
@@ -206,6 +234,11 @@ export function validate(diag: CdlDiagram): void {
206
234
  warnings.push(`state "${s.id}" がどこでも参照されていない (unused)`);
207
235
  }
208
236
  }
237
+ for (const dv of diag.derived ?? []) {
238
+ if (!stateUsed.has(dv.id)) {
239
+ warnings.push(`値 "${dv.id}" がどこでも参照されていない (unused)`);
240
+ }
241
+ }
209
242
  // 使われない lane も warn (どの node も配置されない)
210
243
  const laneUsed = new Set<string>();
211
244
  for (const n of diag.nodes) laneUsed.add(n.lane);