@cardenelabs/cdl 0.5.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/LICENSE +21 -0
- package/README.md +343 -0
- package/SPEC.md +374 -0
- package/dist/index.cjs +28085 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3415 -0
- package/dist/index.d.ts +3415 -0
- package/dist/index.js +27962 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +23043 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +2 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +23041 -0
- package/dist/react.js.map +1 -0
- package/dist/render-C78lIXeC.d.cts +2449 -0
- package/dist/render-C78lIXeC.d.ts +2449 -0
- package/examples/quick-start.md +88 -0
- package/package.json +79 -0
- package/src/anim/core/easing.ts +77 -0
- package/src/anim/core/timeline.ts +335 -0
- package/src/anim/core/types.ts +42 -0
- package/src/anim/index.ts +20 -0
- package/src/anim/react/index.ts +3 -0
- package/src/anim/react/useReducedMotion.ts +27 -0
- package/src/anim/react/useTimeline.ts +48 -0
- package/src/assert-never.ts +16 -0
- package/src/author-intent-verify.ts +587 -0
- package/src/builder.ts +3740 -0
- package/src/compile.ts +12 -0
- package/src/dom-verify-core.ts +121 -0
- package/src/dom-verify-types.ts +21 -0
- package/src/dom-verify.ts +948 -0
- package/src/event-handler/index.ts +184 -0
- package/src/formula/ast.ts +46 -0
- package/src/formula/evaluator.ts +163 -0
- package/src/formula/formula-computeds.ts +83 -0
- package/src/formula/index.ts +5 -0
- package/src/formula/parser.ts +399 -0
- package/src/index.ts +284 -0
- package/src/input-signals.ts +74 -0
- package/src/kinds/actor.tsx +79 -0
- package/src/kinds/card.tsx +61 -0
- package/src/kinds/chart-bar.tsx +121 -0
- package/src/kinds/chart-line.tsx +163 -0
- package/src/kinds/chart-pie.tsx +107 -0
- package/src/kinds/compact-title.ts +164 -0
- package/src/kinds/dyn-shape.tsx +394 -0
- package/src/kinds/event.tsx +70 -0
- package/src/kinds/function.tsx +53 -0
- package/src/kinds/funnel.tsx +122 -0
- package/src/kinds/gantt.tsx +193 -0
- package/src/kinds/generic.tsx +368 -0
- package/src/kinds/mind-map.tsx +367 -0
- package/src/kinds/mind-radial.tsx +209 -0
- package/src/kinds/node-tone.ts +18 -0
- package/src/kinds/quadrant.tsx +164 -0
- package/src/kinds/row-align.ts +179 -0
- package/src/kinds/shape-basement.tsx +683 -0
- package/src/kinds/shape-blockchain.tsx +750 -0
- package/src/kinds/shape-commerce.tsx +553 -0
- package/src/kinds/shape-finance.tsx +706 -0
- package/src/kinds/shape-hardware.tsx +862 -0
- package/src/kinds/shape-people.tsx +439 -0
- package/src/kinds/shape-region.tsx +383 -0
- package/src/kinds/shape-software.tsx +859 -0
- package/src/kinds/storage.tsx +180 -0
- package/src/kinds/text-width.ts +73 -0
- package/src/kinds/tree.tsx +275 -0
- package/src/kinds/user-journey.tsx +240 -0
- package/src/label-text.ts +71 -0
- package/src/layout/clearance-constants.ts +47 -0
- package/src/layout/collisions.ts +2078 -0
- package/src/layout/edges.ts +2498 -0
- package/src/layout/footer-shape.ts +97 -0
- package/src/layout/geometry.ts +135 -0
- package/src/layout/label-shift.ts +28 -0
- package/src/layout/lanes.ts +328 -0
- package/src/layout/nodes.ts +190 -0
- package/src/layout/predict-bbox.ts +76 -0
- package/src/layout/px-projection.ts +176 -0
- package/src/layout/spec.ts +872 -0
- package/src/layout/text-width.ts +133 -0
- package/src/layout/tokens.ts +181 -0
- package/src/layout/viewbox.ts +47 -0
- package/src/layout-with-validation.ts +175 -0
- package/src/layout.ts +381 -0
- package/src/presets.ts +2108 -0
- package/src/reactive/batch.ts +48 -0
- package/src/reactive/computed.ts +85 -0
- package/src/reactive/effect.ts +145 -0
- package/src/reactive/index.ts +6 -0
- package/src/reactive/internal.ts +109 -0
- package/src/reactive/signal.ts +113 -0
- package/src/render/edges.tsx +318 -0
- package/src/render/header.tsx +146 -0
- package/src/render/interactive-panel.tsx +9620 -0
- package/src/render/nodes.tsx +319 -0
- package/src/render/stage.tsx +377 -0
- package/src/render/tone.ts +64 -0
- package/src/render/utils.ts +238 -0
- package/src/render.tsx +221 -0
- package/src/scroll-trigger/index.ts +109 -0
- package/src/scroll-trigger/progress.ts +49 -0
- package/src/thumbnail.tsx +114 -0
- package/src/types.ts +2371 -0
- package/src/validate.ts +268 -0
- package/src/visual-validate.ts +4381 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { CdlDiagram, CdlEventBinding, CdlEventTarget } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* event handler function type (CAR #246)。
|
|
5
|
+
* consumer が signal update / computed 参照 / batch update 等を実装する。
|
|
6
|
+
* event 引数は DOM Event 本体 (target / clientX / preventDefault 等が使える)。
|
|
7
|
+
*/
|
|
8
|
+
export type CdlEventHandler = (event: Event) => void;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* attach 結果 handle (CAR #246)。 dispose 呼出で全 listener を cleanup。
|
|
12
|
+
*/
|
|
13
|
+
export type EventAttachHandle = {
|
|
14
|
+
/** 全 event listener を DOM から解除、 hydration unmount 経路。 */
|
|
15
|
+
dispose: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* diagram の eventBindings を DOM element に attach する (CAR #246)。
|
|
20
|
+
*
|
|
21
|
+
* root element 内で target 記述に応じて element を lookup:
|
|
22
|
+
* - node: `[data-cdl-node="{id}"]` selector
|
|
23
|
+
* - edge: `[data-cdl-edge="{id}"]` selector
|
|
24
|
+
* - lane: `[data-cdl-lane="{id}"]` selector
|
|
25
|
+
* - diagram: root 自身
|
|
26
|
+
*
|
|
27
|
+
* event 種別:
|
|
28
|
+
* - click: `click` event
|
|
29
|
+
* - hover: `mouseenter` + `mouseleave` の 2 event を同 handler に bind、 consumer は
|
|
30
|
+
* `event.type` で enter / leave を分岐する (tooltip / highlight の enter+clear を 1 binding で表現)
|
|
31
|
+
*
|
|
32
|
+
* listener は passive: true で登録 (scroll blocking 回避)。 target element が見つからない場合は
|
|
33
|
+
* silent skip + console warn (initial render 未完了の可能性、 fatal ではない)。
|
|
34
|
+
* handler map に該当 handlerId がない場合も silent skip + warn。
|
|
35
|
+
*
|
|
36
|
+
* 戻り値の dispose() で全 listener を解除。 hydration lifecycle (React useEffect の cleanup / unmount)
|
|
37
|
+
* と紐付けて use する。
|
|
38
|
+
*/
|
|
39
|
+
export function attachEventHandlers(
|
|
40
|
+
root: Element,
|
|
41
|
+
diagram: CdlDiagram,
|
|
42
|
+
handlers: Record<string, CdlEventHandler>,
|
|
43
|
+
): EventAttachHandle {
|
|
44
|
+
const bindings = diagram.eventBindings ?? [];
|
|
45
|
+
const removers: Array<() => void> = [];
|
|
46
|
+
|
|
47
|
+
for (const binding of bindings) {
|
|
48
|
+
if (!Object.hasOwn(handlers, binding.handlerId)) {
|
|
49
|
+
warn(
|
|
50
|
+
`attachEventHandlers: handler '${binding.handlerId}' for binding '${binding.id}' not provided、 skip`,
|
|
51
|
+
);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const handler = handlers[binding.handlerId];
|
|
55
|
+
if (typeof handler !== "function") {
|
|
56
|
+
warn(
|
|
57
|
+
`attachEventHandlers: handler '${binding.handlerId}' is not a function (got ${typeof handler})、 skip`,
|
|
58
|
+
);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const elements = resolveTargetAll(root, binding.target);
|
|
62
|
+
if (elements.length === 0) {
|
|
63
|
+
warn(
|
|
64
|
+
`attachEventHandlers: target ${JSON.stringify(binding.target)} for binding '${binding.id}' not found in root、 skip`,
|
|
65
|
+
);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const eventNames = eventKindToDomEvents(binding.event);
|
|
69
|
+
// passive: false 相当 (default) = handler が event.preventDefault() を呼べる。
|
|
70
|
+
// click / mouseenter / mouseleave は scroll blocking の主要 event ではないため passive は不要、
|
|
71
|
+
// 逆に click で cancelable な interaction (link 抑制 / form submit 抑制) を消してしまう副作用が問題
|
|
72
|
+
// (codex Round 1 P2 finding)。
|
|
73
|
+
for (const el of elements) {
|
|
74
|
+
for (const eventName of eventNames) {
|
|
75
|
+
el.addEventListener(eventName, handler);
|
|
76
|
+
removers.push(() => {
|
|
77
|
+
el.removeEventListener(eventName, handler);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
dispose() {
|
|
85
|
+
while (removers.length > 0) {
|
|
86
|
+
const remover = removers.pop();
|
|
87
|
+
if (remover) remover();
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* event target 記述から実 DOM element を 1 つ lookup (最初の一致)。 test / debug 用途。
|
|
95
|
+
* edge target で label sibling も対象にする場合は resolveTargetAll を使う。
|
|
96
|
+
*/
|
|
97
|
+
export function resolveTarget(root: Element, target: CdlEventTarget): Element | null {
|
|
98
|
+
const all = resolveTargetAll(root, target);
|
|
99
|
+
return all[0] ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* event target 記述から実 DOM element を全て lookup (attach 経路の SSOT)。
|
|
104
|
+
* edge target は本体 `[data-cdl-edge="{id}"]` + label sibling `[data-cdl-edge-label-for="{id}"]` の 2 系統
|
|
105
|
+
* を含める (label 上での click / hover を miss しないため、 codex Round 2 P2 finding)。
|
|
106
|
+
* 該当 element がなければ空配列を返す (silent skip 判定は attach 側)。
|
|
107
|
+
*/
|
|
108
|
+
export function resolveTargetAll(root: Element, target: CdlEventTarget): Element[] {
|
|
109
|
+
switch (target.kind) {
|
|
110
|
+
case "diagram":
|
|
111
|
+
return [root];
|
|
112
|
+
case "node":
|
|
113
|
+
return Array.from(root.querySelectorAll(`[data-cdl-node="${cssEscape(target.id)}"]`));
|
|
114
|
+
case "edge": {
|
|
115
|
+
// edge 本体 + label sibling を両方対象化
|
|
116
|
+
const edgeEls = root.querySelectorAll(`[data-cdl-edge="${cssEscape(target.id)}"]`);
|
|
117
|
+
const labelEls = root.querySelectorAll(
|
|
118
|
+
`[data-cdl-edge-label-for="${cssEscape(target.id)}"]`,
|
|
119
|
+
);
|
|
120
|
+
return [...Array.from(edgeEls), ...Array.from(labelEls)];
|
|
121
|
+
}
|
|
122
|
+
case "lane":
|
|
123
|
+
// node wrapper も `data-cdl-lane={parent lane}` を持つため、 lane container だけを対象化するには
|
|
124
|
+
// `:not([data-cdl-node])` で node element を除外する必要がある (codex Round 4 P2 finding)。
|
|
125
|
+
// 加えて edge 系 (`data-cdl-edge`) も念のため除外して、 純粋な lane container だけを取得。
|
|
126
|
+
return Array.from(
|
|
127
|
+
root.querySelectorAll(
|
|
128
|
+
`[data-cdl-lane="${cssEscape(target.id)}"]:not([data-cdl-node]):not([data-cdl-edge])`,
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
default: {
|
|
132
|
+
const _exhaustive: never = target;
|
|
133
|
+
throw new Error(`resolveTarget: unsupported target kind (got ${JSON.stringify(_exhaustive)})`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* CdlEventKind → 対応する DOM event 名の配列。 hover は mouseenter + mouseleave の 2 event を
|
|
140
|
+
* 同 handler で受ける (handler 内 `event.type` で分岐して highlight / tooltip の enter/leave を実装可能)。
|
|
141
|
+
* click は 1 event のみ。 codex Round 3 P2 finding fix = hover に mouseleave を必ず含める。
|
|
142
|
+
*/
|
|
143
|
+
function eventKindToDomEvents(kind: CdlEventBinding["event"]): string[] {
|
|
144
|
+
switch (kind) {
|
|
145
|
+
case "click":
|
|
146
|
+
return ["click"];
|
|
147
|
+
case "hover":
|
|
148
|
+
return ["mouseenter", "mouseleave"];
|
|
149
|
+
case "double-click":
|
|
150
|
+
return ["dblclick"];
|
|
151
|
+
case "long-press":
|
|
152
|
+
// long-press は pointerdown/up の距離で判定するため、 生 event 側で
|
|
153
|
+
// 2 event 登録して handler 側で time diff を測る簡易実装。 500ms hold で発火
|
|
154
|
+
return ["pointerdown", "pointerup", "pointercancel"];
|
|
155
|
+
case "drag":
|
|
156
|
+
return ["pointerdown", "pointermove", "pointerup"];
|
|
157
|
+
case "drop":
|
|
158
|
+
return ["drop", "dragover"];
|
|
159
|
+
case "keydown":
|
|
160
|
+
return ["keydown"];
|
|
161
|
+
case "focus":
|
|
162
|
+
return ["focus"];
|
|
163
|
+
case "blur":
|
|
164
|
+
return ["blur"];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* CSS.escape の polyfill 相当 (最低限、 id が特殊文字を含む場合の attribute selector 安全化)。
|
|
170
|
+
* CSS.escape が使える環境ではそちらを優先、 SSR / 古い環境の fallback として quotation double を用意。
|
|
171
|
+
*/
|
|
172
|
+
function cssEscape(value: string): string {
|
|
173
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
174
|
+
return CSS.escape(value);
|
|
175
|
+
}
|
|
176
|
+
// fallback = double quote / backslash を escape
|
|
177
|
+
return value.replace(/["\\]/g, (m) => `\\${m}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function warn(message: string): void {
|
|
181
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
182
|
+
console.warn(`[cdl event-handler] ${message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* formula expression の AST 型 (CAR #244)。
|
|
3
|
+
*
|
|
4
|
+
* safe subset のみを表現する。 演算子 (+ - * / %) / 比較 (< <= > >= == !=) / 三項 (?:) / 括弧 /
|
|
5
|
+
* 数値リテラル / identifier (signal 名) / Math.* 関数呼出の 7 pattern。
|
|
6
|
+
*
|
|
7
|
+
* discriminated union で kind ごとに fields を厳密化、 evaluator / extractor が type narrowing で走査する。
|
|
8
|
+
*/
|
|
9
|
+
export type FormulaAst =
|
|
10
|
+
| { type: "number"; value: number }
|
|
11
|
+
| { type: "identifier"; name: string }
|
|
12
|
+
| { type: "binaryOp"; op: BinaryOp; left: FormulaAst; right: FormulaAst }
|
|
13
|
+
| { type: "unaryOp"; op: "-"; operand: FormulaAst }
|
|
14
|
+
| { type: "ternary"; condition: FormulaAst; whenTrue: FormulaAst; whenFalse: FormulaAst }
|
|
15
|
+
| { type: "call"; fn: MathFnName; args: FormulaAst[] };
|
|
16
|
+
|
|
17
|
+
export type BinaryOp =
|
|
18
|
+
| "+"
|
|
19
|
+
| "-"
|
|
20
|
+
| "*"
|
|
21
|
+
| "/"
|
|
22
|
+
| "%"
|
|
23
|
+
| "<"
|
|
24
|
+
| "<="
|
|
25
|
+
| ">"
|
|
26
|
+
| ">="
|
|
27
|
+
| "=="
|
|
28
|
+
| "!=";
|
|
29
|
+
|
|
30
|
+
export type MathFnName =
|
|
31
|
+
| "Math.min"
|
|
32
|
+
| "Math.max"
|
|
33
|
+
| "Math.abs"
|
|
34
|
+
| "Math.floor"
|
|
35
|
+
| "Math.ceil"
|
|
36
|
+
| "Math.round";
|
|
37
|
+
|
|
38
|
+
/** parser / evaluator で許可された Math.* 関数の short name (Math. prefix なし)。 */
|
|
39
|
+
export const ALLOWED_MATH_FNS = new Set<string>([
|
|
40
|
+
"min",
|
|
41
|
+
"max",
|
|
42
|
+
"abs",
|
|
43
|
+
"floor",
|
|
44
|
+
"ceil",
|
|
45
|
+
"round",
|
|
46
|
+
]);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { FormulaAst } from "./ast";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* formula 評価 context = identifier 名 → runtime value の map。 caller が signal.value 等から構築して渡す。
|
|
5
|
+
* `evaluate` に直接渡す eager 経路の型 (簡易 use case)。
|
|
6
|
+
*/
|
|
7
|
+
export type FormulaContext = Record<string, number | boolean>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* formula 評価 resolver = identifier 名 を受け取って値を返す関数。
|
|
11
|
+
* lazy evaluation を可能にする (ternary の unselected branch で undefined identifier / division by zero を回避)。
|
|
12
|
+
*/
|
|
13
|
+
export type FormulaResolver = (name: string) => number | boolean;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* AST を pure に評価して結果を返す (CAR #244)。
|
|
17
|
+
* safe subset のみサポート (parser 側で syntax error は既に排除済)。
|
|
18
|
+
*
|
|
19
|
+
* 第 2 引数は `FormulaContext` (record) or `FormulaResolver` (function) のどちらも受け付ける。
|
|
20
|
+
* ternary の unselected branch を skip したい caller は resolver 経路を使う (evaluator が identifier
|
|
21
|
+
* node に到達した時のみ resolver を呼ぶので、 unselected branch の identifier は lookup されない)。
|
|
22
|
+
*
|
|
23
|
+
* runtime error として:
|
|
24
|
+
* - undefined identifier (resolver / ctx が throw)
|
|
25
|
+
* - number 期待箇所に boolean が来た (or 逆)
|
|
26
|
+
* - division by zero (`/ 0` and `% 0`)
|
|
27
|
+
* - ternary condition が boolean でない
|
|
28
|
+
*/
|
|
29
|
+
export function evaluate(
|
|
30
|
+
ast: FormulaAst,
|
|
31
|
+
ctxOrResolver: FormulaContext | FormulaResolver,
|
|
32
|
+
): number | boolean {
|
|
33
|
+
const resolver: FormulaResolver =
|
|
34
|
+
typeof ctxOrResolver === "function"
|
|
35
|
+
? ctxOrResolver
|
|
36
|
+
: (name: string) => {
|
|
37
|
+
if (!Object.hasOwn(ctxOrResolver, name)) {
|
|
38
|
+
throw new Error(`undefined identifier '${name}' in formula (signal not registered?)`);
|
|
39
|
+
}
|
|
40
|
+
const v = ctxOrResolver[name];
|
|
41
|
+
if (typeof v !== "number" && typeof v !== "boolean") {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`identifier '${name}' must be number or boolean (got ${typeof v})`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return v;
|
|
47
|
+
};
|
|
48
|
+
return evaluateInternal(ast, resolver);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function evaluateInternal(ast: FormulaAst, resolver: FormulaResolver): number | boolean {
|
|
52
|
+
switch (ast.type) {
|
|
53
|
+
case "number":
|
|
54
|
+
return ast.value;
|
|
55
|
+
case "identifier":
|
|
56
|
+
return resolver(ast.name);
|
|
57
|
+
case "unaryOp": {
|
|
58
|
+
const operand = evaluateInternal(ast.operand, resolver);
|
|
59
|
+
if (typeof operand !== "number") {
|
|
60
|
+
throw new Error(`unary '-' requires number operand (got ${typeof operand})`);
|
|
61
|
+
}
|
|
62
|
+
return -operand;
|
|
63
|
+
}
|
|
64
|
+
case "binaryOp": {
|
|
65
|
+
const left = evaluateInternal(ast.left, resolver);
|
|
66
|
+
const right = evaluateInternal(ast.right, resolver);
|
|
67
|
+
switch (ast.op) {
|
|
68
|
+
case "+":
|
|
69
|
+
case "-":
|
|
70
|
+
case "*":
|
|
71
|
+
case "/":
|
|
72
|
+
case "%":
|
|
73
|
+
if (typeof left !== "number" || typeof right !== "number") {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`binary '${ast.op}' requires number operands (got ${typeof left} and ${typeof right})`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if ((ast.op === "/" || ast.op === "%") && right === 0) {
|
|
79
|
+
throw new Error(`division by zero in formula ('${ast.op}' with right = 0)`);
|
|
80
|
+
}
|
|
81
|
+
switch (ast.op) {
|
|
82
|
+
case "+":
|
|
83
|
+
return left + right;
|
|
84
|
+
case "-":
|
|
85
|
+
return left - right;
|
|
86
|
+
case "*":
|
|
87
|
+
return left * right;
|
|
88
|
+
case "/":
|
|
89
|
+
return left / right;
|
|
90
|
+
case "%":
|
|
91
|
+
return left % right;
|
|
92
|
+
}
|
|
93
|
+
// eslint-disable-next-line no-fallthrough
|
|
94
|
+
case "<":
|
|
95
|
+
case "<=":
|
|
96
|
+
case ">":
|
|
97
|
+
case ">=":
|
|
98
|
+
if (typeof left !== "number" || typeof right !== "number") {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`comparison '${ast.op}' requires number operands (got ${typeof left} and ${typeof right})`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
switch (ast.op) {
|
|
104
|
+
case "<":
|
|
105
|
+
return left < right;
|
|
106
|
+
case "<=":
|
|
107
|
+
return left <= right;
|
|
108
|
+
case ">":
|
|
109
|
+
return left > right;
|
|
110
|
+
case ">=":
|
|
111
|
+
return left >= right;
|
|
112
|
+
}
|
|
113
|
+
// eslint-disable-next-line no-fallthrough
|
|
114
|
+
case "==":
|
|
115
|
+
return left === right;
|
|
116
|
+
case "!=":
|
|
117
|
+
return left !== right;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// eslint-disable-next-line no-fallthrough
|
|
121
|
+
case "ternary": {
|
|
122
|
+
const condition = evaluateInternal(ast.condition, resolver);
|
|
123
|
+
if (typeof condition !== "boolean") {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`ternary condition must be boolean (got ${typeof condition})`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
// lazy: unselected branch は evaluate しない (undefined identifier / division by zero を skip 可能)
|
|
129
|
+
return condition
|
|
130
|
+
? evaluateInternal(ast.whenTrue, resolver)
|
|
131
|
+
: evaluateInternal(ast.whenFalse, resolver);
|
|
132
|
+
}
|
|
133
|
+
case "call": {
|
|
134
|
+
const args = ast.args.map((a) => evaluateInternal(a, resolver));
|
|
135
|
+
for (const a of args) {
|
|
136
|
+
if (typeof a !== "number") {
|
|
137
|
+
throw new Error(`${ast.fn} requires number arguments (got ${typeof a})`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const nums = args as number[];
|
|
141
|
+
switch (ast.fn) {
|
|
142
|
+
case "Math.min":
|
|
143
|
+
if (nums.length === 0) throw new Error("Math.min requires at least 1 argument");
|
|
144
|
+
return Math.min(...nums);
|
|
145
|
+
case "Math.max":
|
|
146
|
+
if (nums.length === 0) throw new Error("Math.max requires at least 1 argument");
|
|
147
|
+
return Math.max(...nums);
|
|
148
|
+
case "Math.abs":
|
|
149
|
+
if (nums.length !== 1) throw new Error("Math.abs requires exactly 1 argument");
|
|
150
|
+
return Math.abs(nums[0]!);
|
|
151
|
+
case "Math.floor":
|
|
152
|
+
if (nums.length !== 1) throw new Error("Math.floor requires exactly 1 argument");
|
|
153
|
+
return Math.floor(nums[0]!);
|
|
154
|
+
case "Math.ceil":
|
|
155
|
+
if (nums.length !== 1) throw new Error("Math.ceil requires exactly 1 argument");
|
|
156
|
+
return Math.ceil(nums[0]!);
|
|
157
|
+
case "Math.round":
|
|
158
|
+
if (nums.length !== 1) throw new Error("Math.round requires exactly 1 argument");
|
|
159
|
+
return Math.round(nums[0]!);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { computed, type Computed, type Signal } from "../reactive";
|
|
2
|
+
import type { CdlDiagram } from "../types";
|
|
3
|
+
import { extractIdentifiers, parseFormula } from "./parser";
|
|
4
|
+
import { evaluate } from "./evaluator";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* diagram の `formulas` から computed を生成、 formula.id をキーにした map を返す helper (CAR #244)。
|
|
8
|
+
*
|
|
9
|
+
* 各 formula は expression を parse し、 参照する identifier (signal 名) を signals map から lookup して
|
|
10
|
+
* computed を生成する。 computed.value を read すると:
|
|
11
|
+
* 1. formula 内 identifier に対応する signal.value を read (自動 dep 登録)
|
|
12
|
+
* 2. AST を evaluate して結果を返す
|
|
13
|
+
* signal 変化で computed が自動再計算される (Step 2 の reactive graph に統合)。
|
|
14
|
+
*
|
|
15
|
+
* `Object.create(null)` で prototype pollution safe (input-signals.ts と同 pattern)。
|
|
16
|
+
*/
|
|
17
|
+
export function createFormulaComputeds(
|
|
18
|
+
diagram: CdlDiagram,
|
|
19
|
+
signals: Record<string, Signal<unknown>>,
|
|
20
|
+
): Record<string, Computed<number | boolean>> {
|
|
21
|
+
const result = Object.create(null) as Record<string, Computed<number | boolean>>;
|
|
22
|
+
const formulas = diagram.formulas ?? [];
|
|
23
|
+
|
|
24
|
+
// signals map と formula id の cross-check。 collision があると (formulas x=2 / y=x*3 に対して
|
|
25
|
+
// 外部から signals.x = signal(10) を渡すと) y が formula "x" ではなく signal "x" を参照して
|
|
26
|
+
// 誤った値を silent に返す (codex Round 3 finding)。 builder は input 側の collision を防いでいるが、
|
|
27
|
+
// 手動 signal registration 経路には触れないため createFormulaComputeds 側でも checked する。
|
|
28
|
+
for (const formula of formulas) {
|
|
29
|
+
if (Object.hasOwn(signals, formula.id)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`signal / formula id collision: signal '${formula.id}' が formula id と衝突しています (formula '${formula.id}' の resolution が silent に上書きされます)`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const formula of formulas) {
|
|
37
|
+
let ast;
|
|
38
|
+
try {
|
|
39
|
+
ast = parseFormula(formula.expression);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
42
|
+
throw new Error(`formula '${formula.id}' parse error: ${msg}`);
|
|
43
|
+
}
|
|
44
|
+
const referenced = extractIdentifiers(ast);
|
|
45
|
+
// pre-check: 各 identifier は signal or 既生成の formula computed のいずれかに存在必須。
|
|
46
|
+
// formulas 配列の 宣言順で iterate するため、 後方 formula が前方 formula を参照するのは OK、
|
|
47
|
+
// 逆向き (前方 formula が後方 formula を参照) と self-reference は「未定義 identifier」 で throw。
|
|
48
|
+
for (const name of referenced) {
|
|
49
|
+
if (!Object.hasOwn(signals, name) && !Object.hasOwn(result, name)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`formula '${formula.id}' references undefined identifier '${name}' (must be a signal or a previously-declared formula)`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
result[formula.id] = computed(() => {
|
|
56
|
+
// lazy resolver: evaluator が identifier node に到達した時のみ signal / formula.value を read する。
|
|
57
|
+
// ternary の unselected branch や skip pass では該当 identifier に触れないため、
|
|
58
|
+
// 「safe = useRisk ? risky : 0」 で useRisk=false の時に risky の division by zero を回避できる (codex Round 4)。
|
|
59
|
+
return evaluate(ast, (name: string): number | boolean => {
|
|
60
|
+
let v: unknown;
|
|
61
|
+
let source: "signal" | "formula";
|
|
62
|
+
if (Object.hasOwn(signals, name)) {
|
|
63
|
+
v = signals[name]!.value;
|
|
64
|
+
source = "signal";
|
|
65
|
+
} else if (Object.hasOwn(result, name)) {
|
|
66
|
+
v = result[name]!.value;
|
|
67
|
+
source = "formula";
|
|
68
|
+
} else {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`formula '${formula.id}' identifier '${name}' disappeared at evaluation time`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (typeof v !== "number" && typeof v !== "boolean") {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`formula '${formula.id}' ${source} '${name}' must resolve to number or boolean at evaluation time (got ${typeof v})`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return v;
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { parseFormula, extractIdentifiers } from "./parser";
|
|
2
|
+
export { evaluate } from "./evaluator";
|
|
3
|
+
export type { FormulaContext } from "./evaluator";
|
|
4
|
+
export type { FormulaAst, BinaryOp, MathFnName } from "./ast";
|
|
5
|
+
export { createFormulaComputeds } from "./formula-computeds";
|