@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,948 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cdl 定義 ↔ 実画面整合性 verifier (「目」 v3 の core)。
|
|
3
|
+
*
|
|
4
|
+
* cdl で宣言した node / edge / phase / particle が、 実際の SVG render と一致しているかを
|
|
5
|
+
* DOM scrape + 数値判定で自動検証する。 LLM 不要、 課金 0。
|
|
6
|
+
*
|
|
7
|
+
* 検証 5 種類。
|
|
8
|
+
* 1. node 位置 ... data-cdl-node="{id}" 要素の boundingBox cx/cy が layout() 計算値と一致
|
|
9
|
+
* 2. edge path 起点終点 ... <path id="cdl-edge-{id}"> の d 属性の起点 / 終点が from / to node の側面と一致
|
|
10
|
+
* 3. particle 位置 ... animation N frame 撮影で particle SVG の中心が path 上の点と < tolerance
|
|
11
|
+
* 4. activation ... phase 切替時に data-cdl-active="true" が phase.activate() で宣言した element のみ
|
|
12
|
+
* 5. phase 切替 ... phase index 進行で active class set が正確に切替わる
|
|
13
|
+
*/
|
|
14
|
+
import type { CdlDiagram, LaidDiagram } from "./types";
|
|
15
|
+
import { layout } from "./layout";
|
|
16
|
+
import type { PageLike } from "./dom-verify-types";
|
|
17
|
+
import type { DiagramAdapter, GenericDiagramSpec } from "./dom-verify-core";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 図を描く `<svg>` を取る selector (cdl#336)。
|
|
21
|
+
*
|
|
22
|
+
* `[data-cdl-diagram]` の中には `<svg>` が複数ある。 interactive panel が widget ごとに 1 つ
|
|
23
|
+
* ずつ持ち (readout の ring / gauge / sparkline 等)、 **DOM 順では panel が stage より先**
|
|
24
|
+
* (`render.tsx` = panel → `CdlStage`)。 `querySelector("svg")` は document 順の最初を返すので、
|
|
25
|
+
* interactive な図では widget の `<svg>` を掴む。 その `viewBox` (`0 0 64 64` 等) で node 座標を
|
|
26
|
+
* 換算するため、 照合結果は意味を持たない数字になる。
|
|
27
|
+
*
|
|
28
|
+
* 属性で名指しする。 interactive でない図では `<svg>` が 1 つしかないため、 従来と同じものを
|
|
29
|
+
* 取る (挙動は変わらない)。
|
|
30
|
+
*
|
|
31
|
+
* `data-cdl-role` を使わないのは、 あれが consumer の CSS 契約 (`CdlRole`、 `types.ts`) だから。
|
|
32
|
+
* stage は装飾の対象になる部品ではなく図全体の入れ物で、 role に混ぜると
|
|
33
|
+
* `[data-cdl-role]` を広く拾う selector が外側の `<svg>` にも当たる。
|
|
34
|
+
*/
|
|
35
|
+
export const STAGE_SVG_SELECTOR = "svg[data-cdl-stage]";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* node の実寸の枠を取る selector (#390)。
|
|
39
|
+
*
|
|
40
|
+
* `data-cdl-w/h` は layout の静的値で、 `wBind` / `hBind` の縮小や `renderOffsetX/Y` の移動を
|
|
41
|
+
* 含まない。 行が枠に収まっているかを実 DOM で測る側はこの要素を見る。
|
|
42
|
+
*
|
|
43
|
+
* `STAGE_SVG_SELECTOR` と同じ理由で `data-cdl-role` に置かない。 この矩形は
|
|
44
|
+
* `fill="none" stroke="none" pointer-events="none"` で、 描画されない計測用の目印。
|
|
45
|
+
*
|
|
46
|
+
* **`$$eval` / `evaluate` の callback からこの定数を参照してはいけない**。 callback は文字列
|
|
47
|
+
* として browser に渡るので module scope が消え、 実行時に `ReferenceError` になる。 callback
|
|
48
|
+
* 内では同じ値を literal で書くか、 `STAGE_SVG_SELECTOR` のように引数として渡す。
|
|
49
|
+
*/
|
|
50
|
+
export const NODE_FRAME_SELECTOR = "[data-cdl-frame]";
|
|
51
|
+
|
|
52
|
+
export type DiscrepancyKind =
|
|
53
|
+
| "missing-node-element"
|
|
54
|
+
| "missing-edge-element"
|
|
55
|
+
| "node-position-mismatch"
|
|
56
|
+
| "node-size-mismatch"
|
|
57
|
+
| "edge-path-endpoint-mismatch"
|
|
58
|
+
| "edge-label-position-mismatch"
|
|
59
|
+
// #390 = 行の文字が node の枠から出ている。 layout 出力だけを見る軸 16 は「字形が baseline から
|
|
60
|
+
// どれだけ下に伸びるか」 を見込みで置いており、 文字列ごとの実寸は取れない。 実 DOM でのみ捕まる。
|
|
61
|
+
| "node-row-overflow"
|
|
62
|
+
// #390 = 行の実寸を測れなかった。 「測った上で収まっていた」 と区別する。
|
|
63
|
+
| "node-row-unverifiable"
|
|
64
|
+
| "particle-out-of-path"
|
|
65
|
+
| "activation-mismatch"
|
|
66
|
+
| "phase-transition-failure";
|
|
67
|
+
|
|
68
|
+
export interface Discrepancy {
|
|
69
|
+
kind: DiscrepancyKind;
|
|
70
|
+
diagramId: string;
|
|
71
|
+
elementId: string;
|
|
72
|
+
expected: unknown;
|
|
73
|
+
actual: unknown;
|
|
74
|
+
detail: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface VerifyOptions {
|
|
78
|
+
/** node 位置の許容誤差 (px、 default 2) */
|
|
79
|
+
positionTolerance?: number;
|
|
80
|
+
/** edge path 起点終点と node 側面の許容誤差 (px、 default 4) */
|
|
81
|
+
edgeEndpointTolerance?: number;
|
|
82
|
+
/**
|
|
83
|
+
* particle 位置 vs path 上の点との許容誤差 (px、 default 250)。
|
|
84
|
+
* Chrome / Firefox の animateMotion + getBoundingClientRect が animation 中の particle 位置を
|
|
85
|
+
* 必ずしも正確に返さない (frame 1 で static 0,0 fallback / mpath transform が screen CTM に反映されない 等)
|
|
86
|
+
* ため、 user 視覚的に同 diagram 内で許容できる 250px (= 1 lane 程度) を default に。
|
|
87
|
+
* 実エンジン bug (particle が画面外 / 別 diagram 領域へ飛ぶ) は依然検知できる。
|
|
88
|
+
*/
|
|
89
|
+
particleTolerance?: number;
|
|
90
|
+
/** particle 検証時の animation frame 撮影数 (default 8、 0.25s 間隔) */
|
|
91
|
+
particleFrameCount?: number;
|
|
92
|
+
/** particle が「path から外れている」 と判定する連続 frame 数の閾値 (default 3、 偶発的 1 frame ずれを許容) */
|
|
93
|
+
particleConsecutiveFailThreshold?: number;
|
|
94
|
+
/** boundingBox 照合の許容誤差 (px、 viewBox transform scale を考慮するため大きめ default 8) */
|
|
95
|
+
bboxTolerance?: number;
|
|
96
|
+
/** boundingBox 照合を skip (高速 partial run 用) */
|
|
97
|
+
skipBbox?: boolean;
|
|
98
|
+
/** particle 検証を skip (animation がない static diagram 用) */
|
|
99
|
+
skipParticle?: boolean;
|
|
100
|
+
/** phase 切替検証を skip (single phase diagram 用) */
|
|
101
|
+
skipPhase?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* 行の文字が node の枠に収まっているかの検査を skip する。
|
|
104
|
+
*
|
|
105
|
+
* 実 font に依存するので、 font が読み込まれていない環境では意味のある判定にならない。
|
|
106
|
+
*/
|
|
107
|
+
skipRowBounds?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* 行の文字が枠から出ていると判定する許容量 (screen px、 default 2)。
|
|
110
|
+
*
|
|
111
|
+
* 0 にしない理由は 2 つ。 `getBoundingClientRect()` は字形の外接矩形を返すので、 letter-spacing や
|
|
112
|
+
* antialias の分だけ数値が揺れる。 加えて枠の角が丸い (`rx`) ため、 角の近くでは枠の内側でも
|
|
113
|
+
* 矩形としては外に出る。 「明らかに出ている」 だけを拾う。
|
|
114
|
+
*/
|
|
115
|
+
rowBoundsTolerance?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Playwright Page-like 抽象を ./dom-verify-types から re-export (backward compat)。
|
|
120
|
+
* 将来 library 化時に dom-verify-core / cdl-adapter 両方で同 type を共有するため、
|
|
121
|
+
* 定義は dom-verify-types.ts に集約。
|
|
122
|
+
*/
|
|
123
|
+
export type { PageLike };
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 1 つの diagram について 5 種類の verifier を全部走らせる。
|
|
127
|
+
*
|
|
128
|
+
* options.skipBbox / skipParticle / skipPhase で各検証を skip 可能 (動作確認 / partial run 用)。
|
|
129
|
+
*/
|
|
130
|
+
export async function verifyDiagramDom(
|
|
131
|
+
page: PageLike,
|
|
132
|
+
diagram: CdlDiagram,
|
|
133
|
+
options: VerifyOptions = {},
|
|
134
|
+
): Promise<Discrepancy[]> {
|
|
135
|
+
const laid = layout(diagram);
|
|
136
|
+
const out: Discrepancy[] = [];
|
|
137
|
+
|
|
138
|
+
// 1. node 位置 + サイズ照合 (data-cdl 値 vs layout 計算値)
|
|
139
|
+
out.push(...await verifyNodes(page, laid, options));
|
|
140
|
+
|
|
141
|
+
// 2. edge path 起点終点 + label 位置
|
|
142
|
+
out.push(...await verifyEdges(page, laid, options));
|
|
143
|
+
|
|
144
|
+
// 3. SVG boundingBox 照合 (実描画位置 vs data-cdl 値、 transform 込み)
|
|
145
|
+
if (!options.skipBbox) {
|
|
146
|
+
out.push(...await verifyBoundingBoxes(page, laid, options));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 4. animation particle 位置 (N frame 撮影で path 上にあるか)
|
|
150
|
+
if (!options.skipParticle) {
|
|
151
|
+
out.push(...await verifyParticles(page, laid, options));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 5. phase 切替 (active class set が phase.activate() と一致するか)
|
|
155
|
+
if (!options.skipPhase) {
|
|
156
|
+
out.push(...await verifyPhaseTransitions(page, laid, diagram, options));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 6. 行の文字が node の枠に収まっているか (#390)
|
|
160
|
+
if (!options.skipRowBounds) {
|
|
161
|
+
out.push(...await verifyRowBounds(page, laid, options));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function verifyNodes(
|
|
168
|
+
page: PageLike,
|
|
169
|
+
laid: LaidDiagram,
|
|
170
|
+
options: VerifyOptions,
|
|
171
|
+
): Promise<Discrepancy[]> {
|
|
172
|
+
const out: Discrepancy[] = [];
|
|
173
|
+
const tol = options.positionTolerance ?? 2;
|
|
174
|
+
const diagramId = laid.id;
|
|
175
|
+
const sel = `[data-cdl-diagram="${diagramId}"]`;
|
|
176
|
+
// 全 node の data-cdl-cx / cy / w / h を一括取得
|
|
177
|
+
const domNodes = await page.$$eval<Array<{ id: string; cx: number; cy: number; w: number; h: number }>>(
|
|
178
|
+
`${sel} [data-cdl-node]`,
|
|
179
|
+
(els) =>
|
|
180
|
+
els.map((el) => ({
|
|
181
|
+
id: el.getAttribute("data-cdl-node") ?? "",
|
|
182
|
+
cx: parseFloat(el.getAttribute("data-cdl-cx") ?? "NaN"),
|
|
183
|
+
cy: parseFloat(el.getAttribute("data-cdl-cy") ?? "NaN"),
|
|
184
|
+
w: parseFloat(el.getAttribute("data-cdl-w") ?? "NaN"),
|
|
185
|
+
h: parseFloat(el.getAttribute("data-cdl-h") ?? "NaN"),
|
|
186
|
+
})),
|
|
187
|
+
);
|
|
188
|
+
const domNodeMap = new Map(domNodes.map((n) => [n.id, n]));
|
|
189
|
+
for (const node of laid.nodes) {
|
|
190
|
+
const dom = domNodeMap.get(node.id);
|
|
191
|
+
if (!dom) {
|
|
192
|
+
out.push({
|
|
193
|
+
kind: "missing-node-element",
|
|
194
|
+
diagramId,
|
|
195
|
+
elementId: node.id,
|
|
196
|
+
expected: { cx: node.cx, cy: node.cy },
|
|
197
|
+
actual: null,
|
|
198
|
+
detail: `node "${node.id}" の DOM 要素 [data-cdl-node="${node.id}"] が見つからない`,
|
|
199
|
+
});
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (Math.abs(dom.cx - node.cx) > tol || Math.abs(dom.cy - node.cy) > tol) {
|
|
203
|
+
out.push({
|
|
204
|
+
kind: "node-position-mismatch",
|
|
205
|
+
diagramId,
|
|
206
|
+
elementId: node.id,
|
|
207
|
+
expected: { cx: node.cx, cy: node.cy },
|
|
208
|
+
actual: { cx: dom.cx, cy: dom.cy },
|
|
209
|
+
detail: `node "${node.id}" の cx/cy が layout 計算値 (${node.cx}, ${node.cy}) と data-cdl 値 (${dom.cx}, ${dom.cy}) で乖離 > ${tol}px`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (Math.abs(dom.w - node.w) > tol || Math.abs(dom.h - node.h) > tol) {
|
|
213
|
+
out.push({
|
|
214
|
+
kind: "node-size-mismatch",
|
|
215
|
+
diagramId,
|
|
216
|
+
elementId: node.id,
|
|
217
|
+
expected: { w: node.w, h: node.h },
|
|
218
|
+
actual: { w: dom.w, h: dom.h },
|
|
219
|
+
detail: `node "${node.id}" の w/h が layout 計算値 (${node.w}, ${node.h}) と data-cdl 値 (${dom.w}, ${dom.h}) で乖離 > ${tol}px`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 行の `<text>` が node の枠に収まっているかを **実寸** で見る (#390)。
|
|
228
|
+
*
|
|
229
|
+
* 軸 16 (`row-vertical-spacing`) は layout 出力だけを見るので、 字形が baseline から下へどれだけ
|
|
230
|
+
* 伸びるかを見込みで置いている (`ROW_GLYPH_DEPTH_RATIO` = 0.4 em)。 文字列ごとの実寸は実 DOM で
|
|
231
|
+
* しか取れない。
|
|
232
|
+
*
|
|
233
|
+
* ## 縦と横で測り方が違う
|
|
234
|
+
*
|
|
235
|
+
* **`getBoundingClientRect()` / `getBBox()` の高さは字形を反映しない**。 実測すると、 `a` /
|
|
236
|
+
* `gjpqy` / `日本語` / 結合文字を 40 個重ねた文字列 のいずれも高さが同じ値を返す (font metrics の
|
|
237
|
+
* 箱を返しており、 ink の箱ではない)。 幅は中身に追随する。
|
|
238
|
+
*
|
|
239
|
+
* そのため縦だけ canvas の `measureText` で測る。 `actualBoundingBoxAscent` /
|
|
240
|
+
* `actualBoundingBoxDescent` は字形の実際の上端 / 下端で、 実測では結合文字を重ねた文字列に
|
|
241
|
+
* 対して 6.2 - 12.9 em を返した (通常の文字は 0.28 em 以下)。
|
|
242
|
+
*
|
|
243
|
+
* ## なぜ枠として宣言された寸法を使うか
|
|
244
|
+
*
|
|
245
|
+
* 描かれている要素は使えない。 `node-body` は `generic.tsx` では `<g>` に付いており、 その外接
|
|
246
|
+
* 矩形は子 (溢れた文字を含む) の和集合になるため、 溢れていても必ず「収まっている」 と出る。
|
|
247
|
+
*
|
|
248
|
+
* 宣言された寸法は world 単位、 実寸は screen px なので、 world 側を `getScreenCTM()` で screen に
|
|
249
|
+
* 写して同じ空間で比べる。
|
|
250
|
+
*/
|
|
251
|
+
async function verifyRowBounds(
|
|
252
|
+
page: PageLike,
|
|
253
|
+
laid: LaidDiagram,
|
|
254
|
+
options: VerifyOptions,
|
|
255
|
+
): Promise<Discrepancy[]> {
|
|
256
|
+
const out: Discrepancy[] = [];
|
|
257
|
+
const tol = options.rowBoundsTolerance ?? 2;
|
|
258
|
+
const diagramId = laid.id;
|
|
259
|
+
const sel = `[data-cdl-diagram="${diagramId}"]`;
|
|
260
|
+
|
|
261
|
+
const rows = await page.$$eval<Array<{
|
|
262
|
+
nodeId: string; index: string; text: string;
|
|
263
|
+
over: { left: number; right: number; top: number; bottom: number } | null;
|
|
264
|
+
skip: string | null;
|
|
265
|
+
/** 測る対象が無い (空の `<text>`)。 測定不能とは別で、 報告しない。 */
|
|
266
|
+
empty: boolean;
|
|
267
|
+
}>>(
|
|
268
|
+
`${sel} [data-cdl-node] [data-cdl-role="node-row"]`,
|
|
269
|
+
(els) => {
|
|
270
|
+
// canvas は 1 個だけ作って全行で使い回す (行ごとに作ると図と行の数だけ確保する)。
|
|
271
|
+
let ctx: CanvasRenderingContext2D | null | undefined;
|
|
272
|
+
const sharedCtx = (): CanvasRenderingContext2D | null => {
|
|
273
|
+
if (ctx === undefined) {
|
|
274
|
+
try {
|
|
275
|
+
ctx = document.createElement("canvas").getContext("2d");
|
|
276
|
+
} catch {
|
|
277
|
+
ctx = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return ctx;
|
|
281
|
+
};
|
|
282
|
+
return els.map((el) => {
|
|
283
|
+
const nodeEl = el.closest("[data-cdl-node]");
|
|
284
|
+
const nodeId = nodeEl?.getAttribute("data-cdl-node") ?? "";
|
|
285
|
+
const index = el.getAttribute("data-cdl-row-index") ?? "";
|
|
286
|
+
const text = el.textContent ?? "";
|
|
287
|
+
// 「測れなかった」 (報告する) と「測る対象が無い」 (報告しない) を分ける。
|
|
288
|
+
// 空の `<text>` は字形を持たないので枠から出ようがなく、 測定不能ではない。
|
|
289
|
+
const bail = (skip: string) => ({ nodeId, index, text, over: null, skip, empty: false });
|
|
290
|
+
const noContent = () => ({ nodeId, index, text, over: null, skip: null, empty: true });
|
|
291
|
+
|
|
292
|
+
// 枠は **描かれている実寸の矩形** を使う。 `data-cdl-w/h` は layout の静的値で、
|
|
293
|
+
// `wBind` / `hBind` による縮小や `renderOffsetX/Y` による移動を含まないため、
|
|
294
|
+
// 動いた node を枠外と誤判定し、 縮んだ node の溢れを見逃す。
|
|
295
|
+
//
|
|
296
|
+
// `node-body` は使えない。 `generic.tsx` では `<g>` に付いており、 外接矩形が子
|
|
297
|
+
// (溢れた文字を含む) の和集合になるため、 溢れていても必ず「収まっている」 と出る。
|
|
298
|
+
//
|
|
299
|
+
// selector を定数で参照しない。 この callback は `$$eval` が browser に文字列として
|
|
300
|
+
// 渡すので、 module scope を閉じ込められない (実行時に ReferenceError)。 定数の説明は
|
|
301
|
+
// `NODE_FRAME_SELECTOR` の doc comment。
|
|
302
|
+
const frameEl = nodeEl?.querySelector("[data-cdl-frame]");
|
|
303
|
+
if (!frameEl) return bail("枠の要素が無い");
|
|
304
|
+
const frame = frameEl.getBoundingClientRect();
|
|
305
|
+
if (!(frame.width > 0 && frame.height > 0)) return bail("枠の実寸が取れない");
|
|
306
|
+
|
|
307
|
+
const t = el as SVGTextElement;
|
|
308
|
+
const local = t.getScreenCTM();
|
|
309
|
+
if (!local) return bail("getScreenCTM が使えない");
|
|
310
|
+
// 回転 / skew が入ると「下」 の向きが screen の下と一致しない。 ink を縦方向の
|
|
311
|
+
// 長さとして足す前提が崩れるので、 測れないものとして報告する (誤検知より良い)。
|
|
312
|
+
if (Math.abs(local.b) > 1e-6 || Math.abs(local.c) > 1e-6) return bail("回転 / skew が入っている");
|
|
313
|
+
if (text.trim().length === 0) return noContent();
|
|
314
|
+
|
|
315
|
+
// 横は矩形が中身に追随するのでそのまま使う。
|
|
316
|
+
const r = el.getBoundingClientRect();
|
|
317
|
+
if (r.width === 0 && r.height === 0) return noContent();
|
|
318
|
+
|
|
319
|
+
// 縦は canvas の ink 実寸で測る。 矩形の高さは font metrics の箱で、 字形を反映しない
|
|
320
|
+
// (実測 = `a` / `gjpqy` / `日本語` / 結合文字 40 個 のいずれも同じ高さ)。
|
|
321
|
+
const cs = getComputedStyle(el);
|
|
322
|
+
const ctx = sharedCtx();
|
|
323
|
+
if (!ctx) return bail("canvas が使えない");
|
|
324
|
+
const font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
|
325
|
+
// font が読み込まれていないと代替 font の字形で測ることになり、 判定が環境に依存する。
|
|
326
|
+
// 「読み込み前の値」 を正常な計測として扱わない。
|
|
327
|
+
//
|
|
328
|
+
// **`document.fonts.check()` は使わない**。 実測すると存在しない family
|
|
329
|
+
// (`18px ZZNoSuchFontZZ`) でも、 文字を渡しても渡さなくても `true` を返す = 使用可能性の
|
|
330
|
+
// signal にならない。 代わりに読み込まれた face の一覧を直接見る。
|
|
331
|
+
try {
|
|
332
|
+
if (document.fonts.status !== "loaded") return bail("font の読込が終わっていない");
|
|
333
|
+
const family = (cs.fontFamily.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
334
|
+
const weight = Number.parseInt(cs.fontWeight, 10);
|
|
335
|
+
const faces = Array.from(document.fonts);
|
|
336
|
+
const sameFamily = faces.filter(
|
|
337
|
+
(f) => f.family.replace(/^["']|["']$/g, "").toLowerCase() === family.toLowerCase(),
|
|
338
|
+
);
|
|
339
|
+
// 一覧に無い family は端末側の font (`monospace` / `-apple-system` 等) の可能性があり、
|
|
340
|
+
// 「無い」 と「端末にある」 を区別できない。 web font として宣言されている時だけ、
|
|
341
|
+
// その太さの face が読み込まれていることを要求する。
|
|
342
|
+
if (sameFamily.length > 0) {
|
|
343
|
+
const loaded = sameFamily.some(
|
|
344
|
+
(f) => f.status === "loaded" && (Number.isNaN(weight) || String(weight) === f.weight),
|
|
345
|
+
);
|
|
346
|
+
if (!loaded) return bail(`font の face が読み込まれていない (${family} / ${cs.fontWeight})`);
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
return bail("font の状態を確認できない");
|
|
350
|
+
}
|
|
351
|
+
ctx.font = font;
|
|
352
|
+
const tm = ctx.measureText(text);
|
|
353
|
+
const asc = tm.actualBoundingBoxAscent, desc = tm.actualBoundingBoxDescent;
|
|
354
|
+
if (!Number.isFinite(asc) || !Number.isFinite(desc)) return bail("ink 実寸が取れない");
|
|
355
|
+
|
|
356
|
+
// baseline の位置 (screen)。 字形の上下端はここから伸びる。
|
|
357
|
+
let originY: number;
|
|
358
|
+
try {
|
|
359
|
+
originY = t.getStartPositionOfChar(0).matrixTransform(local).y;
|
|
360
|
+
} catch {
|
|
361
|
+
return bail("baseline の位置が取れない");
|
|
362
|
+
}
|
|
363
|
+
// canvas は user 単位で測る (SVG の `font-size` は user 単位)。 screen へは **その text の**
|
|
364
|
+
// 縦倍率を掛ける (root の CTM だと text 側に掛かった scale を落とす)。
|
|
365
|
+
const scaleY = Math.abs(local.d);
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
nodeId, index, text, skip: null, empty: false,
|
|
369
|
+
over: {
|
|
370
|
+
left: frame.left - r.left,
|
|
371
|
+
right: r.right - frame.right,
|
|
372
|
+
top: frame.top - (originY - asc * scaleY),
|
|
373
|
+
bottom: (originY + desc * scaleY) - frame.bottom,
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// 宣言された行が DOM に出ているかを照合する。 検出できた行だけを見ると、 属性が全部
|
|
381
|
+
// 消えた時に「違反 0 件」 になる = 検査していないのに pass する (#393 と同じ形)。
|
|
382
|
+
const foundPerNode = new Map<string, number>();
|
|
383
|
+
for (const row of rows) {
|
|
384
|
+
foundPerNode.set(row.nodeId, (foundPerNode.get(row.nodeId) ?? 0) + 1);
|
|
385
|
+
}
|
|
386
|
+
for (const n of laid.nodes) {
|
|
387
|
+
const declared = n.rows?.length ?? 0;
|
|
388
|
+
if (declared === 0) continue;
|
|
389
|
+
// 1 行は左右 2 つの `<text>` に分かれる。
|
|
390
|
+
const expected = declared * 2;
|
|
391
|
+
const found = foundPerNode.get(n.id) ?? 0;
|
|
392
|
+
if (found === expected) continue;
|
|
393
|
+
out.push({
|
|
394
|
+
kind: "node-row-unverifiable",
|
|
395
|
+
diagramId,
|
|
396
|
+
elementId: n.id,
|
|
397
|
+
expected,
|
|
398
|
+
actual: found,
|
|
399
|
+
detail: `node "${n.id}" は ${declared} 行を宣言しているが、 DOM の行の要素が ${found} 個 (期待 ${expected} 個) で照合できない`,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const row of rows) {
|
|
404
|
+
if (row.empty) continue; // 字形が無いので枠から出ようがない
|
|
405
|
+
if (row.skip !== null || row.over === null) {
|
|
406
|
+
// **測れなかったことを黙らせない**。 「測った上で収まっていた」 と区別できないと、
|
|
407
|
+
// 検査していないのに違反 0 件という報告になる (#393 で消したのと同じ形)。
|
|
408
|
+
out.push({
|
|
409
|
+
kind: "node-row-unverifiable",
|
|
410
|
+
diagramId,
|
|
411
|
+
elementId: row.nodeId,
|
|
412
|
+
expected: `node "${row.nodeId}" の行 ${row.index} を実寸で測れること`,
|
|
413
|
+
actual: row.skip,
|
|
414
|
+
detail: `node "${row.nodeId}" の行 ${row.index} ("${row.text.slice(0, 30)}") の実寸を測れなかった (${row.skip ?? "理由不明"})`,
|
|
415
|
+
});
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const { left, right, top, bottom } = row.over;
|
|
419
|
+
if (Math.max(left, right, top, bottom) <= tol) continue;
|
|
420
|
+
const sides = ([["左", left], ["右", right], ["上", top], ["下", bottom]] as const)
|
|
421
|
+
.filter(([, v]) => v > tol)
|
|
422
|
+
.map(([name, v]) => `${name} ${v.toFixed(1)}px`)
|
|
423
|
+
.join(" / ");
|
|
424
|
+
out.push({
|
|
425
|
+
kind: "node-row-overflow",
|
|
426
|
+
diagramId,
|
|
427
|
+
elementId: row.nodeId,
|
|
428
|
+
expected: `node "${row.nodeId}" の枠内`,
|
|
429
|
+
actual: { over: row.over },
|
|
430
|
+
detail: `node "${row.nodeId}" の行 ${row.index} ("${row.text.slice(0, 30)}") が枠から出ている (${sides}、 許容 ${tol}px)`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function verifyEdges(
|
|
437
|
+
page: PageLike,
|
|
438
|
+
laid: LaidDiagram,
|
|
439
|
+
options: VerifyOptions,
|
|
440
|
+
): Promise<Discrepancy[]> {
|
|
441
|
+
const out: Discrepancy[] = [];
|
|
442
|
+
const tol = options.edgeEndpointTolerance ?? 4;
|
|
443
|
+
const diagramId = laid.id;
|
|
444
|
+
const sel = `[data-cdl-diagram="${diagramId}"]`;
|
|
445
|
+
const domEdges = await page.$$eval<
|
|
446
|
+
Array<{ id: string; from: string; to: string; pathD: string; labelX: number; labelY: number }>
|
|
447
|
+
>(`${sel} [data-cdl-edge]`, (els) =>
|
|
448
|
+
els.map((el) => ({
|
|
449
|
+
id: el.getAttribute("data-cdl-edge") ?? "",
|
|
450
|
+
from: el.getAttribute("data-cdl-from") ?? "",
|
|
451
|
+
to: el.getAttribute("data-cdl-to") ?? "",
|
|
452
|
+
pathD: el.getAttribute("data-cdl-path-d") ?? "",
|
|
453
|
+
labelX: parseFloat(el.getAttribute("data-cdl-label-x") ?? "NaN"),
|
|
454
|
+
labelY: parseFloat(el.getAttribute("data-cdl-label-y") ?? "NaN"),
|
|
455
|
+
})),
|
|
456
|
+
);
|
|
457
|
+
const domEdgeMap = new Map(domEdges.map((e) => [e.id, e]));
|
|
458
|
+
const nodeMap = new Map(laid.nodes.map((n) => [n.id, n]));
|
|
459
|
+
|
|
460
|
+
for (const edge of laid.edges) {
|
|
461
|
+
const dom = domEdgeMap.get(edge.id);
|
|
462
|
+
if (!dom) {
|
|
463
|
+
out.push({
|
|
464
|
+
kind: "missing-edge-element",
|
|
465
|
+
diagramId,
|
|
466
|
+
elementId: edge.id,
|
|
467
|
+
expected: { from: edge.from, to: edge.to },
|
|
468
|
+
actual: null,
|
|
469
|
+
detail: `edge "${edge.id}" の DOM 要素 [data-cdl-edge="${edge.id}"] が見つからない`,
|
|
470
|
+
});
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
// edge.d の起点 / 終点を抽出 (M / L コマンドの最初 / 最後の座標)
|
|
474
|
+
const points = extractPathEndpoints(dom.pathD);
|
|
475
|
+
if (!points) continue;
|
|
476
|
+
const from = nodeMap.get(edge.from);
|
|
477
|
+
const to = nodeMap.get(edge.to);
|
|
478
|
+
if (!from || !to) continue;
|
|
479
|
+
// 起点が from node の bbox 上 (側面のいずれか) にあるか
|
|
480
|
+
const startOnFrom = isPointOnNodeBoundary(points.start, from, tol);
|
|
481
|
+
const endOnTo = isPointOnNodeBoundary(points.end, to, tol);
|
|
482
|
+
if (!startOnFrom) {
|
|
483
|
+
out.push({
|
|
484
|
+
kind: "edge-path-endpoint-mismatch",
|
|
485
|
+
diagramId,
|
|
486
|
+
elementId: edge.id,
|
|
487
|
+
expected: `from node "${edge.from}" boundary`,
|
|
488
|
+
actual: { x: points.start.x, y: points.start.y },
|
|
489
|
+
detail: `edge "${edge.id}" の path 起点 (${points.start.x}, ${points.start.y}) が from node "${edge.from}" の側面に乗っていない (tolerance ${tol}px)`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
if (!endOnTo) {
|
|
493
|
+
out.push({
|
|
494
|
+
kind: "edge-path-endpoint-mismatch",
|
|
495
|
+
diagramId,
|
|
496
|
+
elementId: edge.id,
|
|
497
|
+
expected: `to node "${edge.to}" boundary`,
|
|
498
|
+
actual: { x: points.end.x, y: points.end.y },
|
|
499
|
+
detail: `edge "${edge.id}" の path 終点 (${points.end.x}, ${points.end.y}) が to node "${edge.to}" の側面に乗っていない (tolerance ${tol}px)`,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
// label 位置が layout 計算値と一致するか
|
|
503
|
+
if (Math.abs(dom.labelX - edge.labelX) > 1 || Math.abs(dom.labelY - edge.labelY) > 1) {
|
|
504
|
+
out.push({
|
|
505
|
+
kind: "edge-label-position-mismatch",
|
|
506
|
+
diagramId,
|
|
507
|
+
elementId: edge.id,
|
|
508
|
+
expected: { labelX: edge.labelX, labelY: edge.labelY },
|
|
509
|
+
actual: { labelX: dom.labelX, labelY: dom.labelY },
|
|
510
|
+
detail: `edge "${edge.id}" の label 位置が layout 計算値と data-cdl 値で乖離`,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* SVG path d 文字列から起点 (M) と 終点 (最後の L / Q / C) の座標を抽出。
|
|
519
|
+
*/
|
|
520
|
+
function extractPathEndpoints(d: string): { start: { x: number; y: number }; end: { x: number; y: number } } | null {
|
|
521
|
+
const cmdRe = /([MLCQZ])\s*([^MLCQZ]*)/gi;
|
|
522
|
+
let start: { x: number; y: number } | null = null;
|
|
523
|
+
let end: { x: number; y: number } | null = null;
|
|
524
|
+
let m: RegExpExecArray | null;
|
|
525
|
+
while ((m = cmdRe.exec(d)) !== null) {
|
|
526
|
+
const cmd = m[1]!.toUpperCase();
|
|
527
|
+
const args = (m[2] ?? "").trim().split(/[\s,]+/).filter(Boolean).map(parseFloat);
|
|
528
|
+
if (cmd === "Z") continue;
|
|
529
|
+
let x = 0;
|
|
530
|
+
let y = 0;
|
|
531
|
+
if (cmd === "M" || cmd === "L") {
|
|
532
|
+
x = args[0]!;
|
|
533
|
+
y = args[1]!;
|
|
534
|
+
} else if (cmd === "Q") {
|
|
535
|
+
// Q cx cy x y → 終点は (args[2], args[3])
|
|
536
|
+
x = args[2]!;
|
|
537
|
+
y = args[3]!;
|
|
538
|
+
} else if (cmd === "C") {
|
|
539
|
+
// C c1x c1y c2x c2y x y → 終点は (args[4], args[5])
|
|
540
|
+
x = args[4]!;
|
|
541
|
+
y = args[5]!;
|
|
542
|
+
}
|
|
543
|
+
if (start === null) start = { x, y };
|
|
544
|
+
end = { x, y };
|
|
545
|
+
}
|
|
546
|
+
if (!start || !end) return null;
|
|
547
|
+
return { start, end };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 点 (px, py) が node の bbox 側面 (4 辺のいずれか) に tolerance 内で乗っているか判定。
|
|
552
|
+
*/
|
|
553
|
+
function isPointOnNodeBoundary(
|
|
554
|
+
point: { x: number; y: number },
|
|
555
|
+
node: { cx: number; cy: number; w: number; h: number },
|
|
556
|
+
tol: number,
|
|
557
|
+
): boolean {
|
|
558
|
+
const left = node.cx - node.w / 2;
|
|
559
|
+
const right = node.cx + node.w / 2;
|
|
560
|
+
const top = node.cy - node.h / 2;
|
|
561
|
+
const bot = node.cy + node.h / 2;
|
|
562
|
+
// 左辺 ... px が left 近く + py が [top, bot] 内
|
|
563
|
+
if (Math.abs(point.x - left) <= tol && point.y >= top - tol && point.y <= bot + tol) return true;
|
|
564
|
+
// 右辺
|
|
565
|
+
if (Math.abs(point.x - right) <= tol && point.y >= top - tol && point.y <= bot + tol) return true;
|
|
566
|
+
// 上辺
|
|
567
|
+
if (Math.abs(point.y - top) <= tol && point.x >= left - tol && point.x <= right + tol) return true;
|
|
568
|
+
// 下辺
|
|
569
|
+
if (Math.abs(point.y - bot) <= tol && point.x >= left - tol && point.x <= right + tol) return true;
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* 3. SVG boundingBox 照合 ... 実描画位置 (Playwright bbox in viewport px) と
|
|
575
|
+
* SVG userspace 座標 (data-cdl 値) を viewBox transform で換算して比較。
|
|
576
|
+
*
|
|
577
|
+
* Astro が SVG に viewBox + 任意 CSS scale を適用するため、 単純比較は不可。
|
|
578
|
+
* (1) SVG element の getBoundingClientRect で viewport px の bbox 取得
|
|
579
|
+
* (2) data-cdl-viewbox から viewBox 4 値を取得
|
|
580
|
+
* (3) viewport bbox / viewBox = scale 計算
|
|
581
|
+
* (4) 各 node の data-cdl-cx を viewport 座標に変換 → 該当 [data-cdl-node] の bbox 中心と照合
|
|
582
|
+
*/
|
|
583
|
+
async function verifyBoundingBoxes(
|
|
584
|
+
page: PageLike,
|
|
585
|
+
laid: LaidDiagram,
|
|
586
|
+
options: VerifyOptions,
|
|
587
|
+
): Promise<Discrepancy[]> {
|
|
588
|
+
const out: Discrepancy[] = [];
|
|
589
|
+
const tol = options.bboxTolerance ?? 16;
|
|
590
|
+
const diagramId = laid.id;
|
|
591
|
+
// browser context で SVG transform を換算した中心 座標を取得
|
|
592
|
+
type BboxInfo = {
|
|
593
|
+
ok: true;
|
|
594
|
+
nodes: Array<{ id: string; cxView: number; cyView: number; wView: number; hView: number; cxExpected: number; cyExpected: number; wExpected: number; hExpected: number }>;
|
|
595
|
+
} | { ok: false; reason: string };
|
|
596
|
+
const result = await page.evaluate(
|
|
597
|
+
({ diagId, stageSel }) => {
|
|
598
|
+
const root = document.querySelector(`[data-cdl-diagram="${diagId}"]`);
|
|
599
|
+
if (!root) return { ok: false as const, reason: `root [data-cdl-diagram="${diagId}"] not found` };
|
|
600
|
+
const svg = root.querySelector(stageSel);
|
|
601
|
+
if (!svg) return { ok: false as const, reason: `${stageSel} not found inside [data-cdl-diagram="${diagId}"]` };
|
|
602
|
+
const vbAttr = svg.getAttribute("viewBox");
|
|
603
|
+
if (!vbAttr) return { ok: false as const, reason: "svg viewBox attribute missing" };
|
|
604
|
+
const vbParts = vbAttr.split(/\s+/).map(parseFloat);
|
|
605
|
+
const vx = vbParts[0] ?? 0;
|
|
606
|
+
const vy = vbParts[1] ?? 0;
|
|
607
|
+
const vw = vbParts[2] ?? 1;
|
|
608
|
+
const vh = vbParts[3] ?? 1;
|
|
609
|
+
const svgRect = svg.getBoundingClientRect();
|
|
610
|
+
// viewBox → viewport の scale (preserveAspectRatio=meet 想定で min)
|
|
611
|
+
const scaleX = svgRect.width / vw;
|
|
612
|
+
const scaleY = svgRect.height / vh;
|
|
613
|
+
const scale = Math.min(scaleX, scaleY);
|
|
614
|
+
const offsetX = svgRect.left + (svgRect.width - vw * scale) / 2;
|
|
615
|
+
const offsetY = svgRect.top + (svgRect.height - vh * scale) / 2;
|
|
616
|
+
const nodeEls = Array.from(root.querySelectorAll("[data-cdl-node]"));
|
|
617
|
+
const nodes = nodeEls.map((el) => {
|
|
618
|
+
const id = el.getAttribute("data-cdl-node") ?? "";
|
|
619
|
+
const cxSvg = parseFloat(el.getAttribute("data-cdl-cx") ?? "NaN");
|
|
620
|
+
const cySvg = parseFloat(el.getAttribute("data-cdl-cy") ?? "NaN");
|
|
621
|
+
const wSvg = parseFloat(el.getAttribute("data-cdl-w") ?? "NaN");
|
|
622
|
+
const hSvg = parseFloat(el.getAttribute("data-cdl-h") ?? "NaN");
|
|
623
|
+
// SVG userspace → viewport へ変換
|
|
624
|
+
const cxExpected = offsetX + (cxSvg - vx) * scale;
|
|
625
|
+
const cyExpected = offsetY + (cySvg - vy) * scale;
|
|
626
|
+
const wExpected = wSvg * scale;
|
|
627
|
+
const hExpected = hSvg * scale;
|
|
628
|
+
// 実 boundingBox から viewport center 取得
|
|
629
|
+
const rect = (el as Element).getBoundingClientRect();
|
|
630
|
+
return {
|
|
631
|
+
id,
|
|
632
|
+
cxView: rect.left + rect.width / 2,
|
|
633
|
+
cyView: rect.top + rect.height / 2,
|
|
634
|
+
wView: rect.width,
|
|
635
|
+
hView: rect.height,
|
|
636
|
+
cxExpected,
|
|
637
|
+
cyExpected,
|
|
638
|
+
wExpected,
|
|
639
|
+
hExpected,
|
|
640
|
+
};
|
|
641
|
+
});
|
|
642
|
+
return { ok: true as const, nodes };
|
|
643
|
+
},
|
|
644
|
+
{ diagId: diagramId, stageSel: STAGE_SVG_SELECTOR },
|
|
645
|
+
) as BboxInfo;
|
|
646
|
+
|
|
647
|
+
if (!result.ok) {
|
|
648
|
+
out.push({
|
|
649
|
+
kind: "missing-node-element",
|
|
650
|
+
diagramId,
|
|
651
|
+
elementId: "(root)",
|
|
652
|
+
expected: "svg with viewBox",
|
|
653
|
+
actual: null,
|
|
654
|
+
detail: `bbox verification skipped: ${result.reason}`,
|
|
655
|
+
});
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
const expectedNodeIds = new Set(laid.nodes.map((n) => n.id));
|
|
659
|
+
for (const dom of result.nodes) {
|
|
660
|
+
if (!expectedNodeIds.has(dom.id)) continue;
|
|
661
|
+
const dx = Math.abs(dom.cxView - dom.cxExpected);
|
|
662
|
+
const dy = Math.abs(dom.cyView - dom.cyExpected);
|
|
663
|
+
if (dx > tol || dy > tol) {
|
|
664
|
+
out.push({
|
|
665
|
+
kind: "node-position-mismatch",
|
|
666
|
+
diagramId,
|
|
667
|
+
elementId: dom.id,
|
|
668
|
+
expected: { cx: dom.cxExpected.toFixed(1), cy: dom.cyExpected.toFixed(1) },
|
|
669
|
+
actual: { cx: dom.cxView.toFixed(1), cy: dom.cyView.toFixed(1) },
|
|
670
|
+
detail: `bbox: node "${dom.id}" の viewport 中心が SVG userspace 計算値と (dx=${dx.toFixed(1)}, dy=${dy.toFixed(1)}) ずれ > ${tol}px`,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return out;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* 4. animation particle 位置 ... Playwright で animation N frame 撮影し、
|
|
679
|
+
* 各 frame で particle SVG 要素 (data-cdl-particle) の中心が path 上にあるか判定。
|
|
680
|
+
*
|
|
681
|
+
* 判定 ... browser context で SVGPathElement.getPointAtLength() を使い、
|
|
682
|
+
* particle 中心から最も近い path 上の点を sample (10 点) して距離 < tolerance なら pass。
|
|
683
|
+
*
|
|
684
|
+
* dotted-flow 以外の edge (particle 描画なし) は対象外。
|
|
685
|
+
*/
|
|
686
|
+
async function verifyParticles(
|
|
687
|
+
page: PageLike,
|
|
688
|
+
laid: LaidDiagram,
|
|
689
|
+
options: VerifyOptions,
|
|
690
|
+
): Promise<Discrepancy[]> {
|
|
691
|
+
const out: Discrepancy[] = [];
|
|
692
|
+
const tol = options.particleTolerance ?? 250;
|
|
693
|
+
const frameCount = options.particleFrameCount ?? 8;
|
|
694
|
+
const consecutiveThreshold = options.particleConsecutiveFailThreshold ?? 3;
|
|
695
|
+
const diagramId = laid.id;
|
|
696
|
+
const dottedEdges = laid.edges.filter((e) => e.style === "dotted-flow");
|
|
697
|
+
if (dottedEdges.length === 0) return out;
|
|
698
|
+
// edge id ごとに consecutive fail 回数を集計、 閾値超で discrepancy 計上
|
|
699
|
+
const failCountByEdge = new Map<string, { count: number; maxDist: number; lastFrame: number }>();
|
|
700
|
+
|
|
701
|
+
// animation 進行を待ちつつ N frame で particle 位置を計測
|
|
702
|
+
for (let frame = 0; frame < frameCount; frame++) {
|
|
703
|
+
await page.waitForTimeout(250); // 0.25s 間隔
|
|
704
|
+
type FrameResult = {
|
|
705
|
+
ok: true;
|
|
706
|
+
particles: Array<{ id: string; cxView: number; cyView: number; minDistOnPath: number }>;
|
|
707
|
+
} | { ok: false; reason: string };
|
|
708
|
+
const res = await page.evaluate(
|
|
709
|
+
({ diagId, edgeIds }) => {
|
|
710
|
+
const root = document.querySelector(`[data-cdl-diagram="${diagId}"]`);
|
|
711
|
+
if (!root) return { ok: false as const, reason: "root not found" };
|
|
712
|
+
const out: Array<{ id: string; cxView: number; cyView: number; minDistOnPath: number }> = [];
|
|
713
|
+
for (const eid of edgeIds) {
|
|
714
|
+
const particleEl = root.querySelector(`[data-cdl-particle="${eid}"]`);
|
|
715
|
+
const pathEl = root.querySelector(`#cdl-edge-${eid}`) as SVGPathElement | null;
|
|
716
|
+
if (!particleEl || !pathEl) continue;
|
|
717
|
+
// particle の中心 (最初の circle で代用、 3 重 glow の中心はほぼ同位置)。
|
|
718
|
+
// animateMotion は <circle> の transform attribute を動的に書換える形で動くが、
|
|
719
|
+
// Chrome の getBoundingClientRect() は animation 反映済の bbox を返すはず。
|
|
720
|
+
// 初回 frame で static 位置に fallback する場合があるため、
|
|
721
|
+
// SVG getCTM() で current matrix の e/f (translation) を直接取得する経路も併用。
|
|
722
|
+
const firstCircle = particleEl.querySelector("circle") as SVGGraphicsElement | null;
|
|
723
|
+
if (!firstCircle) continue;
|
|
724
|
+
// Path 1: getBoundingClientRect (animation 中の bbox)
|
|
725
|
+
const cRect = firstCircle.getBoundingClientRect();
|
|
726
|
+
let cx = cRect.left + cRect.width / 2;
|
|
727
|
+
let cy = cRect.top + cRect.height / 2;
|
|
728
|
+
// Path 2: getCTM().e/f で current translation を取得して補正。
|
|
729
|
+
// animateMotion は要素の transform matrix を直接書換えるが、
|
|
730
|
+
// bbox に反映されないケースで cx/cy 0,0 が返ることがある。
|
|
731
|
+
// CTM の translation 成分が path 上にあれば、 それを particle center として採用。
|
|
732
|
+
const circleCtm = firstCircle.getCTM();
|
|
733
|
+
if (circleCtm) {
|
|
734
|
+
const pCtm = pathEl.getScreenCTM();
|
|
735
|
+
if (pCtm) {
|
|
736
|
+
// circle CTM の translation を viewport 座標へ
|
|
737
|
+
const cxAlt = circleCtm.e * pCtm.a + circleCtm.f * pCtm.c + pCtm.e;
|
|
738
|
+
const cyAlt = circleCtm.e * pCtm.b + circleCtm.f * pCtm.d + pCtm.f;
|
|
739
|
+
// bbox center と CTM-based center を比較し、 より path 上に近い方を採用
|
|
740
|
+
// (animation 進行中は bbox が正確、 frame 0 では CTM が正確というケースが交互に来る)
|
|
741
|
+
// ここでは bbox cx/cy が viewport origin に近い (0,0 fallback) ときに alt を採用
|
|
742
|
+
const isBboxFallback = Math.abs(cx) < 5 && Math.abs(cy) < 5;
|
|
743
|
+
if (isBboxFallback) {
|
|
744
|
+
cx = cxAlt;
|
|
745
|
+
cy = cyAlt;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// path 上を 20 sample して particle 中心との最小距離
|
|
750
|
+
const len = pathEl.getTotalLength();
|
|
751
|
+
if (!isFinite(len) || len === 0) continue;
|
|
752
|
+
let minDist = Infinity;
|
|
753
|
+
const samples = 20;
|
|
754
|
+
// CTM (SVG → screen) で各 sample 点を viewport 座標に変換
|
|
755
|
+
const ctm = pathEl.getScreenCTM();
|
|
756
|
+
if (!ctm) continue;
|
|
757
|
+
for (let i = 0; i <= samples; i++) {
|
|
758
|
+
const t = (i / samples) * len;
|
|
759
|
+
const p = pathEl.getPointAtLength(t);
|
|
760
|
+
const vp = { x: p.x * ctm.a + p.y * ctm.c + ctm.e, y: p.x * ctm.b + p.y * ctm.d + ctm.f };
|
|
761
|
+
const d = Math.hypot(cx - vp.x, cy - vp.y);
|
|
762
|
+
if (d < minDist) minDist = d;
|
|
763
|
+
}
|
|
764
|
+
out.push({ id: eid, cxView: cx, cyView: cy, minDistOnPath: minDist });
|
|
765
|
+
}
|
|
766
|
+
return { ok: true as const, particles: out };
|
|
767
|
+
},
|
|
768
|
+
{ diagId: diagramId, edgeIds: dottedEdges.map((e) => e.id) },
|
|
769
|
+
) as FrameResult;
|
|
770
|
+
if (!res.ok) continue;
|
|
771
|
+
for (const p of res.particles) {
|
|
772
|
+
if (p.minDistOnPath > tol) {
|
|
773
|
+
// 連続 fail を集計 ... animateMotion の CTM 取得が瞬間的に静的 bbox に fallback する
|
|
774
|
+
// ケースがあり、 1-2 frame 単発で path から外れて見える false positive を回避。
|
|
775
|
+
// consecutiveThreshold 回 (default 3) 以上連続で fail したら本物の path 外れと判定。
|
|
776
|
+
const prev = failCountByEdge.get(p.id) ?? { count: 0, maxDist: 0, lastFrame: -1 };
|
|
777
|
+
const isConsecutive = prev.lastFrame === frame - 1 || prev.lastFrame === -1;
|
|
778
|
+
failCountByEdge.set(p.id, {
|
|
779
|
+
count: isConsecutive ? prev.count + 1 : 1,
|
|
780
|
+
maxDist: Math.max(prev.maxDist, p.minDistOnPath),
|
|
781
|
+
lastFrame: frame,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
// 連続 fail 数が閾値以上の edge のみ discrepancy 計上
|
|
787
|
+
for (const [edgeId, info] of failCountByEdge) {
|
|
788
|
+
if (info.count >= consecutiveThreshold) {
|
|
789
|
+
out.push({
|
|
790
|
+
kind: "particle-out-of-path",
|
|
791
|
+
diagramId,
|
|
792
|
+
elementId: edgeId,
|
|
793
|
+
expected: `path 上 (tolerance ${tol}px)`,
|
|
794
|
+
actual: { maxDist: info.maxDist.toFixed(1), consecutiveFails: info.count },
|
|
795
|
+
detail: `edge "${edgeId}" の particle が ${info.count} frame 連続で path から最大 ${info.maxDist.toFixed(1)}px 外れています`,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return out;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* 5. phase 切替 ... phase index を進めて active class set が phase.activate() と一致するか動的検証。
|
|
804
|
+
*
|
|
805
|
+
* data-cdl-phase-index attribute を直接更新する経路がなければ、 phase 進行は
|
|
806
|
+
* Playwright で待つだけで時間経過に伴い自動進行する (CdlDiagramView の useTimeline)。
|
|
807
|
+
*/
|
|
808
|
+
async function verifyPhaseTransitions(
|
|
809
|
+
page: PageLike,
|
|
810
|
+
laid: LaidDiagram,
|
|
811
|
+
diagram: CdlDiagram,
|
|
812
|
+
_options: VerifyOptions,
|
|
813
|
+
): Promise<Discrepancy[]> {
|
|
814
|
+
const out: Discrepancy[] = [];
|
|
815
|
+
const diagramId = laid.id;
|
|
816
|
+
if (diagram.phases.length === 0) return out;
|
|
817
|
+
|
|
818
|
+
// 各 phase に対して、 期待される active element set と実 active class set を照合。
|
|
819
|
+
// 注意 ... CdlDiagramView は時間経過で phase 進行するが、 dom-verify は static check で十分。
|
|
820
|
+
// 現在の phase index を取り、 その phase の expected vs actual を比較。
|
|
821
|
+
type PhaseState = {
|
|
822
|
+
ok: true;
|
|
823
|
+
phaseIdx: number;
|
|
824
|
+
activeNodes: string[];
|
|
825
|
+
activeEdges: string[];
|
|
826
|
+
} | { ok: false; reason: string };
|
|
827
|
+
const state = await page.evaluate(
|
|
828
|
+
({ diagId }) => {
|
|
829
|
+
const root = document.querySelector(`[data-cdl-diagram="${diagId}"]`);
|
|
830
|
+
if (!root) return { ok: false as const, reason: "root not found" };
|
|
831
|
+
const phaseIdxAttr = root.getAttribute("data-cdl-phase-index");
|
|
832
|
+
const phaseIdx = phaseIdxAttr === null ? -1 : parseInt(phaseIdxAttr, 10);
|
|
833
|
+
const activeNodeEls = Array.from(root.querySelectorAll('[data-cdl-node][data-cdl-active="true"]'));
|
|
834
|
+
const activeEdgeEls = Array.from(root.querySelectorAll('[data-cdl-edge][data-cdl-active="true"]'));
|
|
835
|
+
return {
|
|
836
|
+
ok: true as const,
|
|
837
|
+
phaseIdx,
|
|
838
|
+
activeNodes: activeNodeEls.map((el) => el.getAttribute("data-cdl-node") ?? ""),
|
|
839
|
+
activeEdges: activeEdgeEls.map((el) => el.getAttribute("data-cdl-edge") ?? ""),
|
|
840
|
+
};
|
|
841
|
+
},
|
|
842
|
+
{ diagId: diagramId },
|
|
843
|
+
) as PhaseState;
|
|
844
|
+
if (!state.ok) return out;
|
|
845
|
+
if (state.phaseIdx < 0 || state.phaseIdx >= diagram.phases.length) return out;
|
|
846
|
+
const expectedPhase = diagram.phases[state.phaseIdx]!;
|
|
847
|
+
const expectedActive = new Set(expectedPhase.activate);
|
|
848
|
+
const actualActive = new Set([...state.activeNodes, ...state.activeEdges]);
|
|
849
|
+
|
|
850
|
+
// 期待される active が actual に欠落
|
|
851
|
+
for (const id of expectedActive) {
|
|
852
|
+
if (!actualActive.has(id)) {
|
|
853
|
+
out.push({
|
|
854
|
+
kind: "activation-mismatch",
|
|
855
|
+
diagramId,
|
|
856
|
+
elementId: id,
|
|
857
|
+
expected: "active",
|
|
858
|
+
actual: "inactive",
|
|
859
|
+
detail: `phase "${expectedPhase.id}" で活性化すべき要素 "${id}" が data-cdl-active="true" になっていない`,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
// 期待されていない要素が active になっている
|
|
864
|
+
for (const id of actualActive) {
|
|
865
|
+
if (!expectedActive.has(id)) {
|
|
866
|
+
out.push({
|
|
867
|
+
kind: "activation-mismatch",
|
|
868
|
+
diagramId,
|
|
869
|
+
elementId: id,
|
|
870
|
+
expected: "inactive",
|
|
871
|
+
actual: "active",
|
|
872
|
+
detail: `phase "${expectedPhase.id}" で活性化すべきでない要素 "${id}" が data-cdl-active="true" になっている`,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return out;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* 複数 diagram を一括 verify。
|
|
881
|
+
*/
|
|
882
|
+
export interface DomVerifyReport {
|
|
883
|
+
total: number;
|
|
884
|
+
pass: number;
|
|
885
|
+
fail: number;
|
|
886
|
+
discrepancies: Discrepancy[];
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export async function verifyAllDiagramsDom(
|
|
890
|
+
page: PageLike,
|
|
891
|
+
diagrams: CdlDiagram[],
|
|
892
|
+
options?: VerifyOptions,
|
|
893
|
+
): Promise<DomVerifyReport> {
|
|
894
|
+
const all: Discrepancy[] = [];
|
|
895
|
+
let fail = 0;
|
|
896
|
+
for (const d of diagrams) {
|
|
897
|
+
const ds = await verifyDiagramDom(page, d, options);
|
|
898
|
+
if (ds.length > 0) fail++;
|
|
899
|
+
all.push(...ds);
|
|
900
|
+
}
|
|
901
|
+
return {
|
|
902
|
+
total: diagrams.length,
|
|
903
|
+
pass: diagrams.length - fail,
|
|
904
|
+
fail,
|
|
905
|
+
discrepancies: all,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* cdl-adapter: CdlDiagram → GenericDiagramSpec への変換 (目 v4 library 化前段)。
|
|
911
|
+
*
|
|
912
|
+
* 将来 @cardenelabs/diagram-verifier を独立 npm publish する際、
|
|
913
|
+
* dom-verify-core.ts (generic) + 本 adapter (cdl 用) の 2 層構成にする。
|
|
914
|
+
* 同様の adapter を Mermaid / D2 / 任意 SVG tool 用に増やせる構造。
|
|
915
|
+
*/
|
|
916
|
+
export const cdlAdapter: DiagramAdapter<CdlDiagram> = {
|
|
917
|
+
name: "cdl",
|
|
918
|
+
toSpec(diagram: CdlDiagram): GenericDiagramSpec {
|
|
919
|
+
const laid = layout(diagram);
|
|
920
|
+
return {
|
|
921
|
+
id: laid.id,
|
|
922
|
+
rootAttribute: "data-cdl-diagram",
|
|
923
|
+
nodeAttribute: "data-cdl-node",
|
|
924
|
+
edgeAttribute: "data-cdl-edge",
|
|
925
|
+
particleAttribute: "data-cdl-particle",
|
|
926
|
+
nodes: laid.nodes.map((n) => ({
|
|
927
|
+
id: n.id,
|
|
928
|
+
cx: n.cx,
|
|
929
|
+
cy: n.cy,
|
|
930
|
+
w: n.w,
|
|
931
|
+
h: n.h,
|
|
932
|
+
})),
|
|
933
|
+
edges: laid.edges.map((e) => ({
|
|
934
|
+
id: e.id,
|
|
935
|
+
from: e.from,
|
|
936
|
+
to: e.to,
|
|
937
|
+
d: e.d,
|
|
938
|
+
labelX: e.labelX,
|
|
939
|
+
labelY: e.labelY,
|
|
940
|
+
hasParticle: e.style === "dotted-flow",
|
|
941
|
+
})),
|
|
942
|
+
phases: diagram.phases.map((p) => ({
|
|
943
|
+
id: p.id,
|
|
944
|
+
activate: p.activate,
|
|
945
|
+
})),
|
|
946
|
+
};
|
|
947
|
+
},
|
|
948
|
+
};
|