@cardenelabs/cdl 0.112.0 → 0.114.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.112.0",
3
+ "version": "0.114.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,10 +1,17 @@
1
1
  import type { JSX } from "react";
2
2
  import { 使える幅, 幅に収める, 箱に積む, type 積む行 } from "./box-lines";
3
+ /*
4
+ 名前を描く幅の見積り。 **切る側と同じ物差しを使う** (#966)。
5
+
6
+ `箱に積む` と `幅に収める` はこの `textWidth` で切るかどうかを決める。 置き場所を別の
7
+ 見積り (`layout/text-width` の `measureTextWidth`) で決めていたため、ちょうど収まる位置に
8
+ 置いたつもりの名前が描く側で切られた (実測 = `Features` が `Featur…` になった)。
9
+ */
10
+ import { textWidth as 名前の幅 } from "./text-width";
3
11
  import type { LaidNode, Tone, MindBranchNode, MindBranchPayload } from "../types";
4
12
  import { TONE } from "../render/tone";
5
13
  import { resolveMindData } from "../render/payload-binding";
6
14
  import { 進みを畳む, 伸ばすdash, 段の伸び } from "./draw-progress";
7
- import { measureTextWidth } from "../layout/text-width";
8
15
 
9
16
  const MIND_TONES: Tone[] = ["accent", "teal", "success", "warning", "info", "error"];
10
17
 
@@ -104,7 +111,7 @@ export function 放射の中身(
104
111
  *
105
112
  * 字の大きさをここに集める (#538)。 理由は `木の行` と同じ。
106
113
  */
107
- export function 放射の行(n: LayoutNode): 積む行[] {
114
+ export function 放射の行(n: Pick<LayoutNode, "title" | "subtitle" | "isRoot">): 積む行[] {
108
115
  return [
109
116
  { 役割: "title", text: n.title, fontSize: n.isRoot ? 15 : 13 },
110
117
  { 役割: "subtitle", text: n.subtitle ?? "", fontSize: 9 },
@@ -302,11 +309,11 @@ type LayoutEdge = {
302
309
  * 名前が描画域に入らない時、描く側は字を縮めてから切る。 ここで出すのは縮める前の幅 =
303
310
  * 実際に描く幅の上限になる。 縮むのは名前が溢れる図だけで、その時も箱の幅で測るより近い。
304
311
  */
305
- function 描く横の端(n: LayoutNode): [number, number] {
312
+ export function 描く横の端(n: LayoutNode): [number, number] {
306
313
  if (n.isRoot || !n.isLeaf) return [n.x - n.w / 2, n.x + n.w / 2];
307
314
  const 起点 = n.x + (n.side === "right" ? -n.w / 2 : n.w / 2);
308
315
  const 名前 = 放射の行(n)[0]!;
309
- const 幅 = measureTextWidth(名前.text, { fontSize: 名前.fontSize, fontWeight: 600 });
316
+ const 幅 = 名前の幅(名前.text, 名前.fontSize);
310
317
  return n.side === "right" ? [起点, 起点 + 幅] : [起点 - 幅, 起点];
311
318
  }
312
319
 
@@ -401,48 +408,108 @@ export function computeMindMapLayout(
401
408
  return kids.reduce((sum, k) => sum + leafCountOf(k.id, seen), 0);
402
409
  };
403
410
 
404
- const maxSubDepth =
405
- firstLevel.length > 0
406
- ? Math.max(0, ...firstLevel.map((br) => subDepthOf(br.id, new Set())))
407
- : 0;
411
+ // 左右の振り分け。 横の置き場所を側ごとに決めるので、割付より先に分ける (#966)
412
+ const leftBranches = firstLevel.slice(0, leftCount);
413
+ const rightBranches = firstLevel.slice(leftCount);
408
414
 
409
415
  // branch を root からどれだけ離すか。 深いネストがあるほど sub 世代のぶん内側に寄せる。
410
416
  const minLayer1XOffset = rootW / 2 + branchW / 2 + MIND_BOX_GAP;
411
417
  const preferredSubDx = subBranchW + 20;
412
- const reserve = maxSubDepth > 0 ? branchW / 2 + maxSubDepth * preferredSubDx : 0;
413
- const maxLayer1XOffset = Math.max(minLayer1XOffset, canvasW / 2 - reserve);
414
- const layer1XOffset = Math.max(minLayer1XOffset, Math.min(canvasW * 0.32, maxLayer1XOffset));
415
-
416
- // branch の外側に残る横の余白。 これを深さで割り、 sub box もそこに収まる幅に縮める。
417
- // 単一 chain は同じ縦レーンを共有するので、 横 step が box より広くないと重なる。
418
- const room = canvasW / 2 - layer1XOffset;
419
418
  /** 箱の中の文字が読める最小の幅。 これを割ると文字だけが縮まず箱からはみ出す。 */
420
419
  const MIN_SUB_W = 56;
421
420
  /** 親と子の箱の間隔として望ましい値。 中心 → 第 1 階層と同じ `MIND_BOX_GAP` を使う。 */
422
421
  const PREFERRED_GAP = MIND_BOX_GAP;
423
- //
424
- // **箱の幅を先に確保し、 余った分を間隔に回す**。 浅い図では望ましい値になり、 深い図では
425
- // 従来どおりまで詰まる = 箱が潰れる形は増やさない。
426
- const gapRoom =
427
- maxSubDepth > 0 ? (room - branchW / 2 - maxSubDepth * MIN_SUB_W) / maxSubDepth : 0;
428
- const gap = Math.max(8, Math.min(PREFERRED_GAP, gapRoom));
429
- // 最深 box の外端が canvas 端に収まる effSubW を解く。
430
- // layer1XOffset + branchW/2 + effSubW*maxSubDepth + maxSubDepth*gap <= canvasW/2
431
- // (step1 = (branchW+effSubW)/2 + gap、 stepN = effSubW + gap、 最深 box 半幅 effSubW/2 込み)
432
- // 下限は極小 (1)。 大きな floor は深いネストで subOffset の累積を膨らませる。 位置の
433
- // はみ出しは最終スケール (下部) が捌くので、 ここでは重なり回避に必要な最小だけ残す。
434
- const effSubW =
435
- maxSubDepth > 0
436
- ? Math.max(1, Math.min(subBranchW, (room - branchW / 2 - maxSubDepth * gap) / maxSubDepth))
437
- : subBranchW;
438
- const step1 = (branchW + effSubW) / 2 + gap; // branch → 第 1 sub
439
- const stepN = effSubW + gap; // sub → sub
440
- const subOffset = (subDepth: number): number =>
441
- subDepth <= 0 ? 0 : step1 + (subDepth - 1) * stepN;
422
+ /** 枝の名前を描く幅。 字の級は `放射の行` から取る (#538、 書き写すと 2 経路で別の値になる) */
423
+ const 枝の名前の幅 = (title: string): number => {
424
+ const 名前 = 放射の行({ title, isRoot: false })[0]!;
425
+ return 名前の幅(名前.text, 名前.fontSize);
426
+ };
427
+
428
+ /** 片側の横の割付。 枝の離れと、その側の sub box の幅と、深さごとの追い出し量を持つ */
429
+ type 横の割付 = {
430
+ layer1XOffset: number;
431
+ effSubW: number;
432
+ subOffset: (subDepth: number) => number;
433
+ };
434
+
435
+ /*
436
+ 横の割付を **枝ごとに** 出す (#966)。
437
+
438
+ それまでは図全体で 1 つの値を使っていた。 入れ子を持つ枝は子のぶんを内側に空ける必要が
439
+ あるので、その都合が入れ子を持たない枝にも効く。 左が入れ子で右が葉 1 枚の図では
440
+ 右の枝も中心から 149 に留まり、右には空ける相手が居ないのにその 171 を誰も使わない
441
+ (実測 = 左が 320 届くのに右は 108 で、札の横を 52% しか使わなかった)。
442
+
443
+ 式そのものは #964 と同じで、材料をその枝の subtree だけから取る。
444
+ 枝は自分の中身が許すところまで外へ出る。
445
+ */
446
+ const 枝の横を決める = (br: MindBranchNode): 横の割付 => {
447
+ const maxSubDepth = subDepthOf(br.id, new Set());
448
+ /*
449
+ どこまで外に出られるかは **枝が末端かどうか** で変わる (#966)。
450
+
451
+ 子を持つ枝は箱が描かれるので、子の世代のぶんを内側に空けて箱ごと描画域に収める。
452
+ 末端の枝は枠を持たず (#907)、名前を枠があった位置の内側の端から外へ伸ばすだけなので、
453
+ 収めるのは名前で、箱ではない。 箱まで収めると、名前より 41 広い箱の都合で
454
+ 名前が描画域の内側で終わる (実測 = 名前 24 の図で端まで 41 余った)。
455
+
456
+ 名前の右端は `offset - branchW / 2 + 名前の幅` に来る。 これが `canvasW / 2` に
457
+ 一致する offset が末端の上限になる。 決め打ちの割合 (以前は `canvasW * 0.32`) だと
458
+ 名前の長さと無関係に中央寄りへ留まった (#964 の実測で横の使用率 66%)。
459
+ */
460
+ const 上限 =
461
+ maxSubDepth > 0
462
+ ? canvasW / 2 - (branchW / 2 + maxSubDepth * preferredSubDx)
463
+ : canvasW / 2 + branchW / 2 - 枝の名前の幅(br.title);
464
+ /*
465
+ **整数に落とす** (#966)。 枝の長さは座標の差から出るので、端数を持ったまま置くと
466
+ 丸め誤差が乗る (実測 = 24 であるべき枝が 23.99999999999997 になり `render-polish-240`
467
+ が落ちた)。 下に倒すのは、上限を 1px でも超えると描く側が名前を 1 級縮めるため。
468
+ すぐ下の寄せが整数に丸めているのと同じ理由になる。
469
+ */
470
+ const layer1XOffset = Math.floor(Math.max(minLayer1XOffset, 上限));
471
+
472
+ // branch の外側に残る横の余白。 これを深さで割り、 sub box もそこに収まる幅に縮める。
473
+ // 単一 chain は同じ縦レーンを共有するので、 横 step が box より広くないと重なる。
474
+ const room = canvasW / 2 - layer1XOffset;
475
+ //
476
+ // **箱の幅を先に確保し、 余った分を間隔に回す**。 浅い図では望ましい値になり、 深い図では
477
+ // 従来どおりまで詰まる = 箱が潰れる形は増やさない。
478
+ const gapRoom =
479
+ maxSubDepth > 0 ? (room - branchW / 2 - maxSubDepth * MIN_SUB_W) / maxSubDepth : 0;
480
+ const gap = Math.floor(Math.max(8, Math.min(PREFERRED_GAP, gapRoom)));
481
+ // 最深 box の外端が canvas 端に収まる effSubW を解く。
482
+ // layer1XOffset + branchW/2 + effSubW*maxSubDepth + maxSubDepth*gap <= canvasW/2
483
+ // (step1 = (branchW+effSubW)/2 + gap、 stepN = effSubW + gap、 最深 box 半幅 effSubW/2 込み)
484
+ // 下限は極小 (1)。 大きな floor は深いネストで subOffset の累積を膨らませる。 位置の
485
+ // はみ出しは最終スケール (下部) が捌くので、 ここでは重なり回避に必要な最小だけ残す。
486
+ const effSubW =
487
+ maxSubDepth > 0
488
+ ? Math.floor(
489
+ Math.max(1, Math.min(subBranchW, (room - branchW / 2 - maxSubDepth * gap) / maxSubDepth)),
490
+ )
491
+ : subBranchW;
492
+ const step1 = (branchW + effSubW) / 2 + gap; // branch → 第 1 sub
493
+ const stepN = effSubW + gap; // sub → sub
494
+ return {
495
+ layer1XOffset,
496
+ effSubW,
497
+ subOffset: (subDepth: number): number =>
498
+ subDepth <= 0 ? 0 : step1 + (subDepth - 1) * stepN,
499
+ };
500
+ };
501
+ /** 枝ごとの横の割付。 同じ枝を 2 度計算しない */
502
+ const 横の割付表 = new Map<string, 横の割付>(
503
+ firstLevel.map((br) => [br.id, 枝の横を決める(br)]),
504
+ );
505
+ /** 一番内側に置かれた枝の離れ。 帯の間隔の上限に使う */
506
+ const 一番内側の離れ = Math.min(
507
+ ...(firstLevel.length > 0
508
+ ? firstLevel.map((br) => 横の割付表.get(br.id)!.layer1XOffset)
509
+ : [minLayer1XOffset]),
510
+ );
442
511
 
443
512
  // 縦レーン。 左右それぞれの branch 群の葉数合計でレーンを割る。
444
- const leftBranches = firstLevel.slice(0, leftCount);
445
- const rightBranches = firstLevel.slice(leftCount);
446
513
  const leavesOfSide = (brs: MindBranchNode[]): number =>
447
514
  Math.max(
448
515
  1,
@@ -459,7 +526,12 @@ export function computeMindMapLayout(
459
526
  const laneBandH = Math.max(0, canvasH - BRANCH_BOX_H_MAX);
460
527
  const rowGap =
461
528
  sideLaneCount > 1
462
- ? Math.min(laneBandH / (sideLaneCount - 1), 縦の間隔の上限(layer1XOffset))
529
+ ? Math.min(
530
+ laneBandH / (sideLaneCount - 1),
531
+ // 帯は図で 1 つなので、**一番内側の枝** から上限を取る (#966)。
532
+ // 外へ出た枝に合わせると、内側に残った枝の傾きが 45 度を超える
533
+ 縦の間隔の上限(一番内側の離れ),
534
+ )
463
535
  : subBranchH + 14;
464
536
  // box 高は必ず rowGap の一定割合に収める。 加算的な floor を置くと、 leaf が多く rowGap が
465
537
  // 小さいとき floor が rowGap を超えて縦重なりする。 rowGap の 70% にすれば規模によらず gap が残る。
@@ -501,11 +573,12 @@ export function computeMindMapLayout(
501
573
  tone: Tone | undefined,
502
574
  placed: Set<string>,
503
575
  laneRef: { cursor: number },
576
+ 横: 横の割付,
504
577
  ): number => {
505
578
  placed.add(node.id);
506
579
  const dirX = isLeft ? -1 : 1;
507
- const x = cx + dirX * (layer1XOffset + subOffset(subDepth));
508
- const w = subDepth === 0 ? branchW : effSubW;
580
+ const x = cx + dirX * (横.layer1XOffset + 横.subOffset(subDepth));
581
+ const w = subDepth === 0 ? branchW : 横.effSubW;
509
582
  const h = subDepth === 0 ? effBranchH : effSubH;
510
583
  const myTone = node.tone ?? tone;
511
584
 
@@ -523,6 +596,7 @@ export function computeMindMapLayout(
523
596
  myTone ?? MIND_TONES[i % MIND_TONES.length]!,
524
597
  placed,
525
598
  laneRef,
599
+ 横,
526
600
  ),
527
601
  );
528
602
  y = (ys[0]! + ys[ys.length - 1]!) / 2;
@@ -559,7 +633,7 @@ export function computeMindMapLayout(
559
633
  const placed = new Set<string>();
560
634
  brs.forEach((br, i) => {
561
635
  const tone = br.tone ?? MIND_TONES[(toneBase + i) % MIND_TONES.length]!;
562
- placeSubtree(br, 0, isLeft, tone, placed, laneRef);
636
+ placeSubtree(br, 0, isLeft, tone, placed, laneRef, 横の割付表.get(br.id) ?? 枝の横を決める(br));
563
637
  });
564
638
  };
565
639
  placeSide(leftBranches, true, 0);
@@ -576,7 +650,13 @@ export function computeMindMapLayout(
576
650
  let maxExtentX = rootW / 2;
577
651
  let maxExtentY = rootH / 2;
578
652
  for (const n of nodes) {
579
- maxExtentX = Math.max(maxExtentX, Math.abs(n.x - cx) + n.w / 2);
653
+ // 横は **描く端** で測る (#964)。 末端は枠を持たず (#907)、箱は名前を置く目印としてしか
654
+ // 残っていない。 箱で測ると、枠の無い末端が描いてもいない幅を占めていることになり、
655
+ // 何もはみ出していない図まで縮む (実測 = 枝 1 本の図が 0.83 倍になり、
656
+ // 箱の高さが 30.8 → 25.6 に縮んで補足の行が落ちた)。
657
+ // すぐ下の寄せも同じ `描く横の端` で測るので、縮めると寄せの基準も揃う。
658
+ const [左, 右] = 描く横の端(n);
659
+ maxExtentX = Math.max(maxExtentX, Math.abs(左 - cx), Math.abs(右 - cx));
580
660
  maxExtentY = Math.max(maxExtentY, Math.abs(n.y - cy) + n.h / 2);
581
661
  }
582
662
  const scale = Math.min(1, drawableHalfW / maxExtentX, drawableHalfH / maxExtentY);