@cardenelabs/cdl 0.7.0 → 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.
- package/CHANGELOG.md +401 -0
- package/SPEC.md +32 -17
- package/dist/index.cjs +700 -306
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +68 -14
- package/dist/index.d.ts +68 -14
- package/dist/index.js +700 -306
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +572 -196
- 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 +572 -196
- package/dist/react.js.map +1 -1
- package/dist/{render-DquCvgOB.d.cts → render-hmFRAabt.d.cts} +33 -2
- package/dist/{render-DquCvgOB.d.ts → render-hmFRAabt.d.ts} +33 -2
- package/package.json +6 -3
- package/src/kinds/box-lines.ts +120 -0
- 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/payload-binding.ts +31 -8
- package/src/render/stage.tsx +17 -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 +33 -2
- package/src/validate.ts +27 -9
- package/src/visual-validate.ts +34 -1
- package/src/layout/label-shift.ts +0 -28
|
@@ -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
|
*
|
package/src/layout/text-width.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/layout/viewbox.ts
CHANGED
|
@@ -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
|
-
|
|
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 は
|
package/src/presets.ts
CHANGED
|
@@ -2,7 +2,40 @@ import { diagram } from "./builder";
|
|
|
2
2
|
import type { DiagramBuilder } from "./builder";
|
|
3
3
|
import { LANE_PAD_MIN, NODE_SIZE } from "./layout/tokens";
|
|
4
4
|
import { requiredRowsWidth } from "./kinds/row-align";
|
|
5
|
-
import type { NodeKind, Tone, EdgeStyle, JourneyEmotion, QuadrantKey } from "./types";
|
|
5
|
+
import type { NodeKind, Tone, EdgeStyle, JourneyEmotion, QuadrantKey, BoundNumber } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 状態を読む書き方 (`{名前}`)。
|
|
9
|
+
*
|
|
10
|
+
* 中身の型 (`BoundNumber` / `BoundEnum`) は任意の文字列を受けるが、 **入口はこの形だけに
|
|
11
|
+
* 限る** (#468 Round 1)。 広いままだと `value: "abc"` や `quadrant: "topLfet"` のような
|
|
12
|
+
* 書き間違いが、 これまで組み立ての時点で止まっていたのに実行時まで進み、 解決側の既定値
|
|
13
|
+
* (0 / 左下 / 普通) に落ちた図が出る。
|
|
14
|
+
*/
|
|
15
|
+
type 名前の頭 =
|
|
16
|
+
| "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m"
|
|
17
|
+
| "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z"
|
|
18
|
+
| "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L" | "M"
|
|
19
|
+
| "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X" | "Y" | "Z"
|
|
20
|
+
| "_";
|
|
21
|
+
|
|
22
|
+
export type BindingRef = `{${名前の頭}${string}}`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* その文字列が **全体で** 状態を読む書き方かどうか (#468 Round 5)。
|
|
26
|
+
*
|
|
27
|
+
* `hasUnresolvedRef` は「参照らしい部分を含むか」 を見るため、 `Phase {A}` のような通常の
|
|
28
|
+
* 名前まで参照とみなす。 工程表の入口は名前と参照を同じ欄で受けるので、 部分一致で判定すると
|
|
29
|
+
* **正当な名前が黙って消える** (実測 = `start: "Phase {A}"` が位置 `"Phase {A}"` になり、
|
|
30
|
+
* 名前の欄が空になった)。
|
|
31
|
+
*
|
|
32
|
+
* 型 (`BindingRef`) が「文字列全体が `{名前}`」 を要求しているので、 判定もそれに揃える。
|
|
33
|
+
*
|
|
34
|
+
* **中身に中括弧を許す**。 型は `{${名前の頭}${string}}` で、 `string` が中括弧を含む形
|
|
35
|
+
* (`{a}tail}` / `{a{b}`) も受ける。 実装だけ狭めると、 **型では通るのに名前として扱われる**
|
|
36
|
+
* 食い違いができる (Round 6 の指摘)。 型で中括弧を除く条件は書けないため、 実装を型に合わせる。
|
|
37
|
+
*/
|
|
38
|
+
const 参照そのもの = (v: string): boolean => /^\{[A-Za-z_][\s\S]*\}$/.test(v);
|
|
6
39
|
|
|
7
40
|
/**
|
|
8
41
|
* 図の格子の目 (world unit)。 `grid-alignment` (軸 21) が見る単位と同じ。
|
|
@@ -213,11 +246,32 @@ export type FlowPreset = {
|
|
|
213
246
|
/** edge tone / style の default (各 step → next step の edge) */
|
|
214
247
|
defaultTone?: Tone;
|
|
215
248
|
defaultStyle?: EdgeStyle;
|
|
249
|
+
/**
|
|
250
|
+
* step 間の edge を自動生成するか (既定 true)。
|
|
251
|
+
*
|
|
252
|
+
* `false` にすると `step()` は node だけを置き、矢印は呼び手が `edge()` で宣言する。
|
|
253
|
+
* **並び順と違う流れ** (`alpha` から `gamma` へ直接繋いで `beta` を経由しない等) を
|
|
254
|
+
* 書きたい時に使う。 自動生成しか無かった間、その形は本 preset では作れなかった (#474)。
|
|
255
|
+
*
|
|
256
|
+
* 既定を `true` にしているので、既存の呼び出しは 1 行も変えずに同じ図になる。
|
|
257
|
+
*/
|
|
258
|
+
autoChain?: boolean;
|
|
216
259
|
};
|
|
217
260
|
|
|
218
261
|
export type FlowBuilder = {
|
|
219
|
-
/** step を順番に追加、 自動で前 step から edge 接続 */
|
|
262
|
+
/** step を順番に追加、 自動で前 step から edge 接続 (`autoChain: false` なら node だけ) */
|
|
220
263
|
step: (node: FlowStepInput, edgeLabel?: string) => FlowBuilder;
|
|
264
|
+
/**
|
|
265
|
+
* 矢印を宣言する (#474)。 tone / style は preset の既定を引き継ぐ。
|
|
266
|
+
*
|
|
267
|
+
* ここで作った矢印も `build()` の phase の `activate` に載る (自動生成分と同じ扱い)。
|
|
268
|
+
* 載せないと animation で光らず、自動生成と見た目が揃わない。
|
|
269
|
+
*/
|
|
270
|
+
edge: (
|
|
271
|
+
from: string,
|
|
272
|
+
to: string,
|
|
273
|
+
opts?: { id?: string; label?: string; tone?: Tone; style?: EdgeStyle },
|
|
274
|
+
) => FlowBuilder;
|
|
221
275
|
build: () => ReturnType<DiagramBuilder["build"]>;
|
|
222
276
|
};
|
|
223
277
|
|
|
@@ -238,6 +292,8 @@ export function flow(preset: FlowPreset): FlowBuilder {
|
|
|
238
292
|
b.lane("flow", { width: w, ...(preset.laneLabel ? { label: preset.laneLabel } : {}) });
|
|
239
293
|
const tone: Tone = preset.defaultTone ?? "accent";
|
|
240
294
|
const style: EdgeStyle = preset.defaultStyle ?? "dotted-flow";
|
|
295
|
+
// 既定は自動生成。 省略した既存の呼び出しの挙動を変えない (#474)
|
|
296
|
+
const autoChain = preset.autoChain ?? true;
|
|
241
297
|
let prevId: string | null = null;
|
|
242
298
|
let stackIdx = 0;
|
|
243
299
|
const phaseActivate: string[] = [];
|
|
@@ -253,7 +309,7 @@ export function flow(preset: FlowPreset): FlowBuilder {
|
|
|
253
309
|
...(node.subtitle ? { subtitle: node.subtitle } : {}),
|
|
254
310
|
});
|
|
255
311
|
phaseActivate.push(node.id);
|
|
256
|
-
if (prevId !== null) {
|
|
312
|
+
if (autoChain && prevId !== null) {
|
|
257
313
|
const edgeId = `e-${prevId}-${node.id}`;
|
|
258
314
|
b.edge(prevId, node.id, {
|
|
259
315
|
id: edgeId,
|
|
@@ -266,6 +322,17 @@ export function flow(preset: FlowPreset): FlowBuilder {
|
|
|
266
322
|
prevId = node.id;
|
|
267
323
|
return api;
|
|
268
324
|
},
|
|
325
|
+
edge(from, to, opts) {
|
|
326
|
+
const edgeId = opts?.id ?? `e-${from}-${to}`;
|
|
327
|
+
b.edge(from, to, {
|
|
328
|
+
id: edgeId,
|
|
329
|
+
label: opts?.label ?? "→",
|
|
330
|
+
tone: opts?.tone ?? tone,
|
|
331
|
+
style: opts?.style ?? style,
|
|
332
|
+
});
|
|
333
|
+
phaseActivate.push(edgeId);
|
|
334
|
+
return api;
|
|
335
|
+
},
|
|
269
336
|
build() {
|
|
270
337
|
b.phase(
|
|
271
338
|
"flow",
|
|
@@ -398,7 +465,8 @@ const actorW = Math.max(140, label.length * 22 + 52);
|
|
|
398
465
|
// warning suppress、 ASCII long name (AuthenticationService 21 char) は 514 になり
|
|
399
466
|
// 過大化との trade-off だが 「警告出るのに対応できない」 の user feedback 優先。
|
|
400
467
|
const actorW = Math.max(140, label.length * 22 + 52);
|
|
401
|
-
|
|
468
|
+
// `role` を付けるのは、 描画側が「どれが下端の名札か」 を位置で推測しないため (#458)
|
|
469
|
+
b.node(footerId, { lane: laneId, stack: footerStack, kind: "card", title: label, w: actorW, h: 72, role: "lifeline-footer" });
|
|
402
470
|
phaseActivate.push(footerId);
|
|
403
471
|
});
|
|
404
472
|
b.phase(
|
|
@@ -1052,8 +1120,8 @@ export type TreeNode = {
|
|
|
1052
1120
|
parent?: string;
|
|
1053
1121
|
/** 表示 kind (default "card") */
|
|
1054
1122
|
kind?: NodeKind;
|
|
1123
|
+
/** 箱の 2 行目に出す補足。 上の小ラベルは木では受けない (#471、 `TreeNodePayload` 参照) */
|
|
1055
1124
|
subtitle?: string;
|
|
1056
|
-
eyebrow?: string;
|
|
1057
1125
|
};
|
|
1058
1126
|
|
|
1059
1127
|
export type TreePreset = {
|
|
@@ -1109,7 +1177,6 @@ export function tree(preset: TreePreset): TreeBuilder {
|
|
|
1109
1177
|
title: n.title,
|
|
1110
1178
|
parent: n.parent,
|
|
1111
1179
|
subtitle: n.subtitle,
|
|
1112
|
-
eyebrow: n.eyebrow,
|
|
1113
1180
|
})),
|
|
1114
1181
|
});
|
|
1115
1182
|
b.phase(
|
|
@@ -1133,7 +1200,8 @@ export type { JourneyEmotion } from "./types";
|
|
|
1133
1200
|
export type JourneyStep = {
|
|
1134
1201
|
id: string;
|
|
1135
1202
|
title: string;
|
|
1136
|
-
|
|
1203
|
+
/** 起伏の段階。 `{名前}` を書くと状態から読む (#468) */
|
|
1204
|
+
emotion: JourneyEmotion | BindingRef;
|
|
1137
1205
|
/** どの touchpoint (例 "Website", "Email", "Support") */
|
|
1138
1206
|
touchpoint?: string;
|
|
1139
1207
|
/** opportunity (改善余地のメモ) */
|
|
@@ -1296,8 +1364,8 @@ export function mindMap(preset: MindMapPreset): MindMapBuilder {
|
|
|
1296
1364
|
export type FunnelStage = {
|
|
1297
1365
|
id: string;
|
|
1298
1366
|
title: string;
|
|
1299
|
-
/** この stage の人数 /
|
|
1300
|
-
count: number;
|
|
1367
|
+
/** この stage の人数 / 件数。 `{名前}` を書くと状態から読む (#468) */
|
|
1368
|
+
count: number | BindingRef;
|
|
1301
1369
|
/** stage 詳細 */
|
|
1302
1370
|
subtitle?: string;
|
|
1303
1371
|
};
|
|
@@ -1376,8 +1444,8 @@ export type QuadrantQuadrantLabel = QuadrantKey;
|
|
|
1376
1444
|
export type QuadrantItem = {
|
|
1377
1445
|
id: string;
|
|
1378
1446
|
title: string;
|
|
1379
|
-
/** どの象限 (4 象限) */
|
|
1380
|
-
quadrant: QuadrantQuadrantLabel;
|
|
1447
|
+
/** どの象限 (4 象限)。 `{名前}` を書くと状態から読む (#468) */
|
|
1448
|
+
quadrant: QuadrantQuadrantLabel | BindingRef;
|
|
1381
1449
|
subtitle?: string;
|
|
1382
1450
|
};
|
|
1383
1451
|
|
|
@@ -1472,7 +1540,8 @@ export type ChartType = "pie" | "bar" | "line";
|
|
|
1472
1540
|
export type ChartDatum = {
|
|
1473
1541
|
id: string;
|
|
1474
1542
|
label: string;
|
|
1475
|
-
|
|
1543
|
+
/** 棒 / 折れ線 / 扇の大きさ。 `{名前}` を書くと状態から読む (#468) */
|
|
1544
|
+
value: number | BindingRef;
|
|
1476
1545
|
/** 個別 tone (default は preset の defaultTone) */
|
|
1477
1546
|
tone?: Tone;
|
|
1478
1547
|
};
|
|
@@ -1553,9 +1622,15 @@ export function chart(preset: ChartPreset): ChartBuilder {
|
|
|
1553
1622
|
export type GanttTask = {
|
|
1554
1623
|
id: string;
|
|
1555
1624
|
title: string;
|
|
1556
|
-
/**
|
|
1625
|
+
/**
|
|
1626
|
+
* 開始の目盛りの名前 (例 "2026-Q1" / "Day 1")。
|
|
1627
|
+
*
|
|
1628
|
+
* `{名前}` を書くと **位置そのもの** を状態から読む (#468)。 目盛りの名前の対応付けは
|
|
1629
|
+
* 飛ばす = 名前の並びに `{名前}` が混ざると、 位置が状態で動くたびに目盛りの並びが
|
|
1630
|
+
* 変わってしまう。
|
|
1631
|
+
*/
|
|
1557
1632
|
start: string;
|
|
1558
|
-
/**
|
|
1633
|
+
/** 終了の目盛りの名前。 `{名前}` の扱いは `start` と同じ (#468) */
|
|
1559
1634
|
end: string;
|
|
1560
1635
|
/** owner / assignee */
|
|
1561
1636
|
owner?: string;
|
|
@@ -1568,6 +1643,17 @@ export type GanttPreset = {
|
|
|
1568
1643
|
topic: string;
|
|
1569
1644
|
defaultTone?: Tone;
|
|
1570
1645
|
laneWidth?: number;
|
|
1646
|
+
/**
|
|
1647
|
+
* 横軸を何目盛り分にするか (`#483`)。
|
|
1648
|
+
*
|
|
1649
|
+
* 書かないと今までどおり全 task の最大値から自動で決まるため、 `{名前}` で動く task が
|
|
1650
|
+
* 1 つあると **動かしていない task の帯まで伸び縮みする** (実測 = 端を 3 から 9 に動かすと
|
|
1651
|
+
* 静的な task の帯が 252 から 97.2 になった)。
|
|
1652
|
+
*
|
|
1653
|
+
* 書くと尺がその値で固定され、 他の task の帯の位置と幅が変わらない。 範囲から外れる task の
|
|
1654
|
+
* 帯は右端の目盛りに寄せて描く (`kinds/gantt.tsx` SSOT)。
|
|
1655
|
+
*/
|
|
1656
|
+
axisMax?: number;
|
|
1571
1657
|
};
|
|
1572
1658
|
|
|
1573
1659
|
export type GanttBuilder = {
|
|
@@ -1579,8 +1665,11 @@ export type GanttBuilder = {
|
|
|
1579
1665
|
* gantt preset ... Gantt chart / sprint planning / release timeline。
|
|
1580
1666
|
* task ごとに 1 lane を作り、 start - end + owner を subtitle、 dependsOn で前 task → 次 task の edge を auto 生成。
|
|
1581
1667
|
*
|
|
1668
|
+
* 横軸の範囲は既定では全 task の最大値から自動で決まる。 `axisMax` を書くと尺がその値で
|
|
1669
|
+
* 固定され、 `{名前}` で動く task があっても他の task の帯が動かない (`#483`)。
|
|
1670
|
+
*
|
|
1582
1671
|
* @example
|
|
1583
|
-
* gantt({ id: "release", topic: "Release timeline" })
|
|
1672
|
+
* gantt({ id: "release", topic: "Release timeline", axisMax: 4 })
|
|
1584
1673
|
* .task({ id: "design", title: "Design", start: "Q1", end: "Q1" })
|
|
1585
1674
|
* .task({ id: "build", title: "Build", start: "Q2", end: "Q2", dependsOn: "design" })
|
|
1586
1675
|
* .task({ id: "test", title: "Test", start: "Q3", end: "Q3", dependsOn: "build" })
|
|
@@ -1599,12 +1688,19 @@ export function gantt(preset: GanttPreset): GanttBuilder {
|
|
|
1599
1688
|
return api;
|
|
1600
1689
|
},
|
|
1601
1690
|
build() {
|
|
1602
|
-
// start / end label を index に変換 (unique label を順に並べる)
|
|
1691
|
+
// start / end label を index に変換 (unique label を順に並べる)。
|
|
1692
|
+
//
|
|
1693
|
+
// `{名前}` を書いた分は **並びに入れない** (#468)。 入れると位置が状態で動くたびに
|
|
1694
|
+
// 目盛りの並びが変わり、 動かしていない task の位置まで動く。
|
|
1603
1695
|
const labelSeq: string[] = [];
|
|
1604
1696
|
for (const t of tasks) {
|
|
1605
|
-
if (!labelSeq.includes(t.start)) labelSeq.push(t.start);
|
|
1606
|
-
if (!labelSeq.includes(t.end)) labelSeq.push(t.end);
|
|
1697
|
+
if (!参照そのもの(t.start) && !labelSeq.includes(t.start)) labelSeq.push(t.start);
|
|
1698
|
+
if (!参照そのもの(t.end) && !labelSeq.includes(t.end)) labelSeq.push(t.end);
|
|
1607
1699
|
}
|
|
1700
|
+
// `{名前}` は位置そのものとして通す。 名前の欄は空にする = 目盛りは位置にしか無く、
|
|
1701
|
+
// 状態から来る位置に付ける名前が無い (空の目盛りは描かれない)
|
|
1702
|
+
const 位置 = (v: string): BoundNumber => (参照そのもの(v) ? v : labelSeq.indexOf(v));
|
|
1703
|
+
const 名札 = (v: string): string => (参照そのもの(v) ? "" : v);
|
|
1608
1704
|
b.lane("gantt", { width: canvasW });
|
|
1609
1705
|
const nodeId = `${preset.id}-gantt`;
|
|
1610
1706
|
b.node(nodeId, {
|
|
@@ -1615,13 +1711,15 @@ export function gantt(preset: GanttPreset): GanttBuilder {
|
|
|
1615
1711
|
eyebrow: "gantt",
|
|
1616
1712
|
w: canvasW,
|
|
1617
1713
|
h: canvasH,
|
|
1714
|
+
// 宣言していない図には欄自体を載せない = 自動で決める経路をそのまま通す
|
|
1715
|
+
...(preset.axisMax !== undefined ? { ganttAxisMax: preset.axisMax } : {}),
|
|
1618
1716
|
ganttData: tasks.map((t) => ({
|
|
1619
1717
|
id: t.id,
|
|
1620
1718
|
title: t.title,
|
|
1621
|
-
startIdx:
|
|
1622
|
-
endIdx:
|
|
1623
|
-
startLabel: t.start,
|
|
1624
|
-
endLabel: t.end,
|
|
1719
|
+
startIdx: 位置(t.start),
|
|
1720
|
+
endIdx: 位置(t.end),
|
|
1721
|
+
startLabel: 名札(t.start),
|
|
1722
|
+
endLabel: 名札(t.end),
|
|
1625
1723
|
owner: t.owner,
|
|
1626
1724
|
dependsOn: t.dependsOn,
|
|
1627
1725
|
tone,
|
package/src/render/header.tsx
CHANGED
|
@@ -124,7 +124,9 @@ function StateDiffCard({
|
|
|
124
124
|
<tbody style={{ color: "var(--cdl-text, #1a1f2a)" }}>
|
|
125
125
|
{states.map((s) => {
|
|
126
126
|
const initial = String(s.initial);
|
|
127
|
-
|
|
127
|
+
// 自分が持つ名前だけを見る (#456)。 通常の object を渡された場合、 `toString` の
|
|
128
|
+
// ような名前の状態で prototype の関数がそのまま表に出る
|
|
129
|
+
const current = Object.hasOwn(stateValues, s.id) ? stateValues[s.id]! : initial;
|
|
128
130
|
const changed = initial !== current;
|
|
129
131
|
return (
|
|
130
132
|
<tr key={s.id}>
|
|
@@ -226,32 +226,55 @@ function resolveBoundText(raw: string, values: Record<string, string>): BindingR
|
|
|
226
226
|
return hasUnresolvedRef(resolved) ? { value: resolved, ok: false } : { value: resolved, ok: true };
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* 木の名前と補足を解決する。 親子の繋がりは触らない。
|
|
231
|
+
*
|
|
232
|
+
* 補足を解決対象に入れたのは #471 で **補足を描くようにしたため**。 描かない欄を解決すると、
|
|
233
|
+
* 画面は何も変わらないのに解決できない時だけ印が付く状態になる = 印の意味が読めない。
|
|
234
|
+
* 逆に描くようになった欄を解決しないと、 図に `{名前}` が素のまま出る。
|
|
235
|
+
*/
|
|
230
236
|
export function resolveTreeData(
|
|
231
237
|
nodes: TreeNodePayload[],
|
|
232
238
|
values: Record<string, string>,
|
|
233
239
|
): ResolvedTreeNode[] {
|
|
234
|
-
|
|
240
|
+
const 読む = nodes.some((n) => n.title.includes("{") || (n.subtitle?.includes("{") ?? false));
|
|
241
|
+
if (!読む) return nodes as ResolvedTreeNode[];
|
|
235
242
|
return nodes.map((n) => {
|
|
236
243
|
const title = resolveBoundText(n.title, values);
|
|
237
|
-
const
|
|
238
|
-
|
|
244
|
+
const subtitle = n.subtitle === undefined ? undefined : resolveBoundText(n.subtitle, values);
|
|
245
|
+
const 解決後: ResolvedTreeNode = {
|
|
246
|
+
...n,
|
|
247
|
+
title: title.value,
|
|
248
|
+
...(subtitle ? { subtitle: subtitle.value } : {}),
|
|
249
|
+
};
|
|
250
|
+
return title.ok && (subtitle?.ok ?? true) ? 解決後 : { ...解決後, unresolved: true as const };
|
|
239
251
|
});
|
|
240
252
|
}
|
|
241
253
|
|
|
242
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* 放射の名前と補足を解決する。 中心の名前と枝の名前 / 補足を見る。
|
|
256
|
+
*
|
|
257
|
+
* 補足を入れた理由は `resolveTreeData` と同じ (#471)。 中心は補足の欄を持たない。
|
|
258
|
+
*/
|
|
243
259
|
export function resolveMindData(
|
|
244
260
|
data: MindBranchPayload,
|
|
245
261
|
values: Record<string, string>,
|
|
246
262
|
): ResolvedMindData {
|
|
247
|
-
const 読む =
|
|
263
|
+
const 読む =
|
|
264
|
+
data.rootTitle.includes("{") ||
|
|
265
|
+
data.branches.some((b) => b.title.includes("{") || (b.subtitle?.includes("{") ?? false));
|
|
248
266
|
if (!読む) return data as ResolvedMindData;
|
|
249
267
|
|
|
250
268
|
const root = resolveBoundText(data.rootTitle, values);
|
|
251
269
|
const branches = data.branches.map((b) => {
|
|
252
270
|
const title = resolveBoundText(b.title, values);
|
|
253
|
-
const
|
|
254
|
-
|
|
271
|
+
const subtitle = b.subtitle === undefined ? undefined : resolveBoundText(b.subtitle, values);
|
|
272
|
+
const 解決後: ResolvedMindBranch = {
|
|
273
|
+
...b,
|
|
274
|
+
title: title.value,
|
|
275
|
+
...(subtitle ? { subtitle: subtitle.value } : {}),
|
|
276
|
+
};
|
|
277
|
+
return title.ok && (subtitle?.ok ?? true) ? 解決後 : { ...解決後, unresolved: true as const };
|
|
255
278
|
});
|
|
256
279
|
const 解決後: ResolvedMindData = { ...data, rootTitle: root.value, branches };
|
|
257
280
|
return root.ok ? 解決後 : { ...解決後, unresolved: true as const };
|