@cardenelabs/cdl 0.123.0 → 0.125.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 +55 -1
- package/README.md +14 -1
- package/dist/index.cjs +924 -134
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +924 -135
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +909 -134
- 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 +909 -134
- package/dist/react.js.map +1 -1
- package/dist/{render-CVczwfRb.d.cts → render-7yEHIUK1.d.cts} +60 -5
- package/dist/{render-CVczwfRb.d.ts → render-7yEHIUK1.d.ts} +60 -5
- package/package.json +12 -9
- package/src/builder.ts +6 -0
- package/src/index.ts +4 -0
- package/src/kinds/card.tsx +12 -4
- package/src/kinds/generic.tsx +19 -9
- package/src/kinds/station.tsx +105 -19
- package/src/kinds/terminal-mark.tsx +36 -9
- package/src/layout/edges.ts +169 -17
- package/src/layout/lanes.ts +69 -5
- package/src/layout/legend.ts +68 -0
- package/src/layout/metro-path.ts +123 -30
- package/src/layout/nodes.ts +13 -2
- package/src/layout/spec.ts +17 -1
- package/src/layout/tokens.ts +8 -1
- package/src/layout.ts +20 -2
- package/src/render/edge-head.ts +6 -0
- package/src/render/edges.tsx +27 -6
- package/src/render/legend.tsx +120 -0
- package/src/render/nodes.tsx +21 -5
- package/src/render/stage.tsx +255 -10
- package/src/render/template-fields.ts +2 -0
- package/src/types.ts +109 -7
- package/src/validate.ts +5 -0
- package/src/visual-validate.ts +7 -7
package/src/layout/metro-path.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { CdlEdge, CdlTextMetrics, LaidNode, Side } from "../types";
|
|
2
2
|
import { SAME_HEIGHT_THRESHOLD } from "./curve-path";
|
|
3
|
-
import {
|
|
3
|
+
import { EDGE_STUB_OUT } from "./spec";
|
|
4
4
|
|
|
5
|
-
type 箱 = Pick<LaidNode, "cx" | "cy" | "w" | "h"
|
|
5
|
+
type 箱 = Pick<LaidNode, "cx" | "cy" | "w" | "h"> & Partial<Pick<LaidNode, "kind">>;
|
|
6
6
|
type 点 = { x: number; y: number };
|
|
7
7
|
|
|
8
8
|
type 路線図の線の結果 = {
|
|
@@ -12,9 +12,19 @@ type 路線図の線の結果 = {
|
|
|
12
12
|
labelX: number;
|
|
13
13
|
labelY: number;
|
|
14
14
|
labelAnchor: "middle";
|
|
15
|
+
metroReturn?: true;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export const METRO_CORNER_RADIUS = 24;
|
|
19
|
+
export const METRO_BRANCH_CORNER_RADIUS = 22;
|
|
20
|
+
export const METRO_BRANCH_STUB = 30;
|
|
21
|
+
export const METRO_DECISION_HEIGHT = 88;
|
|
22
|
+
export const METRO_DECISION_WIDTH = 116;
|
|
23
|
+
export const METRO_RETURN_END_OFFSET = 43;
|
|
24
|
+
export const METRO_RETURN_START_OFFSET = 24;
|
|
25
|
+
/** 見本の線路幅。数値の fallback は CSS を読まない SVG 書き出しでも残る。 */
|
|
26
|
+
export const METRO_LINE_WIDTH = 10;
|
|
27
|
+
export const METRO_LINE_WIDTH_CSS = `var(--cdl-metro-line-width, ${METRO_LINE_WIDTH}px)`;
|
|
18
28
|
|
|
19
29
|
const T = Math.SQRT2 - 1;
|
|
20
30
|
|
|
@@ -38,18 +48,80 @@ function polyline(points: readonly 点[]): string {
|
|
|
38
48
|
return distinct.map((p, i) => `${i === 0 ? "M" : "L"} ${point(p)}`).join(" ");
|
|
39
49
|
}
|
|
40
50
|
|
|
51
|
+
/** 路線図の見本と同じ二次曲線で、横線から縦線へ 1 回だけ曲がる。 */
|
|
52
|
+
function quadraticCornerPath(start: 点, corner: 点, end: 点, radius: number): string {
|
|
53
|
+
const horizontalDir = Math.sign(corner.x - start.x) as 1 | -1;
|
|
54
|
+
const verticalDir = Math.sign(end.y - corner.y) as 1 | -1;
|
|
55
|
+
const r = Math.min(
|
|
56
|
+
radius,
|
|
57
|
+
Math.abs(corner.x - start.x),
|
|
58
|
+
Math.abs(end.y - corner.y),
|
|
59
|
+
);
|
|
60
|
+
const before = { x: corner.x - horizontalDir * r, y: corner.y };
|
|
61
|
+
const after = { x: corner.x, y: corner.y + verticalDir * r };
|
|
62
|
+
return [
|
|
63
|
+
`M ${point(start)}`,
|
|
64
|
+
`L ${point(before)}`,
|
|
65
|
+
`Q ${point(corner)} ${point(after)}`,
|
|
66
|
+
`L ${point(end)}`,
|
|
67
|
+
].join(" ");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 分かれ道の横角から出て、行き先の段へ上がってから駅へ入る。 */
|
|
71
|
+
function decisionBranchPath(start: 点, end: 点): { d: string; turnX: number } {
|
|
72
|
+
const horizontalDir = Math.sign(end.x - start.x) as 1 | -1;
|
|
73
|
+
const verticalDir = Math.sign(end.y - start.y) as 1 | -1;
|
|
74
|
+
const turnX = start.x + horizontalDir * (METRO_BRANCH_STUB + METRO_BRANCH_CORNER_RADIUS);
|
|
75
|
+
const radius = Math.min(
|
|
76
|
+
METRO_BRANCH_CORNER_RADIUS,
|
|
77
|
+
Math.abs(end.y - start.y) / 2,
|
|
78
|
+
Math.abs(end.x - turnX),
|
|
79
|
+
);
|
|
80
|
+
const firstCorner = { x: turnX, y: start.y };
|
|
81
|
+
const secondCorner = { x: turnX, y: end.y };
|
|
82
|
+
const beforeFirst = {
|
|
83
|
+
x: turnX - horizontalDir * radius,
|
|
84
|
+
y: start.y,
|
|
85
|
+
};
|
|
86
|
+
const afterFirst = {
|
|
87
|
+
x: turnX,
|
|
88
|
+
y: start.y + verticalDir * radius,
|
|
89
|
+
};
|
|
90
|
+
const beforeSecond = {
|
|
91
|
+
x: turnX,
|
|
92
|
+
y: end.y - verticalDir * radius,
|
|
93
|
+
};
|
|
94
|
+
const afterSecond = {
|
|
95
|
+
x: turnX + horizontalDir * radius,
|
|
96
|
+
y: end.y,
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
d: [
|
|
100
|
+
`M ${point(start)}`,
|
|
101
|
+
`L ${point(beforeFirst)}`,
|
|
102
|
+
`Q ${point(firstCorner)} ${point(afterFirst)}`,
|
|
103
|
+
`L ${point(beforeSecond)}`,
|
|
104
|
+
`Q ${point(secondCorner)} ${point(afterSecond)}`,
|
|
105
|
+
`L ${point(end)}`,
|
|
106
|
+
].join(" "),
|
|
107
|
+
turnX,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
41
111
|
/**
|
|
42
|
-
* 2
|
|
112
|
+
* 2 つの箱を路線図の線で結ぶ。
|
|
43
113
|
*
|
|
44
114
|
* 向かい合う辺の間に高さの差以上の横幅があれば斜線を中央へ置く。幅が足りない時は、
|
|
45
115
|
* 斜線の傾きを崩さず両方の右辺を結ぶため、右へ張り出すくの字にする。途中の箱は避けない。
|
|
116
|
+
* `decision` から横位置の違う駅へは左右の角から出し、同じ横位置なら行き先側の角から真っ直ぐ出す。
|
|
117
|
+
* 左へ戻る刻み線は駅の丸の外から出し、行き先の矢印より 4 下で止める。
|
|
46
118
|
*/
|
|
47
119
|
export function metroPath(
|
|
48
120
|
from: 箱,
|
|
49
121
|
to: 箱,
|
|
50
122
|
edge?: Pick<CdlEdge, "label" | "sub" | "style" | "overlay">,
|
|
51
|
-
|
|
52
|
-
|
|
123
|
+
_labelMarginExtra = 0,
|
|
124
|
+
_textMetrics: CdlTextMetrics = "proportional",
|
|
53
125
|
): 路線図の線の結果 {
|
|
54
126
|
const fromLeft = from.cx - from.w / 2;
|
|
55
127
|
const fromRight = from.cx + from.w / 2;
|
|
@@ -88,12 +160,52 @@ export function metroPath(
|
|
|
88
160
|
const sgn = Math.sign(dy);
|
|
89
161
|
let d: string;
|
|
90
162
|
let mid: 点;
|
|
91
|
-
let
|
|
163
|
+
let metroReturn = false;
|
|
92
164
|
|
|
93
|
-
if (Math.abs(dy)
|
|
165
|
+
if (from.kind === "decision" && Math.abs(dy) >= SAME_HEIGHT_THRESHOLD) {
|
|
166
|
+
const verticalDir = Math.sign(dy) as 1 | -1;
|
|
167
|
+
if (Math.abs(to.cx - from.cx) < SAME_HEIGHT_THRESHOLD) {
|
|
168
|
+
const start = { x: from.cx, y: from.cy + verticalDir * from.h / 2 };
|
|
169
|
+
const end = { x: to.cx, y: to.cy };
|
|
170
|
+
d = polyline([start, end]);
|
|
171
|
+
fromSide = verticalDir < 0 ? "top" : "bottom";
|
|
172
|
+
toSide = verticalDir < 0 ? "bottom" : "top";
|
|
173
|
+
mid = { x: start.x, y: (start.y + end.y) / 2 };
|
|
174
|
+
} else {
|
|
175
|
+
const horizontalDir = Math.sign(to.cx - from.cx) as 1 | -1;
|
|
176
|
+
const start = {
|
|
177
|
+
x: from.cx + horizontalDir * from.w / 2,
|
|
178
|
+
y: from.cy,
|
|
179
|
+
};
|
|
180
|
+
const end = {
|
|
181
|
+
x: to.cx - horizontalDir * to.w / 2,
|
|
182
|
+
y: to.cy,
|
|
183
|
+
};
|
|
184
|
+
const branch = decisionBranchPath(start, end);
|
|
185
|
+
d = branch.d;
|
|
186
|
+
fromSide = horizontalDir > 0 ? "right" : "left";
|
|
187
|
+
toSide = horizontalDir > 0 ? "left" : "right";
|
|
188
|
+
mid = { x: branch.turnX, y: (start.y + end.y) / 2 };
|
|
189
|
+
}
|
|
190
|
+
} else if (
|
|
191
|
+
edge?.style === "dashed" &&
|
|
192
|
+
from.kind === "station" &&
|
|
193
|
+
to.kind === "station" &&
|
|
194
|
+
to.cx < from.cx &&
|
|
195
|
+
Math.abs(dy) >= SAME_HEIGHT_THRESHOLD
|
|
196
|
+
) {
|
|
197
|
+
const verticalDir = Math.sign(to.cy - from.cy) as 1 | -1;
|
|
198
|
+
const start = { x: from.cx - METRO_RETURN_START_OFFSET, y: from.cy };
|
|
199
|
+
const end = { x: to.cx, y: to.cy - verticalDir * METRO_RETURN_END_OFFSET };
|
|
200
|
+
const corner = { x: end.x, y: start.y };
|
|
201
|
+
fromSide = "left";
|
|
202
|
+
toSide = verticalDir < 0 ? "bottom" : "top";
|
|
203
|
+
d = quadraticCornerPath(start, corner, end, METRO_BRANCH_CORNER_RADIUS);
|
|
204
|
+
mid = { x: (start.x + corner.x) / 2, y: start.y };
|
|
205
|
+
metroReturn = true;
|
|
206
|
+
} else if (Math.abs(dy) < SAME_HEIGHT_THRESHOLD) {
|
|
94
207
|
d = `M ${point({ x: x1, y: y1 })} L ${point({ x: x2, y: y2 })}`;
|
|
95
208
|
mid = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
|
|
96
|
-
tangent = { x: x2 - x1, y: y2 - y1 };
|
|
97
209
|
} else if (fromSide !== toSide && Math.abs(x2 - x1) >= Math.abs(dy)) {
|
|
98
210
|
const horizontal = Math.abs(x2 - x1);
|
|
99
211
|
const s = (horizontal - Math.abs(dy)) / 2;
|
|
@@ -123,7 +235,6 @@ export function metroPath(
|
|
|
123
235
|
d = polyline([p0, p1, p2, p3]);
|
|
124
236
|
}
|
|
125
237
|
mid = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
|
|
126
|
-
tangent = w;
|
|
127
238
|
} else {
|
|
128
239
|
fromSide = "right";
|
|
129
240
|
toSide = "right";
|
|
@@ -153,33 +264,15 @@ export function metroPath(
|
|
|
153
264
|
`L ${point(p4)}`,
|
|
154
265
|
].join(" ");
|
|
155
266
|
mid = { x: p2.x - T * r, y: p2.y };
|
|
156
|
-
tangent = { x: 0, y: sgn };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (edge?.overlay) {
|
|
160
|
-
return { d, fromSide, toSide, labelX: mid.x, labelY: mid.y, labelAnchor: "middle" };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const tangentLength = Math.hypot(tangent.x, tangent.y);
|
|
164
|
-
let normalX = tangent.y / tangentLength;
|
|
165
|
-
let normalY = -tangent.x / tangentLength;
|
|
166
|
-
if (normalY > Number.EPSILON || (Math.abs(normalY) <= Number.EPSILON && normalX < 0)) {
|
|
167
|
-
normalX *= -1;
|
|
168
|
-
normalY *= -1;
|
|
169
267
|
}
|
|
170
|
-
const labelW = computeLabelBoxW(edge?.label, edge?.sub, textMetrics);
|
|
171
|
-
const labelH = labelPillH(edge?.sub);
|
|
172
|
-
const distance =
|
|
173
|
-
labelPathClearanceHorizontal(edge) +
|
|
174
|
-
(Math.abs(normalX) * labelW + Math.abs(normalY) * labelH) / 2 +
|
|
175
|
-
labelMarginExtra;
|
|
176
268
|
|
|
177
269
|
return {
|
|
178
270
|
d,
|
|
179
271
|
fromSide,
|
|
180
272
|
toSide,
|
|
181
|
-
labelX: mid.x
|
|
182
|
-
labelY: mid.y
|
|
273
|
+
labelX: mid.x,
|
|
274
|
+
labelY: mid.y,
|
|
183
275
|
labelAnchor: "middle",
|
|
276
|
+
...(metroReturn ? { metroReturn: true as const } : {}),
|
|
184
277
|
};
|
|
185
278
|
}
|
package/src/layout/nodes.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { requiredRowsWidth } from "../kinds/row-align";
|
|
2
2
|
import type { CdlDiagram, CdlTextMetrics, LaidLane, LaidNode } from "../types";
|
|
3
3
|
import { 端の字に要る線の長さ } from "./head-label";
|
|
4
|
+
import { METRO_DECISION_HEIGHT, METRO_DECISION_WIDTH } from "./metro-path";
|
|
4
5
|
import {
|
|
5
6
|
hasRenderedLabel,
|
|
6
7
|
labelPillH,
|
|
@@ -181,6 +182,16 @@ export function layoutNodes(
|
|
|
181
182
|
textMetrics: CdlTextMetrics,
|
|
182
183
|
): LaidNode[] {
|
|
183
184
|
const laneMap = new Map(lanes.map((l) => [l.id, l]));
|
|
185
|
+
const metroDecisionIds = new Set(
|
|
186
|
+
diag.edges
|
|
187
|
+
.filter((edge) => edge.routing === "metro")
|
|
188
|
+
.flatMap((edge) => [edge.from, edge.to])
|
|
189
|
+
.filter((id) => diag.nodes.some((node) => node.id === id && node.kind === "decision")),
|
|
190
|
+
);
|
|
191
|
+
const nodeSize = (node: CdlDiagram["nodes"][number]) =>
|
|
192
|
+
metroDecisionIds.has(node.id)
|
|
193
|
+
? { w: METRO_DECISION_WIDTH, h: METRO_DECISION_HEIGHT }
|
|
194
|
+
: NODE_SIZE[node.kind];
|
|
184
195
|
|
|
185
196
|
// 全 lane を跨いで stack 番号で row を共有する。
|
|
186
197
|
// 同 stack 番号の node は全 lane で同じ y 中心になり、 lane 間 edge が水平で揃う。
|
|
@@ -189,7 +200,7 @@ export function layoutNodes(
|
|
|
189
200
|
const rowH = new Map<number, number>();
|
|
190
201
|
for (const stack of allStacks) {
|
|
191
202
|
const nodesAtStack = diag.nodes.filter((n) => n.stack === stack);
|
|
192
|
-
const h = Math.max(...nodesAtStack.map((n) => 箱の高さ(n,
|
|
203
|
+
const h = Math.max(...nodesAtStack.map((n) => 箱の高さ(n, nodeSize(n).h)));
|
|
193
204
|
rowH.set(stack, h);
|
|
194
205
|
}
|
|
195
206
|
const rowCy = new Map<number, number>();
|
|
@@ -238,7 +249,7 @@ export function layoutNodes(
|
|
|
238
249
|
if (!lane) {
|
|
239
250
|
throw new Error(`cdl layout: node "${n.id}" の lane "${n.lane}" が定義されていない`);
|
|
240
251
|
}
|
|
241
|
-
const size =
|
|
252
|
+
const size = nodeSize(n);
|
|
242
253
|
if (n.posX !== undefined && n.posY !== undefined) {
|
|
243
254
|
const w = n.posW ?? n.w ?? autoRowsWidth(n, size.w, textMetrics);
|
|
244
255
|
const h = n.posH ?? n.h ?? rowH.get(n.stack) ?? size.h;
|
package/src/layout/spec.ts
CHANGED
|
@@ -200,7 +200,7 @@ export function drawsTitleOutsideBox(kind: string | undefined): boolean {
|
|
|
200
200
|
*/
|
|
201
201
|
const TITLE_FONT_SIZE_DEFAULT = 18;
|
|
202
202
|
const TITLE_FONT_SIZE: ReadonlyMap<string, number> = new Map<string, number>([
|
|
203
|
-
...(["dyn-rect", "dyn-circle", "dyn-arc", "dyn-wave", "dyn-polygon"
|
|
203
|
+
...(["dyn-rect", "dyn-circle", "dyn-arc", "dyn-wave", "dyn-polygon"] as const).map(
|
|
204
204
|
(k) => [k, 13] as const,
|
|
205
205
|
),
|
|
206
206
|
...(
|
|
@@ -251,8 +251,10 @@ const TITLE_FONT_SIZE: ReadonlyMap<string, number> = new Map<string, number>([
|
|
|
251
251
|
] as const
|
|
252
252
|
).map((k) => [k, 20] as const),
|
|
253
253
|
...(["card", "event", "function"] as const).map((k) => [k, 24] as const),
|
|
254
|
+
["station", 25],
|
|
254
255
|
["actor", 32],
|
|
255
256
|
["storage", 34],
|
|
257
|
+
["timeline-number", 18],
|
|
256
258
|
]);
|
|
257
259
|
|
|
258
260
|
/**
|
|
@@ -286,6 +288,17 @@ export function skipsNodeTitleWidthCheck(kind: string | undefined): boolean {
|
|
|
286
288
|
*/
|
|
287
289
|
export const TITLE_BOX_PADDING = 52;
|
|
288
290
|
|
|
291
|
+
/** 時間軸の番号と軸に共通する縁の太さ。active でも変えない。 */
|
|
292
|
+
export const TIMELINE_NUMBER_STROKE = 4;
|
|
293
|
+
|
|
294
|
+
/** 題を描ける幅。丸い番号は縁の内側、それ以外は既存の箱内外の余白を使う。 */
|
|
295
|
+
export function titleUsableWidth(kind: string | undefined, w: number, h: number): number {
|
|
296
|
+
if (kind === "timeline-number") {
|
|
297
|
+
return Math.min(w, h) - 2 * TIMELINE_NUMBER_STROKE;
|
|
298
|
+
}
|
|
299
|
+
return w - (drawsTitleOutsideBox(kind) ? 0 : TITLE_BOX_PADDING);
|
|
300
|
+
}
|
|
301
|
+
|
|
289
302
|
/**
|
|
290
303
|
* 低い箱の時だけ題を箱に収まるまで小さくする種別。
|
|
291
304
|
*
|
|
@@ -303,6 +316,7 @@ const KINDS_SCALING_TITLE_WHEN_LOW: ReadonlySet<string> = new Set(["actor", "eve
|
|
|
303
316
|
*/
|
|
304
317
|
const KINDS_SCALING_TITLE_ALWAYS: ReadonlySet<string> = new Set([
|
|
305
318
|
"card",
|
|
319
|
+
"timeline-number",
|
|
306
320
|
"shape-file",
|
|
307
321
|
"shape-folder",
|
|
308
322
|
"shape-cloud",
|
|
@@ -470,6 +484,7 @@ const KINDS_DRAWING_ONLY_SHAPE: readonly string[] = [
|
|
|
470
484
|
const KINDS_WITHOUT_EYEBROW_TEXT: ReadonlySet<string> = new Set([
|
|
471
485
|
...KINDS_DRAWING_ONLY_SHAPE,
|
|
472
486
|
...KINDS_WITHOUT_TITLE_TEXT,
|
|
487
|
+
"timeline-number",
|
|
473
488
|
]);
|
|
474
489
|
|
|
475
490
|
/**
|
|
@@ -499,6 +514,7 @@ export function rendersEyebrow(kind: string | undefined): boolean {
|
|
|
499
514
|
const KINDS_WITHOUT_SUBTITLE_TEXT: ReadonlySet<string> = new Set([
|
|
500
515
|
...KINDS_DRAWING_OWN_PAYLOAD,
|
|
501
516
|
...KINDS_WITHOUT_TITLE_TEXT,
|
|
517
|
+
"timeline-number",
|
|
502
518
|
]);
|
|
503
519
|
|
|
504
520
|
/** その node が `subtitle` を文字として描くか (SSOT)。 破綻の扱いは `rendersEyebrow` と同じ */
|
package/src/layout/tokens.ts
CHANGED
|
@@ -14,6 +14,8 @@ export const TOKENS = {
|
|
|
14
14
|
lane_label_h: 40,
|
|
15
15
|
lane_top_pad: 100,
|
|
16
16
|
contain_pad: 28,
|
|
17
|
+
stage_column_gap: 48,
|
|
18
|
+
stage_card_text_inset: 18,
|
|
17
19
|
|
|
18
20
|
// node sizes (kind ごと、 unified row h は engine が max() で算出)
|
|
19
21
|
node: {
|
|
@@ -141,7 +143,10 @@ export const TOKENS = {
|
|
|
141
143
|
"mark-start": { w: 96, h: 96 },
|
|
142
144
|
"mark-end": { w: 96, h: 96 },
|
|
143
145
|
// 路線図の駅 (#996) ... 正方形にして小さな丸が縦横どちらにも歪まないようにする。
|
|
144
|
-
|
|
146
|
+
// 半径 16 と縁 6 の外径を、線路との接点を含めて収める。
|
|
147
|
+
station: { w: 38, h: 38 },
|
|
148
|
+
// 時間軸の番号 (#1017) ... 半径 24 の丸と太さ 4 の縁を収める。
|
|
149
|
+
"timeline-number": { w: 52, h: 52 },
|
|
145
150
|
// 時系列のやり取り (#578)。 実寸は中身から決まる (`sequenceBoardMetrics`) ので、
|
|
146
151
|
// ここは書かなかった時の受け皿
|
|
147
152
|
"sequence-board": { w: 960, h: 640 },
|
|
@@ -176,6 +181,8 @@ export const LANE_TOP_PAD = TOKENS.lane_top_pad;
|
|
|
176
181
|
export const STACK_GAP = TOKENS.row_gap;
|
|
177
182
|
export const NODE_SIZE = TOKENS.node;
|
|
178
183
|
export const CONTAIN_PAD = TOKENS.contain_pad;
|
|
184
|
+
export const STAGE_COLUMN_GAP = TOKENS.stage_column_gap;
|
|
185
|
+
export const STAGE_CARD_TEXT_INSET = TOKENS.stage_card_text_inset;
|
|
179
186
|
export const VIEW_PAD = TOKENS.view_pad;
|
|
180
187
|
export const HEADER_RESERVE = TOKENS.header_reserve;
|
|
181
188
|
export const LANE_AUTO_GAP = 80;
|
package/src/layout.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
slideLabelsAlongOwnPath,
|
|
26
26
|
} from "./layout/edges";
|
|
27
27
|
import { applyFooterShapeDrops } from "./layout/footer-shape";
|
|
28
|
+
import { layoutLegend } from "./layout/legend";
|
|
28
29
|
import {
|
|
29
30
|
expandLaneGapsForEdgeLabels,
|
|
30
31
|
expandLanesForNodes,
|
|
@@ -181,7 +182,7 @@ function dropUnsafeLaneDx(
|
|
|
181
182
|
*/
|
|
182
183
|
export function applyRowLevelNodeShifts<
|
|
183
184
|
N extends { id: string; stack: number; lane: string; cx: number; cy: number },
|
|
184
|
-
L extends { id: string; x: number },
|
|
185
|
+
L extends { id: string; x: number; stage?: unknown },
|
|
185
186
|
>(
|
|
186
187
|
nodes: readonly N[],
|
|
187
188
|
lanes: readonly L[],
|
|
@@ -189,6 +190,11 @@ export function applyRowLevelNodeShifts<
|
|
|
189
190
|
): { nodes: N[]; lanes: L[] } | null {
|
|
190
191
|
const dyByStack = aggregateRowDy(nodes, nodeShifts);
|
|
191
192
|
const dxByLane = dropUnsafeLaneDx(aggregateLaneDx(nodes, nodeShifts), nodes);
|
|
193
|
+
// 段階列は隣り合う列面の間を線の回廊として使うため、札回避の横 shift で列だけを動かさない。
|
|
194
|
+
// 縦 shift は stack の整列に必要なので、従来どおり適用する。
|
|
195
|
+
for (const lane of lanes) {
|
|
196
|
+
if (lane.stage !== undefined) dxByLane.delete(lane.id);
|
|
197
|
+
}
|
|
192
198
|
let moved = false;
|
|
193
199
|
const outNodes = nodes.map((n) => {
|
|
194
200
|
const dx = dxByLane.get(n.lane) ?? 0;
|
|
@@ -434,7 +440,18 @@ export function layout(diag: CdlDiagram): LaidDiagram {
|
|
|
434
440
|
}
|
|
435
441
|
}
|
|
436
442
|
|
|
437
|
-
const
|
|
443
|
+
const contentViewBox = computeViewBox(
|
|
444
|
+
containedLanesFinal,
|
|
445
|
+
nodesFinal,
|
|
446
|
+
edges,
|
|
447
|
+
bboxes,
|
|
448
|
+
diag.viewport,
|
|
449
|
+
);
|
|
450
|
+
// 凡例なしの経路では関数を呼ばず、既存の viewBox と配置結果の形を保つ。
|
|
451
|
+
const legendLayout = diag.legend?.length
|
|
452
|
+
? layoutLegend(diag.legend, contentViewBox, textMetrics, nodesFinal)
|
|
453
|
+
: undefined;
|
|
454
|
+
const viewBox = legendLayout?.viewBox ?? contentViewBox;
|
|
438
455
|
|
|
439
456
|
return {
|
|
440
457
|
id: diag.id,
|
|
@@ -462,6 +479,7 @@ export function layout(diag: CdlDiagram): LaidDiagram {
|
|
|
462
479
|
...(textMetrics === "monospace" ? { textMetrics: "monospace" as const } : {}),
|
|
463
480
|
// 種類も配色と同じく空の目印を出さない。 遮断をここに揃えると手書きの図にも効く
|
|
464
481
|
...(diag.type ? { type: diag.type } : {}),
|
|
482
|
+
...(legendLayout ? { legend: legendLayout.legend } : {}),
|
|
465
483
|
bboxes,
|
|
466
484
|
collisions,
|
|
467
485
|
nearCollisions,
|
package/src/render/edge-head.ts
CHANGED
|
@@ -7,6 +7,12 @@ import {
|
|
|
7
7
|
import { hasTone } from "./tone";
|
|
8
8
|
|
|
9
9
|
export const EDGE_HEAD_MUTED_MARKER_ID = "cdl-arrow-muted";
|
|
10
|
+
export const METRO_RETURN_ARROW_D = "M 21 0 L 4 9.5 L 4 -9.5 Z";
|
|
11
|
+
|
|
12
|
+
/** 戻る metro 線専用の矢印。通常の edge marker と寸法・線端からの距離が違う。 */
|
|
13
|
+
export function metroReturnArrowMarkerId(tone: string): string {
|
|
14
|
+
return `cdl-metro-return-arrow-${tone}`;
|
|
15
|
+
}
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
18
|
* 関係の端の形の **描き方と marker の名前** (SSOT、 #560)。
|
package/src/render/edges.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { 等幅の字 } from "./mono-text";
|
|
|
2
2
|
import type { JSX } from "react";
|
|
3
3
|
import { computeLabelBBoxWorld, hasRenderedLabel, LABEL_PILL_PAD_X } from "../layout/spec";
|
|
4
4
|
import { 端の字の大きさ, 端の字を置く, type 線の端 } from "../layout/head-label";
|
|
5
|
+
import { METRO_LINE_WIDTH, METRO_LINE_WIDTH_CSS } from "../layout/metro-path";
|
|
5
6
|
import {
|
|
6
7
|
EDGE_HEAD_MARKER_SIZE,
|
|
7
8
|
type CdlTextMetrics,
|
|
@@ -9,9 +10,12 @@ import {
|
|
|
9
10
|
type LaidEdge,
|
|
10
11
|
type Tone,
|
|
11
12
|
} from "../types";
|
|
12
|
-
import { edgeHeadMarkerName } from "./edge-head";
|
|
13
|
+
import { edgeHeadMarkerName, metroReturnArrowMarkerId } from "./edge-head";
|
|
13
14
|
import { toneColor } from "./tone";
|
|
14
15
|
|
|
16
|
+
/** 段がまだ通っていない metro 区間を、意匠側が選ぶための属性名。 */
|
|
17
|
+
export const METRO_PENDING_ATTRIBUTE = "data-cdl-pending";
|
|
18
|
+
|
|
15
19
|
/**
|
|
16
20
|
* 辺の色 (#618)。 役目を書いた辺だけ「いま」 の色で引く。
|
|
17
21
|
*
|
|
@@ -62,6 +66,7 @@ export function CdlEdgeView({
|
|
|
62
66
|
progress,
|
|
63
67
|
stateValues,
|
|
64
68
|
flowing = false,
|
|
69
|
+
pending = false,
|
|
65
70
|
}: {
|
|
66
71
|
edge: LaidEdge;
|
|
67
72
|
active: boolean;
|
|
@@ -81,6 +86,8 @@ export function CdlEdgeView({
|
|
|
81
86
|
* 2 つの動きが同時に走って線がどちらに進んでいるか読めなくなる。
|
|
82
87
|
*/
|
|
83
88
|
flowing?: boolean;
|
|
89
|
+
/** metro 線の初出より前の段だけに付く。非 metro 線では常に偽。 */
|
|
90
|
+
pending?: boolean;
|
|
84
91
|
}): JSX.Element {
|
|
85
92
|
const color = edgeColor(edge);
|
|
86
93
|
// 光っていない edge も tone の色で描く。 以前は一律の灰色に落としていたため、 何も光っていない
|
|
@@ -96,7 +103,11 @@ export function CdlEdgeView({
|
|
|
96
103
|
// その色なら 0.9 で 4.51-4.92 と 4.5:1 を満たす (0.8 では 3.69-4.04 で届かない)。
|
|
97
104
|
// 実線と点線で同じ組を使い、 光らせた側が薄くなる逆転を作らない。
|
|
98
105
|
// 端の形は線の種類と別の軸 (#560)。 書かなければ既定の塗った三角で、名前も従来のまま
|
|
99
|
-
const
|
|
106
|
+
const style = edge.style ?? "solid";
|
|
107
|
+
const isMetroReturn = edge.metroReturn === true;
|
|
108
|
+
const marker = isMetroReturn
|
|
109
|
+
? metroReturnArrowMarkerId(edge.tone)
|
|
110
|
+
: edgeHeadMarkerName(edge.tone, edge.head, !active, edge.headFill);
|
|
100
111
|
// 出どころ側の印 (#578)。 marker は経路の向きに従って回るので、始点に置いた印は
|
|
101
112
|
// 自動で外向きになる (`orient="auto-start-reverse"`)。 書かない図では端が無い
|
|
102
113
|
const tailMarker =
|
|
@@ -104,7 +115,6 @@ export function CdlEdgeView({
|
|
|
104
115
|
? undefined
|
|
105
116
|
: edgeHeadMarkerName(edge.tone, edge.tailHead, !active, edge.tailHeadFill);
|
|
106
117
|
|
|
107
|
-
const style = edge.style ?? "solid";
|
|
108
118
|
const isDotted = style === "dotted-flow";
|
|
109
119
|
const isPassThrough = isDotted && Boolean(edge.dThrough);
|
|
110
120
|
|
|
@@ -124,6 +134,7 @@ export function CdlEdgeView({
|
|
|
124
134
|
|
|
125
135
|
return (
|
|
126
136
|
<g
|
|
137
|
+
{...(pending ? { [METRO_PENDING_ATTRIBUTE]: "true" } : {})}
|
|
127
138
|
data-cdl-edge={edge.id}
|
|
128
139
|
data-cdl-routing={edge.routing === "metro" ? "metro" : undefined}
|
|
129
140
|
data-cdl-from={edge.from}
|
|
@@ -148,11 +159,12 @@ export function CdlEdgeView({
|
|
|
148
159
|
fill="none"
|
|
149
160
|
stroke={color}
|
|
150
161
|
strokeOpacity={active ? 0.95 : 0.9}
|
|
151
|
-
strokeWidth={active ? 5 : 4}
|
|
162
|
+
strokeWidth={edge.routing === "metro" ? METRO_LINE_WIDTH : active ? 5 : 4}
|
|
152
163
|
strokeDasharray="0 18"
|
|
153
164
|
strokeLinecap="round"
|
|
154
165
|
style={{
|
|
155
166
|
transition: "stroke 280ms ease, stroke-opacity 280ms ease, stroke-width 280ms ease",
|
|
167
|
+
strokeWidth: edge.routing === "metro" ? METRO_LINE_WIDTH_CSS : undefined,
|
|
156
168
|
}}
|
|
157
169
|
/>
|
|
158
170
|
{active && !isPassThrough && <EdgeGlowCircles color={color} edgeId={edge.id} />}
|
|
@@ -237,12 +249,20 @@ function SolidEdgePath({
|
|
|
237
249
|
const boundDashOffset = edge.dashOffsetBind && stateValues
|
|
238
250
|
? resolveNumericAttr(edge.dashOffsetBind, stateValues, 0)
|
|
239
251
|
: undefined;
|
|
252
|
+
const strokeWidth = edge.routing === "metro" && edge.widthBind === undefined
|
|
253
|
+
? edge.metroReturn ? 6 : METRO_LINE_WIDTH
|
|
254
|
+
: boundWidth;
|
|
255
|
+
const strokeWidthCss = edge.routing === "metro" && !edge.metroReturn
|
|
256
|
+
? edge.widthBind === undefined
|
|
257
|
+
? METRO_LINE_WIDTH_CSS
|
|
258
|
+
: `var(--cdl-metro-line-width, ${strokeWidth}px)`
|
|
259
|
+
: undefined;
|
|
240
260
|
// dashoffset は dasharray 前提、 signal 経由の流れる線を出したい時は "6 6" 等
|
|
241
261
|
// 刻みは 2 つの役目を持つ。 光っていないことの合図 (`"solid"` の光っていない側) と、
|
|
242
262
|
// 線の種類そのもの (`"dashed"`)。 **後者は光っても刻みのまま**にする = 意味を持つ刻みが
|
|
243
263
|
// 光った瞬間に消えると、その関係だけ種類が変わって見える (#578)
|
|
244
264
|
const dashArray = dashed
|
|
245
|
-
? "10 8"
|
|
265
|
+
? edge.metroReturn ? "0 9.6" : "10 8"
|
|
246
266
|
: active
|
|
247
267
|
? boundDashOffset !== undefined ? "8 6" : undefined
|
|
248
268
|
: "4 5";
|
|
@@ -253,7 +273,7 @@ function SolidEdgePath({
|
|
|
253
273
|
fill="none"
|
|
254
274
|
stroke={boundStroke}
|
|
255
275
|
strokeOpacity={active ? 0.95 : 0.9}
|
|
256
|
-
strokeWidth={
|
|
276
|
+
strokeWidth={strokeWidth}
|
|
257
277
|
strokeDasharray={dashArray}
|
|
258
278
|
strokeDashoffset={boundDashOffset}
|
|
259
279
|
strokeLinecap="round"
|
|
@@ -261,6 +281,7 @@ function SolidEdgePath({
|
|
|
261
281
|
markerStart={tailMarker === undefined ? undefined : `url(#${tailMarker})`}
|
|
262
282
|
style={{
|
|
263
283
|
transition: "stroke 280ms ease, stroke-opacity 280ms ease, stroke-width 280ms ease",
|
|
284
|
+
strokeWidth: strokeWidthCss,
|
|
264
285
|
}}
|
|
265
286
|
/>
|
|
266
287
|
);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { JSX } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
LEGEND_FONT_SIZE,
|
|
5
|
+
LEGEND_MARK_REFERENCE_WIDTH,
|
|
6
|
+
LEGEND_MARK_SCALE,
|
|
7
|
+
LEGEND_MARK_TEXT_GAP,
|
|
8
|
+
LEGEND_MARK_WIDTH,
|
|
9
|
+
} from "../layout/legend";
|
|
10
|
+
import type { LaidLegendItem, LegendMark } from "../types";
|
|
11
|
+
|
|
12
|
+
const ink = "var(--cdl-text-accent, #143a52)";
|
|
13
|
+
const paper = "var(--cdl-stage-bg, #f8fafc)";
|
|
14
|
+
|
|
15
|
+
const LEGEND_MARK_RENDERERS = {
|
|
16
|
+
diamond: () => (
|
|
17
|
+
<path d="M 23 -26 L 46 -8 L 23 10 L 0 -8 Z" fill="none" stroke={ink} strokeWidth={2} />
|
|
18
|
+
),
|
|
19
|
+
"filled-circle": () => <circle cx={23} cy={-8} r={11} fill={ink} />,
|
|
20
|
+
"double-circle": () => (
|
|
21
|
+
<>
|
|
22
|
+
<circle cx={23} cy={-8} r={12} fill="none" stroke={ink} strokeWidth={2.5} />
|
|
23
|
+
<circle cx={23} cy={-8} r={6} fill={ink} />
|
|
24
|
+
</>
|
|
25
|
+
),
|
|
26
|
+
"dotted-line": () => (
|
|
27
|
+
<path
|
|
28
|
+
d={`M 0 -8 H ${LEGEND_MARK_REFERENCE_WIDTH}`}
|
|
29
|
+
fill="none"
|
|
30
|
+
stroke={ink}
|
|
31
|
+
strokeWidth={6}
|
|
32
|
+
strokeLinecap="round"
|
|
33
|
+
strokeDasharray="0 9.6"
|
|
34
|
+
/>
|
|
35
|
+
),
|
|
36
|
+
"solid-line": () => (
|
|
37
|
+
<path
|
|
38
|
+
d={`M 0 -8 H ${LEGEND_MARK_REFERENCE_WIDTH}`}
|
|
39
|
+
fill="none"
|
|
40
|
+
stroke={ink}
|
|
41
|
+
strokeWidth={4}
|
|
42
|
+
strokeLinecap="round"
|
|
43
|
+
/>
|
|
44
|
+
),
|
|
45
|
+
"curved-line": () => (
|
|
46
|
+
<path
|
|
47
|
+
d={`M 0 6 C 12 -22, 34 -22, ${LEGEND_MARK_REFERENCE_WIDTH} 6`}
|
|
48
|
+
fill="none"
|
|
49
|
+
stroke={ink}
|
|
50
|
+
strokeWidth={4}
|
|
51
|
+
strokeLinecap="round"
|
|
52
|
+
/>
|
|
53
|
+
),
|
|
54
|
+
"rounded-label": () => (
|
|
55
|
+
<rect
|
|
56
|
+
x={0}
|
|
57
|
+
y={-23}
|
|
58
|
+
width={LEGEND_MARK_REFERENCE_WIDTH}
|
|
59
|
+
height={30}
|
|
60
|
+
rx={9}
|
|
61
|
+
fill={paper}
|
|
62
|
+
stroke={ink}
|
|
63
|
+
strokeWidth={2}
|
|
64
|
+
/>
|
|
65
|
+
),
|
|
66
|
+
"numbered-circle": () => (
|
|
67
|
+
<>
|
|
68
|
+
<circle cx={23} cy={-8} r={13} fill={paper} stroke={ink} strokeWidth={2.5} />
|
|
69
|
+
<text x={23} y={-2} textAnchor="middle" fontSize={17} fontWeight={700} fill={ink}>
|
|
70
|
+
1
|
|
71
|
+
</text>
|
|
72
|
+
</>
|
|
73
|
+
),
|
|
74
|
+
station: () => <circle cx={23} cy={-8} r={11} fill={paper} stroke={ink} strokeWidth={4} />,
|
|
75
|
+
arrow: () => (
|
|
76
|
+
<path
|
|
77
|
+
d={`M 0 -8 H 42 M 32 -18 L ${LEGEND_MARK_REFERENCE_WIDTH} -8 L 32 2`}
|
|
78
|
+
fill="none"
|
|
79
|
+
stroke={ink}
|
|
80
|
+
strokeWidth={3}
|
|
81
|
+
strokeLinecap="round"
|
|
82
|
+
strokeLinejoin="round"
|
|
83
|
+
/>
|
|
84
|
+
),
|
|
85
|
+
} satisfies Record<LegendMark, () => JSX.Element>;
|
|
86
|
+
|
|
87
|
+
export function CdlLegend({ items }: { items: readonly LaidLegendItem[] }): JSX.Element {
|
|
88
|
+
return (
|
|
89
|
+
<g data-cdl-role="legend">
|
|
90
|
+
{items.map((item, index) => {
|
|
91
|
+
const Mark = LEGEND_MARK_RENDERERS[item.mark];
|
|
92
|
+
return (
|
|
93
|
+
<g
|
|
94
|
+
key={`${item.mark}-${index}`}
|
|
95
|
+
data-cdl-role="legend-item"
|
|
96
|
+
data-cdl-y={item.y}
|
|
97
|
+
transform={`translate(${item.x} ${item.y})`}
|
|
98
|
+
>
|
|
99
|
+
<g
|
|
100
|
+
data-cdl-role="legend-mark"
|
|
101
|
+
data-cdl-legend-mark={item.mark}
|
|
102
|
+
transform={`scale(${LEGEND_MARK_SCALE})`}
|
|
103
|
+
>
|
|
104
|
+
<Mark />
|
|
105
|
+
</g>
|
|
106
|
+
<text
|
|
107
|
+
data-cdl-role="legend-text"
|
|
108
|
+
x={LEGEND_MARK_WIDTH + LEGEND_MARK_TEXT_GAP}
|
|
109
|
+
y={0}
|
|
110
|
+
fontSize={LEGEND_FONT_SIZE}
|
|
111
|
+
fill="var(--cdl-text-mute, #4d7187)"
|
|
112
|
+
>
|
|
113
|
+
{item.text}
|
|
114
|
+
</text>
|
|
115
|
+
</g>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</g>
|
|
119
|
+
);
|
|
120
|
+
}
|