@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,48 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import { Timeline, type TimelineOptions } from "../core/timeline";
|
|
3
|
+
import type { PhaseDescriptor, TimelineState } from "../core/types";
|
|
4
|
+
|
|
5
|
+
export type UseTimelineReturn = {
|
|
6
|
+
timeline: Timeline;
|
|
7
|
+
state: TimelineState;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* React 19 + concurrent rendering safe な Timeline hook。
|
|
12
|
+
* useSyncExternalStore で外部ストアの状態を tearing なく購読する。
|
|
13
|
+
*
|
|
14
|
+
* 引数 phases が変わったら timeline を新しく作り直し、 古い方を destroy する。
|
|
15
|
+
* (options は ref 越しに参照、 再生成 trigger に含めない)
|
|
16
|
+
*/
|
|
17
|
+
export function useTimeline(
|
|
18
|
+
phases: PhaseDescriptor[],
|
|
19
|
+
options: TimelineOptions = {},
|
|
20
|
+
): UseTimelineReturn {
|
|
21
|
+
const optionsRef = useRef(options);
|
|
22
|
+
optionsRef.current = options;
|
|
23
|
+
|
|
24
|
+
const timeline = useMemo(
|
|
25
|
+
() => new Timeline(phases, optionsRef.current),
|
|
26
|
+
// intentionally re-create only when the phases identity changes
|
|
27
|
+
[phases],
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
useEffect(
|
|
31
|
+
() => () => {
|
|
32
|
+
timeline.destroy();
|
|
33
|
+
},
|
|
34
|
+
[timeline],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// subscribe と getSnapshot は stable な関数として渡す
|
|
38
|
+
// (毎 render 新規生成すると useSyncExternalStore が無限 re-subscribe する)
|
|
39
|
+
const subscribe = useCallback(
|
|
40
|
+
(callback: () => void) => timeline.subscribe(() => callback()),
|
|
41
|
+
[timeline],
|
|
42
|
+
);
|
|
43
|
+
const getSnapshot = useCallback(() => timeline.snapshot(), [timeline]);
|
|
44
|
+
|
|
45
|
+
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
46
|
+
|
|
47
|
+
return { timeline, state };
|
|
48
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discriminated union の exhaustive check helper (CAR #262)。
|
|
3
|
+
*
|
|
4
|
+
* switch case で全 union member を処理した後 `default: return assertNever(x)` と書くと、
|
|
5
|
+
* union に新 member が追加された時に compile error で検知できる (x: never が narrow できずに
|
|
6
|
+
* TypeScript 側で type error になる)。 runtime に到達した場合は Error を throw する
|
|
7
|
+
* (typing 通り不到達だが、 想定外 kind が type check bypass 経路で流れ込んだ時の fail-fast)。
|
|
8
|
+
*
|
|
9
|
+
* 適用対象は builder / factory / render 内で input.kind / edge.style / node.kind 等の
|
|
10
|
+
* discriminated union を分岐する経路。 新 kind 追加時に silent fallthrough を防ぐ。
|
|
11
|
+
*/
|
|
12
|
+
export function assertNever(value: never, context = "discriminated union"): never {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`[cdl assertNever] exhaustive check failed in ${context}: unexpected value ${JSON.stringify(value)}`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「目」 v5 = author intent verifier。
|
|
3
|
+
*
|
|
4
|
+
* cdl で人が宣言した意味的事実 (= 作者意図) が、 実画面 (SVG render) に
|
|
5
|
+
* 期待通り反映されているかを 6 軸で照合する。 LLM 不要、 課金 0、 規則ベース。
|
|
6
|
+
*
|
|
7
|
+
* これまでの dom-verify は engine の自己整合 (cdl 計算値 ↔ DOM attribute 同期) しか見ていなかった。
|
|
8
|
+
* v5 では「人が `.node("user", { title: "User" })` と書いた時、 画面に `User` という文字が
|
|
9
|
+
* 出ているか」 のような mermaid 的に当然期待される事実を確認する。
|
|
10
|
+
*
|
|
11
|
+
* 6 軸。
|
|
12
|
+
* 1. node-in-lane ... node が宣言した lane の領域内に居る
|
|
13
|
+
* 2. node-text-visible ... node.title / subtitle / eyebrow が文字として SVG に存在
|
|
14
|
+
* 3. edge-label-visible ... edge.label / sub が文字として SVG に存在
|
|
15
|
+
* 4. edge-connected ... edge path 起点 / 終点が from / to node の bbox の近く
|
|
16
|
+
* 5. activation-visible ... phase.activate() で宣言した要素が画面で「強調」 されている
|
|
17
|
+
* 6. tone-color ... edge.tone の指定色が SVG stroke / fill に反映
|
|
18
|
+
*/
|
|
19
|
+
import type { CdlDiagram, LaidDiagram, Tone } from "./types";
|
|
20
|
+
import { TONE_HEX } from "./render/tone";
|
|
21
|
+
import { layout } from "./layout";
|
|
22
|
+
import type { PageLike } from "./dom-verify-types";
|
|
23
|
+
|
|
24
|
+
export type IntentDiscrepancyKind =
|
|
25
|
+
| "node-outside-lane"
|
|
26
|
+
| "node-title-missing"
|
|
27
|
+
| "node-subtitle-missing"
|
|
28
|
+
| "node-eyebrow-missing"
|
|
29
|
+
| "edge-label-missing"
|
|
30
|
+
| "edge-sub-missing"
|
|
31
|
+
| "edge-disconnected-from-source"
|
|
32
|
+
| "edge-disconnected-from-target"
|
|
33
|
+
| "activation-not-visible"
|
|
34
|
+
| "tone-color-mismatch";
|
|
35
|
+
|
|
36
|
+
export interface IntentDiscrepancy {
|
|
37
|
+
kind: IntentDiscrepancyKind;
|
|
38
|
+
diagramId: string;
|
|
39
|
+
elementId: string;
|
|
40
|
+
expected: unknown;
|
|
41
|
+
actual: unknown;
|
|
42
|
+
detail: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AuthorIntentOptions {
|
|
46
|
+
/** edge 起点 / 終点と from / to node bbox の許容距離 (px、 default 80) */
|
|
47
|
+
edgeConnectionTolerance?: number;
|
|
48
|
+
/**
|
|
49
|
+
* lane bbox からの node はみ出し許容 (px、 default 500)。
|
|
50
|
+
* lane height 計算が node h 全部を含まないケースが engine layer に存在し、
|
|
51
|
+
* 視覚的には許容範囲 (同じ diagram 内に収まる) なので default は緩く設定。
|
|
52
|
+
* engine bug hunting は --strict (= 24px) で。
|
|
53
|
+
*/
|
|
54
|
+
laneContainmentTolerance?: number;
|
|
55
|
+
/** tone 色判定の RGB 距離許容 (default 30) */
|
|
56
|
+
toneColorTolerance?: number;
|
|
57
|
+
/** activation 視覚効果判定の opacity / scale 差最小値 (default 0.1) */
|
|
58
|
+
activationVisualDelta?: number;
|
|
59
|
+
/** 軸を skip する flag (個別 disable 用) */
|
|
60
|
+
skipNodeInLane?: boolean;
|
|
61
|
+
skipNodeText?: boolean;
|
|
62
|
+
skipEdgeLabel?: boolean;
|
|
63
|
+
skipEdgeConnected?: boolean;
|
|
64
|
+
skipActivation?: boolean;
|
|
65
|
+
skipToneColor?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 検証に使う色は描画と同じ表を引く。 直書きすると、 描画側を直した時に検証だけが古い値で
|
|
69
|
+
// 突き合わせて「合っているのに違う」 と言い出す。
|
|
70
|
+
const TONE_COLOR_MAP = TONE_HEX;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 1 つの diagram について 6 軸検証を全部走らせる。
|
|
74
|
+
*/
|
|
75
|
+
export async function verifyAuthorIntent(
|
|
76
|
+
page: PageLike,
|
|
77
|
+
diagram: CdlDiagram,
|
|
78
|
+
options: AuthorIntentOptions = {},
|
|
79
|
+
): Promise<IntentDiscrepancy[]> {
|
|
80
|
+
const laid = layout(diagram);
|
|
81
|
+
const out: IntentDiscrepancy[] = [];
|
|
82
|
+
|
|
83
|
+
if (!options.skipNodeInLane) out.push(...(await verifyNodeInLane(page, laid, options)));
|
|
84
|
+
if (!options.skipNodeText) out.push(...(await verifyNodeText(page, laid, options)));
|
|
85
|
+
if (!options.skipEdgeLabel) out.push(...(await verifyEdgeLabel(page, laid, options)));
|
|
86
|
+
if (!options.skipEdgeConnected) out.push(...(await verifyEdgeConnected(page, laid, options)));
|
|
87
|
+
if (!options.skipActivation) out.push(...(await verifyActivationVisible(page, diagram, options)));
|
|
88
|
+
if (!options.skipToneColor) out.push(...(await verifyToneColor(page, laid, options)));
|
|
89
|
+
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 1. node が宣言した lane の領域内に居る */
|
|
94
|
+
async function verifyNodeInLane(
|
|
95
|
+
page: PageLike,
|
|
96
|
+
laid: LaidDiagram,
|
|
97
|
+
options: AuthorIntentOptions,
|
|
98
|
+
): Promise<IntentDiscrepancy[]> {
|
|
99
|
+
const out: IntentDiscrepancy[] = [];
|
|
100
|
+
const tol = options.laneContainmentTolerance ?? 500;
|
|
101
|
+
const diagramId = laid.id;
|
|
102
|
+
// lane.contain=true な lane のみ検証対象 (contain=false は boundary 描画なしで
|
|
103
|
+
// lane height = label 高さのみ、 node がはみ出るのは intentional design)
|
|
104
|
+
const containLaneIds = new Set(laid.lanes.filter((l) => l.contain).map((l) => l.id));
|
|
105
|
+
|
|
106
|
+
type Result = {
|
|
107
|
+
ok: true;
|
|
108
|
+
pairs: Array<{
|
|
109
|
+
nodeId: string;
|
|
110
|
+
laneId: string;
|
|
111
|
+
nodeBox: { l: number; r: number; t: number; b: number };
|
|
112
|
+
laneBox: { l: number; r: number; t: number; b: number } | null;
|
|
113
|
+
}>;
|
|
114
|
+
} | { ok: false; reason: string };
|
|
115
|
+
|
|
116
|
+
const res = (await page.evaluate(
|
|
117
|
+
({ diagId }) => {
|
|
118
|
+
const root = document.querySelector(`[data-cdl-diagram="${diagId}"]`);
|
|
119
|
+
if (!root) return { ok: false as const, reason: "root not found" };
|
|
120
|
+
const nodes = Array.from(root.querySelectorAll("[data-cdl-node]"));
|
|
121
|
+
const lanes = Array.from(root.querySelectorAll("[data-cdl-lane]"));
|
|
122
|
+
const laneMap = new Map<string, { l: number; r: number; t: number; b: number }>();
|
|
123
|
+
for (const lane of lanes) {
|
|
124
|
+
const id = lane.getAttribute("data-cdl-lane") ?? "";
|
|
125
|
+
const r = (lane as Element).getBoundingClientRect();
|
|
126
|
+
laneMap.set(id, { l: r.left, r: r.right, t: r.top, b: r.bottom });
|
|
127
|
+
}
|
|
128
|
+
const pairs = nodes.map((n) => {
|
|
129
|
+
const nodeId = n.getAttribute("data-cdl-node") ?? "";
|
|
130
|
+
const laneId = n.getAttribute("data-cdl-lane") ?? "";
|
|
131
|
+
const r = (n as Element).getBoundingClientRect();
|
|
132
|
+
return {
|
|
133
|
+
nodeId,
|
|
134
|
+
laneId,
|
|
135
|
+
nodeBox: { l: r.left, r: r.right, t: r.top, b: r.bottom },
|
|
136
|
+
laneBox: laneMap.get(laneId) ?? null,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
return { ok: true as const, pairs };
|
|
140
|
+
},
|
|
141
|
+
{ diagId: diagramId },
|
|
142
|
+
)) as Result;
|
|
143
|
+
|
|
144
|
+
if (!res.ok) return out;
|
|
145
|
+
for (const p of res.pairs) {
|
|
146
|
+
if (!p.laneBox) continue;
|
|
147
|
+
// lane.contain=true な lane のみ検証 (contain=false は boundary なしで意図的に node はみ出る)
|
|
148
|
+
if (!containLaneIds.has(p.laneId)) continue;
|
|
149
|
+
// node が lane bbox 内 (tolerance 込み) か
|
|
150
|
+
const outsideX = p.nodeBox.l < p.laneBox.l - tol || p.nodeBox.r > p.laneBox.r + tol;
|
|
151
|
+
const outsideY = p.nodeBox.t < p.laneBox.t - tol || p.nodeBox.b > p.laneBox.b + tol;
|
|
152
|
+
if (outsideX || outsideY) {
|
|
153
|
+
out.push({
|
|
154
|
+
kind: "node-outside-lane",
|
|
155
|
+
diagramId,
|
|
156
|
+
elementId: p.nodeId,
|
|
157
|
+
expected: `lane "${p.laneId}" 内 (contain=true)`,
|
|
158
|
+
actual: { nodeBox: p.nodeBox, laneBox: p.laneBox },
|
|
159
|
+
detail: `node "${p.nodeId}" が lane "${p.laneId}" (contain=true) の bbox 外 (tolerance ${tol}px)`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 2. node.title / subtitle / eyebrow が文字として SVG に存在 */
|
|
167
|
+
async function verifyNodeText(
|
|
168
|
+
page: PageLike,
|
|
169
|
+
laid: LaidDiagram,
|
|
170
|
+
_options: AuthorIntentOptions,
|
|
171
|
+
): Promise<IntentDiscrepancy[]> {
|
|
172
|
+
const out: IntentDiscrepancy[] = [];
|
|
173
|
+
const diagramId = laid.id;
|
|
174
|
+
|
|
175
|
+
type Result = {
|
|
176
|
+
ok: true;
|
|
177
|
+
nodes: Array<{
|
|
178
|
+
id: string;
|
|
179
|
+
title: string;
|
|
180
|
+
subtitle: string;
|
|
181
|
+
eyebrow: string;
|
|
182
|
+
visibleText: string;
|
|
183
|
+
}>;
|
|
184
|
+
} | { ok: false; reason: string };
|
|
185
|
+
|
|
186
|
+
const res = (await page.evaluate(
|
|
187
|
+
({ diagId }) => {
|
|
188
|
+
const root = document.querySelector(`[data-cdl-diagram="${diagId}"]`);
|
|
189
|
+
if (!root) return { ok: false as const, reason: "root not found" };
|
|
190
|
+
const nodes = Array.from(root.querySelectorAll("[data-cdl-node]"));
|
|
191
|
+
return {
|
|
192
|
+
ok: true as const,
|
|
193
|
+
nodes: nodes.map((n) => ({
|
|
194
|
+
id: n.getAttribute("data-cdl-node") ?? "",
|
|
195
|
+
title: n.getAttribute("data-cdl-title") ?? "",
|
|
196
|
+
subtitle: n.getAttribute("data-cdl-subtitle") ?? "",
|
|
197
|
+
eyebrow: n.getAttribute("data-cdl-eyebrow") ?? "",
|
|
198
|
+
// SVG 内の <text> 要素の textContent を全部結合
|
|
199
|
+
visibleText: Array.from(n.querySelectorAll("text"))
|
|
200
|
+
.map((t) => (t as Element).textContent ?? "")
|
|
201
|
+
.join(" "),
|
|
202
|
+
})),
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
{ diagId: diagramId },
|
|
206
|
+
)) as Result;
|
|
207
|
+
|
|
208
|
+
if (!res.ok) return out;
|
|
209
|
+
for (const n of res.nodes) {
|
|
210
|
+
if (n.title && !n.visibleText.includes(n.title)) {
|
|
211
|
+
out.push({
|
|
212
|
+
kind: "node-title-missing",
|
|
213
|
+
diagramId,
|
|
214
|
+
elementId: n.id,
|
|
215
|
+
expected: `title "${n.title}" visible`,
|
|
216
|
+
actual: { visibleText: n.visibleText },
|
|
217
|
+
detail: `node "${n.id}" の title "${n.title}" が SVG <text> に visible でない`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (n.subtitle && !n.visibleText.includes(n.subtitle)) {
|
|
221
|
+
out.push({
|
|
222
|
+
kind: "node-subtitle-missing",
|
|
223
|
+
diagramId,
|
|
224
|
+
elementId: n.id,
|
|
225
|
+
expected: `subtitle "${n.subtitle}" visible`,
|
|
226
|
+
actual: { visibleText: n.visibleText },
|
|
227
|
+
detail: `node "${n.id}" の subtitle "${n.subtitle}" が SVG <text> に visible でない`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (n.eyebrow && !n.visibleText.includes(n.eyebrow)) {
|
|
231
|
+
out.push({
|
|
232
|
+
kind: "node-eyebrow-missing",
|
|
233
|
+
diagramId,
|
|
234
|
+
elementId: n.id,
|
|
235
|
+
expected: `eyebrow "${n.eyebrow}" visible`,
|
|
236
|
+
actual: { visibleText: n.visibleText },
|
|
237
|
+
detail: `node "${n.id}" の eyebrow "${n.eyebrow}" が SVG <text> に visible でない`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 3. edge.label / sub が文字として SVG に存在 */
|
|
245
|
+
async function verifyEdgeLabel(
|
|
246
|
+
page: PageLike,
|
|
247
|
+
laid: LaidDiagram,
|
|
248
|
+
_options: AuthorIntentOptions,
|
|
249
|
+
): Promise<IntentDiscrepancy[]> {
|
|
250
|
+
const out: IntentDiscrepancy[] = [];
|
|
251
|
+
const diagramId = laid.id;
|
|
252
|
+
|
|
253
|
+
type Result = {
|
|
254
|
+
ok: true;
|
|
255
|
+
edges: Array<{ id: string; label: string; sub: string; visibleText: string }>;
|
|
256
|
+
} | { ok: false; reason: string };
|
|
257
|
+
|
|
258
|
+
const res = (await page.evaluate(
|
|
259
|
+
({ diagId }) => {
|
|
260
|
+
const root = document.querySelector(`[data-cdl-diagram="${diagId}"]`);
|
|
261
|
+
if (!root) return { ok: false as const, reason: "root not found" };
|
|
262
|
+
const edges = Array.from(root.querySelectorAll("[data-cdl-edge]"));
|
|
263
|
+
// edge label は別 <g data-cdl-edge-label-for="..."> として diagram root 直下に render される。
|
|
264
|
+
// edge wrapper の中ではなく root から query する。
|
|
265
|
+
const labelGroups = Array.from(root.querySelectorAll("[data-cdl-edge-label-for]"));
|
|
266
|
+
const labelMap = new Map<string, string>();
|
|
267
|
+
for (const lg of labelGroups) {
|
|
268
|
+
const eid = lg.getAttribute("data-cdl-edge-label-for") ?? "";
|
|
269
|
+
const t = Array.from(lg.querySelectorAll("text"))
|
|
270
|
+
.map((tx) => (tx as Element).textContent ?? "")
|
|
271
|
+
.join(" ");
|
|
272
|
+
labelMap.set(eid, t);
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
ok: true as const,
|
|
276
|
+
edges: edges.map((e) => ({
|
|
277
|
+
id: e.getAttribute("data-cdl-edge") ?? "",
|
|
278
|
+
label: e.getAttribute("data-cdl-edge-label") ?? "",
|
|
279
|
+
sub: e.getAttribute("data-cdl-edge-sub") ?? "",
|
|
280
|
+
visibleText: labelMap.get(e.getAttribute("data-cdl-edge") ?? "") ?? "",
|
|
281
|
+
})),
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
{ diagId: diagramId },
|
|
285
|
+
)) as Result;
|
|
286
|
+
|
|
287
|
+
if (!res.ok) return out;
|
|
288
|
+
for (const e of res.edges) {
|
|
289
|
+
if (e.label && !e.visibleText.includes(e.label)) {
|
|
290
|
+
out.push({
|
|
291
|
+
kind: "edge-label-missing",
|
|
292
|
+
diagramId,
|
|
293
|
+
elementId: e.id,
|
|
294
|
+
expected: `label "${e.label}" visible`,
|
|
295
|
+
actual: { visibleText: e.visibleText },
|
|
296
|
+
detail: `edge "${e.id}" の label "${e.label}" が SVG <text> に visible でない`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (e.sub && !e.visibleText.includes(e.sub)) {
|
|
300
|
+
out.push({
|
|
301
|
+
kind: "edge-sub-missing",
|
|
302
|
+
diagramId,
|
|
303
|
+
elementId: e.id,
|
|
304
|
+
expected: `sub "${e.sub}" visible`,
|
|
305
|
+
actual: { visibleText: e.visibleText },
|
|
306
|
+
detail: `edge "${e.id}" の sub "${e.sub}" が SVG <text> に visible でない`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 4. edge path 起点 / 終点が from / to node の bbox の近く */
|
|
314
|
+
async function verifyEdgeConnected(
|
|
315
|
+
page: PageLike,
|
|
316
|
+
laid: LaidDiagram,
|
|
317
|
+
options: AuthorIntentOptions,
|
|
318
|
+
): Promise<IntentDiscrepancy[]> {
|
|
319
|
+
const out: IntentDiscrepancy[] = [];
|
|
320
|
+
const tol = options.edgeConnectionTolerance ?? 80;
|
|
321
|
+
const diagramId = laid.id;
|
|
322
|
+
|
|
323
|
+
type Result = {
|
|
324
|
+
ok: true;
|
|
325
|
+
edges: Array<{
|
|
326
|
+
id: string;
|
|
327
|
+
from: string;
|
|
328
|
+
to: string;
|
|
329
|
+
startView: { x: number; y: number } | null;
|
|
330
|
+
endView: { x: number; y: number } | null;
|
|
331
|
+
fromCenter: { x: number; y: number } | null;
|
|
332
|
+
toCenter: { x: number; y: number } | null;
|
|
333
|
+
}>;
|
|
334
|
+
} | { ok: false; reason: string };
|
|
335
|
+
|
|
336
|
+
const res = (await page.evaluate(
|
|
337
|
+
({ diagId }) => {
|
|
338
|
+
const root = document.querySelector(`[data-cdl-diagram="${diagId}"]`);
|
|
339
|
+
if (!root) return { ok: false as const, reason: "root not found" };
|
|
340
|
+
const edges = Array.from(root.querySelectorAll("[data-cdl-edge]"));
|
|
341
|
+
const out: Array<{
|
|
342
|
+
id: string;
|
|
343
|
+
from: string;
|
|
344
|
+
to: string;
|
|
345
|
+
startView: { x: number; y: number } | null;
|
|
346
|
+
endView: { x: number; y: number } | null;
|
|
347
|
+
fromCenter: { x: number; y: number } | null;
|
|
348
|
+
toCenter: { x: number; y: number } | null;
|
|
349
|
+
}> = [];
|
|
350
|
+
for (const e of edges) {
|
|
351
|
+
const id = e.getAttribute("data-cdl-edge") ?? "";
|
|
352
|
+
const from = e.getAttribute("data-cdl-from") ?? "";
|
|
353
|
+
const to = e.getAttribute("data-cdl-to") ?? "";
|
|
354
|
+
const pathEl = root.querySelector(`#cdl-edge-${id}`) as SVGPathElement | null;
|
|
355
|
+
if (!pathEl) {
|
|
356
|
+
out.push({ id, from, to, startView: null, endView: null, fromCenter: null, toCenter: null });
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const len = pathEl.getTotalLength();
|
|
360
|
+
const ctm = pathEl.getScreenCTM();
|
|
361
|
+
let startView: { x: number; y: number } | null = null;
|
|
362
|
+
let endView: { x: number; y: number } | null = null;
|
|
363
|
+
if (ctm && isFinite(len)) {
|
|
364
|
+
const sp = pathEl.getPointAtLength(0);
|
|
365
|
+
const ep = pathEl.getPointAtLength(len);
|
|
366
|
+
startView = { x: sp.x * ctm.a + sp.y * ctm.c + ctm.e, y: sp.x * ctm.b + sp.y * ctm.d + ctm.f };
|
|
367
|
+
endView = { x: ep.x * ctm.a + ep.y * ctm.c + ctm.e, y: ep.x * ctm.b + ep.y * ctm.d + ctm.f };
|
|
368
|
+
}
|
|
369
|
+
const fromEl = root.querySelector(`[data-cdl-node="${from}"]`);
|
|
370
|
+
const toEl = root.querySelector(`[data-cdl-node="${to}"]`);
|
|
371
|
+
const fromR = fromEl ? (fromEl as Element).getBoundingClientRect() : null;
|
|
372
|
+
const toR = toEl ? (toEl as Element).getBoundingClientRect() : null;
|
|
373
|
+
const fromCenter = fromR ? { x: fromR.left + fromR.width / 2, y: fromR.top + fromR.height / 2 } : null;
|
|
374
|
+
const toCenter = toR ? { x: toR.left + toR.width / 2, y: toR.top + toR.height / 2 } : null;
|
|
375
|
+
out.push({ id, from, to, startView, endView, fromCenter, toCenter });
|
|
376
|
+
}
|
|
377
|
+
return { ok: true as const, edges: out };
|
|
378
|
+
},
|
|
379
|
+
{ diagId: diagramId },
|
|
380
|
+
)) as Result;
|
|
381
|
+
|
|
382
|
+
if (!res.ok) return out;
|
|
383
|
+
for (const e of res.edges) {
|
|
384
|
+
if (e.startView && e.fromCenter) {
|
|
385
|
+
const d = Math.hypot(e.startView.x - e.fromCenter.x, e.startView.y - e.fromCenter.y);
|
|
386
|
+
// node の半径相当 (= bbox 対角線の半分程度 = 200px くらい) 以下なら OK。
|
|
387
|
+
// 実際は from node の bbox 端まで来てる事を期待するため、 起点と center の距離は
|
|
388
|
+
// node 半径 + tol くらい以下なら宣言通りに繋がっていると判定。
|
|
389
|
+
// bbox から bbox の中心の距離 = 概ね 80-200px なので、 tol 80 + node 半径相当 200 = 280px くらい
|
|
390
|
+
const nodeRadius = 200; // 大きめの node でも 200px 半径
|
|
391
|
+
if (d > nodeRadius + tol) {
|
|
392
|
+
out.push({
|
|
393
|
+
kind: "edge-disconnected-from-source",
|
|
394
|
+
diagramId,
|
|
395
|
+
elementId: e.id,
|
|
396
|
+
expected: `from node "${e.from}" center まで ${nodeRadius + tol}px 以内`,
|
|
397
|
+
actual: { dist: d.toFixed(1) },
|
|
398
|
+
detail: `edge "${e.id}" の path 起点が from node "${e.from}" から ${d.toFixed(1)}px 離れている`,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (e.endView && e.toCenter) {
|
|
403
|
+
const d = Math.hypot(e.endView.x - e.toCenter.x, e.endView.y - e.toCenter.y);
|
|
404
|
+
const nodeRadius = 200;
|
|
405
|
+
if (d > nodeRadius + tol) {
|
|
406
|
+
out.push({
|
|
407
|
+
kind: "edge-disconnected-from-target",
|
|
408
|
+
diagramId,
|
|
409
|
+
elementId: e.id,
|
|
410
|
+
expected: `to node "${e.to}" center まで ${nodeRadius + tol}px 以内`,
|
|
411
|
+
actual: { dist: d.toFixed(1) },
|
|
412
|
+
detail: `edge "${e.id}" の path 終点が to node "${e.to}" から ${d.toFixed(1)}px 離れている`,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 5. phase.activate() で宣言した要素が画面で「強調」 されている (data-cdl-active=true) */
|
|
421
|
+
async function verifyActivationVisible(
|
|
422
|
+
page: PageLike,
|
|
423
|
+
diagram: CdlDiagram,
|
|
424
|
+
_options: AuthorIntentOptions,
|
|
425
|
+
): Promise<IntentDiscrepancy[]> {
|
|
426
|
+
const out: IntentDiscrepancy[] = [];
|
|
427
|
+
const diagramId = diagram.id;
|
|
428
|
+
if (diagram.phases.length === 0) return out;
|
|
429
|
+
|
|
430
|
+
type Result = {
|
|
431
|
+
ok: true;
|
|
432
|
+
phaseIdx: number;
|
|
433
|
+
activeIds: string[];
|
|
434
|
+
} | { ok: false; reason: string };
|
|
435
|
+
|
|
436
|
+
const res = (await page.evaluate(
|
|
437
|
+
({ diagId }) => {
|
|
438
|
+
const root = document.querySelector(`[data-cdl-diagram="${diagId}"]`);
|
|
439
|
+
if (!root) return { ok: false as const, reason: "root not found" };
|
|
440
|
+
const idxAttr = root.getAttribute("data-cdl-phase-index");
|
|
441
|
+
const phaseIdx = idxAttr === null ? -1 : parseInt(idxAttr, 10);
|
|
442
|
+
const activeNodes = Array.from(root.querySelectorAll('[data-cdl-node][data-cdl-active="true"]'))
|
|
443
|
+
.map((el) => el.getAttribute("data-cdl-node") ?? "");
|
|
444
|
+
const activeEdges = Array.from(root.querySelectorAll('[data-cdl-edge][data-cdl-active="true"]'))
|
|
445
|
+
.map((el) => el.getAttribute("data-cdl-edge") ?? "");
|
|
446
|
+
return { ok: true as const, phaseIdx, activeIds: [...activeNodes, ...activeEdges] };
|
|
447
|
+
},
|
|
448
|
+
{ diagId: diagramId },
|
|
449
|
+
)) as Result;
|
|
450
|
+
|
|
451
|
+
if (!res.ok || res.phaseIdx < 0 || res.phaseIdx >= diagram.phases.length) return out;
|
|
452
|
+
const phase = diagram.phases[res.phaseIdx]!;
|
|
453
|
+
const actualSet = new Set(res.activeIds);
|
|
454
|
+
for (const id of phase.activate) {
|
|
455
|
+
if (!actualSet.has(id)) {
|
|
456
|
+
out.push({
|
|
457
|
+
kind: "activation-not-visible",
|
|
458
|
+
diagramId,
|
|
459
|
+
elementId: id,
|
|
460
|
+
expected: `phase "${phase.id}" で active 強調表示`,
|
|
461
|
+
actual: "inactive",
|
|
462
|
+
detail: `phase "${phase.id}" で .activate("${id}") 宣言されているが、 画面上で active 強調 (data-cdl-active=true) されていない`,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** 6. edge.tone の指定色が SVG stroke に反映 */
|
|
470
|
+
async function verifyToneColor(
|
|
471
|
+
page: PageLike,
|
|
472
|
+
laid: LaidDiagram,
|
|
473
|
+
options: AuthorIntentOptions,
|
|
474
|
+
): Promise<IntentDiscrepancy[]> {
|
|
475
|
+
const out: IntentDiscrepancy[] = [];
|
|
476
|
+
const tol = options.toneColorTolerance ?? 30;
|
|
477
|
+
const diagramId = laid.id;
|
|
478
|
+
|
|
479
|
+
type Result = {
|
|
480
|
+
ok: true;
|
|
481
|
+
edges: Array<{ id: string; tone: string; active: boolean; strokeColor: string }>;
|
|
482
|
+
} | { ok: false; reason: string };
|
|
483
|
+
|
|
484
|
+
const res = (await page.evaluate(
|
|
485
|
+
({ diagId }) => {
|
|
486
|
+
const root = document.querySelector(`[data-cdl-diagram="${diagId}"]`);
|
|
487
|
+
if (!root) return { ok: false as const, reason: "root not found" };
|
|
488
|
+
const edges = Array.from(root.querySelectorAll("[data-cdl-edge]"));
|
|
489
|
+
const out: Array<{ id: string; tone: string; active: boolean; strokeColor: string }> = [];
|
|
490
|
+
for (const e of edges) {
|
|
491
|
+
const id = e.getAttribute("data-cdl-edge") ?? "";
|
|
492
|
+
const tone = e.getAttribute("data-cdl-tone") ?? "";
|
|
493
|
+
const active = e.getAttribute("data-cdl-active") === "true";
|
|
494
|
+
// edge 内の <path> で stroke 属性 / style から色を取得 (visible な太い path = lineDotsD or solid)
|
|
495
|
+
const paths = Array.from(e.querySelectorAll("path"));
|
|
496
|
+
let strokeColor = "";
|
|
497
|
+
for (const p of paths) {
|
|
498
|
+
const s = (p as SVGElement).getAttribute("stroke");
|
|
499
|
+
if (s && s !== "none" && s !== "transparent") {
|
|
500
|
+
strokeColor = s;
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
const cs = window.getComputedStyle(p as Element).stroke;
|
|
504
|
+
if (cs && cs !== "none") {
|
|
505
|
+
strokeColor = cs;
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
out.push({ id, tone, active, strokeColor });
|
|
510
|
+
}
|
|
511
|
+
return { ok: true as const, edges: out };
|
|
512
|
+
},
|
|
513
|
+
{ diagId: diagramId },
|
|
514
|
+
)) as Result;
|
|
515
|
+
|
|
516
|
+
if (!res.ok) return out;
|
|
517
|
+
for (const e of res.edges) {
|
|
518
|
+
if (!e.active) continue; // inactive edge は muted 色なので tone 判定対象外
|
|
519
|
+
const expected = TONE_COLOR_MAP[e.tone as Tone];
|
|
520
|
+
if (!expected) continue;
|
|
521
|
+
const actualRgb = parseColorToRgb(e.strokeColor);
|
|
522
|
+
const expectedRgb = parseColorToRgb(expected);
|
|
523
|
+
if (!actualRgb || !expectedRgb) continue;
|
|
524
|
+
const dist = rgbDistance(actualRgb, expectedRgb);
|
|
525
|
+
if (dist > tol) {
|
|
526
|
+
out.push({
|
|
527
|
+
kind: "tone-color-mismatch",
|
|
528
|
+
diagramId,
|
|
529
|
+
elementId: e.id,
|
|
530
|
+
expected: `tone="${e.tone}" → ${expected}`,
|
|
531
|
+
actual: { strokeColor: e.strokeColor, rgbDist: dist.toFixed(1) },
|
|
532
|
+
detail: `edge "${e.id}" の active stroke 色が tone="${e.tone}" の期待値 ${expected} と RGB 距離 ${dist.toFixed(1)} > tolerance ${tol}`,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function parseColorToRgb(c: string): [number, number, number] | null {
|
|
540
|
+
if (!c) return null;
|
|
541
|
+
// #rgb / #rrggbb / rgb(r, g, b) / rgba(...) を parse
|
|
542
|
+
if (c.startsWith("#")) {
|
|
543
|
+
const hex = c.slice(1);
|
|
544
|
+
if (hex.length === 3) {
|
|
545
|
+
const r = parseInt(hex[0]! + hex[0]!, 16);
|
|
546
|
+
const g = parseInt(hex[1]! + hex[1]!, 16);
|
|
547
|
+
const b = parseInt(hex[2]! + hex[2]!, 16);
|
|
548
|
+
return [r, g, b];
|
|
549
|
+
}
|
|
550
|
+
if (hex.length === 6) {
|
|
551
|
+
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
|
|
552
|
+
}
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
556
|
+
if (m) return [parseInt(m[1]!, 10), parseInt(m[2]!, 10), parseInt(m[3]!, 10)];
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function rgbDistance(a: [number, number, number], b: [number, number, number]): number {
|
|
561
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* 複数 diagram を一括 verify。
|
|
566
|
+
*/
|
|
567
|
+
export interface AuthorIntentReport {
|
|
568
|
+
total: number;
|
|
569
|
+
pass: number;
|
|
570
|
+
fail: number;
|
|
571
|
+
discrepancies: IntentDiscrepancy[];
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export async function verifyAuthorIntentAll(
|
|
575
|
+
page: PageLike,
|
|
576
|
+
diagrams: CdlDiagram[],
|
|
577
|
+
options?: AuthorIntentOptions,
|
|
578
|
+
): Promise<AuthorIntentReport> {
|
|
579
|
+
const all: IntentDiscrepancy[] = [];
|
|
580
|
+
let fail = 0;
|
|
581
|
+
for (const d of diagrams) {
|
|
582
|
+
const ds = await verifyAuthorIntent(page, d, options);
|
|
583
|
+
if (ds.length > 0) fail++;
|
|
584
|
+
all.push(...ds);
|
|
585
|
+
}
|
|
586
|
+
return { total: diagrams.length, pass: diagrams.length - fail, fail, discrepancies: all };
|
|
587
|
+
}
|