@cardenelabs/cdl 0.5.0 → 0.6.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.
@@ -1,16 +1,26 @@
1
1
  import type { JSX } from "react";
2
2
  import type { LaidNode, Tone } from "../types";
3
3
  import { TONE } from "../render/tone";
4
+ import { resolveGanttData } from "../render/payload-binding";
4
5
 
5
6
  /**
6
7
  * gantt-timeline kind ... 1 node に task 配列 (ganttData) を保持し、 帯状 timeline を SVG 描画する。
7
8
  * layout = 縦軸 task lane (task ごと 1 行)、 横軸日付 (startIdx / endIdx で bar 位置と幅)。
8
9
  * dependsOn は task 間の連結 arrow (elbow line + arrowhead) を追加。
10
+ * startIdx / endIdx は数でも `{signal}` でもよく、 後者は状態が動くたび帯が伸び縮みする。
9
11
  */
10
- export function GanttNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
12
+ export function GanttNode({
13
+ node,
14
+ active,
15
+ stateValues = {},
16
+ }: {
17
+ node: LaidNode;
18
+ active: boolean;
19
+ stateValues?: Record<string, string>;
20
+ }): JSX.Element {
11
21
  const x0 = node.cx - node.w / 2;
12
22
  const y0 = node.cy - node.h / 2;
13
- const tasks = node.ganttData ?? [];
23
+ const tasks = resolveGanttData(node.ganttData ?? [], stateValues);
14
24
 
15
25
  const PAD_TOP = 32;
16
26
  const PAD_RIGHT = 48;
@@ -23,7 +33,9 @@ export function GanttNode({ node, active }: { node: LaidNode; active: boolean })
23
33
  const rowCount = tasks.length || 1;
24
34
  const rowH = Math.max(28, (canvasH - rowGap * (rowCount + 1)) / rowCount);
25
35
 
26
- const maxIdx = Math.max(1, ...tasks.map((t) => t.endIdx + 1));
36
+ // 帯の右端が状態で動くと端数が出る。 目盛りは本数を数えるので切り上げて整数にする
37
+ // (静止した図では endIdx が整数なので切り上げても値は変わらない)。
38
+ const maxIdx = Math.max(1, Math.ceil(Math.max(...tasks.map((t) => t.endIdx + 1), 1)));
27
39
  const cellW = canvasW / maxIdx;
28
40
 
29
41
  const xAt = (idx: number) => PAD_LEFT + idx * cellW;
@@ -107,6 +119,7 @@ export function GanttNode({ node, active }: { node: LaidNode; active: boolean })
107
119
  <g key={`bar-${t.id}`}>
108
120
  <rect
109
121
  data-cdl-role="gantt-bar"
122
+ data-cdl-unresolved={t.unresolved ? "true" : undefined}
110
123
  x={bx}
111
124
  y={by}
112
125
  width={bw}
@@ -1,6 +1,7 @@
1
1
  import type { JSX } from "react";
2
2
  import type { LaidNode, NodeKind } from "../types";
3
3
  import { toneColor } from "./node-tone";
4
+ import { interpolate } from "../render/utils";
4
5
  import { rowBaselineY } from "../layout/spec";
5
6
  import {
6
7
  GENERIC_ROW_LEFT_SIZE_PX,
@@ -273,8 +274,12 @@ export function GenericNode({
273
274
  );
274
275
  })();
275
276
 
276
- // value placeholder 解決 ({stateId} 形式)
277
- const resolvedValue = value?.replace(/\{(\w+)\}/g, (_, id: string) => stateValues[id] ?? "");
277
+ // value placeholder 解決 ({名前} 形式)。 engine 共通の `interpolate` を使う。
278
+ //
279
+ // 独自に書いていた間、 解決できない参照を空文字に潰していた = 書き間違いが
280
+ // 「値が無い」 と区別できなかった。
281
+ // 共通経路は解決できない参照を `{名前}` のまま残すので、 読み手が気付ける。
282
+ const resolvedValue = value !== undefined ? interpolate(value, stateValues) : undefined;
278
283
 
279
284
  // shape 別の text 配置 (diamond / cloud は中心揃え、 rect 等は左上揃え)
280
285
  const centered = shape === "diamond" || shape === "cloud";
@@ -329,9 +334,8 @@ export function GenericNode({
329
334
  // storage と同じ row-align helper で left / right を分離し、 全 row 共通の
330
335
  // rightColX (= 左列 max 幅 + gap) で右列開始 x を揃える (DB table / UML class 風)。
331
336
  // 旧実装は split(":") 単一 delimiter + 右 anchor (node 右端寄せ) で行毎にズレていた。
332
- const resolved = node.rows.map((row) =>
333
- row.replace(/\{(\w+)\}/g, (_, id: string) => stateValues[id] ?? ""),
334
- );
337
+ // value と同じく engine 共通の経路で解決する (独自に書くと字種と既定値がずれる)
338
+ const resolved = node.rows.map((row) => interpolate(row, stateValues));
335
339
  const splits = resolved.map(splitRow);
336
340
  // 左列は比例の書体で描く (`font-family` を指定していないので継承する)。 字数に一定を
337
341
  // 掛ける形では実物に届かず、 右列が左列の字に重なる (`#425`)。 字ごとの幅で見積もる。
@@ -2,6 +2,7 @@ import type { JSX } from "react";
2
2
  import type { LaidNode } from "../types";
3
3
  import { TONE, toneFill } from "../render/tone";
4
4
  import { measureTextWidth } from "../layout/text-width";
5
+ import { resolveQuadrantData } from "../render/payload-binding";
5
6
 
6
7
  /**
7
8
  * quadrant-matrix kind ... 1 node に quadrantData (xAxis / yAxis / quadrantLabels / items)
@@ -9,11 +10,22 @@ import { measureTextWidth } from "../layout/text-width";
9
10
  * layout = 中央十字線で 4 象限に分割、 各象限にほんのり色分け背景 + 象限 label、
10
11
  * item は象限内に大きめの card で配置、 軸両端に矢印 + label を配置。
11
12
  */
12
- export function QuadrantNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
13
+ export function QuadrantNode({
14
+ node,
15
+ active,
16
+ stateValues = {},
17
+ }: {
18
+ node: LaidNode;
19
+ active: boolean;
20
+ stateValues?: Record<string, string>;
21
+ }): JSX.Element {
13
22
  const x0 = node.cx - node.w / 2;
14
23
  const y0 = node.cy - node.h / 2;
15
- const data = node.quadrantData;
16
- if (!data) return <g transform={`translate(${x0} ${y0})`} />;
24
+ const raw = node.quadrantData;
25
+ if (!raw) return <g transform={`translate(${x0} ${y0})`} />;
26
+ // item の象限は `{signal}` で動く。 4 象限のどれにも解決できない項目は消さず左下に置き、
27
+ // 印を付ける (消すと「書いた項目が図に無い」 理由が読み取れない)。
28
+ const data = resolveQuadrantData(raw, stateValues);
17
29
 
18
30
  const PAD_TOP = 40;
19
31
  const PAD_BOTTOM = 56;
@@ -143,6 +155,7 @@ export function QuadrantNode({ node, active }: { node: LaidNode; active: boolean
143
155
  <g key={`item-${it.id}`}>
144
156
  <rect
145
157
  data-cdl-role="quadrant-item"
158
+ data-cdl-unresolved={it.unresolved ? "true" : undefined}
146
159
  x={qxStart}
147
160
  y={py}
148
161
  width={qHalfW}
@@ -1,6 +1,7 @@
1
1
  import type { JSX } from "react";
2
- import type { LaidNode, JourneyStepPayload } from "../types";
2
+ import type { LaidNode, JourneyEmotion } from "../types";
3
3
  import { TONE } from "../render/tone";
4
+ import { resolveJourneyData } from "../render/payload-binding";
4
5
 
5
6
  /**
6
7
  * journey-map kind ... 1 node に step 配列 (journeyData) を保持し、 UX journey map を SVG 描画する。
@@ -13,10 +14,20 @@ import { TONE } from "../render/tone";
13
14
  * - opportunity は 電球 + 吹き出し風 rounded rect
14
15
  * - 「ただの折れ線グラフ」 との差別化 = 5 emotion band + touchpoint 装飾 + opportunity call-out
15
16
  */
16
- export function UserJourneyNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
17
+ export function UserJourneyNode({
18
+ node,
19
+ active,
20
+ stateValues = {},
21
+ }: {
22
+ node: LaidNode;
23
+ active: boolean;
24
+ stateValues?: Record<string, string>;
25
+ }): JSX.Element {
17
26
  const x0 = node.cx - node.w / 2;
18
27
  const y0 = node.cy - node.h / 2;
19
- const steps = node.journeyData ?? [];
28
+ // 段の気持ちは `{signal}` で動く。 5 段階のどれにも解決できない段は消さず真ん中に置き、
29
+ // 印を付ける (消すと折れ線の形が別物になり、 どこが壊れたか読み取れない)。
30
+ const steps = resolveJourneyData(node.journeyData ?? [], stateValues);
20
31
 
21
32
  const PAD_TOP = 20;
22
33
  const PAD_RIGHT = 32;
@@ -25,8 +36,8 @@ export function UserJourneyNode({ node, active }: { node: LaidNode; active: bool
25
36
  const canvasW = node.w - PAD_LEFT - PAD_RIGHT;
26
37
  const canvasH = node.h - PAD_TOP - PAD_BOTTOM;
27
38
 
28
- const emotionRows: JourneyStepPayload["emotion"][] = ["delighted", "happy", "neutral", "frustrated", "angry"];
29
- const emotionScore: Record<JourneyStepPayload["emotion"], number> = {
39
+ const emotionRows: JourneyEmotion[] = ["delighted", "happy", "neutral", "frustrated", "angry"];
40
+ const emotionScore: Record<JourneyEmotion, number> = {
30
41
  delighted: 4,
31
42
  happy: 3,
32
43
  neutral: 2,
@@ -34,14 +45,14 @@ export function UserJourneyNode({ node, active }: { node: LaidNode; active: bool
34
45
  angry: 0,
35
46
  };
36
47
  // 感情ごとの色。 共有の表から引く (直書きすると表を直した時に取り残される)。
37
- const emotionColor: Record<JourneyStepPayload["emotion"], string> = {
48
+ const emotionColor: Record<JourneyEmotion, string> = {
38
49
  delighted: TONE.success,
39
50
  happy: TONE.teal,
40
51
  neutral: TONE.info,
41
52
  frustrated: TONE.warning,
42
53
  angry: TONE.error,
43
54
  };
44
- const emotionEmoji: Record<JourneyStepPayload["emotion"], string> = {
55
+ const emotionEmoji: Record<JourneyEmotion, string> = {
45
56
  delighted: "😄",
46
57
  happy: "🙂",
47
58
  neutral: "😐",
@@ -54,7 +65,7 @@ export function UserJourneyNode({ node, active }: { node: LaidNode; active: bool
54
65
  const colW = canvasW / Math.max(steps.length, 1);
55
66
  const xAt = (idx: number) =>
56
67
  PAD_LEFT + (steps.length <= 1 ? canvasW / 2 : colW / 2 + colW * idx);
57
- const yAt = (emotion: JourneyStepPayload["emotion"]) =>
68
+ const yAt = (emotion: JourneyEmotion) =>
58
69
  PAD_TOP + rowH / 2 + (4 - emotionScore[emotion]) * rowH;
59
70
 
60
71
  // smooth curve for emotion path
@@ -155,7 +166,11 @@ export function UserJourneyNode({ node, active }: { node: LaidNode; active: bool
155
166
  )}
156
167
 
157
168
  {steps.map((s, idx) => (
158
- <g key={`step-${s.id}`}>
169
+ <g
170
+ key={`step-${s.id}`}
171
+ data-cdl-role="journey-step"
172
+ data-cdl-unresolved={s.unresolved ? "true" : undefined}
173
+ >
159
174
  <circle
160
175
  cx={xAt(idx)}
161
176
  cy={yAt(s.emotion)}
@@ -64,7 +64,7 @@ export function hasRenderedLabel(e: { label?: string; sub?: string }): boolean {
64
64
  *
65
65
  * 2 群ある。 形だけを描く `dyn-*` 5 種は `DynShapeNode` に回り、 `node.title` を一度も参照しない
66
66
  * (描くのは形と数値の小さな label だけ)。 payload を自前 layout する chart / timeline / analytic
67
- * 11 種は、 描くのが payload 側の label で `title` は使わない。
67
+ * 10 種は、 描くのが payload 側の label で `title` は使わない。
68
68
  *
69
69
  * 一覧は手で持つと描画側とずれるため、 `test/node-title-render.test.tsx` が `NODE_KINDS` を
70
70
  * **全件 render** して「title の文字が出るか」 と本述語の返り値の一致を検査する。 新しい種別を
@@ -83,7 +83,6 @@ const KINDS_WITHOUT_TITLE_TEXT: ReadonlySet<string> = new Set([
83
83
  "chart-bar",
84
84
  "gantt-timeline",
85
85
  "mind-map",
86
- "mind-radial",
87
86
  "funnel-stages",
88
87
  "quadrant-matrix",
89
88
  "tree-hierarchy",
@@ -52,10 +52,9 @@ export const TOKENS = {
52
52
  "chart-line": { w: 640, h: 360 },
53
53
  "chart-pie": { w: 640, h: 320 },
54
54
  "chart-bar": { w: 640, h: 360 },
55
- // Timeline / Radial 系 (CAR-994 Phase B)
55
+ // Timeline / Mind 系 (CAR-994 Phase B)
56
56
  "gantt-timeline": { w: 720, h: 360 },
57
57
  "mind-map": { w: 720, h: 480 },
58
- "mind-radial": { w: 640, h: 640 },
59
58
  // Analytic 系 (CAR-994 Phase C)
60
59
  "funnel-stages": { w: 560, h: 480 },
61
60
  "quadrant-matrix": { w: 640, h: 480 },
package/src/layout.ts CHANGED
@@ -363,6 +363,7 @@ export function layout(diag: CdlDiagram): LaidDiagram {
363
363
  edges,
364
364
  states: diag.states,
365
365
  phases: diag.phases,
366
+ ...(diag.derived ? { derived: diag.derived } : {}),
366
367
  bboxes,
367
368
  collisions,
368
369
  nearCollisions,
package/src/presets.ts CHANGED
@@ -2,7 +2,7 @@ import { diagram } from "./builder";
2
2
  import type { DiagramBuilder } from "./builder";
3
3
  import { LANE_PAD_MIN, NODE_SIZE } from "./layout/tokens";
4
4
  import { requiredRowsWidth } from "./kinds/row-align";
5
- import type { NodeKind, Tone, EdgeStyle } from "./types";
5
+ import type { NodeKind, Tone, EdgeStyle, JourneyEmotion, QuadrantKey } from "./types";
6
6
 
7
7
  /**
8
8
  * 図の格子の目 (world unit)。 `grid-alignment` (軸 21) が見る単位と同じ。
@@ -1126,7 +1126,9 @@ export function tree(preset: TreePreset): TreeBuilder {
1126
1126
 
1127
1127
  // ─── userJourney (step + emotion + touchpoint) ────────────────
1128
1128
 
1129
- export type JourneyEmotion = "delighted" | "happy" | "neutral" | "frustrated" | "angry";
1129
+ // 気持ちの 5 段階は描画側 (`types.ts`) が持つものを使う。 同じ並びを 2 箇所に書くと、
1130
+ // 段階を足した時に片方だけが古いまま残る。
1131
+ export type { JourneyEmotion } from "./types";
1130
1132
 
1131
1133
  export type JourneyStep = {
1132
1134
  id: string;
@@ -1367,7 +1369,9 @@ export function funnel(preset: FunnelPreset): FunnelBuilder {
1367
1369
 
1368
1370
  // ─── quadrant (2 軸 マトリクス) ────────────────────────────────
1369
1371
 
1370
- export type QuadrantQuadrantLabel = "topLeft" | "topRight" | "bottomLeft" | "bottomRight";
1372
+ // 象限 4 種も描画側 (`types.ts` `QuadrantKey`) が持つものを使う。 名前は既に公開して
1373
+ // いるため残す。
1374
+ export type QuadrantQuadrantLabel = QuadrantKey;
1371
1375
 
1372
1376
  export type QuadrantItem = {
1373
1377
  id: string;
@@ -2001,103 +2005,6 @@ export function stateMachine2(preset: StateMachine2Preset): StateMachine2Builder
2001
2005
  return api;
2002
2006
  }
2003
2007
 
2004
- // ─── mindMapRadial (中心 topic + 8 方向 45 度間隔 放射) ─────────
2005
-
2006
- export type MindMapRadialBranch = {
2007
- id: string;
2008
- title: string;
2009
- /** 色 tone (default は defaultTone)。 8 方向で auto rotate も可 */
2010
- tone?: Tone;
2011
- subtitle?: string;
2012
- };
2013
-
2014
- export type MindMapRadialPreset = {
2015
- id: string;
2016
- topic: string;
2017
- /** 中心 node の title (id は builder が "center" で内部生成) */
2018
- centerTitle: string;
2019
- /** 中心 node の id override (default "center") */
2020
- centerId?: string;
2021
- /** 中心から branch までの距離 (world coord、 default 350) */
2022
- radius?: number;
2023
- /** branch node の width (default 260) */
2024
- branchWidth?: number;
2025
- /** 中心 node の width (default 260) */
2026
- centerWidth?: number;
2027
- defaultTone?: Tone;
2028
- };
2029
-
2030
- export type MindMapRadialBuilder = {
2031
- branch: (b: MindMapRadialBranch) => MindMapRadialBuilder;
2032
- build: () => ReturnType<DiagramBuilder["build"]>;
2033
- };
2034
-
2035
- /**
2036
- * mindMapRadial preset ... 中心 node を軸に 8 方向 (0°/45°/90°/135°/180°/225°/270°/315°)
2037
- * へ branch を放射状に配置する。 現行 mindMap (左から右へ水平 tree) と使い分ける用途。
2038
- *
2039
- * 実装戦略 ... engine の lane × stack grid を活用し、 3 lane (left / center / right) を
2040
- * radius に基づき explicit x で配置、 3 stack row (top / middle / bottom) を stack index 0/1/2 で
2041
- * 割当てる。 branch 順は position 順 (E/SE/S/SW/W/NW/N/NE) に固定し、 index → grid cell mapping。
2042
- *
2043
- * @example
2044
- * mindMapRadial({ id: "mr", topic: "Product Radial", centerTitle: "Product" })
2045
- * .branch({ id: "users", title: "Users" }) // 0° (right)
2046
- * .branch({ id: "road", title: "Roadmap" }) // 45° (bottom-right)
2047
- * .branch({ id: "metrics", title: "Metrics" }) // 90° (bottom)
2048
- * ...
2049
- * .build();
2050
- */
2051
- export function mindMapRadial(preset: MindMapRadialPreset): MindMapRadialBuilder {
2052
- const centerId = preset.centerId ?? "center";
2053
- const canvasW = 640;
2054
- const canvasH = 640;
2055
- const b = diagram(preset.id, { topic: preset.topic });
2056
- const branches: MindMapRadialBranch[] = [];
2057
-
2058
- const api: MindMapRadialBuilder = {
2059
- branch(br) {
2060
- if (branches.length >= 8) {
2061
- throw new Error(`cdl mindMapRadial: 8 方向配置のため branch は最大 8 件 (preset id "${preset.id}")`);
2062
- }
2063
- branches.push(br);
2064
- return api;
2065
- },
2066
- build() {
2067
- b.lane("radial", { width: canvasW });
2068
- const nodeId = `${preset.id}-radial`;
2069
- b.node(nodeId, {
2070
- lane: "radial",
2071
- stack: 0,
2072
- kind: "mind-radial",
2073
- title: preset.topic,
2074
- eyebrow: "mindMapRadial",
2075
- w: canvasW,
2076
- h: canvasH,
2077
- mindData: {
2078
- rootId: centerId,
2079
- rootTitle: preset.centerTitle,
2080
- branches: branches.map((br) => ({
2081
- id: br.id,
2082
- title: br.title,
2083
- parent: centerId,
2084
- tone: br.tone,
2085
- subtitle: br.subtitle,
2086
- })),
2087
- },
2088
- });
2089
- b.phase(
2090
- "mindmap-radial",
2091
- { duration: 2400, title: preset.topic, body: "mindMapRadial 全 branch を visible 化、 中心 + 8 方向 45 度で描画。" },
2092
- (p) => p.activate(nodeId).badge("mindmap-radial"),
2093
- );
2094
- return b.build();
2095
- },
2096
- };
2097
-
2098
- return api;
2099
- }
2100
-
2101
2008
  // ─── helper ────────────────────────────────────────────────────
2102
2009
 
2103
2010
  function slugify(s: string): string {
@@ -0,0 +1,261 @@
1
+ import type { FormulaAst } from "../formula/ast";
2
+ import { evaluate } from "../formula/evaluator";
3
+ import { extractIdentifiers, parseFormula } from "../formula/parser";
4
+ import { isValidTemplateName } from "../template-name";
5
+ import type { CdlDerivedValue } from "../types";
6
+
7
+ /**
8
+ * 他の値から自動で決まる値を解く層 (cdl#453 / dragon#1161 段 1)。
9
+ *
10
+ * 段 (`animation`) が動かす値を出した後に、 参照の関係から順序を決めて式を評価する。
11
+ * 段の補間中も毎 frame ここを通るため、 掛け算 / 割り算 / 比較 / `min` / `max` のような
12
+ * 端点 2 点では表せない関係も途中の値が正しくなる。
13
+ *
14
+ * 止まった値は結果に載せない。 載せなければ図には `{名前}` が残り、 読み手
15
+ * (`resolveNumericAttr` / `payload-binding` / `visibleIf`) が既に持っている
16
+ * 「解決できていない」 の扱いにそのまま乗る。 伝えるための経路を新設しない。
17
+ *
18
+ * ただし同じ名前が `states` にもある場合は、 段が出した初期値をそのまま残す
19
+ * (「まだ計算されていない間の値」 として使う)。
20
+ */
21
+
22
+ /** 値が止まった理由。 呼び出し側が伝え方を決められるように種類を分けておく */
23
+ export type DerivedNoticeKind =
24
+ | "cycle"
25
+ | "unknown-reference"
26
+ | "parse-error"
27
+ | "eval-error"
28
+ | "duplicate-id"
29
+ | "invalid-id";
30
+
31
+ export type DerivedNotice = {
32
+ /** 止まった値の名前 */
33
+ id: string;
34
+ kind: DerivedNoticeKind;
35
+ message: string;
36
+ };
37
+
38
+ export type DerivedResult = {
39
+ /** 段の値に、 解けた値を足したもの */
40
+ values: Record<string, string>;
41
+ /** 止まった値。 解けた値しか無ければ空 */
42
+ notices: DerivedNotice[];
43
+ };
44
+
45
+ /** 構文解析の結果を式ごとに使い回す。 毎 frame 解き直すと項目数に比例して重くなる */
46
+ type ParsedEntry = { id: string; ast: FormulaAst; refs: ReadonlySet<string> };
47
+ type ParsePhase = { entries: ParsedEntry[]; notices: DerivedNotice[]; cyclic: string[]; stamp: string };
48
+ const parseCache = new WeakMap<readonly CdlDerivedValue[], ParsePhase>();
49
+
50
+ /**
51
+ * 宣言の中身を表す印。 cache が今の中身のものかを確かめるために使う。
52
+ *
53
+ * 同じ配列を書き換える形 (`derived.push(...)` / 式の差し替え) では参照が変わらないため、
54
+ * 参照だけを鍵にすると古い解析結果を返し続ける。 中身の比較は宣言数に比例するだけで、
55
+ * 構文解析をやり直すのに比べれば桁違いに軽い。
56
+ */
57
+ function contentStamp(derived: readonly CdlDerivedValue[]): string {
58
+ // 長さを先に置くので、 区切り文字が名前や式に現れても境界は曖昧にならない
59
+ // (区切りだけに頼ると `a|b` という名前と `a` / `b` の 2 件が同じ印になる)
60
+ return derived.map((d) => `${d.id.length}:${d.id}|${d.expression.length}:${d.expression}`).join("|");
61
+ }
62
+
63
+ /**
64
+ * 段の値に、 他の値から決まる値を足して返す。
65
+ *
66
+ * 宣言が無ければ受け取った値をそのまま返す (同じ object をそのまま返すので、
67
+ * 宣言を持たない図は追加の割当てを 1 つも払わない)。
68
+ */
69
+ export function applyDerivedValues(
70
+ base: Record<string, string>,
71
+ derived: readonly CdlDerivedValue[] | undefined,
72
+ ): DerivedResult {
73
+ if (!derived || derived.length === 0) return { values: base, notices: [] };
74
+
75
+ // 構文解析と順序解決は式の中身だけで決まり、 値には依存しない。 毎 frame やり直さず
76
+ // 宣言ごとに 1 度だけ行う (項目数に比例して重くなるのを避ける、 Issue の破れる条件)
77
+ const phase = parseAndOrder(derived);
78
+ // 継承を持たない入れ物に移す。 通常の object だと `constructor` / `__proto__` のような
79
+ // 名前が「値が無い」 と区別できず、 読み手が prototype の中身を図に出す
80
+ // (実測 = `{constructor}` が `function Object() { [native code] }` として描かれた)。
81
+ // `__proto__` は代入自体が無視されるので、 値を入れたのに読めない形にもなる。
82
+ const values: Record<string, string> = Object.assign(Object.create(null), base);
83
+ const notices: DerivedNotice[] = [...phase.notices];
84
+
85
+ for (const entry of phase.entries) {
86
+ // 輪に入った値を参照している値は、 参照先が結果に載らないのでここで止まる
87
+ const out = evaluateOne(entry, values);
88
+ if (out.ok) values[entry.id] = out.value;
89
+ else notices.push({ id: entry.id, kind: out.kind, message: out.message });
90
+ }
91
+
92
+ return { values, notices };
93
+ }
94
+
95
+ /**
96
+ * 式が読む名前を返す。 読めない式は空 (その値は評価時に止まるので何も読まない)。
97
+ *
98
+ * **式の参照はここで取る**。 `templateRefIds` は `{名前}` しか拾わないが、 式は素の
99
+ * 識別子 (`inflow + 1`) も読めるため、 検証側が `templateRefIds` を使うと評価側と
100
+ * 食い違う (実際は読まれている状態が unused と報告される)。
101
+ */
102
+ export function derivedRefIds(expression: string): string[] {
103
+ try {
104
+ return [...extractIdentifiers(parseFormula(expression))];
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+
110
+ /** 段の値に、 他の値から決まる値を足した record だけを返す (伝える情報が要らない呼び出し用) */
111
+ export function withDerivedValues(
112
+ base: Record<string, string>,
113
+ derived: readonly CdlDerivedValue[] | undefined,
114
+ ): Record<string, string> {
115
+ return applyDerivedValues(base, derived).values;
116
+ }
117
+
118
+ /** 構文解析と順序解決。 宣言ごとに 1 度だけ行い、 結果を使い回す */
119
+ function parseAndOrder(derived: readonly CdlDerivedValue[]): ParsePhase {
120
+ const stamp = contentStamp(derived);
121
+ const cached = parseCache.get(derived);
122
+ if (cached && cached.stamp === stamp) return cached;
123
+
124
+ const notices: DerivedNotice[] = [];
125
+ const seen = new Set<string>();
126
+ const parsed: ParsedEntry[] = [];
127
+
128
+ for (const d of derived) {
129
+ if (seen.has(d.id)) {
130
+ // 同じ名前を 2 度書いた形。 先に書いた方を残す (後勝ちにすると、 打ち間違いで
131
+ // 上書きされた値が黙って消える)
132
+ notices.push({
133
+ id: d.id,
134
+ kind: "duplicate-id",
135
+ message: `"${d.id}" が 2 度宣言されている。 先に書いた式を使う`,
136
+ });
137
+ continue;
138
+ }
139
+ seen.add(d.id);
140
+ // 名前は式の中と同じ規則に従う。 id 側だけ素通りさせると、 解けてもどの箱からも
141
+ // 読めない値ができる (`{a.b}` は `.` が accessor の始まりなので読み手が拾えない)
142
+ if (!isValidTemplateName(d.id)) {
143
+ notices.push({
144
+ id: d.id,
145
+ kind: "invalid-id",
146
+ message: `"${d.id}" は名前として使えない (英数字と _ のみ)。 読み手が拾えないため止めた`,
147
+ });
148
+ continue;
149
+ }
150
+ try {
151
+ const ast = parseFormula(d.expression);
152
+ parsed.push({ id: d.id, ast, refs: extractIdentifiers(ast) });
153
+ } catch (e) {
154
+ notices.push({
155
+ id: d.id,
156
+ kind: "parse-error",
157
+ message: `"${d.id}" の式を読めない: ${e instanceof Error ? e.message : String(e)}`,
158
+ });
159
+ }
160
+ }
161
+
162
+ const { order, cyclic } = topoSort(parsed);
163
+ for (const id of cyclic) {
164
+ notices.push({
165
+ id,
166
+ kind: "cycle",
167
+ message: `"${id}" は参照を辿ると自分に戻るため止めた (輪に入っていない値は動く)`,
168
+ });
169
+ }
170
+
171
+ const phase: ParsePhase = { entries: order, notices, cyclic, stamp };
172
+ parseCache.set(derived, phase);
173
+ return phase;
174
+ }
175
+
176
+ /**
177
+ * 参照の関係から評価順を決める。 輪に入った値だけを外す。
178
+ *
179
+ * 宣言の外にある名前 (`states` や入力欄の値) への参照は葉として扱い、 順序には関わらない。
180
+ * 輪を見つけたら、 いま辿っている道のうち戻り先から先の全員を輪の一員として外す
181
+ * (見つけた 1 つだけを外すと、 残りが「存在しない名前を参照している」 として報告され、
182
+ * 本当の原因が輪であることが読み取れない)。
183
+ */
184
+ function topoSort(entries: ParsedEntry[]): { order: ParsedEntry[]; cyclic: string[] } {
185
+ const byId = new Map(entries.map((e) => [e.id, e]));
186
+ const state = new Map<string, "visiting" | "done">();
187
+ const order: ParsedEntry[] = [];
188
+ const cyclic = new Set<string>();
189
+ const path: string[] = [];
190
+
191
+ const visit = (entry: ParsedEntry): void => {
192
+ const s = state.get(entry.id);
193
+ if (s === "done") return;
194
+ if (s === "visiting") {
195
+ const at = path.indexOf(entry.id);
196
+ for (const id of path.slice(at < 0 ? 0 : at)) cyclic.add(id);
197
+ return;
198
+ }
199
+ state.set(entry.id, "visiting");
200
+ path.push(entry.id);
201
+ for (const ref of entry.refs) {
202
+ if (ref === entry.id) {
203
+ cyclic.add(entry.id);
204
+ continue;
205
+ }
206
+ const next = byId.get(ref);
207
+ if (next) visit(next);
208
+ }
209
+ path.pop();
210
+ state.set(entry.id, "done");
211
+ // 輪の一員だと分かるのは自分を order に積む前なので、 ここで確かめれば取りこぼさない
212
+ if (!cyclic.has(entry.id)) order.push(entry);
213
+ };
214
+
215
+ for (const e of entries) visit(e);
216
+
217
+ // 輪の中の値を参照しているだけの値は外さない。 参照先が結果に載らないので評価時に止まり、
218
+ // 「自分が輪に入っている」 のと「輪に入った値を読んでいる」 のを分けて報告できる
219
+ return { order, cyclic: [...cyclic] };
220
+ }
221
+
222
+ type EvalOutcome =
223
+ | { ok: true; value: string }
224
+ | { ok: false; kind: "unknown-reference" | "eval-error"; message: string };
225
+
226
+ /** 参照先が無いことを他の失敗と区別するための印 */
227
+ class MissingReferenceError extends Error {}
228
+
229
+ function evaluateOne(entry: ParsedEntry, values: Record<string, string>): EvalOutcome {
230
+ const resolver = (name: string): number => {
231
+ // 自分が持つ名前だけを見る。 継承を辿ると `constructor` 等が「値がある」 と読める
232
+ const raw = Object.hasOwn(values, name) ? values[name] : undefined;
233
+ if (raw === undefined) throw new MissingReferenceError(`"${name}" という値が無い`);
234
+ const trimmed = raw.trim();
235
+ const num = Number(trimmed);
236
+ // 空文字を弾いてから数に直す。 `Number("")` は 0 を返すので、 弾かないと
237
+ // 「値が無い」 と「0 と書いてある」 を区別できない
238
+ if (trimmed === "" || !Number.isFinite(num)) {
239
+ throw new Error(`"${name}" の値 "${raw}" は数として読めない`);
240
+ }
241
+ return num;
242
+ };
243
+
244
+ try {
245
+ const result = evaluate(entry.ast, resolver);
246
+ if (typeof result === "number" && !Number.isFinite(result)) {
247
+ return {
248
+ ok: false,
249
+ kind: "eval-error",
250
+ message: `"${entry.id}" を止めた: 結果が数にならなかった`,
251
+ };
252
+ }
253
+ // 比較の結果は真 = 1 / 偽 = 0 の数として載せる。 "true" のまま載せると、
254
+ // 数として読む側 (`resolveNumericAttr` 等) が全て既定値に落ちる
255
+ return { ok: true, value: typeof result === "boolean" ? (result ? "1" : "0") : String(result) };
256
+ } catch (e) {
257
+ const detail = e instanceof Error ? e.message : String(e);
258
+ const kind = e instanceof MissingReferenceError ? "unknown-reference" : "eval-error";
259
+ return { ok: false, kind, message: `"${entry.id}" を止めた: ${detail}` };
260
+ }
261
+ }