@cardenelabs/cdl 0.6.1 → 0.9.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +401 -0
  2. package/SPEC.md +32 -17
  3. package/dist/index.cjs +820 -364
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +68 -14
  6. package/dist/index.d.ts +68 -14
  7. package/dist/index.js +820 -364
  8. package/dist/index.js.map +1 -1
  9. package/dist/react.cjs +692 -254
  10. package/dist/react.cjs.map +1 -1
  11. package/dist/react.d.cts +1 -1
  12. package/dist/react.d.ts +1 -1
  13. package/dist/react.js +692 -254
  14. package/dist/react.js.map +1 -1
  15. package/dist/{render-DquCvgOB.d.cts → render-hmFRAabt.d.cts} +33 -2
  16. package/dist/{render-DquCvgOB.d.ts → render-hmFRAabt.d.ts} +33 -2
  17. package/package.json +6 -3
  18. package/src/kinds/box-lines.ts +120 -0
  19. package/src/kinds/gantt.tsx +152 -24
  20. package/src/kinds/mind-map.tsx +77 -16
  21. package/src/kinds/quadrant.tsx +28 -2
  22. package/src/kinds/shape-blockchain.tsx +65 -12
  23. package/src/kinds/shape-region.tsx +152 -34
  24. package/src/kinds/tree.tsx +121 -24
  25. package/src/layout/collisions.ts +10 -2
  26. package/src/layout/footer-shape.ts +86 -24
  27. package/src/layout/lifeline.ts +80 -0
  28. package/src/layout/spec.ts +21 -2
  29. package/src/layout/text-width.ts +92 -4
  30. package/src/layout/viewbox.ts +0 -8
  31. package/src/layout.ts +8 -1
  32. package/src/presets.ts +120 -22
  33. package/src/render/header.tsx +3 -1
  34. package/src/render/nodes.tsx +6 -5
  35. package/src/render/payload-binding.ts +88 -3
  36. package/src/render/stage.tsx +17 -34
  37. package/src/render/template-fields.ts +212 -0
  38. package/src/render/utils.ts +32 -2
  39. package/src/render.tsx +2 -9
  40. package/src/types.ts +33 -2
  41. package/src/validate.ts +27 -9
  42. package/src/visual-validate.ts +34 -1
  43. package/src/layout/label-shift.ts +0 -28
@@ -28,35 +28,45 @@ export const SHAPE_DRAW_UP_EXTENT: Readonly<Record<string, number>> = {
28
28
  /**
29
29
  * 縦線を持つ lane の **下端の名札** を集める。
30
30
  *
31
- * 順序図は lane ごとに上端と下端へ名札を置く。 node 1 つ以下の lane は下端の名札を持たない
32
- * (上端だけ、 もしくは空) ため対象から外す。
31
+ * 判定は node `role` で行う。 順序図を組み立てる側 (`presets.ts` `build()`) が
32
+ * `role: "lifeline-footer"` を付けるため、 描画側は位置を見る必要が無い。
33
33
  *
34
- * 縦線の終点 (`CdlStage`) と絵を下げる量 (`footerShapeDrops`) と図の下端 (`computeViewBox`)
35
- * 3 箇所が同じ名札を指す必要があるため、 「どれが下端の名札か」 の判定をここに 1 つだけ置く。
34
+ * **位置で推測していた形は誤りだった** (`#458`) 「node 2 つ以上ある lane の一番下」 と
35
+ * 決めていたため、 手で組んだ図で一番下が名札でない `shape-` node だと、 その絵が下がった。
36
+ *
37
+ * 目印に加えて 2 つの条件を見る。 どちらも **どれが名札かの判定ではなく、 名札として扱うと
38
+ * 図が壊れる形を除く** ためのもの。
39
+ *
40
+ * | 条件 | 除く理由 |
41
+ * |---|---|
42
+ * | `lane.lifeline` が真 | 縦線が無い lane では絵を下げる理由 (縦線と絵の重なり) が起きない |
43
+ * | その lane に node が 2 つ以上 | 1 つしか無い lane の唯一の node を名札にすると縦線が退化する |
44
+ *
45
+ * 2 つ目は実測に基づく。 縦線の終点は **全 lane で 1 つに揃える** ため、 名札の位置が異常な lane が
46
+ * 1 つあるだけで図の全 lane から縦線が消えていた (1 node の lane を名札にして実測、 通常の lane と
47
+ * 混在させても全て消えた)。 1 node の lane にあるのは上端の名札のはずで、 それを下端として扱う形
48
+ * 自体が誤り。 終点の選び方そのものは `uniformLifelineEndY` (`#479`) が直した。
49
+ *
50
+ * 同じ lane に名札が複数あれば全て返す = 数を絞る根拠が無く、 絞るとどれを残すかで再び位置の
51
+ * 推測に戻る。
52
+ *
53
+ * 縦線の終点 (`uniformLifelineEndY`) と絵を下げる量 (`applyFooterShapeDrops`) と外接矩形
54
+ * (`collectBBoxes`) が同じ名札を指す必要があるため、 「どれが下端の名札か」 の判定をここに
55
+ * 1 つだけ置く。
36
56
  */
37
57
  export function lifelineFooterNodes(lanes: LaidLane[], nodes: LaidNode[]): LaidNode[] {
38
- // lane ごとに node を集める索引を 1 度だけ作る。 lane ごとに全 node を走査すると
39
- // lane 数 × node 数に膨らみ、 `CdlStage` は動きの 1 コマごとに描き直すため毎コマ払う
40
- const byLane = new Map<string, LaidNode[]>();
41
- for (const n of nodes) {
42
- const inside = byLane.get(n.lane);
43
- if (inside) inside.push(n);
44
- else byLane.set(n.lane, [n]);
45
- }
46
- const footers: LaidNode[] = [];
58
+ // lane ごとの node 数を 1 度だけ数える。 node ごとに全 node を走査すると node 数の 2 乗に
59
+ // 膨らみ、 `CdlStage` は動きの 1 コマごとに描き直すため毎コマ払う
60
+ const = new Map<string, number>();
61
+ for (const n of nodes) 数.set(n.lane, (数.get(n.lane) ?? 0) + 1);
62
+
63
+ const lifelineLanes = new Set<string>();
47
64
  for (const lane of lanes) {
48
- if (!lane.lifeline) continue;
49
- const inside = byLane.get(lane.id);
50
- if (inside === undefined || inside.length < 2) continue;
51
- // 一番下 = cy が最大。 並べ替えずに 1 回の走査で選ぶ。 同じ cy が複数ある時は
52
- // **後に現れた方** を採る (安定な並べ替えで末尾を取るのと同じ結果にする)
53
- let footer = inside[0]!;
54
- for (const n of inside) {
55
- if (n.cy >= footer.cy) footer = n;
56
- }
57
- footers.push(footer);
65
+ if (lane.lifeline) lifelineLanes.add(lane.id);
58
66
  }
59
- return footers;
67
+ return nodes.filter(
68
+ (n) => n.role === "lifeline-footer" && lifelineLanes.has(n.lane) && (数.get(n.lane) ?? 0) >= 2,
69
+ );
60
70
  }
61
71
 
62
72
  /**
@@ -95,3 +105,55 @@ export function footerShapeDropsOf(footers: LaidNode[]): Map<string, number> {
95
105
  }
96
106
  return drops;
97
107
  }
108
+
109
+ /**
110
+ * 下端の名札を、 下げる量だけ実際に動かす (`#478`)。
111
+ *
112
+ * **`cy` は「箱の中心」 のまま**。 絵は箱の下端を基準に組み立てられるため、 箱を動かせば
113
+ * 絵も同じだけ動く。 絵の上端は `cy + h / 2 - SHAPE_DRAW_UP_EXTENT[kind]` で、 箱と
114
+ * `SHAPE_DRAW_UP_EXTENT` から一意に導ける (`footerShapeDrawTop`)。
115
+ *
116
+ * ## 描画だけを動かす形を採らない理由
117
+ *
118
+ * 元は描画側が名札を包む `<g>` に `translate(0, dy)` を掛けており、 **箱ごと下へ動いて
119
+ * 見えるのに座標は元のまま** だった。 `cy` も `data-cdl-cy` も外接矩形も動かないため、
120
+ * 座標を読む側 (縦線の終点 / 図の下端 / 外接矩形 / 矢印の接続先) が実際の描画と食い違う。
121
+ *
122
+ * ## 「絵」 ではなく「箱」 を `cy` とする理由
123
+ *
124
+ * `cy` / `w` / `h` は全ての種別で **箱** を表し、 矢印の接続先 / 当たり判定 / `data-cdl-cy` /
125
+ * 絵の組み立ての原点がこの意味に依存している。 一部の種別だけ `cy` を絵の中心に変えると、
126
+ * 同じ field が node によって別の意味を持つ。
127
+ *
128
+ * 絵の範囲は箱から導けるが、 逆は導きにくい = 絵を `cy` にすると箱の位置を出すのに同じ
129
+ * `SHAPE_DRAW_UP_EXTENT` が要り、 依存の向きが 1 つ増えるだけで得るものが無い。
130
+ *
131
+ * ## 下げる量は位置に依存しない
132
+ *
133
+ * `footerShapeDropY` が見るのは種別と箱の高さだけなので、 動かした後に呼び直しても同じ値を
134
+ * 返す。 したがって後段が名札をさらに動かしても、 絵の上端は常に
135
+ * `cy + h / 2 - extent` として正しく導ける。
136
+ */
137
+ export function applyFooterShapeDrops(lanes: LaidLane[], nodes: LaidNode[]): LaidNode[] {
138
+ const drops = footerShapeDrops(lanes, nodes);
139
+ if (drops.size === 0) return nodes;
140
+ return nodes.map((n) => {
141
+ // posX / posY が揃った node は「指定した位置から動かさない」公開契約。 絵が箱の上へ出る
142
+ // 範囲は collectBBoxes / footerShapeDrawTop が扱うため、 絶対配置を崩してまで箱を下げない。
143
+ if (n.posX !== undefined && n.posY !== undefined) return n;
144
+ const dy = drops.get(n.id);
145
+ return dy === undefined ? n : { ...n, cy: n.cy + dy };
146
+ });
147
+ }
148
+
149
+ /**
150
+ * 下げた名札の **絵の上端**。 縦線の終点と外接矩形の上端がこの値を使う。
151
+ *
152
+ * 絵は箱の下端から上へ `SHAPE_DRAW_UP_EXTENT` 伸びるので、 上端は `cy + h / 2 - extent`。
153
+ * 下げた量が `extent - h` である分、 これは `cy - h / 2 - 下げた量` と等しい。
154
+ *
155
+ * 下げていない名札 (箱に収まる種別 / 収まる高さ) では箱の上端をそのまま返す。
156
+ */
157
+ export function footerShapeDrawTop(node: LaidNode): number {
158
+ return node.cy - node.h / 2 - footerShapeDropY(node.kind, node.h);
159
+ }
@@ -0,0 +1,80 @@
1
+ import type { LaidLane, LaidNode } from "../types";
2
+ import { footerShapeDrawTop, lifelineFooterNodes } from "./footer-shape";
3
+
4
+ /**
5
+ * lane ごとの縦線の始点 = その lane の一番上の node の下端。
6
+ *
7
+ * node を 1 つも持たない lane は載せない (縦線を引く相手が無い)。
8
+ *
9
+ * 一番上は `cy` の小さい順で決める。 同値なら先に現れた node を採る = 並べ替えてから先頭を
10
+ * 採る形と同じ結果になる。
11
+ */
12
+ export function lifelineStartYs(nodes: LaidNode[]): Map<string, number> {
13
+ const topmost = new Map<string, LaidNode>();
14
+ for (const n of nodes) {
15
+ const cur = topmost.get(n.lane);
16
+ if (cur === undefined || n.cy < cur.cy) topmost.set(n.lane, n);
17
+ }
18
+ const starts = new Map<string, number>();
19
+ for (const [laneId, n] of topmost) starts.set(laneId, n.cy + n.h / 2);
20
+ return starts;
21
+ }
22
+
23
+ /**
24
+ * 全 lane で揃える縦線の終点。 **下端の名札の絵の上端の最小値** を返す。
25
+ *
26
+ * 1 つの値を全 lane で共有することが元からの設計意図で、 これが全 lane の縦線を同じ高さで
27
+ * 終わらせる。
28
+ *
29
+ * ## 終点に選べる名札を絞る (`#479`)
30
+ *
31
+ * 全ての名札から最小値を採ると、 **絵の上端が他の lane の始点より上にある名札が 1 つあるだけで
32
+ * 図の全 lane から縦線が消える**。 終点は全 lane 共通なのに、 描くかどうかの判定
33
+ * (`終点 > 始点`) は lane ごとに行うため、 1 つの lane の異常が図全体に伝わる。
34
+ *
35
+ * 実測 (`#458` の Round 3)。 目印の付いた node が lane の一番上にある lane に通常の lane を
36
+ * 混ぜると、 通常の lane の縦線まで 1 本も描かれなかった。
37
+ *
38
+ * そこで **絵の上端が全 lane の始点の最大値より下にある名札だけ** を候補にする。 終点がどの lane の
39
+ * 始点より上にも来なくなるため、 候補が 1 つでもあれば全 lane の縦線が描かれる。 名札が
40
+ * 正常な図では 1 つも外れないので、 終点の値は 1 mm も変わらない。
41
+ *
42
+ * 「自分の lane の始点より下か」 で絞る形は採らない。 自分の lane では正常でも他の lane の
43
+ * 始点より上にある名札が残り、 その lane の縦線が消える形が閉じない。 終点は全 lane 共通なので、
44
+ * 絞る基準も全 lane の始点で見るのが筋。
45
+ *
46
+ * ## 却下 = 最小値を始点の最大値で下限を切る
47
+ *
48
+ * 最小値をそのまま採り、 `Math.max(最小値, 始点の最大値)` で下限を切る形も実装して測った。
49
+ * **この図では縦線が 1 本も描かれない**。 lane の並びが揃った図では全 lane の始点が同値になり、
50
+ * 下限を切った終点が全 lane の始点と一致する = `終点 > 始点` をどの lane も満たさない。
51
+ * 下限は「始点より上に来ない」 ことしか保証せず、 描画が要求する「始点より下」 に届かない。
52
+ *
53
+ * ## 候補が 1 つも残らない図
54
+ *
55
+ * 名札が全て異常な図では選べる終点が無いため、 名札を持たない図と同じ fallback (最初の
56
+ * 縦線 lane の下端) に落とす。 lane は自分の node を含む高さを持つので、 通常はどの始点よりも
57
+ * 下に来る。
58
+ */
59
+ export function uniformLifelineEndY(
60
+ lanes: LaidLane[],
61
+ nodes: LaidNode[],
62
+ starts: Map<string, number> = lifelineStartYs(nodes),
63
+ ): number {
64
+ const lifelineLanes = lanes.filter((l) => l.lifeline);
65
+
66
+ let deepestStart = Number.NEGATIVE_INFINITY;
67
+ for (const lane of lifelineLanes) {
68
+ const start = starts.get(lane.id);
69
+ if (start !== undefined) deepestStart = Math.max(deepestStart, start);
70
+ }
71
+
72
+ // 名札の絵が箱の上端より上へ出る種別では、 縦線は **絵の上端** で止める (#417 / #478)。
73
+ // 箱の上端で止めると出た分だけ縦線が絵の中を通る。
74
+ const footerTops = lifelineFooterNodes(lanes, nodes)
75
+ .map((f) => footerShapeDrawTop(f))
76
+ .filter((top) => top > deepestStart);
77
+
78
+ if (footerTops.length > 0) return Math.min(...footerTops);
79
+ return lifelineLanes[0] ? lifelineLanes[0].y + lifelineLanes[0].height : 0;
80
+ }
@@ -87,8 +87,6 @@ const KINDS_WITHOUT_TITLE_TEXT: ReadonlySet<string> = new Set([
87
87
  "quadrant-matrix",
88
88
  "tree-hierarchy",
89
89
  "journey-map",
90
- // 形の中に自前の文字を置く (title は使わない)
91
- "shape-blockchain-block",
92
90
  ]);
93
91
 
94
92
  /**
@@ -104,6 +102,27 @@ export function rendersTitleText(kind: string | undefined): boolean {
104
102
  return !KINDS_WITHOUT_TITLE_TEXT.has(kind ?? "");
105
103
  }
106
104
 
105
+ /**
106
+ * 自分で `title` を描く幅に合わせて切る種別 (#469)。
107
+ *
108
+ * `text-readability` (軸 3) は種別によらず「字数 × 22 + 52 が node 幅を超えるか」 で切れるかを
109
+ * 見る。 22 は `kinds/storage.tsx` の字の大きさで、 それより小さい字で描く種別には当たらない。
110
+ *
111
+ * `shape-blockchain-block` は 13 で描き、 しかも絵ごと `fitArt` で縮尺するため、 字数から幅を
112
+ * 出す近似が実物と大きくずれる (実測 = 幅 280 の箱に 11 字を渡すと、 描画は 1 字も落とさないのに
113
+ * 軸は期待幅 294 として警告した)。 種別自身が描く幅に合わせて切るので、 超えて切れることが
114
+ * そもそも起きない。
115
+ *
116
+ * **`rendersTitleText` とは別の述語にする**。 あちらは「文字として描くか」 で、 RTL / CJK / bidi の
117
+ * 判定はこの種別にも当てたい。 ここで見るのは「幅の近似が当たるか」 だけになる。
118
+ */
119
+ const KINDS_FITTING_OWN_TITLE: ReadonlySet<string> = new Set(["shape-blockchain-block"]);
120
+
121
+ /** その node が `title` を自分で幅に合わせて切るか (SSOT) */
122
+ export function fitsOwnTitleWidth(kind: string | undefined): boolean {
123
+ return KINDS_FITTING_OWN_TITLE.has(kind ?? "");
124
+ }
125
+
107
126
  /**
108
127
  * `rows` を文字として描く node の種別。
109
128
  *
@@ -77,11 +77,93 @@ function isHalfwidthKana(code: number): boolean {
77
77
  return code >= 0xff61 && code <= 0xffdc;
78
78
  }
79
79
 
80
+ /**
81
+ * 絵文字 1 つ分の advance (em)。 実ブラウザで測った値 (`#481`)。
82
+ *
83
+ * `getComputedTextLength()` で fontSize 22 の 1 文字が 23.0 = 1.045 em、 5 / 10 / 20 文字でも
84
+ * 1 文字あたり 23.0 で線形だった。 丸めて 1.05 を採る。
85
+ *
86
+ * **元は class 既定の 0.65 に落ちていた**。 10 文字並べると見積り 143 に対し実描画 230 で、
87
+ * 37.8% 足りない = 枠が実際より狭く見積もられ、 名前が枠からはみ出す。
88
+ */
89
+ const EMOJI_ADVANCE_EM = 1.05;
90
+
91
+ /** mono text から fallback する絵文字の advance (em)。 fontSize 19 の実測 22.0 px。 */
92
+ const MONO_EMOJI_ADVANCE_EM = 1.16;
93
+
94
+ /**
95
+ * 絵文字ではない非 ASCII の記号 1 つ分の advance (em)。 実ブラウザで測った値 (`#481`)。
96
+ *
97
+ * 矢印 (`→←↑↓`) が 1 文字 0.868 em、 幾何図形と印 (`●■★✓`) が 0.948 em だった。 広い方に
98
+ * 寄せて 0.95 を採る = 見積りが実描画より **狭い** と枠からはみ出すが、 広い分には枠が
99
+ * 間延びするだけで済む。
100
+ */
101
+ const WIDE_SYMBOL_ADVANCE_EM = 0.95;
102
+
103
+ /** 絵文字として描かれる符号位置か (色付きの絵として描かれる範囲)。 */
104
+ function isEmoji(code: number): boolean {
105
+ return (
106
+ (code >= 0x1f000 && code <= 0x1faff) || // 絵文字の主要面
107
+ (code >= 0x2600 && code <= 0x27bf) || // 記号と絵文字 (♻ ✅ ❤ 等)
108
+ (code >= 0x1f1e6 && code <= 0x1f1ff) // 国旗を作る地域指示子
109
+ );
110
+ }
111
+
112
+ /**
113
+ * 書記素全体が絵文字表示か。
114
+ *
115
+ * キーキャップ (`1️⃣`) / 著作権記号 (`©️`) / 絵文字表示の矢印 (`↔️`) は先頭の符号位置だけでは
116
+ * 絵文字範囲に入らない。 VS16 が付いた書記素も絵文字として扱う。
117
+ */
118
+ function isEmojiGrapheme(grapheme: string, firstCode: number): boolean {
119
+ return isEmoji(firstCode) || grapheme.includes("\ufe0f");
120
+ }
121
+
122
+ /**
123
+ * 絵文字ではないが `defaultAdvanceEm` の既定 (0.65) より明らかに広い記号か。
124
+ *
125
+ * 矢印 / 幾何図形 / 補助矢印。 いずれも実測で 0.87 em 以上あり、 既定のままだと 25-32% 足りない。
126
+ */
127
+ function isWideSymbol(code: number): boolean {
128
+ return (
129
+ (code >= 0x2190 && code <= 0x21ff) || // 矢印
130
+ (code >= 0x2500 && code <= 0x257f) || // 罫線
131
+ (code >= 0x25a0 && code <= 0x25ff) || // 幾何図形
132
+ (code >= 0x2b00 && code <= 0x2bff) // その他の記号と矢印
133
+ );
134
+ }
135
+
136
+ /**
137
+ * 1 つの絵に畳まれる並びを含むか。 含む時だけ書記素 (glyph) 単位で数える。
138
+ *
139
+ * 家族 (`👨‍👩‍👧` = 符号位置 5 つ) / 肌の色付き (`👍🏽` = 2 つ) / 異体字選択子付き (`❤️` = 2 つ) /
140
+ * 国旗 (`🇯🇵` = 2 つ) は、 いずれも実測で **1 文字分 (23.0)** しか幅を持たない。 符号位置で
141
+ * 数えると家族 1 つで 3 倍に見積もる。
142
+ *
143
+ * 判定を先に置くのは速さのため。 英数字と日本語だけの文字列は書記素と符号位置が一致するので、
144
+ * 分割する意味が無い (`measureTextWidth` は衝突判定から node ごとに呼ばれる)。
145
+ */
146
+ const CLUSTER_JOINER_RE = /\u200d|\ufe0f|[\u{1f000}-\u{1faff}]|[\u{2600}-\u{27bf}]/u;
147
+
148
+ /** 書記素で分ける器。 使う時に 1 度だけ作る (作る cost が呼出ごとに乗らないように)。 */
149
+ let 書記素で分ける: Intl.Segmenter | undefined;
150
+ function segmentGraphemes(text: string): string[] {
151
+ if (typeof Intl === "undefined" || typeof Intl.Segmenter !== "function") {
152
+ // 分ける器が無い環境では符号位置で数える。 絵文字の並びを過大に見積もるが、 広い側に
153
+ // 外れるので枠からはみ出す形にはならない
154
+ return [...text];
155
+ }
156
+ 書記素で分ける ??= new Intl.Segmenter(undefined, { granularity: "grapheme" });
157
+ return [...書記素で分ける.segment(text)].map((s) => s.segment);
158
+ }
159
+
80
160
  /**
81
161
  * char class 別 default advance (em)。 char 表に個別値が無い場合の fallback。
82
162
  * Inter Bold で ASCII 平均 = 0.58、 記号平均 = 0.42、 CJK = 1.05 (font-fallback overhang 込)、 半角カナ = 0.60。
83
163
  */
84
164
  function defaultAdvanceEm(ch: string, code: number): number {
165
+ if (isEmoji(code)) return EMOJI_ADVANCE_EM;
166
+ if (isWideSymbol(code)) return WIDE_SYMBOL_ADVANCE_EM;
85
167
  if (isCJK(code)) return 1.08;
86
168
  if (isHalfwidthKana(code)) return 0.72;
87
169
  if (ch >= "a" && ch <= "z") return 0.73;
@@ -109,14 +191,20 @@ export function measureTextWidth(text: string, opts: MeasureOptions = {}): numbe
109
191
  const fontFamily = opts.fontFamily ?? "sans";
110
192
  let advanceEmSum = 0;
111
193
  let charCount = 0;
112
- for (const ch of text) {
194
+ // 1 つの絵に畳まれる並びがある時だけ書記素で数える (`CLUSTER_JOINER_RE` の説明)
195
+ const 文字列 = CLUSTER_JOINER_RE.test(text) ? segmentGraphemes(text) : [...text];
196
+ for (const ch of 文字列) {
113
197
  charCount++;
198
+ const code = ch.codePointAt(0) ?? 0;
199
+ if (isEmojiGrapheme(ch, code)) {
200
+ // mono を指定しても絵文字は JetBrains Mono ではなく emoji font へ fallback する。
201
+ advanceEmSum += fontFamily === "mono" ? MONO_EMOJI_ADVANCE_EM : EMOJI_ADVANCE_EM;
202
+ continue;
203
+ }
114
204
  if (fontFamily === "mono") {
115
- const code = ch.codePointAt(0) ?? 0;
116
205
  advanceEmSum += isCJK(code) ? 1.08 : JETBRAINS_MONO_ADVANCE_EM;
117
206
  continue;
118
207
  }
119
- const code = ch.codePointAt(0) ?? 0;
120
208
  if (isCJK(code)) {
121
209
  // CJK 実測 = Chrome + macOS system CJK font (Hiragino Sans / Yu Gothic) で
122
210
  // Inter fontSize 22 baseline に対し 1.08 em 前後で描画される (実測係数化)。
@@ -125,7 +213,7 @@ export function measureTextWidth(text: string, opts: MeasureOptions = {}): numbe
125
213
  advanceEmSum += 1.08;
126
214
  continue;
127
215
  }
128
- const table = INTER_BOLD_ADVANCE_EM[ch];
216
+ const table = ch.length === 1 ? INTER_BOLD_ADVANCE_EM[ch] : undefined;
129
217
  advanceEmSum += table !== undefined ? table : defaultAdvanceEm(ch, code);
130
218
  }
131
219
  const w = advanceEmSum * fontSize + letterSpacing * charCount;
@@ -1,5 +1,4 @@
1
1
  import type { BBox, CdlViewport, LaidEdge, LaidLane, LaidNode } from "../types";
2
- import { footerShapeDrops } from "./footer-shape";
3
2
  import { VIEW_PAD } from "./tokens";
4
3
 
5
4
  export function computeViewBox(
@@ -27,13 +26,6 @@ export function computeViewBox(
27
26
  maxX = Math.max(maxX, n.cx + n.w / 2);
28
27
  maxY = Math.max(maxY, n.cy + n.h / 2);
29
28
  }
30
- // 下端の名札で下げた絵は箱より下に出る (`footerShapeDrops`)。 その分を図の下端に含めないと
31
- // 絵と名前が viewBox の外に落ちて切れる = `<svg>` は既定で外側を描かない。
32
- const footerDrops = footerShapeDrops(lanes, nodes);
33
- for (const n of nodes) {
34
- const dy = footerDrops.get(n.id);
35
- if (dy !== undefined) maxY = Math.max(maxY, n.cy + n.h / 2 + dy);
36
- }
37
29
  if (!Number.isFinite(minX)) minX = 0;
38
30
  if (!Number.isFinite(minY)) minY = 0;
39
31
  const x = minX - VIEW_PAD;
package/src/layout.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  resolveOverlaps,
12
12
  } from "./layout/collisions";
13
13
  import { bowSharedStraightPaths, layoutEdges, repositionParallelLabels } from "./layout/edges";
14
+ import { applyFooterShapeDrops } from "./layout/footer-shape";
14
15
  import {
15
16
  expandLaneGapsForEdgeLabels,
16
17
  expandLanesForNodes,
@@ -206,7 +207,13 @@ export function applyRowLevelNodeShifts<
206
207
  */
207
208
  export function layout(diag: CdlDiagram): LaidDiagram {
208
209
  const lanes = layoutLanes(diag);
209
- const rawNodes = layoutNodes(diag, lanes);
210
+ // 下端の名札で絵が箱の上へ出る種別は、 ここで **座標ごと** 下げる (#478)。 以降の全ての
211
+ // 処理 (lane の伸長 / 衝突の解消 / 矢印の経路 / 外接矩形 / 図の下端) が動かした後の位置を
212
+ // 見るため、 描画と座標が食い違わない。
213
+ //
214
+ // 描画側で `<g transform>` を掛ける形だった頃は、 箱ごと下へ動いて見えるのに `cy` も
215
+ // `data-cdl-cy` も外接矩形も元のままで、 座標を読む側が実際の位置を知らなかった。
216
+ const rawNodes = applyFooterShapeDrops(lanes, layoutNodes(diag, lanes));
210
217
  const expandedLanes = expandLanesForNodes(lanes, rawNodes);
211
218
  // CAR-470 SSOT ... 隣接 lane 対を跨ぐ edge label の収納幅を engine 側で先手確保。
212
219
  // 拡張後、 後続 lane は cumulative shift で連鎖 shift される。 lane.width は