@cardenelabs/cdl 0.19.0 → 0.20.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.19.0",
3
+ "version": "0.20.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",
@@ -2,13 +2,17 @@ import type { JSX } from "react";
2
2
  import type { LaidNode } from "../types";
3
3
  import { nodeAccent } from "./node-tone";
4
4
  import { titlePlacement } from "./compact-title";
5
+ import { 目印なしの繰り上げ } from "../layout/spec";
5
6
 
6
7
  export function ActorNode({ node, active, value }: { node: LaidNode; active: boolean; value: string | undefined }): JSX.Element {
7
8
  const accent = nodeAccent(node, "var(--cdl-tone-accent, #2d6a8f)");
8
9
  const x = node.cx - node.w / 2;
9
10
  const y = node.cy - node.h / 2;
11
+ // 小見出しを書かない時は、そのために空けた帯のぶん字を上へ繰り上げる (#589)。
12
+ // 繰り上げ量は `layout/spec.ts` が持つ = 高さを出す側と同じ値を引く
13
+ const 繰り上げ = node.eyebrow ? 0 : 目印なしの繰り上げ("actor");
10
14
  // 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
11
- const 名前 = titlePlacement(node, { x: 26, y: 86, size: 32 }, { hasEyebrow: Boolean(node.eyebrow) });
15
+ const 名前 = titlePlacement(node, { x: 26, y: 86 - 繰り上げ, size: 32 }, { hasEyebrow: Boolean(node.eyebrow) });
12
16
  return (
13
17
  <g transform={`translate(${x} ${y})`}>
14
18
  <rect
@@ -50,14 +54,14 @@ export function ActorNode({ node, active, value }: { node: LaidNode; active: boo
50
54
  <line
51
55
  data-cdl-role="node-row-divider"
52
56
  x1={26}
53
- y1={110}
57
+ y1={110 - 繰り上げ}
54
58
  x2={node.w - 26}
55
- y2={110}
59
+ y2={110 - 繰り上げ}
56
60
  stroke="var(--cdl-row-divider, #7e848e)"
57
61
  />
58
62
  )}
59
63
  {node.subtitle && (
60
- <text x={26} y={140} fontSize={20} fill="var(--cdl-text-dim, #5a6270)">
64
+ <text x={26} y={140 - 繰り上げ} fontSize={20} fill="var(--cdl-text-dim, #5a6270)">
61
65
  {node.subtitle}
62
66
  </text>
63
67
  )}
@@ -2,6 +2,7 @@ import type { JSX } from "react";
2
2
  import type { LaidNode } from "../types";
3
3
  import { nodeAccent } from "./node-tone";
4
4
  import { fitTextSize, isCompactBox } from "./compact-title";
5
+ import { 目印なしの繰り上げ } from "../layout/spec";
5
6
 
6
7
  export function CardNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
7
8
  const accent = nodeAccent(node, "var(--cdl-tone-accent, #2d6a8f)");
@@ -16,7 +17,9 @@ export function CardNode({ node, active }: { node: LaidNode; active: boolean }):
16
17
  const compact = isCompactBox(node.h);
17
18
  // 小型の箱でも呼び名を出す (#578)。 出す時は題を上へ寄せて、2 行を箱の中で釣り合わせる
18
19
  const 小型に呼び名 = compact && Boolean(node.subtitle);
19
- const titleY = compact ? (小型に呼び名 ? node.h / 2 - 2 : node.h / 2 + 8) : 86;
20
+ // 小見出しを書かない時の繰り上げ (#589)。 値は `layout/spec.ts` SSOT
21
+ const 繰り上げ = node.eyebrow ? 0 : 目印なしの繰り上げ("card");
22
+ const titleY = compact ? (小型に呼び名 ? node.h / 2 - 2 : node.h / 2 + 8) : 86 - 繰り上げ;
20
23
  const titleX = compact ? node.w / 2 : 26;
21
24
  const paddingX = compact ? 16 : 52;
22
25
  const availableW = node.w - paddingX;
@@ -54,7 +57,7 @@ export function CardNode({ node, active }: { node: LaidNode; active: boolean }):
54
57
  {node.title}
55
58
  </text>
56
59
  {!compact && node.subtitle && (
57
- <text x={26} y={124} fontSize={subtitleSize} fill="var(--cdl-text-dim, #5a6270)">
60
+ <text x={26} y={124 - 繰り上げ} fontSize={subtitleSize} fill="var(--cdl-text-dim, #5a6270)">
58
61
  {node.subtitle}
59
62
  </text>
60
63
  )}
@@ -2,6 +2,7 @@ import type { JSX } from "react";
2
2
  import type { LaidNode } from "../types";
3
3
  import { nodeAccent } from "./node-tone";
4
4
  import { titlePlacement } from "./compact-title";
5
+ import { 目印なしの繰り上げ } from "../layout/spec";
5
6
 
6
7
  export function EventNode({
7
8
  node,
@@ -15,8 +16,10 @@ export function EventNode({
15
16
  const accent = nodeAccent(node, "var(--cdl-tone-success, #4ea36a)");
16
17
  const x = node.cx - node.w / 2;
17
18
  const y = node.cy - node.h / 2;
19
+ // 小見出しを書かない時の繰り上げ (#589)。 値は `layout/spec.ts` SSOT
20
+ const 繰り上げ = node.eyebrow ? 0 : 目印なしの繰り上げ("event");
18
21
  // 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
19
- const 名前 = titlePlacement(node, { x: 26, y: 88, size: 26 }, { hasEyebrow: Boolean(node.eyebrow) });
22
+ const 名前 = titlePlacement(node, { x: 26, y: 88 - 繰り上げ, size: 26 }, { hasEyebrow: Boolean(node.eyebrow) });
20
23
  const scale = active ? 0.78 + 0.22 * progress : 0.92;
21
24
  const opacity = active ? 0.5 + 0.5 * progress : 0.65;
22
25
  return (
@@ -56,7 +59,7 @@ export function EventNode({
56
59
  {node.subtitle && (
57
60
  <text
58
61
  x={26}
59
- y={128}
62
+ y={128 - 繰り上げ}
60
63
  fontSize={21}
61
64
  fill="var(--cdl-text-dim, #5a6270)"
62
65
  style={{ fontFamily: "'JetBrains Mono', monospace" }}
@@ -2,6 +2,7 @@ import type { JSX } from "react";
2
2
  import type { LaidNode } from "../types";
3
3
  import { nodeAccent } from "./node-tone";
4
4
  import { titlePlacement } from "./compact-title";
5
+ import { 目印なしの繰り上げ } from "../layout/spec";
5
6
 
6
7
  export function FunctionNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
7
8
  const accent = nodeAccent(node, "var(--cdl-tone-teal, #2f8770)");
@@ -10,8 +11,10 @@ export function FunctionNode({ node, active }: { node: LaidNode; active: boolean
10
11
  const stroke = nodeAccent(node, active ? "var(--cdl-tone-accent, #2d6a8f)" : "var(--cdl-tone-teal, #2f8770)");
11
12
  const x = node.cx - node.w / 2;
12
13
  const y = node.cy - node.h / 2;
14
+ // 小見出しを書かない時の繰り上げ (#589)。 値は `layout/spec.ts` SSOT
15
+ const 繰り上げ = node.eyebrow ? 0 : 目印なしの繰り上げ("function");
13
16
  // 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
14
- const 名前 = titlePlacement(node, { x: 26, y: 86, size: 24 }, { hasEyebrow: Boolean(node.eyebrow) });
17
+ const 名前 = titlePlacement(node, { x: 26, y: 86 - 繰り上げ, size: 24 }, { hasEyebrow: Boolean(node.eyebrow) });
15
18
  return (
16
19
  <g transform={`translate(${x} ${y})`}>
17
20
  <rect
@@ -44,7 +47,7 @@ export function FunctionNode({ node, active }: { node: LaidNode; active: boolean
44
47
  {node.title}
45
48
  </text>
46
49
  {node.subtitle && (
47
- <text x={26} y={126} fontSize={20} fill="var(--cdl-text-dim, #5a6270)">
50
+ <text x={26} y={126 - 繰り上げ} fontSize={20} fill="var(--cdl-text-dim, #5a6270)">
48
51
  {node.subtitle}
49
52
  </text>
50
53
  )}
@@ -7,6 +7,7 @@ import {
7
7
  genericTextShift,
8
8
  genericTitleBaselineY,
9
9
  rowBaselineY,
10
+ 目印なしの繰り上げ,
10
11
  } from "../layout/spec";
11
12
  import {
12
13
  GENERIC_ROW_LEFT_SIZE_PX,
@@ -300,9 +301,12 @@ export function GenericNode({
300
301
  // cylinder は上 ellipse (ry=14) の下に text を置く。 ずらし量は `spec.ts` が持つ
301
302
  const cylinderShift = genericTextShift(node.kind);
302
303
  // 中心揃え時は h/2 を baseline 中心 (dominantBaseline=middle で計算するため text y を h/2 ベースに統一)
304
+ // 小見出しを書かない時は、そのために空けた帯のぶん字を上へ繰り上げる (#589)。
305
+ // 中央へ寄せる形と行を持つ形では繰り上げない (前者は塊の置き方が違い、後者は 1 行目と重なる)
306
+ const 繰り上げ = node.eyebrow || centered ? 0 : 目印なしの繰り上げ(node.kind, hasRows);
303
307
  const eyebrowY = centered ? h / 2 - 26 : 32 + cylinderShift;
304
- const titleY = genericTitleBaselineY(node.kind, h, hasRows);
305
- const subtitleY = centered ? h / 2 + 28 : 96 + cylinderShift;
308
+ const titleY = genericTitleBaselineY(node.kind, h, hasRows) - 繰り上げ;
309
+ const subtitleY = centered ? h / 2 + 28 : 96 + cylinderShift - 繰り上げ;
306
310
  // icon 位置 (diamond / cloud は title の真右の少し上、 中心配置に合わせる)
307
311
  const iconX = centered ? w / 2 + 40 : w - 48;
308
312
  const iconY = centered ? h / 2 - 44 : 18 + cylinderShift;
@@ -2,6 +2,7 @@ import type { JSX } from "react";
2
2
  import type { LaidNode, RowMark } from "../types";
3
3
  import { nodeAccent } from "./node-tone";
4
4
  import { titlePlacement } from "./compact-title";
5
+ import { 目印なしの繰り上げ } from "../layout/spec";
5
6
  import {
6
7
  ROW_PITCH_STORAGE,
7
8
  ROW_TOP_STORAGE,
@@ -98,8 +99,11 @@ export function StorageNode({
98
99
  const accent = nodeAccent(node, "var(--cdl-tone-accent, #2d6a8f)");
99
100
  const x = node.cx - node.w / 2;
100
101
  const y = node.cy - node.h / 2;
102
+ // 小見出しを書かない時の繰り上げ (#589)。 行を持つ形では繰り上げない (1 行目と重なる)
103
+ const 繰り上げ =
104
+ node.eyebrow || rows.length > 0 ? 0 : 目印なしの繰り上げ("storage");
101
105
  // 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
102
- const 名前 = titlePlacement(node, { x: 26, y: STORAGE_TITLE_BASELINE, size: STORAGE_TITLE_FONT }, { hasEyebrow: Boolean(node.eyebrow) });
106
+ const 名前 = titlePlacement(node, { x: 26, y: STORAGE_TITLE_BASELINE - 繰り上げ, size: STORAGE_TITLE_FONT }, { hasEyebrow: Boolean(node.eyebrow) });
103
107
  // 全 row を delimiter (`:` / ` = ` / ` → ` / ` -> `) で left / right に分割し、
104
108
  // left 列 max 文字数を算出 → 右列の固定 x 位置を決める (DB table / UML class box 風)。
105
109
  // 結果として全 row の右列開始 x が揃い、 区切り線がガタつかなくなる。
@@ -166,7 +170,7 @@ export function StorageNode({
166
170
  <text
167
171
  data-cdl-role="node-subtitle"
168
172
  x={ROW_PAD_X_PX}
169
- y={SUBTITLE_BASELINE}
173
+ y={SUBTITLE_BASELINE - 繰り上げ}
170
174
  fontSize={SUBTITLE_FONT}
171
175
  fill="var(--cdl-text-dim, #5a6270)"
172
176
  >
@@ -355,10 +355,12 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
355
355
  // 4. 結果 = 全 pill Y 揃え + X 方向のみ最小限 shift で重なり回避、 idx=0 pill (最左 X) は
356
356
  // shift 0 で SSOT LABEL_TO_PATH_CLEARANCE 維持、 他 pill も全て同 Y (path から SSOT 距離)。
357
357
  //
358
- // CAR-636 fan dispersion X 分散の pill 間 gap (world unit)。 label bbox 端 + 20 world で
359
- // 視覚的に明確な間隔を確保、 SSOT clearance-constants の CLEARANCE_NODE_LABEL (16 相当) より
360
- // 大きめに設定 (pill 同士の可読性重視、 node 押し出しとは別軸の SSOT)。
361
- const FAN_LABEL_GAP = 20;
358
+ // fan dispersion X に分散する時の pill 間 gap (world unit)。
359
+ //
360
+ // **検査が要求する値そのものを使う** (#589)。 20 を独自に持っていた間、掃いた結果が
361
+ // `clearance` 軸の要求 (36) に届かず、実測で 32 しか空かない図が残った。 掃く側と見る側で
362
+ // 別の値を持つと、掃いても落ちる。
363
+ const FAN_LABEL_GAP = CLEARANCE_LABEL_LABEL;
362
364
 
363
365
  // CAR-541 SSOT ... 同 node の対称対 edge (out + in が同 side) を上下配置ルールで分離。
364
366
  //
@@ -1045,7 +1047,27 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
1045
1047
  const applyFanDispersion = (list: RoutedEdge[], tracked: Set<string> | null): void => {
1046
1048
  if (list.length < 2) return;
1047
1049
  const obstaclePatterns = new Set(list.map((r) => detectsBlockingObstacle(r.p)));
1048
- if (obstaclePatterns.size > 1) return;
1050
+ if (obstaclePatterns.size > 1) {
1051
+ /*
1052
+ * 形の違う線が混ざる群を **まとめて掃かない** のは元のとおり (Y の差が 100 world 以上
1053
+ * 開き、当たり判定がずれるため)。 ただし丸ごと諦めると、同じ形どうしの重なりまで
1054
+ * 残る (#589)。
1055
+ *
1056
+ * 形ごとに分けてから掃く。 実測 = 4 段の網の図で、迂回する 2 本の説明が 25 しか離れず
1057
+ * `clearance` が error を出していた (要る間隔は 36)。 箱が低くなって間隔が縮んだ時に
1058
+ * 出た = 形の違う 1 本が混ざっていたせいで、残る 2 本も掃かれていなかった。
1059
+ */
1060
+ const 形ごと = new Map<boolean, RoutedEdge[]>();
1061
+ for (const r of list) {
1062
+ const 形 = detectsBlockingObstacle(r.p);
1063
+ 形ごと.set(形, [...(形ごと.get(形) ?? []), r]);
1064
+ }
1065
+ for (const 同じ形 of 形ごと.values()) {
1066
+ // 分けた結果が元と同じ大きさになることは無い (種類が 2 つ以上ある)。 念のため守る
1067
+ if (同じ形.length < list.length) applyFanDispersion(同じ形, tracked);
1068
+ }
1069
+ return;
1070
+ }
1049
1071
  // CAR-615 SSOT ... group 内に overlay:true edge が 1 本でもあれば fan dispersion skip。
1050
1072
  // overlay:true (flowchart 等の true/false condition label、 CAR-549) は「label を path 上に
1051
1073
  // 重ねる」 semantic を持つため、 fan offset を加えると pill が path から離れて overlay 意図が
@@ -6,6 +6,9 @@ import {
6
6
  requiredVerticalLabelGap,
7
7
  requiredRowsHeight,
8
8
  SUBTITLE_ROW_SHIFT,
9
+ 中身から出す高さ,
10
+ 目印なしの繰り上げ,
11
+ 段を積む箱か,
9
12
  } from "./spec";
10
13
  import { DEFAULT_NODE_GAP, HEADER_RESERVE, LANE_TOP_PAD, NODE_SIZE, STACK_GAP } from "./tokens";
11
14
 
@@ -21,7 +24,14 @@ import { DEFAULT_NODE_GAP, HEADER_RESERVE, LANE_TOP_PAD, NODE_SIZE, STACK_GAP }
21
24
  * `row-vertical-spacing` (軸 16) が同じ `rowBaselineY` を見て検査する。
22
25
  */
23
26
  function autoStorageHeight(
24
- n: { kind: string; rows?: string[]; rowMarks?: readonly unknown[]; subtitle?: string; h?: number },
27
+ n: {
28
+ kind: string;
29
+ rows?: string[];
30
+ rowMarks?: readonly unknown[];
31
+ subtitle?: string;
32
+ eyebrow?: string;
33
+ h?: number;
34
+ },
25
35
  defaultH: number,
26
36
  ): number {
27
37
  if (n.kind !== "storage") return n.h ?? defaultH;
@@ -32,7 +42,9 @@ function autoStorageHeight(
32
42
  // 字の高さ 73.2 に上の余白 21 を上下ぶん足して 115 にする。
33
43
  const 印あり = n.rowMarks !== undefined;
34
44
  const 呼び名の分 = 印あり && n.subtitle ? SUBTITLE_ROW_SHIFT : 0;
35
- if (!n.rows || n.rows.length === 0) return 115 + 呼び名の分;
45
+ // 小見出しを書かない形では字を上へ繰り上げるので、その分だけ箱も低くする (#589)
46
+ const 繰り上げ = n.eyebrow ? 0 : 目印なしの繰り上げ("storage");
47
+ if (!n.rows || n.rows.length === 0) return 115 + 呼び名の分 - 繰り上げ;
36
48
  const 必要 = requiredRowsHeight("storage", n.rows.length) ?? defaultH;
37
49
  // 行頭の印を持つ箱は **中身の足し算だけ** で高さを決める (#578)。 下限を残すと、行が
38
50
  // 1 つしか無い箱が 4 行の箱と同じ高さになり、下が空いたまま並ぶ。
@@ -61,6 +73,37 @@ function autoRowsWidth(n: { kind: string; rows?: string[]; w?: number }, default
61
73
  return Math.max(defaultW, required);
62
74
  }
63
75
 
76
+ /**
77
+ * node 1 つの高さ (SSOT)。
78
+ *
79
+ * 2 段で決める。 `storage` は行の数から出し (`autoStorageHeight`)、段を積む種別は
80
+ * **書いた段の数** から出す (`中身から出す高さ`、#589)。 どちらにも当たらない種別は
81
+ * 種別ごとの既定値をそのまま使う。
82
+ *
83
+ * 著者が `h` を書いた時はその値が勝つ。
84
+ */
85
+ function 箱の高さ(
86
+ n: {
87
+ kind: string;
88
+ h?: number;
89
+ rows?: string[];
90
+ rowMarks?: readonly unknown[];
91
+ subtitle?: string;
92
+ eyebrow?: string;
93
+ value?: string;
94
+ },
95
+ 既定h: number,
96
+ ): number {
97
+ if (n.h != null) return n.h;
98
+ if (n.kind === "storage") return autoStorageHeight(n, 既定h);
99
+ return 中身から出す高さ(n.kind, 既定h, {
100
+ hasEyebrow: Boolean(n.eyebrow),
101
+ hasSubtitle: Boolean(n.subtitle),
102
+ hasValue: Boolean(n.value),
103
+ hasRows: (n.rows?.length ?? 0) > 0,
104
+ });
105
+ }
106
+
64
107
  /**
65
108
  * 同 lane 内で隣り合う row を繋ぐ edge の label が収まる stack gap を求める (Issue #202)。
66
109
  *
@@ -124,7 +167,7 @@ export function layoutNodes(diag: CdlDiagram, lanes: LaidLane[]): LaidNode[] {
124
167
  const rowH = new Map<number, number>();
125
168
  for (const stack of allStacks) {
126
169
  const nodesAtStack = diag.nodes.filter((n) => n.stack === stack);
127
- const h = Math.max(...nodesAtStack.map((n) => autoStorageHeight(n, NODE_SIZE[n.kind].h)));
170
+ const h = Math.max(...nodesAtStack.map((n) => 箱の高さ(n, NODE_SIZE[n.kind].h)));
128
171
  rowH.set(stack, h);
129
172
  }
130
173
  const rowCy = new Map<number, number>();
@@ -201,8 +244,12 @@ export function layoutNodes(diag: CdlDiagram, lanes: LaidLane[]): LaidNode[] {
201
244
  * 図全体が上へ寄って見えた (user 指摘)。 芯で揃えると上端も下端も箱ごとに違うが、
202
245
  * 並びの重心が 1 本に通る。 見た目検査の `row-alignment` もこの前提で書かれている。
203
246
  */
204
- const 自前の高さ = n.rowMarks !== undefined;
205
- const h = n.h ?? (自前の高さ ? autoStorageHeight(n, size.h) : 段の高さ);
247
+ //
248
+ // 段を積む箱 (#589) も同じ扱いにする。 書いた段の数から高さを出しているので、段の高さへ
249
+ // 伸ばすとその値が捨てられる = 名前だけの箱が 3 段の箱と同じ高さに戻る。
250
+ const 自前の高さ =
251
+ n.rowMarks !== undefined || 段を積む箱か(n.kind, (n.rows?.length ?? 0) > 0);
252
+ const h = n.h ?? (自前の高さ ? 箱の高さ(n, size.h) : 段の高さ);
206
253
  const cy = 段の芯;
207
254
  // n.w 明示時は尊重、 未指定で rows[] を持つ node は内容長に合わせて auto 拡張。
208
255
  const w = autoRowsWidth(n, size.w);
@@ -996,3 +996,131 @@ export function buildDiagramAltText(
996
996
  else if (body) parts.push(body);
997
997
  return parts.join(" ");
998
998
  }
999
+
1000
+ /**
1001
+ * 箱の中身から高さを出すための、描き手が使う段の位置 (#589)。
1002
+ *
1003
+ * ## なぜ要るか
1004
+ *
1005
+ * 箱の高さは種別ごとの固定値で、小見出し + 名前 + 説明の 3 段が入る前提で決まっている。
1006
+ * 名前 1 語だけの箱も 3 段ぶんの高さになり、実測で `function` は 151 に対し字が 29 しか
1007
+ * 無かった (上 63 / 下 59)。
1008
+ *
1009
+ * 空きは上下の両方に出る。 小見出しを書かないと、そのために空けた帯が上にそのまま残る。
1010
+ * 説明を書かないと、説明の段が下に残る。
1011
+ *
1012
+ * ## 何を持つか
1013
+ *
1014
+ * 描き手 (`kinds/*.tsx`) が持つ baseline と字の大きさを、layout から引ける形で 1 箇所に置く。
1015
+ * 描き手はここから繰り上げ量を引き、layout はここから高さを出す = **同じ値を 2 箇所に書かない**。
1016
+ *
1017
+ * 高さそのものは持たない。 3 段を書いた時の高さは `NODE_SIZE` が SSOT で、ここは
1018
+ * 「そこから何を引くか」 だけを決める。
1019
+ */
1020
+ type 段の並び = {
1021
+ 目印: { y: number; size: number };
1022
+ 名前: { y: number; size: number };
1023
+ 説明: { y: number; size: number };
1024
+ };
1025
+
1026
+ /**
1027
+ * 描き手ごとの段の位置。
1028
+ *
1029
+ * `storage` も入れる。 行を持たない形の高さは `autoStorageHeight` が出すが (#586 で 115)、
1030
+ * 小見出しを書かない形では字が下に寄る (実測で上 53 / 下 21)。 繰り上げ量はここから引く。
1031
+ *
1032
+ * 字を中央へ寄せる 2 種別 (`cloud` / `decision`) も入れない。 段を積む形ではないので
1033
+ * 繰り上げの考え方が当たらない (cardene777/cdl#588 で別に扱う)。
1034
+ */
1035
+ const 段の位置: Readonly<Record<string, 段の並び>> = {
1036
+ actor: { 目印: { y: 38, size: 18 }, 名前: { y: 86, size: 32 }, 説明: { y: 140, size: 20 } },
1037
+ function: { 目印: { y: 38, size: 18 }, 名前: { y: 86, size: 24 }, 説明: { y: 126, size: 20 } },
1038
+ event: { 目印: { y: 38, size: 18 }, 名前: { y: 88, size: 26 }, 説明: { y: 128, size: 21 } },
1039
+ card: { 目印: { y: 38, size: 18 }, 名前: { y: 86, size: 24 }, 説明: { y: 124, size: 21 } },
1040
+ storage: {
1041
+ 目印: { y: 38, size: 18 },
1042
+ 名前: { y: STORAGE_TITLE_BASELINE, size: STORAGE_TITLE_FONT },
1043
+ 説明: { y: 118, size: 20 },
1044
+ },
1045
+ };
1046
+
1047
+ /** `kinds/generic.tsx` が描く 21 種別の段の位置 (円柱のずらしは呼ぶ側が足す) */
1048
+ const 汎用の段: 段の並び = {
1049
+ 目印: { y: 32, size: 17 },
1050
+ 名前: { y: GENERIC_TITLE_TOP, size: GENERIC_TITLE_FONT },
1051
+ 説明: { y: 96, size: 17 },
1052
+ };
1053
+
1054
+ /**
1055
+ * その種別が中身から高さを出せるか。 出せない種別は従来どおり固定の高さを使う。
1056
+ *
1057
+ * 行を持つ形は対象外 = 行は箱の上端から数えて置かれるので、繰り上げると 1 行目と名前が重なる。
1058
+ */
1059
+ export function 段を積む箱か(kind: string | undefined, hasRows: boolean): boolean {
1060
+ if (hasRows) return false;
1061
+ if (kind === undefined) return false;
1062
+ if (genericTextCentered(kind, false)) return false;
1063
+ return kind in 段の位置 || rendersRows(kind);
1064
+ }
1065
+
1066
+ function 段を引く(kind: string): 段の並び | null {
1067
+ const 固有 = 段の位置[kind];
1068
+ if (固有) return 固有;
1069
+ // `rendersRows` が真の種別のうち `storage` を除いた 21 種別が `generic.tsx` に回る
1070
+ if (rendersRows(kind)) return 汎用の段;
1071
+ return null;
1072
+ }
1073
+
1074
+ /**
1075
+ * 小見出しを書かない時に、字の塊を上へ繰り上げる量 (SSOT)。
1076
+ *
1077
+ * **名前の上端が、小見出しの上端と同じところへ来る** 値にする。 小見出しのために空けた帯を
1078
+ * 名前が引き継ぐ形で、上の余白は書き方に依らず同じになる。
1079
+ *
1080
+ * 字形の上端は baseline から `GLYPH_CAP_RATIO` 割上と見込む (`compact-title.ts` と同じ目安)。
1081
+ */
1082
+ export function 目印なしの繰り上げ(kind: string | undefined, hasRows = false): number {
1083
+ if (!段を積む箱か(kind, hasRows)) return 0;
1084
+ const 段 = 段を引く(kind!);
1085
+ if (!段) return 0;
1086
+ const 上げる =
1087
+ 段.名前.y - 段.目印.y + (段.目印.size - 段.名前.size) * GLYPH_CAP_RATIO;
1088
+ return Math.max(0, Math.round(上げる));
1089
+ }
1090
+
1091
+ /** 説明を書かない時に、箱が縮む量 (SSOT)。 名前と説明の段の間隔そのもの */
1092
+ export function 説明なしで縮む量(kind: string | undefined, hasRows = false): number {
1093
+ if (!段を積む箱か(kind, hasRows)) return 0;
1094
+ const 段 = 段を引く(kind!);
1095
+ if (!段) return 0;
1096
+ return Math.max(0, 段.説明.y - 段.名前.y);
1097
+ }
1098
+
1099
+ /**
1100
+ * 書いた段の数から箱の高さを出す (#589)。
1101
+ *
1102
+ * 3 段すべてを書いた形が `満載h` で、書かなかった段のぶんを引く。 3 段を書いた箱の高さは
1103
+ * 変わらない = 既存の図の見た目を動かさない。
1104
+ *
1105
+ * 値 (`value`) を持つ箱は縮めない。 値は箱の下端から置かれるので、縮めると名前と重なる。
1106
+ */
1107
+ export function 中身から出す高さ(
1108
+ kind: string | undefined,
1109
+ 満載h: number,
1110
+ 中身: { hasEyebrow: boolean; hasSubtitle: boolean; hasValue: boolean; hasRows: boolean },
1111
+ ): number {
1112
+ if (!段を積む箱か(kind, 中身.hasRows)) return 満載h;
1113
+ if (中身.hasValue) return 満載h;
1114
+ const 引く =
1115
+ (中身.hasEyebrow ? 0 : 目印なしの繰り上げ(kind)) +
1116
+ (中身.hasSubtitle ? 0 : 説明なしで縮む量(kind));
1117
+ return Math.max(MIN_BOX_H, 満載h - 引く);
1118
+ }
1119
+
1120
+ /**
1121
+ * 中身から出した高さの下限。
1122
+ *
1123
+ * これを下回ると箱として読めない。 名前だけの箱で最も低くなるのは `generic` の 21 種別で、
1124
+ * 実測の見込みは 54 なので、下限が効くのは 3 段の高さを著者が縮めた形に限る。
1125
+ */
1126
+ export const MIN_BOX_H = 44;