@cardenelabs/cdl 0.16.0 → 0.17.1

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.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 時系列のやり取りの図の寸法 (#578)。
3
+ *
4
+ * **絵を描く側と、箱の大きさを決める側の両方が引く**。 前者は `sequence-board.tsx`、後者は
5
+ * `presets.ts` の `sequence()`。 別々に式を持つと、宣言した大きさと実際に描かれる絵がずれる。
6
+ *
7
+ * JSX を持たない module に置くのは `text-width.ts` と同じ理由 = 大きさを決めるだけの経路から
8
+ * React を引き込まない。
9
+ */
10
+ import { textWidth } from "./text-width";
11
+
12
+ /** 画布の縁 */
13
+ export const SEQ_EDGE = 16;
14
+ /** 段の送り。 他の 3 図の行 (56) と近い拍にする */
15
+ export const SEQ_ROW = 64;
16
+ /** 語の左の余白 */
17
+ export const SEQ_GUTTER_PAD = 40;
18
+ /** 語と最初の糸の間 */
19
+ export const SEQ_GUTTER_GAP = 40;
20
+ /** 頭の高さ = 名前 + 呼び名 + 横罫まで */
21
+ export const SEQ_HEAD = 96;
22
+ /** 横罫から 1 段目まで / 最終段から底まで */
23
+ export const SEQ_ROW_PAD = 56;
24
+ /** 列幅に足す余白 */
25
+ export const SEQ_COL_PAD = 80;
26
+ /** 動いている間の帯の幅 */
27
+ export const SEQ_BAND_W = 52;
28
+ /** 帯が段からはみ出す上下の余り */
29
+ export const SEQ_BAND_PAD = 24;
30
+ /** 名前の級 */
31
+ export const SEQ_NAME = 24;
32
+ /** 呼び名の級 */
33
+ export const SEQ_SUB = 20;
34
+ /** 言づての語の級 */
35
+ export const SEQ_LABEL = 26;
36
+ /** 出どころの点の半径 */
37
+ export const SEQ_DOT = 7;
38
+ /** 自分宛ての輪が右へ張り出す幅 */
39
+ export const SEQ_SELF_W = 52;
40
+ /** 列の最小幅。 名前が短い図でも糸が詰まりすぎない */
41
+ export const SEQ_COL_MIN = 200;
42
+
43
+ const g4 = (v: number): number => Math.round(v / 4) * 4;
44
+
45
+ /**
46
+ * 段が進むと濃さが移るための、状態の名前 (#578)。
47
+ *
48
+ * **記号を落とす**。 `{名前}` の中で読めるのは `\w` だけ (`template-name.ts`) で、`-` を
49
+ * 含む名前は 1 度も置換されない = 濃さが動かないまま緑になる (実測)。
50
+ *
51
+ * 見本の側 (`withSteps` の `sets`) と描く側 (`sequenceStep`) が同じ名前を引く必要があるので、
52
+ * 導き方をここに置いて両方から呼ぶ。
53
+ *
54
+ * **図の id からは導かない**。 記法は id を書けず名前から導く規則を持つため、同じ図を
55
+ * 記法から書くと状態の名前だけがずれる (実測)。 図の中に順序図の箱は 1 つしか無く、状態は
56
+ * 図ごとに閉じているので、決め打ちで足りる。
57
+ */
58
+ export function sequenceStepId(): string {
59
+ return "seq_step";
60
+ }
61
+
62
+ export type SequenceBoardActor = { name: string; subtitle?: string };
63
+ export type SequenceBoardMessageKind = "call" | "return" | "fire";
64
+ export type SequenceBoardMessage = {
65
+ label: string;
66
+ /** 出どころの面の番号 */
67
+ from: number;
68
+ /** 着き先の面の番号。 `from` と同じなら自分宛て */
69
+ to: number;
70
+ kind: SequenceBoardMessageKind;
71
+ };
72
+ /** 動いている間の帯。 段の番号で持つ (途中で手が空く面があるので区間の並び) */
73
+ export type SequenceBoardBand = { actor: number; from: number; to: number };
74
+
75
+ export type SequenceBoardData = {
76
+ actors: readonly SequenceBoardActor[];
77
+ messages: readonly SequenceBoardMessage[];
78
+ bands: readonly SequenceBoardBand[];
79
+ };
80
+
81
+ export type SequenceBoardMetrics = {
82
+ w: number;
83
+ h: number;
84
+ /** 語を置く左の桁の幅 = 最初の糸の x */
85
+ gutter: number;
86
+ colW: number;
87
+ /** 横罫の y */
88
+ headY: number;
89
+ /** 面 i の糸の x */
90
+ cx: (i: number) => number;
91
+ /** 段 i の y */
92
+ rowY: (i: number) => number;
93
+ /** 糸の下端 */
94
+ bottom: number;
95
+ };
96
+
97
+ /**
98
+ * 中身から寸法を出す。
99
+ *
100
+ * 幅は **語の桁 + 列 × 面の数**。 語を線の脇ではなく左の桁に置くので、線の長さが語の長さに
101
+ * 引きずられない = 面の数だけで横幅が決まる。
102
+ */
103
+ export function sequenceBoardMetrics(data: SequenceBoardData): SequenceBoardMetrics {
104
+ const 名幅 = data.actors.map((a) =>
105
+ Math.max(textWidth(a.name, SEQ_NAME), a.subtitle ? textWidth(a.subtitle, SEQ_SUB) : 0),
106
+ );
107
+ const colW = g4(Math.max(SEQ_COL_MIN, Math.max(0, ...名幅) + SEQ_COL_PAD));
108
+ const 語幅 = Math.max(0, ...data.messages.map((m) => textWidth(m.label, SEQ_LABEL)));
109
+ const gutter = g4(SEQ_EDGE + SEQ_GUTTER_PAD + 語幅 + SEQ_GUTTER_GAP);
110
+ const headY = SEQ_EDGE + SEQ_HEAD;
111
+ const rowY = (i: number): number => headY + SEQ_ROW_PAD + i * SEQ_ROW;
112
+ const 最終 = data.messages.length > 0 ? rowY(data.messages.length - 1) : headY;
113
+ const bottom = 最終 + SEQ_ROW_PAD;
114
+ const cx = (i: number): number => gutter + colW / 2 + i * colW;
115
+ const w = g4(cx(Math.max(0, data.actors.length - 1)) + colW / 2 + SEQ_EDGE);
116
+ const h = g4(bottom + SEQ_EDGE);
117
+ return { w, h, gutter, colW, headY, cx, rowY, bottom };
118
+ }
@@ -0,0 +1,297 @@
1
+ import type { JSX } from "react";
2
+ import type { LaidNode } from "../types";
3
+ import { interpolate } from "../render/utils";
4
+ import {
5
+ SEQ_BAND_PAD,
6
+ SEQ_BAND_W,
7
+ SEQ_DOT,
8
+ SEQ_EDGE,
9
+ SEQ_GUTTER_PAD,
10
+ SEQ_LABEL,
11
+ SEQ_NAME,
12
+ SEQ_SELF_W,
13
+ SEQ_SUB,
14
+ sequenceBoardMetrics,
15
+ type SequenceBoardData,
16
+ type SequenceBoardMessage,
17
+ } from "./sequence-board-metrics";
18
+
19
+ /**
20
+ * 時系列のやり取りの図 (#578)。 **1 つの箱が図を丸ごと描く**。
21
+ *
22
+ * ## 骨格が他の 3 図と違う
23
+ *
24
+ * クラス図 / ER 図 / 状態遷移図は「箱 + 行 + 関係」 で、同じ描き手を共有できる。 ここだけは
25
+ * **縦が時間** で、箱を持たない。 図表の種別 (`gantt-timeline` / `funnel-stages` 等) と同じく、
26
+ * 中身の並びを 1 つの箱に持たせて内側の配置を自分で決める。
27
+ *
28
+ * ## 教科書の形を捨てた理由
29
+ *
30
+ * 3 版まで静止画として詰めた (教科書の縦形 / 参加者を増やして帯を塗る形 / 寿命線を捨てて左に
31
+ * 一覧を置く形)。 どれも違った。 骨格の選び方の問題ではなく、**時間を位置に畳んでいた** のが
32
+ * 原因だった。
33
+ *
34
+ * 足場 (縦の破線と帯) は言づてを引っ掛けるためだけに在る。 捨てて言づてを **行** にすると、
35
+ * 他の 3 図と同じ拍で読めるようになる。 引っ掛ける先が無くなった代わりに、出どころへ
36
+ * **源の点** を打った。
37
+ *
38
+ * ## 参加者の糸は残す
39
+ *
40
+ * 1 度捨てて失敗している = 縦の連なりは足場ではなく **その参加者を時間で追う手段** で、
41
+ * クラス図で言えば区切り線ではなく箱そのものに当たる。 抜いたら左の一覧が内容を全部持ち、
42
+ * 右が飾りになった。
43
+ */
44
+ export function SequenceBoardNode({
45
+ node,
46
+ stateValues,
47
+ }: {
48
+ node: LaidNode;
49
+ stateValues?: Record<string, string>;
50
+ }): JSX.Element {
51
+ const data: SequenceBoardData | undefined = node.sequenceData;
52
+ if (!data) return <g data-cdl-role="sequence-board" />;
53
+ const m = sequenceBoardMetrics(data);
54
+ const x = node.cx - node.w / 2;
55
+ const y = node.cy - node.h / 2;
56
+
57
+ /*
58
+ * いま何番目の言づてを見ているか (#578)。
59
+ *
60
+ * 済んだ言づては薄く残り、今の 1 本だけが濃い。 全部同じ濃さで残すと、どこを見ているのか
61
+ * 判らなくなる = 動く意味が消える。
62
+ *
63
+ * **書かない図では全部を濃く出す**。 段を持たない使い方 (縮小見本 / 静止画) で薄い絵に
64
+ * なると、読み手には壊れて見える
65
+ */
66
+ const 現在 = (() => {
67
+ if (!node.sequenceStep) return null;
68
+ const v = Number.parseInt(interpolate(node.sequenceStep, stateValues ?? {}).trim(), 10);
69
+ return Number.isFinite(v) ? v : null;
70
+ })();
71
+ /*
72
+ * いまの言づての色 (#580)。
73
+ *
74
+ * **意味の 6 色とは別に持つ**。 `--cdl-tone-accent` は「呼び」 のような図の中の意味を
75
+ * 描き分ける色で、利用側が系統色から取ることがある (実測 = dragon は青)。 ここが指すのは
76
+ * 意味ではなく「いまどこか」 で、役割は強調した箱の枠と同じ = 利用側の主役色に揃うべき。
77
+ * 書かれていなければ従来どおり意味の色へ落ちる。
78
+ */
79
+ const 朱 = "var(--cdl-now, var(--cdl-tone-accent, #2d6a8f))";
80
+ /** 済んだ言づての色。 薄めるのではなく墨へ移すので、後からでも読める濃さを持つ */
81
+ const 墨 = "var(--cdl-text-2, #4a5162)";
82
+
83
+ /*
84
+ * 済んだ言づての落とし方 (#578、A + 墨)。
85
+ *
86
+ * **濃淡ではなく色で「今」 を示す**。 薄めて落とすと、済んだ分が読めなくなる = 図が
87
+ * 進むほど読める行が減る。 色を墨へ移せば濃さは落ちないので、朱は「いまどこか」 だけを指す。
88
+ *
89
+ * これから出る言づては描かない = 行き先が最初から見えていると、順に現れる意味が消える。
90
+ */
91
+ const 見た目 = (i: number): { opacity: number; color: string } => {
92
+ if (現在 === null) return { opacity: 1, color: 朱 };
93
+ if (i > 現在) return { opacity: 0, color: 朱 };
94
+ return i === 現在 ? { opacity: 1, color: 朱 } : { opacity: 1, color: 墨 };
95
+ };
96
+ return (
97
+ <g data-cdl-role="sequence-board" transform={`translate(${x} ${y})`}>
98
+ {/*
99
+ 動いている間の帯。 止まっている間は伸びない = 途中で手が空く面はそこで切れる。
100
+
101
+ **段より先へは伸ばさない** (#578)。 最後まで描いてしまうと、まだ起きていない出番が
102
+ 最初から見えて「上から下へ伸びる」 という動きの意味が消える。
103
+ */}
104
+ {data.bands.map((b, i) => {
105
+ if (現在 !== null && b.from > 現在) return null;
106
+ const 下 = 現在 === null ? b.to : Math.min(b.to, 現在);
107
+ return (
108
+ <rect
109
+ key={`band-${i}`}
110
+ data-cdl-role="sequence-band"
111
+ x={m.cx(b.actor) - SEQ_BAND_W / 2}
112
+ y={m.rowY(b.from) - SEQ_BAND_PAD / 2}
113
+ width={SEQ_BAND_W}
114
+ height={m.rowY(下) - m.rowY(b.from) + SEQ_BAND_PAD}
115
+ rx={4}
116
+ fill={朱}
117
+ fillOpacity={0.12}
118
+ />
119
+ );
120
+ })}
121
+ {/* 参加者の糸 */}
122
+ {data.actors.map((_, i) => (
123
+ <line
124
+ key={`thread-${i}`}
125
+ data-cdl-role="sequence-thread"
126
+ x1={m.cx(i)}
127
+ y1={m.headY}
128
+ x2={m.cx(i)}
129
+ y2={m.bottom}
130
+ stroke="var(--cdl-divider, #d4d4d8)"
131
+ strokeWidth={3}
132
+ />
133
+ ))}
134
+ {/* 頭。 名前と、その下に日本語の呼び名 */}
135
+ {data.actors.map((a, i) => (
136
+ <g key={`head-${i}`}>
137
+ <text
138
+ data-cdl-role="sequence-actor"
139
+ x={m.cx(i)}
140
+ y={SEQ_EDGE + 44}
141
+ fontSize={SEQ_NAME}
142
+ fontWeight={700}
143
+ textAnchor="middle"
144
+ fill="var(--cdl-text, #1a1f2a)"
145
+ style={{ fontFamily: "'JetBrains Mono', monospace" }}
146
+ >
147
+ {a.name}
148
+ </text>
149
+ {a.subtitle && (
150
+ <text
151
+ data-cdl-role="sequence-actor-subtitle"
152
+ x={m.cx(i)}
153
+ y={SEQ_EDGE + 76}
154
+ fontSize={SEQ_SUB}
155
+ textAnchor="middle"
156
+ fill="var(--cdl-text-dim, #5a6270)"
157
+ >
158
+ {a.subtitle}
159
+ </text>
160
+ )}
161
+ </g>
162
+ ))}
163
+ <line
164
+ data-cdl-role="sequence-rule"
165
+ x1={SEQ_EDGE}
166
+ y1={m.headY}
167
+ x2={m.w - SEQ_EDGE}
168
+ y2={m.headY}
169
+ stroke="var(--cdl-divider, #d4d4d8)"
170
+ strokeWidth={2}
171
+ />
172
+ {data.messages.map((msg, i) => (
173
+ <SequenceRow key={`msg-${i}`} msg={msg} yy={m.rowY(i)} cx={m.cx} {...見た目(i)} />
174
+ ))}
175
+ </g>
176
+ );
177
+ }
178
+
179
+ /**
180
+ * 言づて 1 行。 左に語、右に矢。
181
+ *
182
+ * **矢の形で 3 種を分ける** = 呼ぶ (実線 + 塗った矢) はやらせて待つ、返す (破線 + 開いた矢) は
183
+ * 新しい仕事ではない、投げる (実線 + 開いた矢) は返事を待たない。
184
+ */
185
+ function SequenceRow({
186
+ msg,
187
+ yy,
188
+ cx,
189
+ opacity,
190
+ color,
191
+ }: {
192
+ msg: SequenceBoardMessage;
193
+ yy: number;
194
+ cx: (i: number) => number;
195
+ opacity: number;
196
+ color: string;
197
+ }): JSX.Element {
198
+ const 破 = msg.kind === "return";
199
+ const 塗る = msg.kind === "call";
200
+ const 出 = cx(msg.from);
201
+ const 自分へ = msg.from === msg.to;
202
+ const dir = 自分へ ? 1 : Math.sign(cx(msg.to) - 出);
203
+ return (
204
+ <g data-cdl-role="sequence-message" data-cdl-message-kind={msg.kind} opacity={opacity}>
205
+ {/* 語は薄れさせない。 済んだ言づてでも「何が起きたか」 は読めたままにする */}
206
+ <text
207
+ data-cdl-role="sequence-label"
208
+ x={SEQ_EDGE + SEQ_GUTTER_PAD}
209
+ y={yy + 8}
210
+ fontSize={SEQ_LABEL}
211
+ fontWeight={500}
212
+ fill="var(--cdl-text, #1a1f2a)"
213
+ >
214
+ {msg.label}
215
+ </text>
216
+ {自分へ ? (
217
+ // 自分宛て。 相手がいないので糸の右へ小さく回して戻る
218
+ <path
219
+ data-cdl-role="sequence-line"
220
+ d={`M ${出 + 10} ${yy - 16} H ${出 + SEQ_SELF_W - 16} Q ${出 + SEQ_SELF_W} ${yy - 16} ${出 + SEQ_SELF_W} ${yy} Q ${出 + SEQ_SELF_W} ${yy + 16} ${出 + SEQ_SELF_W - 16} ${yy + 16} H ${出 + 22}`}
221
+ fill="none"
222
+ stroke={color}
223
+ strokeWidth={2.5}
224
+ strokeLinecap="round"
225
+ />
226
+ ) : (
227
+ <path
228
+ data-cdl-role="sequence-line"
229
+ d={`M ${出 + dir * 12} ${yy} H ${cx(msg.to) - dir * 14}`}
230
+ fill="none"
231
+ stroke={color}
232
+ strokeWidth={2.5}
233
+ strokeLinecap="round"
234
+ strokeDasharray={破 ? "10 8" : undefined}
235
+ />
236
+ )}
237
+ <Arrow
238
+ x={自分へ ? 出 + 14 : cx(msg.to)}
239
+ y={自分へ ? yy + 16 : yy}
240
+ dir={自分へ ? -1 : dir}
241
+ filled={塗る}
242
+ color={color}
243
+ />
244
+ {/* 出どころの点。 帯の縁に引っ掛ける代わりに、糸へ直に打つ */}
245
+ <circle
246
+ data-cdl-role="sequence-source"
247
+ cx={出}
248
+ cy={自分へ ? yy - 16 : yy}
249
+ r={SEQ_DOT}
250
+ fill={color}
251
+ />
252
+ </g>
253
+ );
254
+ }
255
+
256
+ /** 着き先の矢。 塗ると「やらせて待つ」、閉じないと「待たない」 */
257
+ function Arrow({
258
+ x,
259
+ y,
260
+ dir,
261
+ filled,
262
+ color,
263
+ }: {
264
+ x: number;
265
+ y: number;
266
+ dir: number;
267
+ filled: boolean;
268
+ color: string;
269
+ }): JSX.Element {
270
+ // A 案の寸法 (丈 9 / 半 4.14 の 2 倍)。 閉じた矢を細く長く取り、塊に見せない
271
+ const 丈 = 18;
272
+ const 半 = 8.5;
273
+ const 先 = x;
274
+ const 胴 = x - dir * 丈;
275
+ return filled ? (
276
+ <path
277
+ data-cdl-role="sequence-head"
278
+ data-cdl-head-fill="solid"
279
+ d={`M ${先} ${y} L ${胴} ${y - 半} L ${胴} ${y + 半} Z`}
280
+ fill={color}
281
+ stroke={color}
282
+ strokeWidth={3}
283
+ strokeLinejoin="round"
284
+ />
285
+ ) : (
286
+ <path
287
+ data-cdl-role="sequence-head"
288
+ data-cdl-head-fill="open"
289
+ d={`M ${胴} ${y - 半} L ${先} ${y} L ${胴} ${y + 半}`}
290
+ fill="none"
291
+ stroke={color}
292
+ strokeWidth={2.5}
293
+ strokeLinecap="round"
294
+ strokeLinejoin="round"
295
+ />
296
+ );
297
+ }
@@ -1,5 +1,5 @@
1
1
  import type { JSX } from "react";
2
- import type { LaidNode } from "../types";
2
+ import type { LaidNode, RowMark } from "../types";
3
3
  import { nodeAccent } from "./node-tone";
4
4
  import { titlePlacement } from "./compact-title";
5
5
  import {
@@ -7,6 +7,8 @@ import {
7
7
  ROW_TOP_STORAGE,
8
8
  STORAGE_ROW_DIVIDER_Y,
9
9
  STORAGE_TITLE_BASELINE,
10
+ STORAGE_TITLE_FONT,
11
+ SUBTITLE_ROW_SHIFT,
10
12
  rowBaselineY,
11
13
  rowGlyphDepth,
12
14
  } from "../layout/spec";
@@ -18,6 +20,70 @@ import {
18
20
  splitRow,
19
21
  } from "./row-align";
20
22
 
23
+ /**
24
+ * 行頭の印の半辺 (world)。 名前の級 (26) のおよそ半分に収まる大きさ。
25
+ *
26
+ * 大きくすると名前と競い、小さくすると塗りと中空の違いが読めなくなる。
27
+ */
28
+ const ROW_GLYPH_HALF = 7;
29
+
30
+ /** 印の中心 x = 左の余白 + 半辺 + 1。 印の左端が名前の左端より外へ出ない範囲で内側に置く */
31
+ const ROW_GLYPH_CX = ROW_PAD_X_PX + ROW_GLYPH_HALF + 1;
32
+
33
+ /**
34
+ * 印の右端から名前の左端まで。 印と名前が触れず、かつ名前の頭が揃って見える幅。
35
+ *
36
+ * 名前の x は `ROW_PAD_X_PX + ROW_GLYPH_LEAD` で、印の有無で変わる **唯一の水平位置**。
37
+ */
38
+ const ROW_GLYPH_LEAD = 40;
39
+
40
+ /** 印を持つ行の型の級。 名前 (26) より 1 段落とし、色も薄くして名前を先に読ませる */
41
+ const ROW_TYPE_FONT = 22;
42
+
43
+ /** 印の中心を baseline からどれだけ上へ置くか。 級の 1/3 が字の見た目の中心 */
44
+ const ROW_GLYPH_RISE = 8;
45
+
46
+ /**
47
+ * 題の下に置く呼び名の baseline (#578)。
48
+ *
49
+ * 題 (78) と 1 行目 (130) の間。 印で分ける箱では区切り線を引かないので、その分の余白が空く。
50
+ * ER の表名や状態の名前は識別子なので、その下に日本語の呼び名を薄く添える。
51
+ */
52
+ const SUBTITLE_BASELINE = 118;
53
+
54
+ /** 呼び名の級。 行の名前 (26) より小さく、型 (24) と同じ */
55
+ const SUBTITLE_FONT = 20;
56
+
57
+ /** 主キーの下線を名前の baseline からどれだけ下に引くか */
58
+ const UNDERLINE_DROP = 7;
59
+
60
+
61
+ /**
62
+ * 行頭の印 1 つ (#578)。 四角か山形か × 塗るか中空か の 2 軸で 4 通り。
63
+ *
64
+ * 原点は **字の見た目の中心**。 baseline から級の 1/3 上に置くと、名前の高さと揃う。
65
+ *
66
+ * 山形は右を向く = 「外を向く」 側の軸をそのまま形にしてある (振る舞い / 外部キー /
67
+ * 出入りの瞬間)。 四角は閉じた形で「自分の中」 を表す。
68
+ */
69
+ function RowGlyph({ mark, x, y, color }: { mark: RowMark; x: number; y: number; color: string }): JSX.Element {
70
+ const 半 = ROW_GLYPH_HALF;
71
+ const d = mark.shape === "square"
72
+ ? `M ${x - 半} ${y - 半} H ${x + 半} V ${y + 半} H ${x - 半} Z`
73
+ : `M ${x - 半} ${y - 半} L ${x + 半} ${y} L ${x - 半} ${y + 半} Z`;
74
+ return (
75
+ <path
76
+ data-cdl-role="node-row-glyph"
77
+ data-cdl-row-glyph={`${mark.shape}-${mark.filled ? "filled" : "hollow"}`}
78
+ d={d}
79
+ fill={mark.filled ? color : "var(--cdl-node-fill, #ffffff)"}
80
+ stroke={color}
81
+ strokeWidth={mark.filled ? 0 : 2.4}
82
+ strokeLinejoin="round"
83
+ />
84
+ );
85
+ }
86
+
21
87
  export function StorageNode({
22
88
  node,
23
89
  active,
@@ -33,7 +99,7 @@ export function StorageNode({
33
99
  const x = node.cx - node.w / 2;
34
100
  const y = node.cy - node.h / 2;
35
101
  // 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
36
- const 名前 = titlePlacement(node, { x: 26, y: STORAGE_TITLE_BASELINE, size: 22 }, { hasEyebrow: Boolean(node.eyebrow) });
102
+ const 名前 = titlePlacement(node, { x: 26, y: STORAGE_TITLE_BASELINE, size: STORAGE_TITLE_FONT }, { hasEyebrow: Boolean(node.eyebrow) });
37
103
  // 全 row を delimiter (`:` / ` = ` / ` → ` / ` -> `) で left / right に分割し、
38
104
  // left 列 max 文字数を算出 → 右列の固定 x 位置を決める (DB table / UML class box 風)。
39
105
  // 結果として全 row の右列開始 x が揃い、 区切り線がガタつかなくなる。
@@ -56,6 +122,22 @@ export function StorageNode({
56
122
  // 縦線の下端 = 最終行の字形の下端。 箱の下端まで伸ばすと、 行が終わった後の余白に線だけが残る。
57
123
  // 同時に箱より下へも出さない = 行数と箱の高さは独立に決まるため、 行が多い図や高さを明示した
58
124
  // 図では最終行が箱の外に来る
125
+ /**
126
+ * 行頭の印で群を分ける形か (#578)。
127
+ *
128
+ * **印と区切り線は同じ役目**なので、両方は引かない。 印がある図では線を引かず、
129
+ * 名前を印の右へ、型を箱の右端へ薄く右揃えで置く。
130
+ */
131
+ /*
132
+ * 行頭の印で群を分ける形か (#578)。
133
+ *
134
+ * **欄があるかどうかで見る** = 行を持たない箱 (状態遷移図の `Draft` のように題と呼び名
135
+ * しか無いもの) でも、空の配列を書けば設計の意匠で描く。 長さで見ると、その箱だけ
136
+ * 従来の書き方に落ちて呼び名が消える (実測)。
137
+ */
138
+ const 印で分ける = node.rowMarks !== undefined;
139
+ // 呼び名を出す箱では行の群を下げる。 高さの計算 (`autoStorageHeight`) も同じ値を足す
140
+ const 行の下げ = 印で分ける && node.subtitle ? SUBTITLE_ROW_SHIFT : 0;
59
141
  const lastRowBottom = Math.min(
60
142
  ROW_TOP_STORAGE + (rows.length - 1) * ROW_PITCH_STORAGE + (rowGlyphDepth("storage") ?? 0),
61
143
  node.h - ROW_PAD_X_PX,
@@ -78,6 +160,19 @@ export function StorageNode({
78
160
  {node.eyebrow}
79
161
  </text>
80
162
  )}
163
+ {印で分ける && node.subtitle && (
164
+ // 呼び名は **印で分ける箱でだけ** 出す (#578)。 従来の箱は題の下に区切り線が来るので
165
+ // 置き場が無い
166
+ <text
167
+ data-cdl-role="node-subtitle"
168
+ x={ROW_PAD_X_PX}
169
+ y={SUBTITLE_BASELINE}
170
+ fontSize={SUBTITLE_FONT}
171
+ fill="var(--cdl-text-dim, #5a6270)"
172
+ >
173
+ {node.subtitle}
174
+ </text>
175
+ )}
81
176
  <text
82
177
  data-cdl-role="node-label"
83
178
  x={名前.x}
@@ -113,7 +208,7 @@ export function StorageNode({
113
208
  `data-cdl-role` を付けるのは、 consumer が theme ごとに色を当てられるようにするため。
114
209
  役割が無い要素には CSS が届かず、 変数を上書きする経路しか残らない。
115
210
  */}
116
- {rows.length > 0 && (
211
+ {rows.length > 0 && !印で分ける && (
117
212
  <line
118
213
  data-cdl-role="node-row-divider"
119
214
  x1={26}
@@ -124,7 +219,7 @@ export function StorageNode({
124
219
  strokeWidth={1.25}
125
220
  />
126
221
  )}
127
- {rows.length > 0 && hasRightColumn && columnDividerFits && (
222
+ {rows.length > 0 && hasRightColumn && columnDividerFits && !印で分ける && (
128
223
  <line
129
224
  data-cdl-role="node-column-divider"
130
225
  x1={columnDividerX}
@@ -142,7 +237,27 @@ export function StorageNode({
142
237
  // 右列を「左 anchor」 で配置する点が重要 (旧実装は node.w-26 への右 anchor で、 row 毎の
143
238
  // 値長差により視覚的に区切り線が不揃いに見える問題があった)。
144
239
  return (
145
- <g key={idx} transform={`translate(0 ${rowBaselineY("storage", idx)})`}>
240
+ <g key={idx} transform={`translate(0 ${(rowBaselineY("storage", idx) ?? 0) + 行の下げ})`}>
241
+ {印で分ける && node.rowMarks?.[idx] != null && (
242
+ <RowGlyph
243
+ mark={node.rowMarks[idx]}
244
+ x={ROW_GLYPH_CX}
245
+ y={-ROW_GLYPH_RISE}
246
+ color="var(--cdl-text, #1a1f2a)"
247
+ />
248
+ )}
249
+ {印で分ける && node.rowMarks?.[idx]?.underline === true && (
250
+ // 主キーの下線 (#578)。 印の 2 軸とは別の段に載るので、外部キーの山形と重ねられる
251
+ <line
252
+ data-cdl-role="node-row-underline"
253
+ x1={ROW_PAD_X_PX + ROW_GLYPH_LEAD}
254
+ y1={UNDERLINE_DROP}
255
+ x2={ROW_PAD_X_PX + ROW_GLYPH_LEAD + displayWidth(s.left) * STORAGE_CHAR_ADVANCE_PX}
256
+ y2={UNDERLINE_DROP}
257
+ stroke="var(--cdl-text, #1a1f2a)"
258
+ strokeWidth={2}
259
+ />
260
+ )}
146
261
  {/*
147
262
  左列も等幅で描く。 列の X は「文字数 × 1 文字の送り幅」 で決めており
148
263
  (`STORAGE_CHAR_ADVANCE_PX`)、 その計算は等幅を前提にしている。 左列だけ字幅の違う
@@ -152,22 +267,34 @@ export function StorageNode({
152
267
  <text
153
268
  data-cdl-role="node-row"
154
269
  data-cdl-row-index={idx}
155
- x={ROW_PAD_X_PX}
270
+ x={印で分ける ? ROW_PAD_X_PX + ROW_GLYPH_LEAD : ROW_PAD_X_PX}
156
271
  fontSize={26}
157
- fontWeight={600}
272
+ fontWeight={印で分ける ? 500 : 600}
158
273
  fill="var(--cdl-text, #1a1f2a)"
159
274
  style={{ fontFamily: "'JetBrains Mono', monospace" }}
160
275
  >
161
276
  {s.left}
162
277
  </text>
278
+ {/*
279
+ 型は **読み手が探すものではない**ので、右端で揃えて薄く引く。 名前の右に続けて
280
+ 置くと、名前と型のどちらを読んでいるかが行ごとに変わる (#578)。
281
+ */}
163
282
  <text
164
283
  data-cdl-role="node-row"
165
284
  data-cdl-row-index={idx}
166
- x={rightColX}
167
- fontSize={26}
168
- fontWeight={highlightRows ? 700 : 600}
169
- textAnchor="start"
170
- fill={highlightRows ? accent : "var(--cdl-text, #1a1f2a)"}
285
+ x={印で分ける ? node.w - ROW_PAD_X_PX : rightColX}
286
+ fontSize={印で分ける ? ROW_TYPE_FONT : 26}
287
+ fontWeight={印で分ける ? 500 : highlightRows ? 700 : 600}
288
+ textAnchor={印で分ける ? "end" : "start"}
289
+ // 印を持つ行の右列は **型** で、値ではない。 段が進むたびに型が光ると、読み手は
290
+ // そこに変化があったと読む。 光らせるのは値を持つ従来の行だけにする (#578)
291
+ fill={
292
+ 印で分ける
293
+ ? "var(--cdl-text-dim, #5a6270)"
294
+ : highlightRows
295
+ ? accent
296
+ : "var(--cdl-text, #1a1f2a)"
297
+ }
171
298
  style={{ fontFamily: "'JetBrains Mono', monospace" }}
172
299
  >
173
300
  {s.right}