@cardenelabs/cdl 0.41.0 → 0.42.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.41.0",
3
+ "version": "0.42.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",
@@ -581,9 +581,14 @@ export function requiredVerticalLabelGap(pillHs: readonly number[]): number {
581
581
  * `computeLabelBBoxWorld` は本 helper を呼出、 layout/edges.ts routePathL は labelX / labelY
582
582
  * 未確定の段階で幅だけ必要なので本 helper を単独で呼ぶ。
583
583
  *
584
- * 返す値は **偶数に切り上げる**。 label 位置は `経路の端 + 余白 + 幅 / 2` で決まり、
585
- * 文字幅の実測値をそのまま使うと半端な小数が乗って輪郭がにじむ (`subpixel-precision` 軸)。
586
- * 幅を偶数にすると `幅 / 2` が整数になり、 端と余白が整数である限り位置も整数に揃う。
584
+ * 返す値は **偶数に切り上げる**。 label 位置は `経路の端 + 余白 + 幅 / 2` で決まるので、
585
+ * 幅を偶数にすると `幅 / 2` が整数になり、 端と余白が整数である限り位置も 0.5 刻みに乗る。
586
+ *
587
+ * 元の根拠は「半端な小数が乗ると輪郭がにじむ」 だったが、 にじみは device pixel で起きる
588
+ * のに世界座標を見ていたため、 それを測っていた軸 33 は `#783` で畳んだ。 丸め自体は残す =
589
+ * 外すと全ての label が動く一方、 動かして良くなるものが無いため。 固定するのは
590
+ * 「座標が 0.5 刻みに乗る」 という性質で、 画面上のにじみは主張しない
591
+ * (`test/label-coord-half-grid.test.ts`)。
587
592
  *
588
593
  * 切り下げでなく切り上げるのは、 幅を狭める向きの丸めが節や他の label との間隔を
589
594
  * 減らすため。 広げる向きなら間隔は増える一方で、 増分は 2 world 未満に収まる
@@ -316,8 +316,8 @@ export type VisualAxis =
316
316
  // CAR-Z5 (PR #84) 追加 = SVG filter / marker / gradient integrity 4 領域。
317
317
  | "color-blind-safety"
318
318
  | "marker-gradient-def-integrity"
319
- // CAR-Z6 (PR #85) 追加 = subpixel / DOM complexity / motion / touch 4 領域。
320
- | "subpixel-precision"
319
+ // CAR-Z6 (PR #85) 追加 = DOM complexity / motion / touch 3 領域
320
+ // (subpixel は #783 で削除)。
321
321
  | "dom-complexity-budget"
322
322
  | "reduced-motion-compat"
323
323
  | "touch-target-size"
@@ -471,7 +471,6 @@ function emptyCounts(): Record<VisualAxis, number> {
471
471
  "print-media-compat": 0,
472
472
  "color-blind-safety": 0,
473
473
  "marker-gradient-def-integrity": 0,
474
- "subpixel-precision": 0,
475
474
  "dom-complexity-budget": 0,
476
475
  "reduced-motion-compat": 0,
477
476
  "touch-target-size": 0,
@@ -1217,8 +1216,11 @@ export function countEdgeCrossings(
1217
1216
  * 分割片どうしの比較回数の上限。
1218
1217
  *
1219
1218
  * 辺の総当たりで、 各対の分割片を全比較する。 曲線 380 本の図では 1850 万回になり 264ms
1220
- * かかった (実測)。 1 図あたりの予算は `PERF_BUDGET_PER_DIAGRAM_MS` = 100 で、 他の軸が
1221
- * 150ms 前後使うため本軸に使えるのは僅か。
1219
+ * かかった (実測)。 他の軸が 1 図あたり 150ms 前後を使うため、 本軸に回せるのは僅か。
1220
+ *
1221
+ * **1 図あたりの上限では判定していない** (`#776`)。 壁時計は同じ図を 3 周しても
1222
+ * 53 / 0 / 1 ms と揺れるので、 判定は全図の合計 (`PERF_BUDGET_TOTAL_MS`) だけが持つ。
1223
+ * ここの上限は「1 図が合計を食い潰さない」 ための設計上の目安で、 判定の境ではない。
1222
1224
  *
1223
1225
  * **上限に達したら数えるのをやめる**。 包囲箱で絞れないのは曲線どうしが広く重なる図で、
1224
1226
  * その形は総当たり以外に手が無い。 打ち切ると警告が出ないが、 出ない状態は弦で数えていた
@@ -3046,27 +3048,54 @@ function runAxes(
3046
3048
  }
3047
3049
 
3048
3050
  // ───────────────────────────────────────────────────────────
3049
- // Axis 23: responsive-viewport (viewBox の過剰扁平回避)
3050
- //
3051
- // 判定 = viewBox aspect ratio が RESPONSIVE_MAX_ASPECT (6:1) を超えると横長すぎで
3052
- // responsive 破綻。 縦 1 段の図に lane を並べ続けると高さが伸びないまま幅だけ増え、
3053
- // 親幅に収めた時に帯状に潰れて中身が読めなくなる。
3051
+ // Axis 23: responsive-viewport (親幅に収めた時に文字が小さくなりすぎないか)
3054
3052
  //
3055
- // viewBox width の下限は見ない。 render は `w-full` + `preserveAspectRatio="xMidYMid meet"`
3056
- // で親要素の幅いっぱいに伸ばすため (`render/stage.tsx`)、 画面上の文字サイズは
3053
+ // render は `w-full` + `preserveAspectRatio="xMidYMid meet"` で親要素の幅いっぱいに伸ばす
3054
+ // (`render/stage.tsx`)。 したがって画面上の文字サイズは
3057
3055
  //
3058
3056
  // 画面上の文字サイズ = 文字の world 寸法 × (親要素の幅 px ÷ viewBox width)
3059
3057
  //
3060
- // で決まる。 viewBox width が小さいほど倍率が上がって読みやすくなるので、 「幅が小さい =
3061
- // 識別不能」 は成り立たない。 以前は 400 world 未満を warn にしていたが、 dragon の見本で
3058
+ // で決まる。 **この式に縦横比は出てこない**。 判定は viewBox width の上限で行う。
3059
+ //
3060
+ // 以前は縦横比 6:1 を超えると warn にしていたが、 下流の見本帳 444 枚の実測で順位が
3061
+ // 一致しなかった。 画面上の文字が最も小さい図 (幅 3353 world、 器 1100px で 7.2px) は
3062
+ // 縦横比 1.4 なので 1 度も拾われず、 縦横比が最大の図 (比 8.0、 幅 1535 world) は 15.8px で
3063
+ // 十分読めた。 文字が小さい上位 20 枚のうち縦横比が拾ったのは 5 枚だけ (`#785`)。
3064
+ //
3065
+ // 幅の **下限** は見ない。 viewBox width が小さいほど倍率が上がって読みやすくなるので、
3066
+ // 「幅が小さい = 識別不能」 は成り立たない。 以前 400 world 未満を warn にしていた頃に
3062
3067
  // 発火した 17 件は全て単体部品の図 (271-390 world) で、 幅 375px の携帯では 1.0-1.38 倍に
3063
- // 拡大される最も読みやすい部類だった。 閾値を下げても判定の向きが誤ったまま残るため、
3064
- // 判定ごと外している。
3068
+ // 拡大される最も読みやすい部類だった。
3065
3069
  //
3066
- // 幅の下限を外した分、 描画不能な寸法 (0 / 負値 / 非有限) だけは別に拾う。 縦横比は
3067
- // 0 幅で NaN、 負値で符号が反転して比較が素通りするため、 比を出す前に弾く。
3070
+ // 描画不能な寸法 (0 / 負値 / 非有限) は別に拾う。 倍率は 0 幅で無限大になり、 負値で
3071
+ // 符号が反転して比較が素通りするため、 割る前に弾く。
3068
3072
  // ───────────────────────────────────────────────────────────
3069
- const RESPONSIVE_MAX_ASPECT = 6;
3073
+ //
3074
+ /**
3075
+ * 判定に使う親要素の幅 (px)。 書き出しの窓 1600px から左右の余白を引いた実測の見当。
3076
+ *
3077
+ * **実際の親幅は呼び出し側が決める**。 engine はそれを知らないので、 判定の基準として
3078
+ * 1 つ選ぶ。 選んだ値そのものが閾値の意味を決めるため、 定数として出す。
3079
+ */
3080
+ const RESPONSIVE_REF_CONTAINER_PX = 1100;
3081
+ /**
3082
+ * 画面上で読めると見なす文字の下限 (px)。
3083
+ *
3084
+ * 12px は WCAG が数値で定める下限ではない (WCAG は寸法でなく拡大できることを求める)。
3085
+ * 下流の見本帳で実際に読み取れるかを見て決めた運用上の下限で、 これを下回る図は
3086
+ * 器 1100px の画面で拡大しないと読めない。
3087
+ */
3088
+ const RESPONSIVE_MIN_ON_SCREEN_FONT_PX = 12;
3089
+ /**
3090
+ * 判定の基準にする文字の world 寸法。 箱の題の大きさ (`kinds/storage.tsx` の fontSize 22)。
3091
+ *
3092
+ * 図の中には これより小さい文字 (辺の名札等) もあるが、 基準を最小の文字に取ると
3093
+ * ほぼ全ての図が境を割る。 最も多く出る文字で見る。
3094
+ */
3095
+ const RESPONSIVE_BASE_FONT_WORLD = 22;
3096
+ /** 上の 3 つから導く viewBox width の上限。 数を直接書かない。 */
3097
+ const RESPONSIVE_MAX_VIEWBOX_W =
3098
+ (RESPONSIVE_BASE_FONT_WORLD * RESPONSIVE_REF_CONTAINER_PX) / RESPONSIVE_MIN_ON_SCREEN_FONT_PX;
3070
3099
  const vbResp = laid.viewBox;
3071
3100
  if (
3072
3101
  !(vbResp.w > 0) ||
@@ -3079,17 +3108,15 @@ function runAxes(
3079
3108
  `viewBox 寸法 ${vbResp.w}×${vbResp.h} が描画不能 (正の有限値である必要がある)`,
3080
3109
  "warn",
3081
3110
  );
3082
- } else {
3083
- // 横長と縦長を同じ尺度で見る。 縦長を aspect のまま報告すると detail が
3084
- // 「aspect 0.1 が 6:1 超え」 のように読めない値になる。
3085
- const worstAspect = Math.max(vbResp.w / vbResp.h, vbResp.h / vbResp.w);
3086
- if (worstAspect > RESPONSIVE_MAX_ASPECT) {
3087
- push(
3088
- "responsive-viewport",
3089
- `viewBox aspect ${worstAspect.toFixed(1)}:1 が RESPONSIVE_MAX_ASPECT ${RESPONSIVE_MAX_ASPECT}:1 超え`,
3090
- "warn",
3091
- );
3092
- }
3111
+ } else if (vbResp.w > RESPONSIVE_MAX_VIEWBOX_W) {
3112
+ const 画面上の字 = (RESPONSIVE_BASE_FONT_WORLD * RESPONSIVE_REF_CONTAINER_PX) / vbResp.w;
3113
+ push(
3114
+ "responsive-viewport",
3115
+ `viewBox width ${vbResp.w} が上限 ${RESPONSIVE_MAX_VIEWBOX_W.toFixed(0)} 超え ` +
3116
+ `(親幅 ${RESPONSIVE_REF_CONTAINER_PX}px で箱の題が ${画面上の字.toFixed(1)}px、 ` +
3117
+ `下限 ${RESPONSIVE_MIN_ON_SCREEN_FONT_PX}px)`,
3118
+ "warn",
3119
+ );
3093
3120
  }
3094
3121
 
3095
3122
  // ───────────────────────────────────────────────────────────
@@ -3364,55 +3391,6 @@ function runAxes(
3364
3391
  }
3365
3392
  }
3366
3393
 
3367
- // ───────────────────────────────────────────────────────────
3368
- // Axis 33: subpixel-precision (node / edge label 座標の subpixel drift 検知)
3369
- //
3370
- // 判定 = node cx / cy / edge labelX / labelY / lane x / y が SUBPIXEL_TOL (0.5 world) を
3371
- // 超えて小数点を持つ (e.g. 100.7) と subpixel render 時に SVG 描画が blur を生む。
3372
- // 整数化 or 0.5 刻みが理想。 label は shift 経路の結果として非整数が出やすい。
3373
- // ───────────────────────────────────────────────────────────
3374
- const SUBPIXEL_TOL = 0.5;
3375
- function isSubpixel(v: number): boolean {
3376
- // 整数と 0.5 刻みの両方を許容する。
3377
- //
3378
- // 0.5 は SVG では「にじむ座標」 ではない。 幅 1 の線は中心を .5 に置くと 1 pixel に
3379
- // ちょうど収まって輪郭が立つ (逆に整数に置くと 2 pixel に半分ずつ跨いでぼやける)。
3380
- // 本 axis の説明も「整数化 or 0.5 刻みが理想」 としており、 判定側だけが 0.5 を
3381
- // 弾いていた。 説明に合わせる。
3382
- //
3383
- // 判定は「最も近い 0.5 の倍数からの距離」 で見る。 0.05 以下は float 演算の誤差として許容。
3384
- const nearestHalf = Math.round(v * 2) / 2;
3385
- return Math.abs(v - nearestHalf) > SUBPIXEL_TOL / 10;
3386
- }
3387
- for (const n of laid.nodes) {
3388
- if (n.id.endsWith("-spacer") || /^s\d+-/.test(n.id)) continue;
3389
- if (isSubpixel(n.cx) || isSubpixel(n.cy)) {
3390
- push(
3391
- "subpixel-precision",
3392
- `node "${n.id}" 座標 (${n.cx.toFixed(2)}, ${n.cy.toFixed(2)}) が非整数、 subpixel blur リスク`,
3393
- "warn",
3394
- );
3395
- }
3396
- }
3397
- for (const e of laid.edges) {
3398
- if (isSubpixel(e.labelX) || isSubpixel(e.labelY)) {
3399
- push(
3400
- "subpixel-precision",
3401
- `edge "${e.id}" label 座標 (${e.labelX.toFixed(2)}, ${e.labelY.toFixed(2)}) が非整数、 subpixel blur リスク`,
3402
- "warn",
3403
- );
3404
- }
3405
- }
3406
- for (const l of laid.lanes) {
3407
- if (isSubpixel(l.x) || isSubpixel(l.y)) {
3408
- push(
3409
- "subpixel-precision",
3410
- `lane "${l.id}" 座標 (${l.x.toFixed(2)}, ${l.y.toFixed(2)}) が非整数、 subpixel blur リスク`,
3411
- "warn",
3412
- );
3413
- }
3414
- }
3415
-
3416
3394
  // ───────────────────────────────────────────────────────────
3417
3395
  // Axis 34: dom-complexity-budget (1 diagram あたりの element 総数)
3418
3396
  //
@@ -4333,6 +4311,76 @@ export function visualValidateAll(diagrams: CdlDiagram[], opts?: ValidateOptions
4333
4311
  * source を読めない (build 後は minified) ため、 実装は要素数の一致に留まる。
4334
4312
  * `VisualAxis` / `emptyCounts` / `allAxes` の 3 つが揃っているかは見ない (#381)。
4335
4313
  */
4314
+ /** 全 diagram の検査に掛けてよい合計の時間 (ms)。 */
4315
+ export const PERF_BUDGET_TOTAL_MS = 5000;
4316
+
4317
+ /** 合計が境を超えた時に、内訳として名前を出す図の数。 */
4318
+ export const PERF_SLOWEST_SAMPLE = 5;
4319
+
4320
+ /**
4321
+ * Axis 52 `validate-performance-budget` の判定 (`#776`)。
4322
+ *
4323
+ * ## 1 枚ごとの壁時計では判定しない
4324
+ *
4325
+ * 同じ図を同じ process で 3 周すると答えが変わる。 実測 (下流の見本帳 444 枚)。
4326
+ *
4327
+ * ```
4328
+ * interactive-social-share-buttons 53ms / 0ms / 1ms
4329
+ * interactive-traffic-sankey 50ms / 159ms / 115ms
4330
+ * ```
4331
+ *
4332
+ * 1 つ目は最初の 1 回だけ掛かる下準備で、2 つ目は機械の混み具合。 どちらも図の性質ではない。
4333
+ * 100ms の境で数えると **入力が同じなのに超過が 0 / 1 / 1 件** と周ごとに変わっていた =
4334
+ * 測れなかったことを「遅い」 に倒していた
4335
+ * (`rules/quality.md § 判定できなかったことを値に潰さない`)。
4336
+ *
4337
+ * 合計は同じ 3 周で 559 / 877 / 858 ms と 1.6 倍の幅に収まり、境の 5000ms から充分離れている
4338
+ * ので判定が安定する。 **合計だけを見る**。
4339
+ *
4340
+ * ## 報告を 1 件に丸めない
4341
+ *
4342
+ * 合計だけを見ると「どの図が重いか」 が落ちるので、超過した時に重い順の内訳を文面へ載せる。
4343
+ * 出し切れない分は件数で残す = 黙って捨てない。
4344
+ *
4345
+ * 旧実装は `findIndex` で最初に当たった 1 枚しか出しておらず、20 枚が超過した周でも報告は
4346
+ * 1 枚だった。 残り 19 枚は数にも文面にも出ない。
4347
+ *
4348
+ * ## なぜ関数に切り出すか
4349
+ *
4350
+ * 合計が 5000ms を超える状況を実際に作ると、確かめる検査そのものが 5 秒以上掛かる。
4351
+ * 時間の配列を直接渡せる形にして、両向きを実時間なしで固定する。
4352
+ *
4353
+ * @param durations 図ごとの実行時間 (ms)。 `diagrams` と同じ並び
4354
+ * @param diagrams 図。 名前を内訳に出すためだけに使う
4355
+ */
4356
+ export function perfBudgetViolations(
4357
+ durations: readonly number[],
4358
+ diagrams: readonly { id: string }[] = [],
4359
+ ): Violation[] {
4360
+ const total = durations.reduce((s, d) => s + d, 0);
4361
+ if (total <= PERF_BUDGET_TOTAL_MS) return [];
4362
+
4363
+ const 重い順 = durations
4364
+ .map((ms, i) => ({ id: diagrams[i]?.id ?? "?", ms }))
4365
+ .sort((a, b) => b.ms - a.ms);
4366
+ const 抜粋 = 重い順.slice(0, PERF_SLOWEST_SAMPLE);
4367
+ const 残り = 重い順.length - 抜粋.length;
4368
+ const 内訳 =
4369
+ 抜粋.map((d) => `"${d.id}" ${d.ms}ms`).join(" / ") +
4370
+ (残り > 0 ? `、 残り ${残り} 図は出していない` : "");
4371
+
4372
+ return [
4373
+ {
4374
+ axis: "validate-performance-budget",
4375
+ diagramId: "__meta__",
4376
+ detail:
4377
+ `visualValidateAll 全 ${durations.length} 図の累計 ${total}ms > ` +
4378
+ `${PERF_BUDGET_TOTAL_MS}ms budget (重い順 ${内訳})`,
4379
+ severity: "warn",
4380
+ },
4381
+ ];
4382
+ }
4383
+
4336
4384
  function validateMetaAxes(
4337
4385
  totalCounts: Record<VisualAxis, number>,
4338
4386
  diagrams: CdlDiagram[] = [],
@@ -4364,27 +4412,7 @@ function validateMetaAxes(
4364
4412
  // 本 axis は 空 (engine 単体で判定材料なし)、 発火経路として先取り。
4365
4413
 
4366
4414
  // ─── Axis 52: validate-performance-budget (validate 実行時間) ───
4367
- const PERF_BUDGET_PER_DIAGRAM_MS = 100;
4368
- const PERF_BUDGET_TOTAL_MS = 5000;
4369
- const overBudgetIdx = durations.findIndex((d) => d > PERF_BUDGET_PER_DIAGRAM_MS);
4370
- if (overBudgetIdx >= 0) {
4371
- const did = diagrams[overBudgetIdx]?.id ?? "?";
4372
- out.push({
4373
- axis: "validate-performance-budget",
4374
- diagramId: did,
4375
- detail: `diagram "${did}" validate ${durations[overBudgetIdx]}ms > ${PERF_BUDGET_PER_DIAGRAM_MS}ms budget`,
4376
- severity: "warn",
4377
- });
4378
- }
4379
- const totalDur = durations.reduce((s, d) => s + d, 0);
4380
- if (totalDur > PERF_BUDGET_TOTAL_MS) {
4381
- out.push({
4382
- axis: "validate-performance-budget",
4383
- diagramId: "__meta__",
4384
- detail: `visualValidateAll 全 diagram 累計 ${totalDur}ms > ${PERF_BUDGET_TOTAL_MS}ms budget`,
4385
- severity: "warn",
4386
- });
4387
- }
4415
+ out.push(...perfBudgetViolations(durations, diagrams));
4388
4416
 
4389
4417
  // Axis 47: dead axis 検知
4390
4418
  // security / migration 系は自然発火なしを許容 (実データに XSS / migration 混入は稀)
@@ -4428,7 +4456,6 @@ function validateMetaAxes(
4428
4456
  "print-media-compat",
4429
4457
  "color-blind-safety",
4430
4458
  "marker-gradient-def-integrity",
4431
- "subpixel-precision",
4432
4459
  "dom-complexity-budget",
4433
4460
  "reduced-motion-compat",
4434
4461
  "touch-target-size",
@@ -4497,7 +4524,6 @@ function validateMetaAxes(
4497
4524
  "print-media-compat",
4498
4525
  "color-blind-safety",
4499
4526
  "marker-gradient-def-integrity",
4500
- "subpixel-precision",
4501
4527
  "dom-complexity-budget",
4502
4528
  "reduced-motion-compat",
4503
4529
  "touch-target-size",
@@ -4554,8 +4580,9 @@ function validateMetaAxes(
4554
4580
  //
4555
4581
  // 削除済 = engine-meta 51 (mermaid-parity、 #366) / 軸 29 (svg-filter-integrity) /
4556
4582
  // 軸 30 (neumorphism-shadow-budget、 どちらも #426 で主題の廃止に伴い削除) /
4557
- // 軸 40 (memory-budget、 #775 で軸 34 に一本化)。
4558
- const EXPECTED_AXIS_COUNT = 67;
4583
+ // 軸 40 (memory-budget、 #775 で軸 34 に一本化) /
4584
+ // 軸 33 (subpixel-precision、 #783 で削除)。
4585
+ const EXPECTED_AXIS_COUNT = 66;
4559
4586
  if (allAxes.length !== EXPECTED_AXIS_COUNT) {
4560
4587
  out.push({
4561
4588
  axis: "axis-documentation-completeness",