@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
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}>
|
package/src/render/nodes.tsx
CHANGED
|
@@ -84,12 +84,18 @@ import { hasUnresolvedRef, interpolate, resolveNumericAttr } from "./utils";
|
|
|
84
84
|
export function CdlNodeView({
|
|
85
85
|
node,
|
|
86
86
|
active,
|
|
87
|
+
drawing = false,
|
|
87
88
|
progress,
|
|
88
89
|
stateValues,
|
|
89
90
|
currentPhaseId,
|
|
90
91
|
}: {
|
|
91
92
|
node: LaidNode;
|
|
92
93
|
active: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* この phase の `draw` に載っているか (cdl#512)。 true の間だけ、 対象の種別が
|
|
96
|
+
* phase の進み (`progress`) に合わせて左の起点から描かれる。
|
|
97
|
+
*/
|
|
98
|
+
drawing?: boolean;
|
|
93
99
|
progress: number;
|
|
94
100
|
stateValues: Record<string, string>;
|
|
95
101
|
currentPhaseId: string | undefined;
|
|
@@ -154,7 +160,15 @@ export function CdlNodeView({
|
|
|
154
160
|
// 数と語を取る 7 種に対し、mind-map / tree-hierarchy は **名前だけ** を取る (#467)。
|
|
155
161
|
// 親子の繋がりと、描かれない欄 (補足) は解決しない。
|
|
156
162
|
case "chart-line":
|
|
157
|
-
return
|
|
163
|
+
return (
|
|
164
|
+
<ChartLineNode
|
|
165
|
+
node={resolvedNode}
|
|
166
|
+
active={active}
|
|
167
|
+
stateValues={stateValues}
|
|
168
|
+
drawing={drawing}
|
|
169
|
+
progress={progress}
|
|
170
|
+
/>
|
|
171
|
+
);
|
|
158
172
|
case "chart-pie":
|
|
159
173
|
return <ChartPieNode node={resolvedNode} active={active} stateValues={stateValues} />;
|
|
160
174
|
case "chart-bar":
|
|
@@ -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 };
|
package/src/render/stage.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { CdlEdgeView, CdlEdgeGlowView, CdlEdgeLabelView } from "./edges";
|
|
|
4
4
|
import { CdlNodeView } from "./nodes";
|
|
5
5
|
import { TONE, TONE_HEX } from "./tone";
|
|
6
6
|
import { buildDiagramAltText } from "../layout/spec";
|
|
7
|
-
import {
|
|
7
|
+
import { lifelineStartYs, uniformLifelineEndY } from "../layout/lifeline";
|
|
8
8
|
|
|
9
9
|
export function CdlStage({
|
|
10
10
|
laid,
|
|
@@ -25,28 +25,25 @@ export function CdlStage({
|
|
|
25
25
|
svgRef?: React.RefObject<SVGSVGElement | null>;
|
|
26
26
|
}): JSX.Element {
|
|
27
27
|
const activeSet = new Set(currentPhase?.activate ?? []);
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
// 下端の名札は 2 つの用途に使う。 縦線の終点 (uniformFooterTop) と、 絵を下げる量の表。
|
|
28
|
+
// この phase で左の起点から描き始める node (cdl#512)。 `activate` とは別集合で持つ =
|
|
29
|
+
// 焦点が当たり続ける図で毎 phase 引き直すことになるため、 光っているかを引き金にしない
|
|
30
|
+
const drawSet = new Set(currentPhase?.draw ?? []);
|
|
31
|
+
// 縦線の終点は全 lane で 1 つに揃える (等長保証)。 値の決め方と、 1 lane の異常が図全体から
|
|
32
|
+
// 縦線を消していた形の対処は `uniformLifelineEndY` の説明。
|
|
34
33
|
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
34
|
+
// 下端の名札で絵が箱の上へ出る種別を下げるのは **組み立て側の責務** (`applyFooterShapeDrops`)。
|
|
35
|
+
// ここで `<g transform>` を掛ける形だった頃は、 描画だけが動いて座標が動かず、 座標を読む側
|
|
36
|
+
// (縦線の終点 / 図の下端 / 外接矩形) が実際の位置を知らなかった (#478)。
|
|
37
37
|
//
|
|
38
|
-
//
|
|
39
|
-
// 動きの 1 コマごとに描き直されるため、
|
|
40
|
-
const {
|
|
41
|
-
const
|
|
38
|
+
// 始点と終点は 1 度だけ求め、 配置が変わらない限り作り直さない。 この component は
|
|
39
|
+
// 動きの 1 コマごとに描き直されるため、 毎コマ求め直すと lane 数 × node 数を毎コマ払う
|
|
40
|
+
const { lifelineStarts, uniformFooterTop } = useMemo(() => {
|
|
41
|
+
const starts = lifelineStartYs(laid.nodes);
|
|
42
42
|
return {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
lifelineStarts: starts,
|
|
44
|
+
uniformFooterTop: uniformLifelineEndY(laid.lanes, laid.nodes, starts),
|
|
45
45
|
};
|
|
46
46
|
}, [laid]);
|
|
47
|
-
const uniformFooterTop = footerTops.length > 0
|
|
48
|
-
? Math.min(...footerTops)
|
|
49
|
-
: (lifelineLanes[0] ? lifelineLanes[0].y + lifelineLanes[0].height : 0);
|
|
50
47
|
const diagramScale = laid.diagramScale ?? 1;
|
|
51
48
|
return (
|
|
52
49
|
<div className={compact ? "px-3 py-3" : "px-6 py-6"} style={compact ? undefined : { minHeight: 460 }}>
|
|
@@ -167,14 +164,10 @@ export function CdlStage({
|
|
|
167
164
|
/>
|
|
168
165
|
)}
|
|
169
166
|
{lane.lifeline && (() => {
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (laneNodes.length === 0) return null;
|
|
175
|
-
const sortedByCy = laneNodes.slice().sort((a, b) => a.cy - b.cy);
|
|
176
|
-
const header = sortedByCy[0]!;
|
|
177
|
-
const y1 = header.cy + header.h / 2;
|
|
167
|
+
// 縦線は lane の一番上の node の下端から、 全 lane 共通の終点までを結ぶ。
|
|
168
|
+
// node が 1 つの lane も 2 つ以上の lane も同じ終点を使うことで高さが揃う。
|
|
169
|
+
const y1 = lifelineStarts.get(lane.id);
|
|
170
|
+
if (y1 === undefined) return null;
|
|
178
171
|
const y2 = uniformFooterTop;
|
|
179
172
|
if (y2 <= y1) return null;
|
|
180
173
|
return (
|
|
@@ -218,19 +211,13 @@ export function CdlStage({
|
|
|
218
211
|
<CdlNodeView
|
|
219
212
|
node={node}
|
|
220
213
|
active={activeSet.has(node.id)}
|
|
214
|
+
drawing={drawSet.has(node.id)}
|
|
221
215
|
progress={progress}
|
|
222
216
|
stateValues={stateValues}
|
|
223
217
|
currentPhaseId={currentPhase?.id}
|
|
224
218
|
/>
|
|
225
219
|
);
|
|
226
|
-
|
|
227
|
-
// 下端の名札で絵が上へ出る種別だけ、 絵の上端が箱の上端に来るまで下げる。
|
|
228
|
-
// node の座標そのものは動かさない = 矢印の接続先や当たり判定は箱のままにする
|
|
229
|
-
return dy === undefined ? (
|
|
230
|
-
<g key={node.id}>{view}</g>
|
|
231
|
-
) : (
|
|
232
|
-
<g key={node.id} transform={`translate(0, ${dy})`}>{view}</g>
|
|
233
|
-
);
|
|
220
|
+
return <g key={node.id}>{view}</g>;
|
|
234
221
|
})}
|
|
235
222
|
|
|
236
223
|
{/* 経由 node 貫通する dotted-flow edge の進行点 glow (3rd pass) */}
|