@cardenelabs/cdl 0.7.0 → 0.10.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/CHANGELOG.md +446 -0
- package/SPEC.md +42 -18
- package/dist/index.cjs +793 -347
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +73 -14
- package/dist/index.d.ts +73 -14
- package/dist/index.js +793 -347
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +658 -236
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +658 -236
- package/dist/react.js.map +1 -1
- package/dist/{render-DquCvgOB.d.cts → render-CH1Qn3Jv.d.cts} +47 -2
- package/dist/{render-DquCvgOB.d.ts → render-CH1Qn3Jv.d.ts} +47 -2
- package/package.json +6 -3
- package/src/builder.ts +13 -1
- package/src/kinds/box-lines.ts +120 -0
- package/src/kinds/chart-line.tsx +94 -25
- package/src/kinds/gantt.tsx +152 -24
- package/src/kinds/mind-map.tsx +50 -12
- package/src/kinds/quadrant.tsx +28 -2
- package/src/kinds/shape-blockchain.tsx +65 -12
- package/src/kinds/shape-region.tsx +152 -34
- package/src/kinds/tree.tsx +92 -20
- package/src/layout/collisions.ts +10 -2
- package/src/layout/footer-shape.ts +86 -24
- package/src/layout/lifeline.ts +80 -0
- package/src/layout/spec.ts +21 -2
- package/src/layout/text-width.ts +92 -4
- package/src/layout/viewbox.ts +0 -8
- package/src/layout.ts +8 -1
- package/src/presets.ts +120 -22
- package/src/render/header.tsx +3 -1
- package/src/render/nodes.tsx +15 -1
- package/src/render/payload-binding.ts +31 -8
- package/src/render/stage.tsx +21 -34
- package/src/render/template-fields.ts +212 -0
- package/src/render/utils.ts +32 -2
- package/src/render.tsx +2 -9
- package/src/types.ts +47 -2
- package/src/validate.ts +45 -13
- package/src/visual-validate.ts +34 -1
- package/src/layout/label-shift.ts +0 -28
|
@@ -11,22 +11,16 @@
|
|
|
11
11
|
* | 高さに依存 | 23 | 箱を大きくすると収まる。 絵が固定の寸法で描かれ、 箱の高さを見ていない |
|
|
12
12
|
* | 高さに依らない | 13 | どんな箱でも出る。 幾何そのものの誤り (`#434`) |
|
|
13
13
|
*
|
|
14
|
-
* ##
|
|
14
|
+
* ## 小さい箱の読みやすさ (#459)
|
|
15
15
|
*
|
|
16
16
|
* **収めた分だけ絵は小さくなる**。 高さ 72 の箱に「絵 180px + 名前 + 説明 + 肩書」 を入れると
|
|
17
|
-
*
|
|
17
|
+
* 変更前は絵に残るのが 28px、 倍率が 0.156 になり、 3px の線も 0.47px まで細くなっていた。
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* | 名前だけ | 0.290 | 0.87 |
|
|
22
|
-
* | + 説明 | 0.223 | 0.67 |
|
|
23
|
-
* | + 説明 + 肩書 | 0.156 | 0.47 |
|
|
24
|
-
*
|
|
25
|
-
* 直すには「絵を優先して字を削る」 か「字を優先して絵を描かない」 かの判断が要り、 製品の
|
|
26
|
-
* 方針として決める必要がある。 `#436` で扱う。
|
|
19
|
+
* 絵の帯が 45px を割る時は、 補いの行を肩書 → 説明の順で落とす。 名前は必ず残す。 高さ 72
|
|
20
|
+
* では名前だけの時と同じ倍率 0.308 まで戻り、 3px の線を約 0.92px で描ける。
|
|
27
21
|
*/
|
|
28
22
|
import type { JSX } from "react";
|
|
29
|
-
import type { LaidNode } from "../types";
|
|
23
|
+
import type { LaidNode, NodeKind } from "../types";
|
|
30
24
|
|
|
31
25
|
// 既存の呼出 (検査を含む) がここから取っているため、 そのまま出し直す。
|
|
32
26
|
export { textWidth } from "./text-width";
|
|
@@ -66,6 +60,88 @@ export const DESCENT = 0.25;
|
|
|
66
60
|
/** 絵と名前の塊の間に空ける量。 */
|
|
67
61
|
const ART_GAP = 4;
|
|
68
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 絵に残す帯の下限 (`#459`)。 これを割るなら補いの行 (肩書 → 説明) を描かない。
|
|
65
|
+
*
|
|
66
|
+
* ## 値の出どころ
|
|
67
|
+
*
|
|
68
|
+
* 人型の絵は自然な大きさで縦 180 ある。 `fitArt` は帯に収まるまで一様に縮めるので、 帯が
|
|
69
|
+
* `B` なら倍率は `B / 180` になり、 絵の中の 3 の線は `3B / 180` で描かれる。
|
|
70
|
+
*
|
|
71
|
+
* 倍率 0.25 を下限に採り、 `0.25 × 180 = 45` を帯の下限とした。 この倍率で 3 の線は 0.75、
|
|
72
|
+
* 8 の字は 2 になる。
|
|
73
|
+
*
|
|
74
|
+
* ## なぜ箱の高さの割合にしないか
|
|
75
|
+
*
|
|
76
|
+
* 割合にすると中くらいの箱まで巻き込む。 高さ 106 の箱は 3 行で帯が 39 (割合 0.37) しか無く、
|
|
77
|
+
* 割合 0.65 を下限に置くと **高さ 191 未満の箱が全て** 補いの行を失う (実測)。
|
|
78
|
+
* 絵が読めるかは箱の高さではなく帯の実寸で決まるので、 実寸で判定する。
|
|
79
|
+
*
|
|
80
|
+
* ## 覆えていない範囲
|
|
81
|
+
*
|
|
82
|
+
* 下限は人型 (縦 180) を基準にした 1 つの値で、 絵の大きさが種別ごとに違うことは見ていない。
|
|
83
|
+
* 小さい絵の種別では帯が足りていても行を落とすことがある。 種別ごとの広がりは描く直前
|
|
84
|
+
* (`fitArt` の呼出) にしか分からず、 名前の塊を組む時点では手に入らない。
|
|
85
|
+
*/
|
|
86
|
+
const ART_MIN_BAND = 45;
|
|
87
|
+
|
|
88
|
+
/** 補いの行のうち、 箱が狭くて描けなかったもの。 落とす順は肩書が先。 */
|
|
89
|
+
export type DroppedLabelLine = "肩書" | "説明";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `labelPlan` が落としても固有 layout で描く種別 (`#492`)。
|
|
93
|
+
*
|
|
94
|
+
* 肩書と説明を一括で判定してはいけない。 `shape-terminal` は肩書だけを描き、
|
|
95
|
+
* `shape-blockchain-block` は説明だけを描く。 種別単位で除外すると片方を誤報するか見逃す。
|
|
96
|
+
*
|
|
97
|
+
* 描き方は配置結果から分からないため一覧を持つ。 `test/shared-label-plan-kinds.test.tsx` が全
|
|
98
|
+
* `shape-` 種別の両方の行を実際に描き、 一覧との一致を確かめる。
|
|
99
|
+
*/
|
|
100
|
+
const 落としても描く種別: Readonly<Record<DroppedLabelLine, ReadonlySet<NodeKind>>> = {
|
|
101
|
+
肩書: new Set([
|
|
102
|
+
// basement 系 = `commonLabel` が自前の帯に置く
|
|
103
|
+
"shape-file",
|
|
104
|
+
"shape-folder",
|
|
105
|
+
"shape-cloud",
|
|
106
|
+
"shape-cylinder",
|
|
107
|
+
"shape-hexagon",
|
|
108
|
+
"shape-diamond",
|
|
109
|
+
"shape-stack",
|
|
110
|
+
// software 系 = 固有 layout
|
|
111
|
+
"shape-window",
|
|
112
|
+
"shape-terminal",
|
|
113
|
+
"shape-code-block",
|
|
114
|
+
"shape-message-bubble",
|
|
115
|
+
"shape-gear",
|
|
116
|
+
// hardware 系のうち固有 layout を持つもの
|
|
117
|
+
"shape-iot-sensor",
|
|
118
|
+
"shape-robot-arm",
|
|
119
|
+
]),
|
|
120
|
+
説明: new Set([
|
|
121
|
+
// basement 系 = `commonLabel` が自前の帯に置く
|
|
122
|
+
"shape-file",
|
|
123
|
+
"shape-folder",
|
|
124
|
+
"shape-cloud",
|
|
125
|
+
"shape-cylinder",
|
|
126
|
+
"shape-hexagon",
|
|
127
|
+
"shape-diamond",
|
|
128
|
+
"shape-stack",
|
|
129
|
+
// software 系 = 固有 layout
|
|
130
|
+
"shape-window",
|
|
131
|
+
"shape-message-bubble",
|
|
132
|
+
"shape-gear",
|
|
133
|
+
// hardware / blockchain 系のうち固有 layout を持つもの
|
|
134
|
+
"shape-iot-sensor",
|
|
135
|
+
"shape-robot-arm",
|
|
136
|
+
"shape-blockchain-block",
|
|
137
|
+
]),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** この種別で、共有 plan が落とした特定の行が実際にも描かれないか。 */
|
|
141
|
+
export function droppedLineIsHidden(kind: NodeKind, line: DroppedLabelLine): boolean {
|
|
142
|
+
return kind.startsWith("shape-") && !落としても描く種別[line].has(kind);
|
|
143
|
+
}
|
|
144
|
+
|
|
69
145
|
/** 名前の塊の 1 行。 */
|
|
70
146
|
export type LabelLine = { y: number; size: number };
|
|
71
147
|
|
|
@@ -91,6 +167,13 @@ export function labelPlan(node: LaidNode): {
|
|
|
91
167
|
* なる (高さ 53 で実測、 review 指摘)。 一番小さい行で判定する。
|
|
92
168
|
*/
|
|
93
169
|
読める: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* 箱が狭くて描かなかった補いの行 (`#459`)。 書いていない行は載らない。
|
|
172
|
+
*
|
|
173
|
+
* 書き手に「書いた文字が出ていない」 を伝えるための出口。 描画側は行が `null` かどうかだけを
|
|
174
|
+
* 見るので、 この欄は伝えるためだけに持つ。
|
|
175
|
+
*/
|
|
176
|
+
落とした: DroppedLabelLine[];
|
|
94
177
|
} {
|
|
95
178
|
const 高さ = Math.max(0, node.h);
|
|
96
179
|
const 底 = node.cy + 高さ / 2;
|
|
@@ -125,33 +208,67 @@ export function labelPlan(node: LaidNode): {
|
|
|
125
208
|
|
|
126
209
|
let 説明あり = Boolean(node.subtitle);
|
|
127
210
|
let 肩書あり = Boolean(node.eyebrow);
|
|
128
|
-
|
|
129
|
-
if (!入る(説明あり, 肩書あり)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const 下の行大 = 説明あり ? 小さい字 : size;
|
|
137
|
-
if (積む(size, 説明あり, 肩書あり, 下の行大 * DESCENT).上端 >= 天) {
|
|
138
|
-
名前大 = size;
|
|
139
|
-
break;
|
|
140
|
-
}
|
|
211
|
+
const 落とした: DroppedLabelLine[] = [];
|
|
212
|
+
if (!入る(説明あり, 肩書あり) && 肩書あり) {
|
|
213
|
+
肩書あり = false;
|
|
214
|
+
落とした.push("肩書");
|
|
215
|
+
}
|
|
216
|
+
if (!入る(説明あり, 肩書あり) && 説明あり) {
|
|
217
|
+
説明あり = false;
|
|
218
|
+
落とした.push("説明");
|
|
141
219
|
}
|
|
142
220
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
221
|
+
/** 描く行を決めた上で、 字の大きさと baseline を決める。 */
|
|
222
|
+
const 組む = (説明あり: boolean, 肩書あり: boolean) => {
|
|
223
|
+
// 箱に入る一番大きい字を探す。 上限は「高さに比例」 と「読める上限」 の小さい方
|
|
224
|
+
const 上限 = Math.min(TITLE_MAX, Math.max(1, Math.round(高さ * TITLE_RATIO)));
|
|
225
|
+
let 名前大 = 0;
|
|
226
|
+
for (let size = 上限; size >= 1; size--) {
|
|
227
|
+
const 小さい字 = Math.max(1, Math.round(size * SUB_RATIO));
|
|
228
|
+
const 下の行大 = 説明あり ? 小さい字 : size;
|
|
229
|
+
if (積む(size, 説明あり, 肩書あり, 下の行大 * DESCENT).上端 >= 天) {
|
|
230
|
+
名前大 = size;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// **1 でも入らない箱がある**。 名前 1 行でも上下の張り出しで 0.97 要るため、 高さ 0.97 未満の
|
|
236
|
+
// 箱では整数の大きさに解が無い (review 指摘)。 名前は落とせないので、 ちょうど入る大きさまで
|
|
237
|
+
// 下げる。 見えない字にはなるが、 箱の外へ出て隣の領域を汚すよりは良い
|
|
238
|
+
if (名前大 === 0) 名前大 = 高さ / (ASCENT + DESCENT);
|
|
147
239
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
240
|
+
// 下の余白は余った分までしか取れない。 固定の 4 を無条件に取ると塊が上へ出る
|
|
241
|
+
const 小さい字 = Math.max(1, Math.round(名前大 * SUB_RATIO));
|
|
242
|
+
const 張り出し = (説明あり ? 小さい字 : 名前大) * DESCENT;
|
|
243
|
+
const 余り = Math.max(0, 積む(名前大, 説明あり, 肩書あり, 張り出し).上端 - 天);
|
|
244
|
+
const 下余白 = Math.min(Math.max(BOTTOM_PAD, 張り出し), 張り出し + 余り);
|
|
245
|
+
|
|
246
|
+
return 積む(名前大, 説明あり, 肩書あり, 下余白);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** この組み方で絵に残る帯。 `shapeRegion` が使う式と同じものをここでも使う。 */
|
|
250
|
+
const 絵の帯 = (組: ReturnType<typeof 組む>): number => Math.max(0, 組.上端 - ART_GAP - 天);
|
|
251
|
+
|
|
252
|
+
// 絵に残る帯が下限を割るなら、 補いの行を落として絵に譲る (#459)。
|
|
253
|
+
//
|
|
254
|
+
// **落とす順は肩書 → 説明で、 名前は落とさない**。 名前が無い箱は何を指しているか分からず、
|
|
255
|
+
// 絵が読めても図の意味が失われる。
|
|
256
|
+
//
|
|
257
|
+
// 絵を描かない案 (`#459` の案 B) は採らない = `shape-` は種別ごとの絵を持つことが存在理由で、
|
|
258
|
+
// 絵を落とすと `card` と区別が付かなくなる。 種別ごとに簡略版の絵を持つ案 (案 C) は 49 種
|
|
259
|
+
// それぞれに絵を足すことになり、 本 Issue の範囲を超える。
|
|
260
|
+
let 組 = 組む(説明あり, 肩書あり);
|
|
261
|
+
if (絵の帯(組) < ART_MIN_BAND && 肩書あり) {
|
|
262
|
+
肩書あり = false;
|
|
263
|
+
落とした.push("肩書");
|
|
264
|
+
組 = 組む(説明あり, 肩書あり);
|
|
265
|
+
}
|
|
266
|
+
if (絵の帯(組) < ART_MIN_BAND && 説明あり) {
|
|
267
|
+
説明あり = false;
|
|
268
|
+
落とした.push("説明");
|
|
269
|
+
組 = 組む(説明あり, 肩書あり);
|
|
270
|
+
}
|
|
153
271
|
|
|
154
|
-
const 組 = 積む(名前大, 説明あり, 肩書あり, 下余白);
|
|
155
272
|
const 一番小さい行 = Math.min(
|
|
156
273
|
...[組.名前, 組.説明, 組.肩書].filter((l) => l !== null).map((l) => l.size),
|
|
157
274
|
);
|
|
@@ -161,6 +278,7 @@ export function labelPlan(node: LaidNode): {
|
|
|
161
278
|
肩書: 組.肩書,
|
|
162
279
|
top: 組.上端,
|
|
163
280
|
読める: 一番小さい行 >= TITLE_MIN,
|
|
281
|
+
落とした,
|
|
164
282
|
};
|
|
165
283
|
}
|
|
166
284
|
|
package/src/kinds/tree.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import type { JSX } from "react";
|
|
|
2
2
|
import type { LaidNode, TreeNodePayload } from "../types";
|
|
3
3
|
import { TONE } from "../render/tone";
|
|
4
4
|
import { resolveTreeData } from "../render/payload-binding";
|
|
5
|
+
import { 箱に積む, type 積んだ行 } from "../kinds/box-lines";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* tree-hierarchy kind ... 1 node に tree node 配列 (treeData) を保持し、
|
|
@@ -12,6 +13,42 @@ import { resolveTreeData } from "../render/payload-binding";
|
|
|
12
13
|
* - depth 1+ は tone 別 accent の left-border + 白 fill card
|
|
13
14
|
* - parent-child edge は elbow line + 中間 join dot で「分岐点」 を強調
|
|
14
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* 箱の中に名前と補足を積んで描く (#471)。
|
|
18
|
+
*
|
|
19
|
+
* 補足が無い node は呼ばない = 従来の 1 行の `<text>` をそのまま残すため。 積む形に寄せると
|
|
20
|
+
* baseline の出し方が変わり、 補足を渡していない既存の図まで文字の位置が動く。
|
|
21
|
+
*/
|
|
22
|
+
function 箱の文字({
|
|
23
|
+
行,
|
|
24
|
+
x,
|
|
25
|
+
箱の高さ,
|
|
26
|
+
fill,
|
|
27
|
+
名前の太さ,
|
|
28
|
+
}: {
|
|
29
|
+
行: 積んだ行[];
|
|
30
|
+
x: number;
|
|
31
|
+
箱の高さ: number;
|
|
32
|
+
fill: string;
|
|
33
|
+
名前の太さ: number;
|
|
34
|
+
}): JSX.Element[] {
|
|
35
|
+
return 行.map((r) => (
|
|
36
|
+
<text
|
|
37
|
+
key={r.役割}
|
|
38
|
+
data-cdl-role={r.役割 === "subtitle" ? "tree-node-subtitle" : undefined}
|
|
39
|
+
x={x}
|
|
40
|
+
y={箱の高さ / 2 + r.dy}
|
|
41
|
+
fontSize={r.fontSize}
|
|
42
|
+
fontWeight={r.役割 === "title" ? 名前の太さ : 500}
|
|
43
|
+
textAnchor="middle"
|
|
44
|
+
fill={fill}
|
|
45
|
+
opacity={r.役割 === "subtitle" ? 0.85 : undefined}
|
|
46
|
+
>
|
|
47
|
+
{r.text}
|
|
48
|
+
</text>
|
|
49
|
+
));
|
|
50
|
+
}
|
|
51
|
+
|
|
15
52
|
export function TreeNode({
|
|
16
53
|
node,
|
|
17
54
|
active,
|
|
@@ -88,6 +125,18 @@ export function TreeNode({
|
|
|
88
125
|
{layout.nodes.map((n) => {
|
|
89
126
|
const accent = depthColors[Math.min(n.depth, depthColors.length - 1)] ?? depthColors[0]!;
|
|
90
127
|
const isRoot = n.depth === 0;
|
|
128
|
+
// 補足は箱の中の 2 行目に出す (#471)。 箱の高さは変えないため、 入らない時は落ちる
|
|
129
|
+
const 行 = 箱に積む(
|
|
130
|
+
[
|
|
131
|
+
{ 役割: "title" as const, text: n.title, fontSize: isRoot ? 13 : 12 },
|
|
132
|
+
{ 役割: "subtitle" as const, text: n.subtitle ?? "", fontSize: 9 },
|
|
133
|
+
],
|
|
134
|
+
n.w,
|
|
135
|
+
n.h,
|
|
136
|
+
);
|
|
137
|
+
// **判定は行数でなく「補足を渡したか」 で行う**。 行数で見ると、 箱が低くて補足が落ちた時に
|
|
138
|
+
// 従来の 1 行の経路へ戻り、 幅で切っていない名前が箱から横にはみ出す (#471 Round 1)
|
|
139
|
+
const 補足あり = (n.subtitle ?? "").trim() !== "";
|
|
91
140
|
return (
|
|
92
141
|
<g
|
|
93
142
|
key={`n-${n.id}`}
|
|
@@ -105,16 +154,26 @@ export function TreeNode({
|
|
|
105
154
|
rx={12}
|
|
106
155
|
fill={`url(#${gradientId})`}
|
|
107
156
|
/>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
157
|
+
{補足あり ? (
|
|
158
|
+
箱の文字({
|
|
159
|
+
行,
|
|
160
|
+
x: n.w / 2,
|
|
161
|
+
箱の高さ: n.h,
|
|
162
|
+
fill: "var(--cdl-text-on-tone, #ffffff)",
|
|
163
|
+
名前の太さ: 700,
|
|
164
|
+
})
|
|
165
|
+
) : (
|
|
166
|
+
<text
|
|
167
|
+
x={n.w / 2}
|
|
168
|
+
y={n.h / 2 + 5}
|
|
169
|
+
fontSize={13}
|
|
170
|
+
fontWeight={700}
|
|
171
|
+
textAnchor="middle"
|
|
172
|
+
fill="var(--cdl-text-on-tone, #ffffff)"
|
|
173
|
+
>
|
|
174
|
+
{n.title}
|
|
175
|
+
</text>
|
|
176
|
+
)}
|
|
118
177
|
</>
|
|
119
178
|
) : (
|
|
120
179
|
<>
|
|
@@ -130,16 +189,26 @@ export function TreeNode({
|
|
|
130
189
|
strokeWidth={1.5}
|
|
131
190
|
/>
|
|
132
191
|
<rect x={0} y={0} width={4} height={n.h} rx={2} fill={accent} />
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
192
|
+
{補足あり ? (
|
|
193
|
+
箱の文字({
|
|
194
|
+
行,
|
|
195
|
+
x: n.w / 2 + 2,
|
|
196
|
+
箱の高さ: n.h,
|
|
197
|
+
fill: "var(--cdl-chip-text, #1a1f2a)",
|
|
198
|
+
名前の太さ: 600,
|
|
199
|
+
})
|
|
200
|
+
) : (
|
|
201
|
+
<text
|
|
202
|
+
x={n.w / 2 + 2}
|
|
203
|
+
y={n.h / 2 + 4}
|
|
204
|
+
fontSize={12}
|
|
205
|
+
fontWeight={600}
|
|
206
|
+
textAnchor="middle"
|
|
207
|
+
fill="var(--cdl-chip-text, #1a1f2a)"
|
|
208
|
+
>
|
|
209
|
+
{n.title}
|
|
210
|
+
</text>
|
|
211
|
+
)}
|
|
143
212
|
</>
|
|
144
213
|
)}
|
|
145
214
|
</g>
|
|
@@ -152,6 +221,8 @@ export function TreeNode({
|
|
|
152
221
|
type LayoutNode = {
|
|
153
222
|
id: string;
|
|
154
223
|
title: string;
|
|
224
|
+
/** 箱の 2 行目に出す補足 (#471) */
|
|
225
|
+
subtitle?: string;
|
|
155
226
|
x: number;
|
|
156
227
|
y: number;
|
|
157
228
|
w: number;
|
|
@@ -250,6 +321,7 @@ export function computeTreeLayout(
|
|
|
250
321
|
nodes.push({
|
|
251
322
|
id: n.id,
|
|
252
323
|
title: n.title,
|
|
324
|
+
...(n.subtitle !== undefined ? { subtitle: n.subtitle } : {}),
|
|
253
325
|
x,
|
|
254
326
|
y,
|
|
255
327
|
w: nodeW,
|
package/src/layout/collisions.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BBox, LaidEdge, LaidLane, LaidNode } from "../types";
|
|
2
2
|
import { CHAIN_SHIFT_MAX_ACCUMULATED, computeLabelBBoxWorld, EDGE_STUB_OUT, hasRenderedLabel, LABEL_TO_PATH_CLEARANCE, requiredNearClearance } from "./spec";
|
|
3
|
+
import { footerShapeDrops } from "./footer-shape";
|
|
3
4
|
import { measureTextWidth } from "./text-width";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -26,14 +27,21 @@ export function collectBBoxes(lanes: LaidLane[], nodes: LaidNode[], edges: LaidE
|
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// 下端の名札は絵が箱の上端より上へ出る (`SHAPE_DRAW_UP_EXTENT`)。 出た分を外接矩形に
|
|
31
|
+
// 含めないと、 描かれている範囲と衝突 / 近接の判定材料が食い違う (#478)。
|
|
32
|
+
//
|
|
33
|
+
// 名札を集める条件は `lifelineFooterNodes` が唯一持ち、 出る量は種別と箱の高さだけで
|
|
34
|
+
// 決まるので、 名札を下げた後に呼んでも同じ値になる。
|
|
35
|
+
const footerDraw = footerShapeDrops(lanes, nodes);
|
|
29
36
|
for (const n of nodes) {
|
|
37
|
+
const up = footerDraw.get(n.id) ?? 0;
|
|
30
38
|
out.push({
|
|
31
39
|
kind: "node",
|
|
32
40
|
id: n.id,
|
|
33
41
|
x: n.cx - n.w / 2,
|
|
34
|
-
y: n.cy - n.h / 2,
|
|
42
|
+
y: n.cy - n.h / 2 - up,
|
|
35
43
|
w: n.w,
|
|
36
|
-
h: n.h,
|
|
44
|
+
h: n.h + up,
|
|
37
45
|
});
|
|
38
46
|
}
|
|
39
47
|
|
|
@@ -28,35 +28,45 @@ export const SHAPE_DRAW_UP_EXTENT: Readonly<Record<string, number>> = {
|
|
|
28
28
|
/**
|
|
29
29
|
* 縦線を持つ lane の **下端の名札** を集める。
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* 判定は node の `role` で行う。 順序図を組み立てる側 (`presets.ts` の `build()`) が
|
|
32
|
+
* `role: "lifeline-footer"` を付けるため、 描画側は位置を見る必要が無い。
|
|
33
33
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
39
|
-
//
|
|
40
|
-
const
|
|
41
|
-
for (const n of nodes)
|
|
42
|
-
|
|
43
|
-
|
|
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 (
|
|
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
|
|
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
|
+
}
|
package/src/layout/spec.ts
CHANGED
|
@@ -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
|
*
|