@cardenelabs/cdl 0.121.0 → 0.123.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 +51 -1
- package/README.md +137 -0
- package/SPEC.md +1 -1
- package/dist/index.cjs +1513 -667
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +51 -33
- package/dist/index.d.ts +51 -33
- package/dist/index.js +1513 -668
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +1271 -444
- 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 +1271 -444
- package/dist/react.js.map +1 -1
- package/dist/{render-A5AKjNuy.d.cts → render-CVczwfRb.d.cts} +40 -7
- package/dist/{render-A5AKjNuy.d.ts → render-CVczwfRb.d.ts} +40 -7
- package/package.json +9 -12
- package/src/builder.ts +8 -2
- package/src/index.ts +3 -0
- package/src/kinds/box-lines.ts +17 -4
- package/src/kinds/chart-bar.tsx +4 -0
- package/src/kinds/chart-gauge.tsx +20 -9
- package/src/kinds/chart-pie.tsx +67 -17
- package/src/kinds/chart-radial.tsx +7 -2
- package/src/kinds/chart-slope.tsx +26 -7
- package/src/kinds/chart-stacked-bar.tsx +8 -4
- package/src/kinds/chart-stat.tsx +19 -4
- package/src/kinds/chart-waffle.tsx +7 -2
- package/src/kinds/dyn-shape.tsx +22 -9
- package/src/kinds/funnel.tsx +9 -2
- package/src/kinds/generic.tsx +5 -2
- package/src/kinds/mind-map.tsx +26 -10
- package/src/kinds/quadrant.tsx +14 -4
- package/src/kinds/row-align.ts +19 -7
- package/src/kinds/sequence-board-metrics.ts +13 -3
- package/src/kinds/sequence-board.tsx +4 -2
- package/src/kinds/shape-basement.tsx +57 -32
- package/src/kinds/shape-blockchain.tsx +83 -34
- package/src/kinds/shape-commerce.tsx +65 -24
- package/src/kinds/shape-finance.tsx +90 -26
- package/src/kinds/shape-hardware.tsx +48 -17
- package/src/kinds/shape-region.tsx +16 -6
- package/src/kinds/shape-software.tsx +30 -8
- package/src/kinds/station.tsx +29 -0
- package/src/kinds/text-width.ts +17 -3
- package/src/kinds/tree.tsx +25 -7
- package/src/layout/collisions.ts +50 -28
- package/src/layout/curve-path.ts +126 -0
- package/src/layout/edges.ts +271 -58
- package/src/layout/head-label.ts +11 -5
- package/src/layout/lanes.ts +3 -2
- package/src/layout/metro-path.ts +185 -0
- package/src/layout/nodes.ts +14 -6
- package/src/layout/predict-bbox.ts +6 -3
- package/src/layout/self-loop.ts +10 -4
- package/src/layout/spec.ts +36 -7
- package/src/layout/text-width.ts +25 -8
- package/src/layout/tokens.ts +2 -0
- package/src/layout-with-validation.ts +10 -5
- package/src/layout.ts +55 -21
- package/src/presets.ts +69 -43
- package/src/render/edges.tsx +19 -3
- package/src/render/interactive-panel.tsx +34 -7
- package/src/render/nodes.tsx +100 -48
- package/src/render/stage.tsx +3 -0
- package/src/text-metrics.ts +9 -0
- package/src/types.ts +41 -4
- package/src/visual-validate.ts +91 -21
package/src/layout/lanes.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CdlDiagram, CdlEdge, LaidLane, LaidNode } from "../types";
|
|
1
|
+
import type { CdlDiagram, CdlEdge, CdlTextMetrics, LaidLane, LaidNode } from "../types";
|
|
2
2
|
import { CLEARANCE_NODE_LABEL } from "./clearance-constants";
|
|
3
3
|
import { computeLabelBoxW, EDGE_STUB_OUT, targetNodeNodeClearance } from "./spec";
|
|
4
4
|
import { CONTAIN_PAD, DEFAULT_LANE_GAP, HEADER_RESERVE, LANE_LABEL_H, LANE_PAD_MIN } from "./tokens";
|
|
@@ -194,6 +194,7 @@ export function expandLaneGapsForEdgeLabels(
|
|
|
194
194
|
lanes: LaidLane[],
|
|
195
195
|
nodes: LaidNode[],
|
|
196
196
|
edges: readonly CdlEdge[],
|
|
197
|
+
textMetrics: CdlTextMetrics = "proportional",
|
|
197
198
|
): LaidLane[] {
|
|
198
199
|
if (lanes.length < 2) return lanes;
|
|
199
200
|
// canvas pivot 新 spec (dragon canvas pivot spec §layout-role-conversion)。
|
|
@@ -240,7 +241,7 @@ export function expandLaneGapsForEdgeLabels(
|
|
|
240
241
|
const fi = laneIndexById.get(fromLane);
|
|
241
242
|
const ti = laneIndexById.get(toLane);
|
|
242
243
|
if (fi === undefined || ti === undefined || fi === ti) continue;
|
|
243
|
-
const labelBoxW = computeLabelBoxW(edge.label, edge.sub);
|
|
244
|
+
const labelBoxW = computeLabelBoxW(edge.label, edge.sub, textMetrics);
|
|
244
245
|
if (labelBoxW === 0) continue;
|
|
245
246
|
const lo = Math.min(fi, ti);
|
|
246
247
|
const hi = Math.max(fi, ti);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { CdlEdge, CdlTextMetrics, LaidNode, Side } from "../types";
|
|
2
|
+
import { SAME_HEIGHT_THRESHOLD } from "./curve-path";
|
|
3
|
+
import { computeLabelBoxW, EDGE_STUB_OUT, labelPathClearanceHorizontal, labelPillH } from "./spec";
|
|
4
|
+
|
|
5
|
+
type 箱 = Pick<LaidNode, "cx" | "cy" | "w" | "h">;
|
|
6
|
+
type 点 = { x: number; y: number };
|
|
7
|
+
|
|
8
|
+
type 路線図の線の結果 = {
|
|
9
|
+
d: string;
|
|
10
|
+
fromSide: Side;
|
|
11
|
+
toSide: Side;
|
|
12
|
+
labelX: number;
|
|
13
|
+
labelY: number;
|
|
14
|
+
labelAnchor: "middle";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const METRO_CORNER_RADIUS = 24;
|
|
18
|
+
|
|
19
|
+
const T = Math.SQRT2 - 1;
|
|
20
|
+
|
|
21
|
+
function round2(v: number): number {
|
|
22
|
+
return Math.round(v * 100) / 100;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function point(p: 点): string {
|
|
26
|
+
return `${round2(p.x)} ${round2(p.y)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function add(p: 点, v: 点, scale: number): 点 {
|
|
30
|
+
return { x: p.x + v.x * scale, y: p.y + v.y * scale };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function polyline(points: readonly 点[]): string {
|
|
34
|
+
const distinct = points.filter((p, i) => {
|
|
35
|
+
const previous = points[i - 1];
|
|
36
|
+
return previous === undefined || p.x !== previous.x || p.y !== previous.y;
|
|
37
|
+
});
|
|
38
|
+
return distinct.map((p, i) => `${i === 0 ? "M" : "L"} ${point(p)}`).join(" ");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 2 つの箱を、水平線と傾き ±1 の斜線だけで結ぶ。
|
|
43
|
+
*
|
|
44
|
+
* 向かい合う辺の間に高さの差以上の横幅があれば斜線を中央へ置く。幅が足りない時は、
|
|
45
|
+
* 斜線の傾きを崩さず両方の右辺を結ぶため、右へ張り出すくの字にする。途中の箱は避けない。
|
|
46
|
+
*/
|
|
47
|
+
export function metroPath(
|
|
48
|
+
from: 箱,
|
|
49
|
+
to: 箱,
|
|
50
|
+
edge?: Pick<CdlEdge, "label" | "sub" | "style" | "overlay">,
|
|
51
|
+
labelMarginExtra = 0,
|
|
52
|
+
textMetrics: CdlTextMetrics = "proportional",
|
|
53
|
+
): 路線図の線の結果 {
|
|
54
|
+
const fromLeft = from.cx - from.w / 2;
|
|
55
|
+
const fromRight = from.cx + from.w / 2;
|
|
56
|
+
const toLeft = to.cx - to.w / 2;
|
|
57
|
+
const toRight = to.cx + to.w / 2;
|
|
58
|
+
|
|
59
|
+
let fromSide: Side;
|
|
60
|
+
let toSide: Side;
|
|
61
|
+
let x1: number;
|
|
62
|
+
let x2: number;
|
|
63
|
+
let dir: 1 | -1;
|
|
64
|
+
|
|
65
|
+
if (toLeft >= fromRight) {
|
|
66
|
+
fromSide = "right";
|
|
67
|
+
toSide = "left";
|
|
68
|
+
x1 = fromRight;
|
|
69
|
+
x2 = toLeft;
|
|
70
|
+
dir = 1;
|
|
71
|
+
} else if (toRight <= fromLeft) {
|
|
72
|
+
fromSide = "left";
|
|
73
|
+
toSide = "right";
|
|
74
|
+
x1 = fromLeft;
|
|
75
|
+
x2 = toRight;
|
|
76
|
+
dir = -1;
|
|
77
|
+
} else {
|
|
78
|
+
fromSide = "right";
|
|
79
|
+
toSide = "right";
|
|
80
|
+
x1 = fromRight;
|
|
81
|
+
x2 = toRight;
|
|
82
|
+
dir = 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const y1 = from.cy;
|
|
86
|
+
const y2 = to.cy;
|
|
87
|
+
const dy = y2 - y1;
|
|
88
|
+
const sgn = Math.sign(dy);
|
|
89
|
+
let d: string;
|
|
90
|
+
let mid: 点;
|
|
91
|
+
let tangent: 点;
|
|
92
|
+
|
|
93
|
+
if (Math.abs(dy) < SAME_HEIGHT_THRESHOLD) {
|
|
94
|
+
d = `M ${point({ x: x1, y: y1 })} L ${point({ x: x2, y: y2 })}`;
|
|
95
|
+
mid = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
|
|
96
|
+
tangent = { x: x2 - x1, y: y2 - y1 };
|
|
97
|
+
} else if (fromSide !== toSide && Math.abs(x2 - x1) >= Math.abs(dy)) {
|
|
98
|
+
const horizontal = Math.abs(x2 - x1);
|
|
99
|
+
const s = (horizontal - Math.abs(dy)) / 2;
|
|
100
|
+
const p0 = { x: x1, y: y1 };
|
|
101
|
+
const p1 = { x: x1 + dir * s, y: y1 };
|
|
102
|
+
const p2 = { x: x2 - dir * s, y: y2 };
|
|
103
|
+
const p3 = { x: x2, y: y2 };
|
|
104
|
+
const u = { x: dir, y: 0 };
|
|
105
|
+
const w = { x: dir / Math.SQRT2, y: sgn / Math.SQRT2 };
|
|
106
|
+
const r = Math.min(
|
|
107
|
+
METRO_CORNER_RADIUS,
|
|
108
|
+
s / T,
|
|
109
|
+
(Math.SQRT2 * Math.abs(dy)) / (2 * T),
|
|
110
|
+
);
|
|
111
|
+
const t = r * T;
|
|
112
|
+
if (r >= 0.5) {
|
|
113
|
+
const f1 = dir * sgn > 0 ? 1 : 0;
|
|
114
|
+
d = [
|
|
115
|
+
`M ${point(p0)}`,
|
|
116
|
+
`L ${point(add(p1, u, -t))}`,
|
|
117
|
+
`A ${round2(r)} ${round2(r)} 0 0 ${f1} ${point(add(p1, w, t))}`,
|
|
118
|
+
`L ${point(add(p2, w, -t))}`,
|
|
119
|
+
`A ${round2(r)} ${round2(r)} 0 0 ${1 - f1} ${point(add(p2, u, t))}`,
|
|
120
|
+
`L ${point(p3)}`,
|
|
121
|
+
].join(" ");
|
|
122
|
+
} else {
|
|
123
|
+
d = polyline([p0, p1, p2, p3]);
|
|
124
|
+
}
|
|
125
|
+
mid = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
|
|
126
|
+
tangent = w;
|
|
127
|
+
} else {
|
|
128
|
+
fromSide = "right";
|
|
129
|
+
toSide = "right";
|
|
130
|
+
x1 = fromRight;
|
|
131
|
+
x2 = toRight;
|
|
132
|
+
const xa = Math.max(x1, x2) + EDGE_STUB_OUT;
|
|
133
|
+
const h = Math.abs(dy) / 2;
|
|
134
|
+
const p0 = { x: x1, y: y1 };
|
|
135
|
+
const p1 = { x: xa, y: y1 };
|
|
136
|
+
const p2 = { x: xa + h, y: y1 + sgn * h };
|
|
137
|
+
const p3 = { x: xa, y: y2 };
|
|
138
|
+
const p4 = { x: x2, y: y2 };
|
|
139
|
+
const u = { x: 1, y: 0 };
|
|
140
|
+
const w1 = { x: 1 / Math.SQRT2, y: sgn / Math.SQRT2 };
|
|
141
|
+
const w2 = { x: -1 / Math.SQRT2, y: sgn / Math.SQRT2 };
|
|
142
|
+
const r = Math.min(METRO_CORNER_RADIUS, h);
|
|
143
|
+
const t = r * T;
|
|
144
|
+
const sweep = sgn > 0 ? 1 : 0;
|
|
145
|
+
d = [
|
|
146
|
+
`M ${point(p0)}`,
|
|
147
|
+
`L ${point(add(p1, u, -t))}`,
|
|
148
|
+
`A ${round2(r)} ${round2(r)} 0 0 ${sweep} ${point(add(p1, w1, t))}`,
|
|
149
|
+
`L ${point(add(p2, w1, -r))}`,
|
|
150
|
+
`A ${round2(r)} ${round2(r)} 0 0 ${sweep} ${point(add(p2, w2, r))}`,
|
|
151
|
+
`L ${point(add(p3, w2, -t))}`,
|
|
152
|
+
`A ${round2(r)} ${round2(r)} 0 0 ${sweep} ${point(add(p3, u, -t))}`,
|
|
153
|
+
`L ${point(p4)}`,
|
|
154
|
+
].join(" ");
|
|
155
|
+
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
|
+
}
|
|
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
|
+
|
|
177
|
+
return {
|
|
178
|
+
d,
|
|
179
|
+
fromSide,
|
|
180
|
+
toSide,
|
|
181
|
+
labelX: mid.x + normalX * distance,
|
|
182
|
+
labelY: mid.y + normalY * distance,
|
|
183
|
+
labelAnchor: "middle",
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/layout/nodes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { requiredRowsWidth } from "../kinds/row-align";
|
|
2
|
-
import type { CdlDiagram, LaidLane, LaidNode } from "../types";
|
|
2
|
+
import type { CdlDiagram, CdlTextMetrics, LaidLane, LaidNode } from "../types";
|
|
3
3
|
import { 端の字に要る線の長さ } from "./head-label";
|
|
4
4
|
import {
|
|
5
5
|
hasRenderedLabel,
|
|
@@ -77,10 +77,14 @@ function autoStorageHeight(
|
|
|
77
77
|
*
|
|
78
78
|
* 著者が n.w を明示している場合は尊重 (snapshot 互換 / catalog thumbnail 縮小指定を壊さない)。
|
|
79
79
|
*/
|
|
80
|
-
function autoRowsWidth(
|
|
80
|
+
function autoRowsWidth(
|
|
81
|
+
n: { kind: string; rows?: string[]; w?: number },
|
|
82
|
+
defaultW: number,
|
|
83
|
+
textMetrics: CdlTextMetrics = "proportional",
|
|
84
|
+
): number {
|
|
81
85
|
if (n.w != null) return n.w;
|
|
82
86
|
if (!n.rows || n.rows.length === 0) return defaultW;
|
|
83
|
-
const required = requiredRowsWidth(n.rows, n.kind);
|
|
87
|
+
const required = requiredRowsWidth(n.rows, n.kind, textMetrics);
|
|
84
88
|
return Math.max(defaultW, required);
|
|
85
89
|
}
|
|
86
90
|
|
|
@@ -171,7 +175,11 @@ function requiredStackGapForEdgeLabels(diag: CdlDiagram, allStacks: readonly num
|
|
|
171
175
|
return required;
|
|
172
176
|
}
|
|
173
177
|
|
|
174
|
-
export function layoutNodes(
|
|
178
|
+
export function layoutNodes(
|
|
179
|
+
diag: CdlDiagram,
|
|
180
|
+
lanes: LaidLane[],
|
|
181
|
+
textMetrics: CdlTextMetrics,
|
|
182
|
+
): LaidNode[] {
|
|
175
183
|
const laneMap = new Map(lanes.map((l) => [l.id, l]));
|
|
176
184
|
|
|
177
185
|
// 全 lane を跨いで stack 番号で row を共有する。
|
|
@@ -232,7 +240,7 @@ export function layoutNodes(diag: CdlDiagram, lanes: LaidLane[]): LaidNode[] {
|
|
|
232
240
|
}
|
|
233
241
|
const size = NODE_SIZE[n.kind];
|
|
234
242
|
if (n.posX !== undefined && n.posY !== undefined) {
|
|
235
|
-
const w = n.posW ?? n.w ?? autoRowsWidth(n, size.w);
|
|
243
|
+
const w = n.posW ?? n.w ?? autoRowsWidth(n, size.w, textMetrics);
|
|
236
244
|
const h = n.posH ?? n.h ?? rowH.get(n.stack) ?? size.h;
|
|
237
245
|
out.push({
|
|
238
246
|
...n,
|
|
@@ -270,7 +278,7 @@ export function layoutNodes(diag: CdlDiagram, lanes: LaidLane[]): LaidNode[] {
|
|
|
270
278
|
const h = n.h ?? (自前の高さ ? 箱の高さ(n, size.h) : 段の高さ);
|
|
271
279
|
const cy = 段の芯;
|
|
272
280
|
// n.w 明示時は尊重、 未指定で rows[] を持つ node は内容長に合わせて auto 拡張。
|
|
273
|
-
const w = autoRowsWidth(n, size.w);
|
|
281
|
+
const w = autoRowsWidth(n, size.w, textMetrics);
|
|
274
282
|
out.push({
|
|
275
283
|
...n,
|
|
276
284
|
cx,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { computeLabelBBoxWorld } from "./spec";
|
|
12
|
-
import type { LaidEdge, LaidNode } from "../types";
|
|
12
|
+
import type { CdlTextMetrics, LaidEdge, LaidNode } from "../types";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Edge label の world 単位 bbox 予測 (spec.ts SSOT の re-export)。
|
|
@@ -22,8 +22,11 @@ import type { LaidEdge, LaidNode } from "../types";
|
|
|
22
22
|
* @param edge LaidEdge (labelX, labelY, labelAnchor, label, sub 既確定)
|
|
23
23
|
* @returns { x, y, w, h } の world 座標 bbox (render 実 rect 位置と一致)
|
|
24
24
|
*/
|
|
25
|
-
export function predictLabelBBoxWorld(
|
|
26
|
-
|
|
25
|
+
export function predictLabelBBoxWorld(
|
|
26
|
+
edge: LaidEdge,
|
|
27
|
+
textMetrics: CdlTextMetrics = "proportional",
|
|
28
|
+
): { x: number; y: number; w: number; h: number } {
|
|
29
|
+
return computeLabelBBoxWorld(edge, textMetrics);
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/**
|
package/src/layout/self-loop.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CdlEdge, LaidEdge, LaidNode, Side } from "../types";
|
|
1
|
+
import type { CdlEdge, CdlTextMetrics, LaidEdge, LaidNode, Side } from "../types";
|
|
2
2
|
import { CLEARANCE_LABEL_LABEL } from "./clearance-constants";
|
|
3
3
|
import { computeLabelBoxW, LABEL_TO_PATH_CLEARANCE, labelPillH } from "./spec";
|
|
4
4
|
|
|
@@ -120,7 +120,12 @@ export function pickFreeSides(occupied: ReadonlySet<Side> | undefined, count: nu
|
|
|
120
120
|
*
|
|
121
121
|
* 1 本だけの時は積む必要が無いので、辺の中央の真横に 1 つ置くだけになる。
|
|
122
122
|
*/
|
|
123
|
-
export function routeSelfLoopGroup(
|
|
123
|
+
export function routeSelfLoopGroup(
|
|
124
|
+
edges: CdlEdge[],
|
|
125
|
+
node: LaidNode,
|
|
126
|
+
side: Side,
|
|
127
|
+
textMetrics: CdlTextMetrics = "proportional",
|
|
128
|
+
): LaidEdge[] {
|
|
124
129
|
const horizontal = side === "left" || side === "right";
|
|
125
130
|
const g = sideGeometry(node, side);
|
|
126
131
|
const span = fitSpan(horizontal ? node.h : node.w, SELF_LOOP_SPAN);
|
|
@@ -135,7 +140,7 @@ export function routeSelfLoopGroup(edges: CdlEdge[], node: LaidNode, side: Side)
|
|
|
135
140
|
|
|
136
141
|
// 積む向きの語の大きさ。 横の辺なら縦に積むので高さ、縦の辺なら横に積むので幅
|
|
137
142
|
const 語幅 = edges.map((e) =>
|
|
138
|
-
horizontal ? labelPillH(e.sub) : computeLabelBoxW(e.label, e.sub),
|
|
143
|
+
horizontal ? labelPillH(e.sub) : computeLabelBoxW(e.label, e.sub, textMetrics),
|
|
139
144
|
);
|
|
140
145
|
const 位置: number[] = [];
|
|
141
146
|
let 積 = 0;
|
|
@@ -181,6 +186,7 @@ export function layoutSelfLoops(
|
|
|
181
186
|
edges: CdlEdge[],
|
|
182
187
|
nodes: Map<string, LaidNode>,
|
|
183
188
|
occupiedBySide: ReadonlyMap<string, ReadonlySet<Side>>,
|
|
189
|
+
textMetrics: CdlTextMetrics = "proportional",
|
|
184
190
|
): LaidEdge[] {
|
|
185
191
|
// 辺の割り当ては節ごとに 1 度で決める。 輪を 1 本ずつ処理して選び直すと、2 本目が
|
|
186
192
|
// 1 本目の辺を「空いている」 と見て同じ場所に重なる
|
|
@@ -213,5 +219,5 @@ export function layoutSelfLoops(
|
|
|
213
219
|
}
|
|
214
220
|
// 語の置き場所は束の中の並びで決まるので、束ごとにまとめて経路にする。
|
|
215
221
|
// 返す並びは呼出側が id で引き直すため、ここでは束の順で構わない
|
|
216
|
-
return [...束.values()].flatMap((b) => routeSelfLoopGroup(b.edges, b.node, b.side));
|
|
222
|
+
return [...束.values()].flatMap((b) => routeSelfLoopGroup(b.edges, b.node, b.side, textMetrics));
|
|
217
223
|
}
|
package/src/layout/spec.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { CLEARANCE_LABEL_LABEL, CLEARANCE_NODE_LABEL } from "./clearance-constants";
|
|
19
19
|
import { measureTextWidth } from "./text-width";
|
|
20
20
|
import { EDGE_LABEL_TEXT } from "../label-text";
|
|
21
|
-
import type { CdlEdge, LaidEdge, LaidLane, LaidNode } from "../types";
|
|
21
|
+
import type { CdlEdge, CdlTextMetrics, LaidEdge, LaidLane, LaidNode } from "../types";
|
|
22
22
|
|
|
23
23
|
// ─── edge label pill bbox SSOT (CAR-431 で 3 定義箇所を集約) ──
|
|
24
24
|
|
|
@@ -175,6 +175,7 @@ const KINDS_DRAWING_TITLE_OUTSIDE: ReadonlySet<string> = new Set([
|
|
|
175
175
|
"dyn-arc",
|
|
176
176
|
"dyn-wave",
|
|
177
177
|
"dyn-polygon",
|
|
178
|
+
"station",
|
|
178
179
|
...KINDS_DRAWING_OWN_PAYLOAD,
|
|
179
180
|
]);
|
|
180
181
|
|
|
@@ -199,7 +200,7 @@ export function drawsTitleOutsideBox(kind: string | undefined): boolean {
|
|
|
199
200
|
*/
|
|
200
201
|
const TITLE_FONT_SIZE_DEFAULT = 18;
|
|
201
202
|
const TITLE_FONT_SIZE: ReadonlyMap<string, number> = new Map<string, number>([
|
|
202
|
-
...(["dyn-rect", "dyn-circle", "dyn-arc", "dyn-wave", "dyn-polygon"] as const).map(
|
|
203
|
+
...(["dyn-rect", "dyn-circle", "dyn-arc", "dyn-wave", "dyn-polygon", "station"] as const).map(
|
|
203
204
|
(k) => [k, 13] as const,
|
|
204
205
|
),
|
|
205
206
|
...(
|
|
@@ -264,6 +265,19 @@ export function titleFontSize(kind: string | undefined): number {
|
|
|
264
265
|
return TITLE_FONT_SIZE.get(kind ?? "") ?? TITLE_FONT_SIZE_DEFAULT;
|
|
265
266
|
}
|
|
266
267
|
|
|
268
|
+
/**
|
|
269
|
+
* 題の幅を node の箱幅で測らない種別。
|
|
270
|
+
*
|
|
271
|
+
* 駅の名前は小さな丸の外に置き、丸の幅で切らない。箱幅 24 を上限にすると、通常の駅名まで
|
|
272
|
+
* `text-readability` が読めない語として挙げるため、幅を箱の中で測る軸からだけ外す。
|
|
273
|
+
*/
|
|
274
|
+
const KINDS_WITH_TITLE_WIDTH_OUTSIDE_NODE: ReadonlySet<string> = new Set(["station"]);
|
|
275
|
+
|
|
276
|
+
/** その node の題を箱幅に収める前提が成り立たないか */
|
|
277
|
+
export function skipsNodeTitleWidthCheck(kind: string | undefined): boolean {
|
|
278
|
+
return KINDS_WITH_TITLE_WIDTH_OUTSIDE_NODE.has(kind ?? "");
|
|
279
|
+
}
|
|
280
|
+
|
|
267
281
|
/**
|
|
268
282
|
* 題を箱の中に描く種別で、題が使えない左右の余白 (world)。
|
|
269
283
|
*
|
|
@@ -428,6 +442,7 @@ const KINDS_DRAWING_ONLY_SHAPE: readonly string[] = [
|
|
|
428
442
|
"dyn-arc",
|
|
429
443
|
"dyn-wave",
|
|
430
444
|
"dyn-polygon",
|
|
445
|
+
"station",
|
|
431
446
|
];
|
|
432
447
|
|
|
433
448
|
/**
|
|
@@ -950,10 +965,21 @@ export function requiredVerticalLabelGap(pillHs: readonly number[]): number {
|
|
|
950
965
|
* @param sub sub text (undefined なら 0)
|
|
951
966
|
* @returns pill 全体の world 幅 (偶数)
|
|
952
967
|
*/
|
|
953
|
-
export function computeLabelBoxW(
|
|
954
|
-
|
|
968
|
+
export function computeLabelBoxW(
|
|
969
|
+
label: string | undefined,
|
|
970
|
+
sub: string | undefined,
|
|
971
|
+
textMetrics: CdlTextMetrics = "proportional",
|
|
972
|
+
): number {
|
|
973
|
+
const mainW = measureTextWidth(label ?? "", {
|
|
974
|
+
fontSize: EDGE_LABEL_TEXT.main.fontSize,
|
|
975
|
+
textMetrics,
|
|
976
|
+
});
|
|
955
977
|
const subW = sub
|
|
956
|
-
? measureTextWidth(sub, {
|
|
978
|
+
? measureTextWidth(sub, {
|
|
979
|
+
fontSize: EDGE_LABEL_TEXT.sub.fontSize,
|
|
980
|
+
fontFamily: "mono",
|
|
981
|
+
textMetrics,
|
|
982
|
+
})
|
|
957
983
|
: 0;
|
|
958
984
|
const raw = Math.max(mainW, subW) + LABEL_PILL_PAD_X * 2;
|
|
959
985
|
// 切り上げる前に浮動小数の誤差を落とす。 文字幅は係数の掛け算と足し算を重ねるので、
|
|
@@ -984,8 +1010,11 @@ export function computeLabelBoxW(label: string | undefined, sub: string | undefi
|
|
|
984
1010
|
* @param edge LaidEdge (labelX / labelY / labelAnchor / label / sub 既確定)
|
|
985
1011
|
* @returns { x, y, w, h } の world 座標 bbox (render 実 rect 位置と一致)
|
|
986
1012
|
*/
|
|
987
|
-
export function computeLabelBBoxWorld(
|
|
988
|
-
|
|
1013
|
+
export function computeLabelBBoxWorld(
|
|
1014
|
+
edge: LaidEdge,
|
|
1015
|
+
textMetrics: CdlTextMetrics = "proportional",
|
|
1016
|
+
): { x: number; y: number; w: number; h: number } {
|
|
1017
|
+
const boxW = computeLabelBoxW(edge.label, edge.sub, textMetrics);
|
|
989
1018
|
const boxH = edge.sub ? LABEL_PILL_H_WITH_SUB : LABEL_PILL_H_MAIN_ONLY;
|
|
990
1019
|
const anchor = edge.labelAnchor;
|
|
991
1020
|
const boxLeftX = anchor === "start" ? 0 : anchor === "end" ? -boxW : -boxW / 2;
|
package/src/layout/text-width.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* Bold は Regular に対し英字で +8-15%、 CJK は同じ、 記号は char により大差 (bold ならではの pen 太り)。
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import type { CdlTextMetrics } from "../types";
|
|
23
|
+
|
|
22
24
|
type FontFamily = "sans" | "mono";
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -39,6 +41,8 @@ interface MeasureOptions {
|
|
|
39
41
|
letterSpacing?: number;
|
|
40
42
|
fontFamily?: FontFamily;
|
|
41
43
|
bound?: WidthBound;
|
|
44
|
+
/** 図全体の字幅規則。`monospace` は `fontFamily: "mono"` と同じ経路で測る */
|
|
45
|
+
textMetrics?: CdlTextMetrics;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/**
|
|
@@ -208,22 +212,20 @@ function defaultAdvanceEm(ch: string, code: number): number {
|
|
|
208
212
|
}
|
|
209
213
|
|
|
210
214
|
/**
|
|
211
|
-
*
|
|
215
|
+
* 最低幅を当てる前の SVG render pixel 幅を実測係数で算出する。
|
|
212
216
|
*
|
|
213
|
-
* @param text 対象文字列 (
|
|
217
|
+
* @param text 対象文字列 (空文字は 0)
|
|
214
218
|
* @param opts fontSize / fontWeight / letterSpacing / fontFamily の描画 context
|
|
215
219
|
*
|
|
216
220
|
* default = fontSize 22, fontWeight 700, letterSpacing 0, fontFamily "sans" (Inter Bold)。
|
|
217
221
|
* edge label main text の caller (collisions / predict-bbox / edges) と一致する。
|
|
218
222
|
* sub text (fontSize 19, fontFamily "mono") / lane label (fontSize 20, letterSpacing 2) は
|
|
219
223
|
* opts で明示的に渡す。
|
|
220
|
-
*
|
|
221
|
-
* 最低幅 = fontSize × 1.2 (超短 label で bbox が細くなり shift 判定不安定になる問題を防ぐ)。
|
|
222
224
|
*/
|
|
223
|
-
export function
|
|
225
|
+
export function measureAdvanceWidth(text: string, opts: MeasureOptions = {}): number {
|
|
224
226
|
const fontSize = opts.fontSize ?? 22;
|
|
225
227
|
const letterSpacing = opts.letterSpacing ?? 0;
|
|
226
|
-
const fontFamily = opts.fontFamily ?? "sans";
|
|
228
|
+
const fontFamily = opts.textMetrics === "monospace" ? "mono" : (opts.fontFamily ?? "sans");
|
|
227
229
|
const bound = opts.bound ?? "layout";
|
|
228
230
|
let advanceEmSum = 0;
|
|
229
231
|
let charCount = 0;
|
|
@@ -236,7 +238,7 @@ export function measureTextWidth(text: string, opts: MeasureOptions = {}): numbe
|
|
|
236
238
|
// 割り戻さない = 測っていない字を狭く見積もると、はみ出す題を見落とす
|
|
237
239
|
const 割り戻し =
|
|
238
240
|
bound === "drawn" && !isEmojiGrapheme(ch, code)
|
|
239
|
-
? isAscii(code)
|
|
241
|
+
? isAscii(code) && fontFamily !== "mono"
|
|
240
242
|
? DRAWN_ASCII_RATIO
|
|
241
243
|
: isCJK(code)
|
|
242
244
|
? DRAWN_CJK_RATIO
|
|
@@ -248,6 +250,10 @@ export function measureTextWidth(text: string, opts: MeasureOptions = {}): numbe
|
|
|
248
250
|
continue;
|
|
249
251
|
}
|
|
250
252
|
if (fontFamily === "mono") {
|
|
253
|
+
// `DRAWN_ASCII_RATIO` (1.16) は Inter の比例幅表が実描画より広い分を見本帳の題で測った値。
|
|
254
|
+
// 0.63 は JetBrains Mono を実際に描いて測った値なので、割ると 0.543em になって等幅の
|
|
255
|
+
// 実描画 (0.6em 前後) より狭く見積もる。すると、はみ出す題を見落とし、木の箱の字を
|
|
256
|
+
// 収めたつもりで箱から出す。
|
|
251
257
|
advanceEmSum += (isCJK(code) ? 1.08 : JETBRAINS_MONO_ADVANCE_EM) / 割り戻し;
|
|
252
258
|
continue;
|
|
253
259
|
}
|
|
@@ -262,7 +268,18 @@ export function measureTextWidth(text: string, opts: MeasureOptions = {}): numbe
|
|
|
262
268
|
const table = ch.length === 1 ? INTER_BOLD_ADVANCE_EM[ch] : undefined;
|
|
263
269
|
advanceEmSum += (table !== undefined ? table : defaultAdvanceEm(ch, code)) / 割り戻し;
|
|
264
270
|
}
|
|
265
|
-
|
|
271
|
+
return advanceEmSum * fontSize + letterSpacing * charCount;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 文字列の SVG render pixel 幅を実測係数で算出する。
|
|
276
|
+
*
|
|
277
|
+
* 最低幅 = fontSize × 1.2 (超短 label で bbox が細くなり shift 判定不安定になる問題を防ぐ)。
|
|
278
|
+
*/
|
|
279
|
+
export function measureTextWidth(text: string, opts: MeasureOptions = {}): number {
|
|
280
|
+
const fontSize = opts.fontSize ?? 22;
|
|
281
|
+
const bound = opts.bound ?? "layout";
|
|
282
|
+
const w = measureAdvanceWidth(text, opts);
|
|
266
283
|
// 最低幅は組み方のための下駄 (短い札で bbox が細くなり判定が揺れるのを防ぐ)。
|
|
267
284
|
// 実描画に合わせる時は当てない = 1 字の題が 1.2 em で描かれることは無い
|
|
268
285
|
return bound === "drawn" ? w : Math.max(w, fontSize * 1.2);
|
package/src/layout/tokens.ts
CHANGED
|
@@ -140,6 +140,8 @@ export const TOKENS = {
|
|
|
140
140
|
// 箱としての最小 (80x40) より大きく、名前を持つ箱 (card 320x150) よりは明らかに小さい
|
|
141
141
|
"mark-start": { w: 96, h: 96 },
|
|
142
142
|
"mark-end": { w: 96, h: 96 },
|
|
143
|
+
// 路線図の駅 (#996) ... 正方形にして小さな丸が縦横どちらにも歪まないようにする。
|
|
144
|
+
station: { w: 24, h: 24 },
|
|
143
145
|
// 時系列のやり取り (#578)。 実寸は中身から決まる (`sequenceBoardMetrics`) ので、
|
|
144
146
|
// ここは書かなかった時の受け皿
|
|
145
147
|
"sequence-board": { w: 960, h: 640 },
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* 持つ edge の labelOffsetX/Y を 0 にリセット (作者の手動 offset で bbox 範囲外に押し出された
|
|
15
15
|
* label を default 位置に戻す)
|
|
16
16
|
* - h2 node-shift-down ... node-overlap error node pair の下 node の stack を +1
|
|
17
|
-
* - h3 edge-back-detour ... edge-node-cross error edge の routing を
|
|
17
|
+
* - h3 edge-back-detour ... edge-node-cross error のうち、自動経路の edge の routing を
|
|
18
|
+
* "back-detour" に切替。 書き手が選んだ曲線と路線図の線は途中の箱を避けない約束なので変えない
|
|
18
19
|
*/
|
|
19
20
|
import type { CdlDiagram, CdlEdge, LaidDiagram } from "./types";
|
|
20
21
|
import { layout } from "./layout";
|
|
@@ -152,11 +153,15 @@ function applyHeuristics(
|
|
|
152
153
|
.filter((id): id is string => id !== null),
|
|
153
154
|
);
|
|
154
155
|
if (h3Edges.size > 0) {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
const eligibleEdges = new Set(
|
|
157
|
+
edges
|
|
158
|
+
.filter((e) => h3Edges.has(e.id) && (e.routing === undefined || e.routing === "default"))
|
|
159
|
+
.map((e) => e.id),
|
|
158
160
|
);
|
|
159
|
-
if (
|
|
161
|
+
if (eligibleEdges.size > 0) {
|
|
162
|
+
edges = edges.map((e): CdlEdge =>
|
|
163
|
+
eligibleEdges.has(e.id) ? { ...e, routing: "back-detour" } : e,
|
|
164
|
+
);
|
|
160
165
|
applied.push("h3-edge-back-detour");
|
|
161
166
|
mutated = true;
|
|
162
167
|
}
|