@cardenelabs/cdl 0.113.0 → 0.115.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.113.0",
3
+ "version": "0.115.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
 
@@ -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,72 +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
- /*
415
- 第 1 階層の葉の名前のうち一番広いもの。 葉は枠を持たず、名前を枠があった位置の内側の端から
416
- 外へ伸ばす (#907)。 つまり **枝をどこまで外に置けるかは名前の幅が決める**。
417
-
418
- 葉が 1 つも無ければ 0 になり、下の上限は描画域の端そのものになる = 名前では決まらない。
419
- */
420
- const 葉の名前の最大幅 = Math.max(
421
- 0,
422
- ...firstLevel
423
- .filter((br) => (childrenOf.get(br.id) ?? []).length === 0)
424
- .map((br) => {
425
- const 名前 = 放射の行({ title: br.title, isRoot: false })[0]!;
426
- return measureTextWidth(名前.text, { fontSize: 名前.fontSize, fontWeight: 600 });
427
- }),
428
- );
429
- /*
430
- 名前の右端が描画域の端に届く位置。 枝は `layer1XOffset` だけ中心から離れ、名前は
431
- そこから `branchW / 2` 内側を起点に外へ伸びるので、右端は
432
- `offset - branchW / 2 + 名前の幅` になる。 これが `canvasW / 2` に一致する offset が上限。
433
-
434
- ここを決め打ちの割合 (以前は `canvasW * 0.32`) にすると、名前が短い図でも枝が中央寄りのまま
435
- 留まり、外側に誰も使わない余白が残る (#963 の実測で横の使用率 66%)。
436
- */
437
- const 名前で決まる上限 = canvasW / 2 + branchW / 2 - 葉の名前の最大幅;
438
- const layer1XOffset = Math.max(minLayer1XOffset, Math.min(名前で決まる上限, maxLayer1XOffset));
439
-
440
- // branch の外側に残る横の余白。 これを深さで割り、 sub box もそこに収まる幅に縮める。
441
- // 単一 chain は同じ縦レーンを共有するので、 横 step が box より広くないと重なる。
442
- const room = canvasW / 2 - layer1XOffset;
443
418
  /** 箱の中の文字が読める最小の幅。 これを割ると文字だけが縮まず箱からはみ出す。 */
444
419
  const MIN_SUB_W = 56;
445
420
  /** 親と子の箱の間隔として望ましい値。 中心 → 第 1 階層と同じ `MIND_BOX_GAP` を使う。 */
446
421
  const PREFERRED_GAP = MIND_BOX_GAP;
447
- //
448
- // **箱の幅を先に確保し、 余った分を間隔に回す**。 浅い図では望ましい値になり、 深い図では
449
- // 従来どおりまで詰まる = 箱が潰れる形は増やさない。
450
- const gapRoom =
451
- maxSubDepth > 0 ? (room - branchW / 2 - maxSubDepth * MIN_SUB_W) / maxSubDepth : 0;
452
- const gap = Math.max(8, Math.min(PREFERRED_GAP, gapRoom));
453
- // 最深 box の外端が canvas 端に収まる effSubW を解く。
454
- // layer1XOffset + branchW/2 + effSubW*maxSubDepth + maxSubDepth*gap <= canvasW/2
455
- // (step1 = (branchW+effSubW)/2 + gap、 stepN = effSubW + gap、 最深 box 半幅 effSubW/2 込み)
456
- // 下限は極小 (1)。 大きな floor は深いネストで subOffset の累積を膨らませる。 位置の
457
- // はみ出しは最終スケール (下部) が捌くので、 ここでは重なり回避に必要な最小だけ残す。
458
- const effSubW =
459
- maxSubDepth > 0
460
- ? Math.max(1, Math.min(subBranchW, (room - branchW / 2 - maxSubDepth * gap) / maxSubDepth))
461
- : subBranchW;
462
- const step1 = (branchW + effSubW) / 2 + gap; // branch → 第 1 sub
463
- const stepN = effSubW + gap; // sub → sub
464
- const subOffset = (subDepth: number): number =>
465
- 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
+ );
466
511
 
467
512
  // 縦レーン。 左右それぞれの branch 群の葉数合計でレーンを割る。
468
- const leftBranches = firstLevel.slice(0, leftCount);
469
- const rightBranches = firstLevel.slice(leftCount);
470
513
  const leavesOfSide = (brs: MindBranchNode[]): number =>
471
514
  Math.max(
472
515
  1,
@@ -483,7 +526,12 @@ export function computeMindMapLayout(
483
526
  const laneBandH = Math.max(0, canvasH - BRANCH_BOX_H_MAX);
484
527
  const rowGap =
485
528
  sideLaneCount > 1
486
- ? Math.min(laneBandH / (sideLaneCount - 1), 縦の間隔の上限(layer1XOffset))
529
+ ? Math.min(
530
+ laneBandH / (sideLaneCount - 1),
531
+ // 帯は図で 1 つなので、**一番内側の枝** から上限を取る (#966)。
532
+ // 外へ出た枝に合わせると、内側に残った枝の傾きが 45 度を超える
533
+ 縦の間隔の上限(一番内側の離れ),
534
+ )
487
535
  : subBranchH + 14;
488
536
  // box 高は必ず rowGap の一定割合に収める。 加算的な floor を置くと、 leaf が多く rowGap が
489
537
  // 小さいとき floor が rowGap を超えて縦重なりする。 rowGap の 70% にすれば規模によらず gap が残る。
@@ -525,11 +573,12 @@ export function computeMindMapLayout(
525
573
  tone: Tone | undefined,
526
574
  placed: Set<string>,
527
575
  laneRef: { cursor: number },
576
+ 横: 横の割付,
528
577
  ): number => {
529
578
  placed.add(node.id);
530
579
  const dirX = isLeft ? -1 : 1;
531
- const x = cx + dirX * (layer1XOffset + subOffset(subDepth));
532
- const w = subDepth === 0 ? branchW : effSubW;
580
+ const x = cx + dirX * (横.layer1XOffset + 横.subOffset(subDepth));
581
+ const w = subDepth === 0 ? branchW : 横.effSubW;
533
582
  const h = subDepth === 0 ? effBranchH : effSubH;
534
583
  const myTone = node.tone ?? tone;
535
584
 
@@ -547,6 +596,7 @@ export function computeMindMapLayout(
547
596
  myTone ?? MIND_TONES[i % MIND_TONES.length]!,
548
597
  placed,
549
598
  laneRef,
599
+ 横,
550
600
  ),
551
601
  );
552
602
  y = (ys[0]! + ys[ys.length - 1]!) / 2;
@@ -583,7 +633,7 @@ export function computeMindMapLayout(
583
633
  const placed = new Set<string>();
584
634
  brs.forEach((br, i) => {
585
635
  const tone = br.tone ?? MIND_TONES[(toneBase + i) % MIND_TONES.length]!;
586
- placeSubtree(br, 0, isLeft, tone, placed, laneRef);
636
+ placeSubtree(br, 0, isLeft, tone, placed, laneRef, 横の割付表.get(br.id) ?? 枝の横を決める(br));
587
637
  });
588
638
  };
589
639
  placeSide(leftBranches, true, 0);
@@ -409,6 +409,53 @@ export function computeTreeLayout(
409
409
  return Math.min(Math.max(要る, nodeW), slotW * 0.92);
410
410
  };
411
411
 
412
+ /*
413
+ 置く前に、葉の並びと段を先に出す (#968)。
414
+
415
+ 端の葉の箱がどれだけ幅を取るかが判らないと、箱の辺を描画域の端へ合わせられない。
416
+ 箱の幅は名前と段から決まる (`箱の幅`) ので、位置より先に出せる。
417
+
418
+ 走査の順は `place` と同じ (同じ `childrenOf` を同じ順に辿り、既訪で打ち切る)。
419
+ */
420
+ const 葉の並び: string[] = [];
421
+ const 段の表 = new Map<string, number>();
422
+ const 幅の表 = new Map<string, number>();
423
+ {
424
+ const 見た = new Set<string>();
425
+ const 下見 = (n: TreeNodePayload, depth: number): void => {
426
+ 見た.add(n.id);
427
+ 段の表.set(n.id, depth);
428
+ 幅の表.set(n.id, 箱の幅(n, depth));
429
+ const kids = (childrenOf.get(n.id) ?? []).filter((k) => !見た.has(k.id));
430
+ if (kids.length === 0) {
431
+ 葉の並び.push(n.id);
432
+ return;
433
+ }
434
+ for (const k of kids) 下見(k, depth + 1);
435
+ };
436
+ for (const r of roots) if (!見た.has(r.id)) 下見(r, 0);
437
+ }
438
+
439
+ /*
440
+ 葉と葉の隙間を、**段と段の隙間に揃える** (#968)。
441
+
442
+ それまでは葉を `canvasW / 葉の数` で等分した区画の中央に置いていた。
443
+ 葉が少ない図では区画が箱よりずっと広くなり、兄弟どうしが遠くへ離れる
444
+ (実測 = 葉 2 枚の図で箱の縁の間が 196 空き、段の隙間 95 の 2 倍になった)。
445
+
446
+ 木は根から下へ辿る図なので、横の隙間だけが縦の 2 倍あると、離れた 2 本の列に見える。
447
+ 横の隙間を縦の隙間と同じにすれば、図の中の間隔が 1 つの調子で揃う。
448
+
449
+ **今より広げない** (`Math.min(slotW, ...)`)。 葉が多い図では区画の方が狭いので、
450
+ そちらが効いて位置は 1 ポイントも動かない。
451
+ */
452
+ const 葉の数 = Math.max(葉の並び.length, 1);
453
+ const 段の隙間 = depthGap - nodeH;
454
+ const 葉の間隔 = Math.min(slotW, nodeW + 段の隙間);
455
+ /** 葉の列全体を描画域の真ん中に置く。 葉が 1 枚なら間隔が要らない */
456
+ const 葉のx = (i: number): number =>
457
+ canvasW / 2 - ((葉の数 - 1) * 葉の間隔) / 2 + i * 葉の間隔;
458
+
412
459
  const posOf = new Map<string, { x: number; y: number }>();
413
460
  const nodes: LayoutNode[] = [];
414
461
  const placed = new Set<string>();
@@ -420,7 +467,7 @@ export function computeTreeLayout(
420
467
  const kids = (childrenOf.get(n.id) ?? []).filter((k) => !placed.has(k.id));
421
468
  let x: number;
422
469
  if (kids.length === 0) {
423
- x = leafCursor * slotW + slotW / 2;
470
+ x = 葉のx(leafCursor);
424
471
  leafCursor += 1;
425
472
  } else {
426
473
  const xs = kids.map((k) => place(k, depth + 1));
@@ -434,7 +481,7 @@ export function computeTreeLayout(
434
481
  ...(n.subtitle !== undefined ? { subtitle: n.subtitle } : {}),
435
482
  x,
436
483
  y,
437
- w: 箱の幅(n, depth),
484
+ w: 幅の表.get(n.id) ?? 箱の幅(n, depth),
438
485
  h: nodeH,
439
486
  depth,
440
487
  ...((n as { unresolved?: true }).unresolved ? { unresolved: true as const } : {}),