@cardenelabs/cdl 0.5.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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +343 -0
  3. package/SPEC.md +374 -0
  4. package/dist/index.cjs +28085 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +3415 -0
  7. package/dist/index.d.ts +3415 -0
  8. package/dist/index.js +27962 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/react.cjs +23043 -0
  11. package/dist/react.cjs.map +1 -0
  12. package/dist/react.d.cts +2 -0
  13. package/dist/react.d.ts +2 -0
  14. package/dist/react.js +23041 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/render-C78lIXeC.d.cts +2449 -0
  17. package/dist/render-C78lIXeC.d.ts +2449 -0
  18. package/examples/quick-start.md +88 -0
  19. package/package.json +79 -0
  20. package/src/anim/core/easing.ts +77 -0
  21. package/src/anim/core/timeline.ts +335 -0
  22. package/src/anim/core/types.ts +42 -0
  23. package/src/anim/index.ts +20 -0
  24. package/src/anim/react/index.ts +3 -0
  25. package/src/anim/react/useReducedMotion.ts +27 -0
  26. package/src/anim/react/useTimeline.ts +48 -0
  27. package/src/assert-never.ts +16 -0
  28. package/src/author-intent-verify.ts +587 -0
  29. package/src/builder.ts +3740 -0
  30. package/src/compile.ts +12 -0
  31. package/src/dom-verify-core.ts +121 -0
  32. package/src/dom-verify-types.ts +21 -0
  33. package/src/dom-verify.ts +948 -0
  34. package/src/event-handler/index.ts +184 -0
  35. package/src/formula/ast.ts +46 -0
  36. package/src/formula/evaluator.ts +163 -0
  37. package/src/formula/formula-computeds.ts +83 -0
  38. package/src/formula/index.ts +5 -0
  39. package/src/formula/parser.ts +399 -0
  40. package/src/index.ts +284 -0
  41. package/src/input-signals.ts +74 -0
  42. package/src/kinds/actor.tsx +79 -0
  43. package/src/kinds/card.tsx +61 -0
  44. package/src/kinds/chart-bar.tsx +121 -0
  45. package/src/kinds/chart-line.tsx +163 -0
  46. package/src/kinds/chart-pie.tsx +107 -0
  47. package/src/kinds/compact-title.ts +164 -0
  48. package/src/kinds/dyn-shape.tsx +394 -0
  49. package/src/kinds/event.tsx +70 -0
  50. package/src/kinds/function.tsx +53 -0
  51. package/src/kinds/funnel.tsx +122 -0
  52. package/src/kinds/gantt.tsx +193 -0
  53. package/src/kinds/generic.tsx +368 -0
  54. package/src/kinds/mind-map.tsx +367 -0
  55. package/src/kinds/mind-radial.tsx +209 -0
  56. package/src/kinds/node-tone.ts +18 -0
  57. package/src/kinds/quadrant.tsx +164 -0
  58. package/src/kinds/row-align.ts +179 -0
  59. package/src/kinds/shape-basement.tsx +683 -0
  60. package/src/kinds/shape-blockchain.tsx +750 -0
  61. package/src/kinds/shape-commerce.tsx +553 -0
  62. package/src/kinds/shape-finance.tsx +706 -0
  63. package/src/kinds/shape-hardware.tsx +862 -0
  64. package/src/kinds/shape-people.tsx +439 -0
  65. package/src/kinds/shape-region.tsx +383 -0
  66. package/src/kinds/shape-software.tsx +859 -0
  67. package/src/kinds/storage.tsx +180 -0
  68. package/src/kinds/text-width.ts +73 -0
  69. package/src/kinds/tree.tsx +275 -0
  70. package/src/kinds/user-journey.tsx +240 -0
  71. package/src/label-text.ts +71 -0
  72. package/src/layout/clearance-constants.ts +47 -0
  73. package/src/layout/collisions.ts +2078 -0
  74. package/src/layout/edges.ts +2498 -0
  75. package/src/layout/footer-shape.ts +97 -0
  76. package/src/layout/geometry.ts +135 -0
  77. package/src/layout/label-shift.ts +28 -0
  78. package/src/layout/lanes.ts +328 -0
  79. package/src/layout/nodes.ts +190 -0
  80. package/src/layout/predict-bbox.ts +76 -0
  81. package/src/layout/px-projection.ts +176 -0
  82. package/src/layout/spec.ts +872 -0
  83. package/src/layout/text-width.ts +133 -0
  84. package/src/layout/tokens.ts +181 -0
  85. package/src/layout/viewbox.ts +47 -0
  86. package/src/layout-with-validation.ts +175 -0
  87. package/src/layout.ts +381 -0
  88. package/src/presets.ts +2108 -0
  89. package/src/reactive/batch.ts +48 -0
  90. package/src/reactive/computed.ts +85 -0
  91. package/src/reactive/effect.ts +145 -0
  92. package/src/reactive/index.ts +6 -0
  93. package/src/reactive/internal.ts +109 -0
  94. package/src/reactive/signal.ts +113 -0
  95. package/src/render/edges.tsx +318 -0
  96. package/src/render/header.tsx +146 -0
  97. package/src/render/interactive-panel.tsx +9620 -0
  98. package/src/render/nodes.tsx +319 -0
  99. package/src/render/stage.tsx +377 -0
  100. package/src/render/tone.ts +64 -0
  101. package/src/render/utils.ts +238 -0
  102. package/src/render.tsx +221 -0
  103. package/src/scroll-trigger/index.ts +109 -0
  104. package/src/scroll-trigger/progress.ts +49 -0
  105. package/src/thumbnail.tsx +114 -0
  106. package/src/types.ts +2371 -0
  107. package/src/validate.ts +268 -0
  108. package/src/visual-validate.ts +4381 -0
@@ -0,0 +1,383 @@
1
+ /**
2
+ * `shape-` の絵をどこに置き、 どこまで縮めるかを決める共通部 (#433)。
3
+ *
4
+ * `shape-` は絵を **自分の大きさ** で描くため、 箱が小さいと絵が箱の外に出る。 呼び出し側
5
+ * (順序図の名札は高さ 72) で実測すると 49 種のうち 36 種が下か左右にはみ出していた。
6
+ *
7
+ * 原因は 2 つに分かれる。 ここが直すのは前者。
8
+ *
9
+ * | 型 | 件数 | 中身 |
10
+ * |---|---|---|
11
+ * | 高さに依存 | 23 | 箱を大きくすると収まる。 絵が固定の寸法で描かれ、 箱の高さを見ていない |
12
+ * | 高さに依らない | 13 | どんな箱でも出る。 幾何そのものの誤り (`#434`) |
13
+ *
14
+ * ## 覆えていない範囲 (#436)
15
+ *
16
+ * **収めた分だけ絵は小さくなる**。 高さ 72 の箱に「絵 180px + 名前 + 説明 + 肩書」 を入れると
17
+ * 絵に残るのは 28px で、 倍率 0.156 = 3px の線が 0.47px になり細部が読めない。
18
+ *
19
+ * | 書いた文字 | h=72 の倍率 | 3px の線 |
20
+ * |---|---|---|
21
+ * | 名前だけ | 0.290 | 0.87 |
22
+ * | + 説明 | 0.223 | 0.67 |
23
+ * | + 説明 + 肩書 | 0.156 | 0.47 |
24
+ *
25
+ * 直すには「絵を優先して字を削る」 か「字を優先して絵を描かない」 かの判断が要り、 製品の
26
+ * 方針として決める必要がある。 `#436` で扱う。
27
+ */
28
+ import type { JSX } from "react";
29
+ import type { LaidNode } from "../types";
30
+
31
+ // 既存の呼出 (検査を含む) がここから取っているため、 そのまま出し直す。
32
+ export { textWidth } from "./text-width";
33
+
34
+ /** 名前の字の大きさの上限。 大きい箱ではここに当たり、 今までの見え方を保つ。 */
35
+ const TITLE_MAX = 18;
36
+
37
+ /**
38
+ * 読める字の下限。 **これを割るのは箱が狭すぎる時だけ**。
39
+ *
40
+ * 一律の下限にはできない。 3 行で高さ 36 以下の箱では、 9 を守ると塊が箱の上端を越える
41
+ * (review 指摘)。 箱に入る大きさを優先し、 下限を割ったことは `labelPlan` の返り値で分かる。
42
+ */
43
+ const TITLE_MIN = 9;
44
+
45
+ /** 箱の高さに対する名前の字の割合。 高さ 72 で 12、 高さ 106 以上で上限 18 に当たる。 */
46
+ const TITLE_RATIO = 0.17;
47
+
48
+ /** 名前に対する説明と肩書の字の割合。 */
49
+ const SUB_RATIO = 0.72;
50
+
51
+ /** 字と字の間に空ける量。 */
52
+ const LINE_GAP = 3;
53
+
54
+ /** 箱の下端との間に空ける量の下限。 字の下の張り出しがこれを超えるならそちらを採る。 */
55
+ const BOTTOM_PAD = 4;
56
+
57
+ /**
58
+ * 字が baseline から上下へ張り出す量の見積り。
59
+ *
60
+ * 描く側 (`shape-basement` の `commonLabel`) と測る側 (検査) が同じ値を使う = 書き写すと
61
+ * 片方だけ古くなる。
62
+ */
63
+ export const ASCENT = 0.72;
64
+ export const DESCENT = 0.25;
65
+
66
+ /** 絵と名前の塊の間に空ける量。 */
67
+ const ART_GAP = 4;
68
+
69
+ /** 名前の塊の 1 行。 */
70
+ export type LabelLine = { y: number; size: number };
71
+
72
+ /**
73
+ * 名前の塊を **箱の下端から積み上げる**。
74
+ *
75
+ * 上から順に置くと、 箱が小さい時に肩書が絵の領域へ食い込む (実測 = 高さ 72 で 13.6)。
76
+ * 下から積めば、 書いてある行だけが必要な高さを取り、 残りがそのまま絵の帯になる。
77
+ *
78
+ * 字の大きさは箱の高さに比例させ、 上限で止める。 大きい箱では上限に当たるので今までの
79
+ * 見え方が変わらない。
80
+ */
81
+ export function labelPlan(node: LaidNode): {
82
+ 名前: LabelLine;
83
+ 説明: LabelLine | null;
84
+ 肩書: LabelLine | null;
85
+ /** 名前の塊の上端。 絵の帯はここまで。 */
86
+ top: number;
87
+ /**
88
+ * 描いた行の **すべて** が読める下限 (9) を保てたか。 箱が狭すぎると `false` になる。
89
+ *
90
+ * 名前だけを見てはいけない。 説明と肩書は名前の 0.72 倍なので、 名前が 9 でも補いの行は 6 に
91
+ * なる (高さ 53 で実測、 review 指摘)。 一番小さい行で判定する。
92
+ */
93
+ 読める: boolean;
94
+ } {
95
+ const 高さ = Math.max(0, node.h);
96
+ const 底 = node.cy + 高さ / 2;
97
+ const 天 = node.cy - 高さ / 2;
98
+
99
+ /**
100
+ * 下から順に積んで、 各行の baseline と塊の上端を返す。
101
+ *
102
+ * **式で近似しない**。 積み方 (`y -= 大きさ + 隙間`) と別に高さの式を持つと、 2 つがずれて
103
+ * 塊が箱から出る (h=9 の 2 行で 0.28 出た)。 実際に積んで測る。
104
+ */
105
+ const 積む = (名前大: number, 説明あり: boolean, 肩書あり: boolean, 下余白: number) => {
106
+ const 小さい字 = Math.max(1, Math.round(名前大 * SUB_RATIO));
107
+ let y = 底 - 下余白;
108
+ const 説明 = 説明あり ? { y, size: 小さい字 } : null;
109
+ if (説明 !== null) y -= 小さい字 + LINE_GAP;
110
+ const 名前 = { y, size: 名前大 };
111
+ y -= 名前大 + LINE_GAP;
112
+ const 肩書 = 肩書あり ? { y, size: 小さい字 } : null;
113
+ const 上の行 = 肩書 ?? 名前;
114
+ return { 名前, 説明, 肩書, 上端: 上の行.y - 上の行.size * ASCENT };
115
+ };
116
+
117
+ /**
118
+ * 何行入るか。 **入らない行は描かない**。
119
+ *
120
+ * 隙間だけで箱の高さを超える組合せがある (2 行は隙間 3 で、 高さ 1 の箱には入らない)。
121
+ * 字を小さくしても解けないので、 補いの行を落とす。 落とす順は肩書が先で、 名前は必ず残す。
122
+ */
123
+ const 入る = (説明あり: boolean, 肩書あり: boolean): boolean =>
124
+ 積む(1, 説明あり, 肩書あり, 1 * DESCENT).上端 >= 天;
125
+
126
+ let 説明あり = Boolean(node.subtitle);
127
+ let 肩書あり = Boolean(node.eyebrow);
128
+ if (!入る(説明あり, 肩書あり)) 肩書あり = false;
129
+ if (!入る(説明あり, 肩書あり)) 説明あり = false;
130
+
131
+ // 箱に入る一番大きい字を探す。 上限は「高さに比例」 と「読める上限」 の小さい方
132
+ const 上限 = Math.min(TITLE_MAX, Math.max(1, Math.round(高さ * TITLE_RATIO)));
133
+ let 名前大 = 0;
134
+ for (let size = 上限; size >= 1; size--) {
135
+ const 小さい字 = Math.max(1, Math.round(size * SUB_RATIO));
136
+ const 下の行大 = 説明あり ? 小さい字 : size;
137
+ if (積む(size, 説明あり, 肩書あり, 下の行大 * DESCENT).上端 >= 天) {
138
+ 名前大 = size;
139
+ break;
140
+ }
141
+ }
142
+
143
+ // **1 でも入らない箱がある**。 名前 1 行でも上下の張り出しで 0.97 要るため、 高さ 0.97 未満の
144
+ // 箱では整数の大きさに解が無い (review 指摘)。 名前は落とせないので、 ちょうど入る大きさまで
145
+ // 下げる。 見えない字にはなるが、 箱の外へ出て隣の領域を汚すよりは良い
146
+ if (名前大 === 0) 名前大 = 高さ / (ASCENT + DESCENT);
147
+
148
+ // 下の余白は余った分までしか取れない。 固定の 4 を無条件に取ると塊が上へ出る
149
+ const 小さい字 = Math.max(1, Math.round(名前大 * SUB_RATIO));
150
+ const 張り出し = (説明あり ? 小さい字 : 名前大) * DESCENT;
151
+ const 余り = Math.max(0, 積む(名前大, 説明あり, 肩書あり, 張り出し).上端 - 天);
152
+ const 下余白 = Math.min(Math.max(BOTTOM_PAD, 張り出し), 張り出し + 余り);
153
+
154
+ const 組 = 積む(名前大, 説明あり, 肩書あり, 下余白);
155
+ const 一番小さい行 = Math.min(
156
+ ...[組.名前, 組.説明, 組.肩書].filter((l) => l !== null).map((l) => l.size),
157
+ );
158
+ return {
159
+ 名前: 組.名前,
160
+ 説明: 組.説明,
161
+ 肩書: 組.肩書,
162
+ top: 組.上端,
163
+ 読める: 一番小さい行 >= TITLE_MIN,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * 絵を置く領域。 箱の上端から、 名前の塊の手前まで。
169
+ *
170
+ * `labelY` は名前の baseline (今までの呼出との互換)。
171
+ */
172
+ export function shapeRegion(node: LaidNode): {
173
+ cx: number;
174
+ cy: number;
175
+ availW: number;
176
+ availH: number;
177
+ labelY: number;
178
+ label: ReturnType<typeof labelPlan>;
179
+ } {
180
+ const label = labelPlan(node);
181
+ const 天 = node.cy - node.h / 2;
182
+ const availH = Math.max(0, label.top - ART_GAP - 天);
183
+ return {
184
+ cx: node.cx,
185
+ cy: 天 + availH / 2,
186
+ availW: node.w,
187
+ availH,
188
+ labelY: label.名前.y,
189
+ label,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * 影の楕円を本体の下端からどれだけ下に置くか。 `shape-basement` の `dropShadow` と共有する
195
+ * = 別々に持つと、 影を動かした時に帯の計算だけが古くなる。
196
+ */
197
+ export const SHADOW_GAP = 8;
198
+
199
+ /** 枠線の太さ (`active` の 3) の半分。 線は path の中心から外へこれだけ出る。 */
200
+ export const FRAME_HALF = 1.5;
201
+
202
+ /** 絵を描いてよい帯。 `右` と `底` は `x + w` / `y + h` を書き写さないために持つ。 */
203
+ export type BodyBand = {
204
+ x: number;
205
+ y: number;
206
+ w: number;
207
+ h: number;
208
+ cx: number;
209
+ cy: number;
210
+ 右: number;
211
+ 底: number;
212
+ };
213
+
214
+ /**
215
+ * 箱から **影と枠を先に引いた帯**。 絵はこの帯いっぱいに描く。
216
+ *
217
+ * 影は本体の下端から `SHADOW_GAP` 下がった中心に `ry` で描くため、 **下へ出る量は箱の高さに
218
+ * 依らない** (高さ 72 でも 400 でも 12)。 比で縮めて (`fitArt`) 解こうとすると、 影が出ない
219
+ * 大きい箱まで絵が小さくなるうえ、 小さい箱では影の分がそのまま残る。 先に引いてから描く。
220
+ *
221
+ * 帯を使うと **箱の縁と絵の縁の隙間が箱の高さに依らない定数** になる。 比で縮める直し方は
222
+ * この定数性を破るので、 検査が誤った直し方を捕まえられる。
223
+ *
224
+ * `外` は枠以外の理由で外へ出る絵 (`shape-hexagon` の頂点の点) がある時に広げる。
225
+ *
226
+ * ## 大きい箱でも余白を引く (捨てた要件)
227
+ *
228
+ * **帯は箱の大きさに関わらず余白を引く**。 高さ 400 の箱でも高さ 40 の箱でも同じだけ引く
229
+ * (`外` 既定 1.5 + `SHADOW_GAP` 8 + `影ry` 4 = 縦 13.5、 横は `外` × 2 = 3)。 本体は帯いっぱいに
230
+ * 描くので、 **変更前より本体が縦 13.5 / 横 3 小さくなる**。 高さ 200 でも 400 でも同じ差 (実測)。
231
+ *
232
+ * | 種別 | 本体の縮み (縦 / 横) | 理由 |
233
+ * |---|---|---|
234
+ * | folder / diamond / stack / file / cloud | 13.5 / 3 | `外` 1.5 + 影 12 |
235
+ * | hexagon | 14.5 / 5 | `外` が頂点の点で 2.5 |
236
+ * | cylinder | 12.5 / 3 | `影ry` が 3 |
237
+ *
238
+ * 「影まで箱に収める」 と「大きい箱では本体の大きさと位置を変えない」 は **同時には満たせない**。
239
+ * 影は本体の下端から `SHADOW_GAP + 影ry` 下に出て、 その量は箱の高さに依らないので、 本体を
240
+ * 箱いっぱいに描く限り影は必ず箱の外に残る。 **前者を採った**。 大きい箱で本体が一回り
241
+ * 小さくなるのは、 影を箱に入れるために払う代償。
242
+ */
243
+ export function bodyBand(node: LaidNode, opt: { 影ry: number; 外?: number }): BodyBand {
244
+ const 影 = Math.max(0, opt.影ry);
245
+ const 外 = Math.max(0, opt.外 ?? FRAME_HALF);
246
+ const 下余白 = Math.max(SHADOW_GAP + 影, 外);
247
+ const 箱w = Math.max(0, node.w);
248
+ const 箱h = Math.max(0, node.h);
249
+
250
+ // 余白の合計が箱を超える小さい箱では比で詰める。 引き切ると帯が箱の外へ回り込む
251
+ const 縦余白 = 外 + 下余白;
252
+ const ky = 縦余白 > 箱h && 縦余白 > 0 ? 箱h / 縦余白 : 1;
253
+ const 横余白 = 外 * 2;
254
+ const kx = 横余白 > 箱w && 横余白 > 0 ? 箱w / 横余白 : 1;
255
+
256
+ const x = node.cx - 箱w / 2 + 外 * kx;
257
+ const y = node.cy - 箱h / 2 + 外 * ky;
258
+ const w = Math.max(0, 箱w - 横余白 * kx);
259
+ const h = Math.max(0, 箱h - 縦余白 * ky);
260
+ return { x, y, w, h, cx: x + w / 2, cy: y + h / 2, 右: x + w, 底: y + h };
261
+ }
262
+
263
+ /**
264
+ * 字の塊を帯に収める倍率と置き場所。
265
+ *
266
+ * 字の大きさと行間が固定なので、 小さい箱では塊が箱の下へ出る (高さ 40 の `shape-folder` で
267
+ * 下へ 6.4)。 **一様に縮めてから帯の内へ寄せる** = 塊の縦横はどちらも倍率にそのまま比例
268
+ * するので、 帯に入る倍率が 1 なら大きい箱では今までどおりの位置と大きさになる。
269
+ *
270
+ * **縦だけで倍率を決めてはいけない**。 字数を見ないと、 幅 120 の箱で 22 字の名前が左右へ
271
+ * 50 以上出る (中央揃えなので両側に出る)。 `幅` を分母に入れて横も見る。
272
+ *
273
+ * `上` / `下` は倍率 1 の時に baseline から上下へ張り出す量、 `幅` は一番広い行の横幅。
274
+ * 呼ぶ側が自分の書式から出す。
275
+ */
276
+ export function fitLabel(
277
+ band: { x: number; y: number; w: number; h: number },
278
+ block: { 上: number; 下: number; 幅: number },
279
+ pos: { x: number; y: number },
280
+ ): { k: number; x: number; y: number } {
281
+ const 縦 = block.上 + block.下;
282
+ const 横 = Math.max(0, block.幅);
283
+ if (!(縦 > 0)) return { k: 1, x: pos.x, y: pos.y };
284
+ // 帯が潰れた箱では寄せ先が無い。 倍率 0 で描けば箱の外は汚さない
285
+ if (!(band.h > 0) || !(band.w > 0)) return { k: 0, x: band.x + band.w / 2, y: band.y };
286
+ const k = Math.min(1, band.h / 縦, 横 > 0 ? band.w / 横 : 1);
287
+ // 下端と右端を帯に入れてから、 上端と左端が出る分だけ押し戻す。 倍率が帯に入るまで
288
+ // 落ちているので、 2 つの寄せは両立する
289
+ const 半 = (横 * k) / 2;
290
+ return {
291
+ k,
292
+ x: Math.max(band.x + 半, Math.min(pos.x, band.x + band.w - 半)),
293
+ y: Math.max(band.y + block.上 * k, Math.min(pos.y, band.y + band.h - block.下 * k)),
294
+ };
295
+ }
296
+
297
+ /**
298
+ * 絵が (cx, cy) を中心にどこまで広がるか。 描く側が **自分の定数から** 出す。
299
+ *
300
+ * 表に書き写さない = 絵の定数を変えた時に片方だけ古くなる。
301
+ */
302
+ export type ArtExtent = { 上: number; 下: number; 左: number; 右: number };
303
+
304
+ /**
305
+ * 絵を領域に収める。 **判定できる入力では常に中心を合わせ、 収まらない時だけ縮める**。
306
+ *
307
+ * **縦横の比は保つ**。 潰すと絵が読めなくなる (`shape-wallet` が上へ 12.1 しか出ないのに
308
+ * 名前と重なって読めなくなった例がある)。
309
+ *
310
+ * 手を触れずにそのまま返すのは 4 通り。 それ以外は必ず包む。
311
+ *
312
+ * | 入力 | 返り値 |
313
+ * |---|---|
314
+ * | 広がりの縦か横が有限の正の値でない | 渡された絵をそのまま (判定できないので手を触れない) |
315
+ * | 領域の幅か高さが有限の正の値でない | 同上 |
316
+ * | 倍率が有限の正の値でない | 同上 |
317
+ * | 倍率 1 かつ中心が既に合っている (対称な絵) | 同上 (余計な `<g>` を挟まない) |
318
+ * | 上記以外 | `<g transform="translate(...) scale(...)">` で包む |
319
+ *
320
+ * **「正」 だけでなく「有限」 も要る**。 `Infinity > 0` は真になるため、 大小の比較だけでは
321
+ * 無限値が防御をすり抜ける。 すり抜けても変換の数値は壊れない (`cx` / `cy` が有限なら
322
+ * `dx` / `dy` も有限) が、 **縁を持たない軸では「収まるか」 の判定が常に真になる**。 この
323
+ * 関数が返しているのは「領域に収めた絵」 で、 その約束がその軸で意味を失う。 負の値は倍率も
324
+ * 負にして絵を反転させ、 `NaN` は大小の比較そのものをすり抜ける。 いずれも渡された値を領域
325
+ * として扱えないので、 **判定できない入力では手を触れない**。
326
+ *
327
+ * **倍率 1 でも包むことがある** (#438)。 上下 (左右) の広がりが非対称な絵は、 縮まない
328
+ * 大きさの箱でも中心を合わせるために `scale(1)` 付きで包む。 この wrapper を「不要な層」 と
329
+ * 見て外すと、 非対称な絵が領域の中心から外れて名前に食い込む (実測 = 人型が高さ 240 の箱で
330
+ * 帯を 26.5 超えた)。
331
+ */
332
+ export function fitArt(
333
+ region: { cx: number; cy: number; availW: number; availH: number },
334
+ extent: ArtExtent,
335
+ art: JSX.Element,
336
+ ): JSX.Element {
337
+ const 縦 = extent.上 + extent.下;
338
+ const 横 = extent.左 + extent.右;
339
+ // 広がりが無限なら倍率が 0 になるので後段の `s` の防御でも止まる。 ここで先に落とすのは
340
+ // 契約 (有限で正の値だけを通す) を入口で 1 つに揃えるため
341
+ if (!Number.isFinite(縦) || !Number.isFinite(横) || 縦 <= 0 || 横 <= 0) return art;
342
+
343
+ // **ここは後段の `s` の防御では代替できない**。 無限の軸は倍率の候補が `Infinity` になって
344
+ // `Math.min` から落ちるだけなので、 残りの候補 (上限 1 と反対の軸) で倍率が決まり、 通常の
345
+ // 入力として後段へ進む。 負なら倍率も負になって絵が反転する
346
+ if (
347
+ !Number.isFinite(region.availH) ||
348
+ !Number.isFinite(region.availW) ||
349
+ region.availH <= 0 ||
350
+ region.availW <= 0
351
+ ) {
352
+ return art;
353
+ }
354
+
355
+ const s = Math.min(1, region.availH / 縦, region.availW / 横);
356
+ if (!Number.isFinite(s) || s <= 0) return art;
357
+
358
+ // 絵の外接矩形の中心を領域の中心に合わせる。 上下 (左右) の広がりが違うので、 中心を
359
+ // そろえないと片側だけはみ出す
360
+ const 絵cx = region.cx + (extent.右 - extent.左) / 2;
361
+ const 絵cy = region.cy + (extent.下 - extent.上) / 2;
362
+ const dx = region.cx - s * 絵cx;
363
+ const dy = region.cy - s * 絵cy;
364
+
365
+ // **縮める時だけ中心を合わせるのは誤り** (#438)。 上下の広がりが非対称な絵は、 縮まない
366
+ // 大きさの箱でも領域の中心から外れたまま描かれる。
367
+ //
368
+ // 実測 = 人型は下へ 128 広がる。 高さ 240 の箱では帯が 101.5 で、 縦の合計 (上 51.5 +
369
+ // 下 128 = 179.5) は帯に収まるので倍率は 1 になるが、 絵の下端は帯を 26.5 超えて名前に
370
+ // 食い込む。 「収まるか」 を合計だけで見て位置を見ていなかった。
371
+ //
372
+ // 倍率が 1 で中心も合っている時だけ包まない = 対称な絵に余計な `<g>` を挟まない。
373
+ if (s >= 1 && dx === 0 && dy === 0) return art;
374
+
375
+ // **包む時は必ず `translate` と `scale` を両方書く**。 倍率 1 の時に `translate` だけを
376
+ // 書くと変換の形が 2 通りになり、 読む側が両方を扱う必要が出る (実測 = 検査の変換 parser が
377
+ // `translate(0 1)` を読めず 8 件落ちた)。 `scale(1)` は無害なので形を 1 つに保つ
378
+ return (
379
+ <g transform={`translate(${dx} ${dy}) scale(${s})`}>
380
+ {art}
381
+ </g>
382
+ );
383
+ }