@cardenelabs/cdl 0.13.1 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardenelabs/cdl",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "CDL (Chainome Diagram Language). Mermaid-like declarative DSL that compiles to animated SVG diagrams. Built for blockchain / Solidity flows, generic enough for sequence / flow / state / ER / topology diagrams.",
5
5
  "license": "MIT",
6
6
  "author": "cardene777",
@@ -1,5 +1,5 @@
1
1
  import type { JSX } from "react";
2
- import { 使える幅, 幅に収める, 箱に積む } from "./box-lines";
2
+ import { 使える幅, 幅に収める, 箱に積む, type 積む行 } from "./box-lines";
3
3
  import type { LaidNode, Tone, MindBranchNode, MindBranchPayload } from "../types";
4
4
  import { TONE } from "../render/tone";
5
5
  import { resolveMindData } from "../render/payload-binding";
@@ -28,6 +28,49 @@ export const MIND_BOX_GAP = 24;
28
28
  * 深い階層は parent 方向へ扇状展開。 depth 別 tone を rotate。
29
29
  * curved edge = bezier で親子を接続、 node は rounded rect で読みやすさ優先。
30
30
  */
31
+ /**
32
+ * 箱の内側に取る余白。 図の外枠と放射の描画領域の間。
33
+ *
34
+ * **module に置く** (#538)。 描く側の中に閉じていた間、 検査側は同じ値を書き写すしかなく、
35
+ * 片方を変えた時にもう片方が古いまま残る。
36
+ */
37
+ const MIND_PAD = 40;
38
+
39
+ /**
40
+ * 放射の中の箱と枝を並べる (#538)。
41
+ *
42
+ * 描く側 (`MindMapNode`) と検査側 (`visual-validate.ts` の `box-line-dropped`) の**唯一の入口**。
43
+ * 名前を解決できない (`mindData` を持たない) 節では `undefined` を返す = 描く側はそこで
44
+ * 空の `<g>` を返しており、 検査側も見る箱を持たない。
45
+ */
46
+ export function 放射の中身(
47
+ node: LaidNode,
48
+ stateValues: Record<string, string> = {},
49
+ ): { nodes: LayoutNode[]; edges: LayoutEdge[] } | undefined {
50
+ // 名前は状態を読める (#467)。 中心と枝の両方を解決層に通す
51
+ const mindData = node.mindData ? resolveMindData(node.mindData, stateValues) : undefined;
52
+ if (!mindData) return undefined;
53
+ return computeMindMapLayout(
54
+ mindData,
55
+ node.w / 2,
56
+ node.h / 2,
57
+ node.w - MIND_PAD * 2,
58
+ node.h - MIND_PAD * 2,
59
+ );
60
+ }
61
+
62
+ /**
63
+ * 放射の 1 箱に積む行 (名前 + 補足)。
64
+ *
65
+ * 字の大きさをここに集める (#538)。 理由は `木の行` と同じ。
66
+ */
67
+ export function 放射の行(n: LayoutNode): 積む行[] {
68
+ return [
69
+ { 役割: "title", text: n.title, fontSize: n.isRoot ? 15 : 13 },
70
+ { 役割: "subtitle", text: n.subtitle ?? "", fontSize: 9 },
71
+ ];
72
+ }
73
+
31
74
  export function MindMapNode({
32
75
  node,
33
76
  active,
@@ -45,17 +88,8 @@ export function MindMapNode({
45
88
  }): JSX.Element {
46
89
  const x0 = node.cx - node.w / 2;
47
90
  const y0 = node.cy - node.h / 2;
48
- // 名前は状態を読める (#467)。 中心と枝の両方を解決層に通す
49
- const mindData = node.mindData ? resolveMindData(node.mindData, stateValues) : undefined;
50
- if (!mindData) return <g transform={`translate(${x0} ${y0})`} />;
51
-
52
- const PAD = 40;
53
- const canvasCx = node.w / 2;
54
- const canvasCy = node.h / 2;
55
- const canvasW = node.w - PAD * 2;
56
- const canvasH = node.h - PAD * 2;
57
-
58
- const layout = computeMindMapLayout(mindData, canvasCx, canvasCy, canvasW, canvasH);
91
+ const layout = 放射の中身(node, stateValues);
92
+ if (!layout) return <g transform={`translate(${x0} ${y0})`} />;
59
93
 
60
94
  /**
61
95
  * 枝がどこまで伸びたか (0..1、 cdl#520)。 描く指定が無ければ常に 1 = 全部出ている。
@@ -105,20 +139,16 @@ export function MindMapNode({
105
139
  // 枝が届いてから箱を出す。 届く前に出すと、繋がっていない箱が宙に浮く
106
140
  if (!箱が出る(n.depth)) return null;
107
141
  // 補足は箱の中の 2 行目に出す (#471)。 箱の高さは変えないため、 入らない時は落ちる
108
- const = 箱に積む(
109
- [
110
- { 役割: "title" as const, text: n.title, fontSize: n.isRoot ? 15 : 13 },
111
- { 役割: "subtitle" as const, text: n.subtitle ?? "", fontSize: 9 },
112
- ],
113
- n.w,
114
- n.h,
115
- );
142
+ const 材料 = 放射の行(n);
143
+ const 行 = 箱に積む(材料, n.w, n.h);
116
144
  // 判定は行数でなく「補足を渡したか」 で行う (理由は tree.tsx と同じ、 #471 Round 1)
117
145
  const 補足あり = (n.subtitle ?? "").trim() !== "";
118
- // 補足ありでは `箱に積む` が既に同じ処理を済ませているため、1 行の経路だけ計算する
146
+ // 補足ありでは `箱に積む` が既に同じ処理を済ませているため、1 行の経路だけ計算する。
147
+ // 名前の大きさは `放射の行` から取る (#538、 理由は tree.tsx と同じ)
148
+ const 名前 = 材料[0]!;
119
149
  const 収めた名前 = 補足あり
120
150
  ? undefined
121
- : 幅に収める(n.title, n.isRoot ? 15 : 13, 使える幅(n.w));
151
+ : 幅に収める(名前.text, 名前.fontSize, 使える幅(n.w));
122
152
  const 文字の色 = n.isRoot
123
153
  ? "var(--cdl-text-on-tone, #ffffff)"
124
154
  : "var(--cdl-chip-text, #1a1f2a)";
@@ -2,7 +2,7 @@ import type { JSX } from "react";
2
2
  import type { LaidNode, TreeNodePayload } from "../types";
3
3
  import { TONE } from "../render/tone";
4
4
  import { resolveTreeData } from "../render/payload-binding";
5
- import { 使える幅, 幅に収める, 箱に積む, type 積んだ行 } from "../kinds/box-lines";
5
+ import { 使える幅, 幅に収める, 箱に積む, type 積む行, type 積んだ行 } from "../kinds/box-lines";
6
6
  import { 進みを畳む, 伸ばすdash, 段の伸び } from "./draw-progress";
7
7
 
8
8
  /**
@@ -50,6 +50,49 @@ function 箱の文字({
50
50
  ));
51
51
  }
52
52
 
53
+ /**
54
+ * 箱の内側に取る余白。 図の外枠と木の描画領域の間。
55
+ *
56
+ * **module に置く** (#538)。 描く側の中に閉じていた間、 検査側は同じ値を書き写すしかなく、
57
+ * 片方を変えた時にもう片方が古いまま残る。
58
+ */
59
+ const PAD_TOP = 40;
60
+ const PAD_RIGHT = 24;
61
+ const PAD_BOTTOM = 40;
62
+ const PAD_LEFT = 24;
63
+
64
+ /**
65
+ * 木の中の箱と枝を並べる (#538)。
66
+ *
67
+ * 描く側 (`TreeNode`) と検査側 (`visual-validate.ts` の `box-line-dropped`) の**唯一の入口**。
68
+ * 余白と canvas の出し方を 2 箇所に書くと、 余白を変えた時に検査だけが古い箱を見る。
69
+ */
70
+ export function 木の中身(
71
+ node: LaidNode,
72
+ stateValues: Record<string, string> = {},
73
+ ): { nodes: LayoutNode[]; edges: LayoutEdge[] } {
74
+ // 名前は状態を読める (#467)。 解決できない名前は書いたまま残して印を付ける
75
+ const treeNodes = resolveTreeData(node.treeData ?? [], stateValues);
76
+ return computeTreeLayout(
77
+ treeNodes,
78
+ node.w - PAD_LEFT - PAD_RIGHT,
79
+ node.h - PAD_TOP - PAD_BOTTOM,
80
+ );
81
+ }
82
+
83
+ /**
84
+ * 木の 1 箱に積む行 (名前 + 補足)。
85
+ *
86
+ * 字の大きさをここに集める (#538)。 描く側に直書きすると、 検査側が同じ数を書き写すことになり、
87
+ * 大きさを変えた時に「入るか」 の判定だけが古い値で動く。
88
+ */
89
+ export function 木の行(n: LayoutNode): 積む行[] {
90
+ return [
91
+ { 役割: "title", text: n.title, fontSize: n.depth === 0 ? 13 : 12 },
92
+ { 役割: "subtitle", text: n.subtitle ?? "", fontSize: 9 },
93
+ ];
94
+ }
95
+
53
96
  export function TreeNode({
54
97
  node,
55
98
  active,
@@ -67,18 +110,9 @@ export function TreeNode({
67
110
  }): JSX.Element {
68
111
  const x0 = node.cx - node.w / 2;
69
112
  const y0 = node.cy - node.h / 2;
70
- // 名前は状態を読める (#467)。 解決できない名前は書いたまま残して印を付ける
71
- const treeNodes = resolveTreeData(node.treeData ?? [], stateValues);
72
113
  const gradientId = `tree-root-grad-${node.id}`;
73
114
 
74
- const PAD_TOP = 40;
75
- const PAD_RIGHT = 24;
76
- const PAD_BOTTOM = 40;
77
- const PAD_LEFT = 24;
78
- const canvasW = node.w - PAD_LEFT - PAD_RIGHT;
79
- const canvasH = node.h - PAD_TOP - PAD_BOTTOM;
80
-
81
- const layout = computeTreeLayout(treeNodes, canvasW, canvasH);
115
+ const layout = 木の中身(node, stateValues);
82
116
 
83
117
  /**
84
118
  * 枝がどこまで伸びたか (0..1、 cdl#520)。 描く指定が無ければ常に 1 = 全部出ている。
@@ -153,22 +187,18 @@ export function TreeNode({
153
187
  const accent = depthColors[Math.min(n.depth, depthColors.length - 1)] ?? depthColors[0]!;
154
188
  const isRoot = n.depth === 0;
155
189
  // 補足は箱の中の 2 行目に出す (#471)。 箱の高さは変えないため、 入らない時は落ちる
156
- const = 箱に積む(
157
- [
158
- { 役割: "title" as const, text: n.title, fontSize: isRoot ? 13 : 12 },
159
- { 役割: "subtitle" as const, text: n.subtitle ?? "", fontSize: 9 },
160
- ],
161
- n.w,
162
- n.h,
163
- );
190
+ const 材料 = 木の行(n);
191
+ const 行 = 箱に積む(材料, n.w, n.h);
164
192
  // **判定は行数でなく「補足を渡したか」 で行う**。 行数で見ると、 箱が低くて補足が落ちた時に
165
193
  // 従来の 1 行の経路へ戻り、 幅で切っていない名前が箱から横にはみ出す (#471 Round 1)
166
194
  const 補足あり = (n.subtitle ?? "").trim() !== "";
167
195
  // 補足ありでは `箱に積む` が既に同じ処理を済ませているため、1 行の経路だけ計算する。
168
- // 収める手段は縮めるのが先で、切るのは下限まで縮めても入らない時だけ (#534)
196
+ // 収める手段は縮めるのが先で、切るのは下限まで縮めても入らない時だけ (#534)
197
+ // 名前の大きさは `木の行` から取る = ここに書き写すと 2 経路で別の大きさになる (#538)
198
+ const 名前 = 材料[0]!;
169
199
  const 収めた名前 = 補足あり
170
200
  ? undefined
171
- : 幅に収める(n.title, isRoot ? 13 : 12, 使える幅(n.w));
201
+ : 幅に収める(名前.text, 名前.fontSize, 使える幅(n.w));
172
202
  return (
173
203
  <g
174
204
  key={`n-${n.id}`}
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Visual validator ... layout 出力 (LaidDiagram) を 67 軸で検証する。
2
+ * Visual validator ... layout 出力 (LaidDiagram) を全ての軸で検証する。
3
+ *
4
+ * **軸の数をここに書き写さない** (#538)。 数は `EXPECTED_AXIS_COUNT` が SSOT で、 写すと軸を
5
+ * 足すたびに片方が古くなる (実測 = 本行が 67 のまま、 下の低 layer API の説明が 56 のままだった)。
3
6
  *
4
7
  * cdl 著者が「component が render される」 までで完成と思いがちな問題に対し、
5
8
  * 「視覚的に正しい」 を engine 層で機械判定する。
@@ -74,7 +77,7 @@
74
77
  * test が無いと、 次に種別を足した時に同じ状態へ戻る。
75
78
  * ───────────────────────────────────────────────────────────
76
79
  */
77
- import type { CdlDiagram, LaidDiagram, NodeKind } from "./types";
80
+ import type { CdlDiagram, LaidDiagram, LaidNode, NodeKind } from "./types";
78
81
  import { CLEARANCE_LANE_LABEL, DIST_LABEL_PATH_MAX } from "./layout/clearance-constants";
79
82
  import { layout } from "./layout";
80
83
  import {
@@ -88,6 +91,9 @@ import {
88
91
  } from "./layout/collisions";
89
92
  import { measureTextWidth } from "./layout/text-width";
90
93
  import { droppedLineIsHidden, labelPlan } from "./kinds/shape-region";
94
+ import { 箱に積む, type 積む行 } from "./kinds/box-lines";
95
+ import { 木の中身, 木の行 } from "./kinds/tree";
96
+ import { 放射の中身, 放射の行 } from "./kinds/mind-map";
91
97
  import { templateRefIds } from "./render/utils";
92
98
  import {
93
99
  bboxOverlaps,
@@ -331,6 +337,8 @@ export type VisualAxis =
331
337
  | "node-inside-viewbox"
332
338
  // Axis 67 (`#492` 追加) = 箱が狭くて書いた補いの行が描かれない node を書き手に伝える。
333
339
  | "shape-label-dropped"
340
+ // Axis 68 (`#538` 追加) = 木と放射の中の箱が低くて、 書いた補足が落ちたことを書き手に伝える。
341
+ | "box-line-dropped"
334
342
  // Axis 54 (2026-07-03 追加) = node bbox が所属 lane 内に収まっているか。
335
343
  // Axis 15 (lane-cx-consistency) は中心 x のみ、 本 axis は node 4 辺と lane 4 辺の包含関係を検知。
336
344
  | "node-inside-lane"
@@ -476,6 +484,7 @@ function emptyCounts(): Record<VisualAxis, number> {
476
484
  "node-inside-viewbox": 0,
477
485
  "node-inside-lane": 0,
478
486
  "shape-label-dropped": 0,
487
+ "box-line-dropped": 0,
479
488
  "edge-inside-viewbox": 0,
480
489
  "lane-label-inside-viewbox": 0,
481
490
  "lane-label-overlap": 0,
@@ -494,8 +503,30 @@ function emptyCounts(): Record<VisualAxis, number> {
494
503
  };
495
504
  }
496
505
 
506
+ /** 箱に行を積む種別の、 中の 1 箱。 */
507
+ type 積む箱 = { 行: 積む行[]; w: number; h: number };
508
+
509
+ /**
510
+ * 木と放射の中の箱を、 **描く時と同じ手順で** 集める (`#538`)。
511
+ *
512
+ * 箱の並べ方と積む行を kind 側の 1 箇所 (`木の中身` / `木の行` / `放射の中身` / `放射の行`) から
513
+ * 取る。 ここで余白や字の大きさを書き写すと、 描画側を変えた時に検査だけが古い箱を見る。
514
+ *
515
+ * 状態の値は渡さない。 `箱に積む` が行を落とすかどうかは **字の大きさと行数だけ** で決まり、
516
+ * 文字の中身に依らない。 状態を読む名前 (`{x}`) も解決の前後で行数が変わらないため、
517
+ * 落ちる / 落ちないの判定は状態に依らず同じになる。
518
+ *
519
+ * 行を積まない種別では空を返す。
520
+ */
521
+ function 積む箱を集める(n: LaidNode): 積む箱[] {
522
+ const 箱 = (b: { w: number; h: number }, 行: 積む行[]): 積む箱 => ({ 行, w: b.w, h: b.h });
523
+ if (n.kind === "tree-hierarchy") return 木の中身(n).nodes.map((b) => 箱(b, 木の行(b)));
524
+ if (n.kind === "mind-map") return (放射の中身(n)?.nodes ?? []).map((b) => 箱(b, 放射の行(b)));
525
+ return [];
526
+ }
527
+
497
528
  /**
498
- * LaidDiagram を直接受け取って 56 axis で視覚検証する低 layer API (v0.9+ 追加)。
529
+ * LaidDiagram を直接受け取って全ての軸で視覚検証する低 layer API (v0.9+ 追加)。
499
530
  *
500
531
  * visualValidate() は layout 経由で LaidDiagram を得た後、 本関数を呼び出す thin wrapper。
501
532
  * fixture test で layout を bypass して LaidDiagram を直接構築、 各 axis の real defect を
@@ -2262,6 +2293,59 @@ function runAxes(
2262
2293
  );
2263
2294
  }
2264
2295
 
2296
+ // ───────────────────────────────────────────────────────────
2297
+ // Axis 68: box-line-dropped (箱が低くて、 書いた補足が落ちる、 #538)
2298
+ //
2299
+ // 木 (`tree-hierarchy`) と放射 (`mind-map`) は中の箱に名前と補足を積む (`箱に積む`、 #471)。
2300
+ // 箱の高さは段の間隔から出るため、 段や枝が増えると 2 行が入らなくなる。 入らない行は
2301
+ // **後ろから落ちる** = 書いた補足が知らせも印も無く図から消える。
2302
+ //
2303
+ // 落ち方は段階的でない。 箱の高さは図の中で揃っているので、 1 つ入らなくなると同じ図の補足が
2304
+ // 全部消える (実測 = 1 本道の木は深さ 8 で 9 件すべて出て、 深さ 9 で 10 件すべて消える)。
2305
+ //
2306
+ // 幅の側は切った印 (`…`) が図に出る (#526) が、 高さの側は **箱に描く余地が無いから落ちている**
2307
+ // ので印を出せない。 検査で伝えるのが唯一の出口になる。
2308
+ //
2309
+ // 判定は描く時と同じ関数を同じ引数で呼ぶ。 描いた結果と突き合わせる形は採らない = 描画と判定で
2310
+ // 別の経路を通ると、 片方だけ直したずれに気付けない。
2311
+ //
2312
+ // Axis 67 (`shape-label-dropped`) との境界は **どの箱を見るか**。 あちらは `shape-` の箱が絵に
2313
+ // 帯を譲って補いの行を落とす形 (`labelPlan`)、 こちらは木と放射の中の箱が高さで落とす形。
2314
+ //
2315
+ // 中心の箱 (放射の高さ 52 固定) も対象に含める。 今のところ落ちないが、 高さは実装が決める値
2316
+ // なので、 対象から外すと変えた時に落ちても見えない。
2317
+ // ───────────────────────────────────────────────────────────
2318
+ for (const n of laid.nodes) {
2319
+ const 箱 = 積む箱を集める(n);
2320
+ if (箱.length === 0) continue;
2321
+ /** 補足を書いた箱の数。 書いていない箱は元から描かれないので数えない。 */
2322
+ let 対象 = 0;
2323
+ const 落ちた: { title: string; h: number }[] = [];
2324
+ for (const b of 箱) {
2325
+ // 先頭 (名前) は `箱に積む` が必ず残すので、 落ちうるのは 2 行目以降だけ。
2326
+ const 宣言 = b.行.slice(1).filter((r) => r.text.trim() !== "");
2327
+ if (宣言.length === 0) continue;
2328
+ 対象 += 1;
2329
+ const 残った = new Set(箱に積む(b.行, b.w, b.h).map((r) => r.役割));
2330
+ if (宣言.every((r) => 残った.has(r.役割))) continue;
2331
+ 落ちた.push({ title: b.行[0]?.text ?? "", h: b.h });
2332
+ }
2333
+ if (落ちた.length === 0) continue;
2334
+
2335
+ // 名前は先頭 5 件まで出し、 **残りの件数を必ず添える**。 黙って打ち切ると、 出ている名前が
2336
+ // 全部だと読める。
2337
+ const 名前 = 落ちた.map((d) => `"${d.title}"`);
2338
+ const 見せる = 名前.slice(0, 5).join(" / ");
2339
+ const 残り = 名前.length > 5 ? ` 他 ${名前.length - 5} 件` : "";
2340
+ const 低 = Math.min(...落ちた.map((d) => d.h)).toFixed(1);
2341
+ const 高 = Math.max(...落ちた.map((d) => d.h)).toFixed(1);
2342
+ push(
2343
+ "box-line-dropped",
2344
+ `node "${n.id}" (${n.kind}) は箱が低く、 書いた補足が ${落ちた.length}/${対象} 箱で描かれない (${見せる}${残り}、 箱の高さ ${低 === 高 ? 低 : `${低}〜${高}`})。 図を高くするか段を減らすか、 補足を消す`,
2345
+ "warn",
2346
+ );
2347
+ }
2348
+
2265
2349
  // ───────────────────────────────────────────────────────────
2266
2350
  // Axis 55: edge-inside-viewbox (edge path 全 segment 端点が viewBox 内に収まる)
2267
2351
  //
@@ -4296,6 +4380,7 @@ function validateMetaAxes(
4296
4380
  "node-inside-viewbox",
4297
4381
  "node-inside-lane",
4298
4382
  "shape-label-dropped",
4383
+ "box-line-dropped",
4299
4384
  "edge-inside-viewbox",
4300
4385
  "lane-label-inside-viewbox",
4301
4386
  "lane-label-overlap",
@@ -4365,6 +4450,7 @@ function validateMetaAxes(
4365
4450
  "node-inside-viewbox",
4366
4451
  "node-inside-lane",
4367
4452
  "shape-label-dropped",
4453
+ "box-line-dropped",
4368
4454
  "edge-inside-viewbox",
4369
4455
  "lane-label-inside-viewbox",
4370
4456
  "lane-label-overlap",
@@ -4400,7 +4486,7 @@ function validateMetaAxes(
4400
4486
  //
4401
4487
  // 削除済 = engine-meta 51 (mermaid-parity、 #366) / 軸 29 (svg-filter-integrity) /
4402
4488
  // 軸 30 (neumorphism-shadow-budget、 どちらも #426 で主題の廃止に伴い削除)。
4403
- const EXPECTED_AXIS_COUNT = 67;
4489
+ const EXPECTED_AXIS_COUNT = 68;
4404
4490
  if (allAxes.length !== EXPECTED_AXIS_COUNT) {
4405
4491
  out.push({
4406
4492
  axis: "axis-documentation-completeness",