@cardenelabs/cdl 0.120.0 → 0.122.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 +75 -1
- package/README.md +73 -0
- package/SPEC.md +1 -1
- package/dist/index.cjs +470 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -10
- package/dist/index.d.ts +16 -10
- package/dist/index.js +470 -70
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +459 -61
- 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 +459 -61
- package/dist/react.js.map +1 -1
- package/dist/{render-A5AKjNuy.d.cts → render-U_pC-Ksh.d.cts} +21 -6
- package/dist/{render-A5AKjNuy.d.ts → render-U_pC-Ksh.d.ts} +21 -6
- package/package.json +1 -1
- package/src/builder.ts +3 -2
- package/src/index.ts +1 -0
- package/src/kinds/dyn-shape.tsx +22 -9
- package/src/kinds/station.tsx +29 -0
- package/src/layout/collisions.ts +29 -20
- package/src/layout/curve-path.ts +125 -0
- package/src/layout/edges.ts +207 -39
- package/src/layout/head-label.ts +26 -12
- package/src/layout/metro-path.ts +184 -0
- package/src/layout/spec.ts +16 -1
- package/src/layout/tokens.ts +2 -0
- package/src/layout-with-validation.ts +10 -5
- package/src/layout.ts +22 -12
- package/src/render/edges.tsx +19 -3
- package/src/render/nodes.tsx +3 -0
- package/src/types.ts +22 -4
- package/src/visual-validate.ts +24 -4
package/src/layout/edges.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CdlDiagram, CdlEdge, LaidEdge, LaidLane, LaidNode, Side } from "../types";
|
|
1
|
+
import type { BBox, CdlDiagram, CdlEdge, LaidEdge, LaidLane, LaidNode, Side } from "../types";
|
|
2
2
|
import {
|
|
3
3
|
CLEARANCE_LABEL_LABEL,
|
|
4
4
|
CLEARANCE_NODE_LABEL,
|
|
@@ -8,9 +8,14 @@ import {
|
|
|
8
8
|
extractPathSegments,
|
|
9
9
|
flattenPathSegments,
|
|
10
10
|
measurePathLabelGapSegs,
|
|
11
|
+
札の移動を塞ぐ相手を探す,
|
|
11
12
|
segmentsCrossRect,
|
|
12
13
|
segmentsExcludingOwnPath,
|
|
14
|
+
type 札の周り,
|
|
15
|
+
線の測り方を作る,
|
|
13
16
|
} from "./collisions";
|
|
17
|
+
import { curvePath } from "./curve-path";
|
|
18
|
+
import { metroPath } from "./metro-path";
|
|
14
19
|
import { countEdgeCrossings } from "../visual-validate";
|
|
15
20
|
import {
|
|
16
21
|
computeLabelBBoxWorld,
|
|
@@ -34,6 +39,8 @@ import { DEFAULT_LABEL_MARGIN } from "./tokens";
|
|
|
34
39
|
const VERTICAL_DETOUR_GAP = 50;
|
|
35
40
|
/** 縦 detour の角の丸め半径 (world unit)。 */
|
|
36
41
|
const VERTICAL_DETOUR_CORNER = 14;
|
|
42
|
+
/** overlay の札を「自分の線の上に載せた札」 とみなす、 札の中心から自分の線までの距離の上限 (world unit)。 */
|
|
43
|
+
const OVERLAY_LABEL_ON_PATH_TOLERANCE = 1;
|
|
37
44
|
|
|
38
45
|
/**
|
|
39
46
|
* 縦に進む区間の**進む向き** (下へ = 1、 上へ = -1)。 角の丸めの位置を決める時に使う。
|
|
@@ -214,6 +221,7 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
|
|
|
214
221
|
// label が lane の枠線を跨ぐかを見る 2 か所 (左への迂回と label の反転) が使う縦の範囲 (#863)。
|
|
215
222
|
const laneRangeById = new Map(lanes.map((l) => [l.id, laneVerticalRange(l, nodes)]));
|
|
216
223
|
|
|
224
|
+
// 経路を書き手が決めた線 = 座標を固定した線、曲線の線、路線図の線。
|
|
217
225
|
// canvas pivot 新 spec (dragon canvas pivot spec §layout-role-conversion)。
|
|
218
226
|
// posX1 / posY1 / posX2 / posY2 全て set 済 edge は「user が固定した絶対 path」 = 直線 path
|
|
219
227
|
// (posX1, posY1) → (posX2, posY2) を最終 path とする。 label 位置は path 中点、 fromSide / toSide
|
|
@@ -245,6 +253,36 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
|
|
|
245
253
|
});
|
|
246
254
|
} else if (isSelfLoop(e)) {
|
|
247
255
|
selfLoopEdges.push(e);
|
|
256
|
+
} else if (e.routing === "curve") {
|
|
257
|
+
const from = nmap.get(e.from);
|
|
258
|
+
const to = nmap.get(e.to);
|
|
259
|
+
if (!from) throw new Error(`cdl layout: edge "${e.id}" の from "${e.from}" が node に無い`);
|
|
260
|
+
if (!to) throw new Error(`cdl layout: edge "${e.id}" の to "${e.to}" が node に無い`);
|
|
261
|
+
const c = curvePath(from, to, e, labelMarginExtra);
|
|
262
|
+
positionedEdges.push({
|
|
263
|
+
...e,
|
|
264
|
+
d: c.d,
|
|
265
|
+
fromSide: c.fromSide,
|
|
266
|
+
toSide: c.toSide,
|
|
267
|
+
labelX: c.labelX + (e.labelOffsetX ?? 0),
|
|
268
|
+
labelY: c.labelY + (e.labelOffsetY ?? 0),
|
|
269
|
+
labelAnchor: c.labelAnchor,
|
|
270
|
+
});
|
|
271
|
+
} else if (e.routing === "metro") {
|
|
272
|
+
const from = nmap.get(e.from);
|
|
273
|
+
const to = nmap.get(e.to);
|
|
274
|
+
if (!from) throw new Error(`cdl layout: edge "${e.id}" の from "${e.from}" が node に無い`);
|
|
275
|
+
if (!to) throw new Error(`cdl layout: edge "${e.id}" の to "${e.to}" が node に無い`);
|
|
276
|
+
const m = metroPath(from, to, e, labelMarginExtra);
|
|
277
|
+
positionedEdges.push({
|
|
278
|
+
...e,
|
|
279
|
+
d: m.d,
|
|
280
|
+
fromSide: m.fromSide,
|
|
281
|
+
toSide: m.toSide,
|
|
282
|
+
labelX: m.labelX + (e.labelOffsetX ?? 0),
|
|
283
|
+
labelY: m.labelY + (e.labelOffsetY ?? 0),
|
|
284
|
+
labelAnchor: m.labelAnchor,
|
|
285
|
+
});
|
|
248
286
|
} else {
|
|
249
287
|
autoEdges.push(e);
|
|
250
288
|
}
|
|
@@ -1577,7 +1615,7 @@ function sidePoint(n: LaidNode, side: Side): { x: number; y: number } {
|
|
|
1577
1615
|
/**
|
|
1578
1616
|
* orthogonal routing。
|
|
1579
1617
|
* - 横水平 (left/right pair で y 揃い) → 真横直線
|
|
1580
|
-
* - 横水平 (y 不一致) →
|
|
1618
|
+
* - 横水平 (y 不一致) → 角を丸めた L 字
|
|
1581
1619
|
* - 縦垂直 (top/bottom pair で x 揃い) → 真縦直線
|
|
1582
1620
|
* - 縦垂直 (x 不一致) → L 字 (途中で 90° 曲げ + 角丸)
|
|
1583
1621
|
* - その他 (top→right 等の交差) → L 字
|
|
@@ -2887,10 +2925,12 @@ export function hopEdgeCrossings(edges: LaidEdge[]): void {
|
|
|
2887
2925
|
}
|
|
2888
2926
|
|
|
2889
2927
|
for (const e of edges) {
|
|
2928
|
+
if (e.routing === "metro") continue;
|
|
2890
2929
|
// 部分経路が 2 つ以上ある形 (`M` が複数) は組み直せないので触らない。
|
|
2891
2930
|
if ((e.d.match(/M/g) ?? []).length !== 1) continue;
|
|
2892
2931
|
const own = extractPathSegments(e.d);
|
|
2893
2932
|
if (own.length === 0) continue;
|
|
2933
|
+
if (own.some((s) => s.cmd === "A")) continue;
|
|
2894
2934
|
const myEnds = ends.get(e.id) ?? [];
|
|
2895
2935
|
|
|
2896
2936
|
/** 区間 index → その区間で跨ぐ点 */
|
|
@@ -2995,7 +3035,7 @@ type Vertex = { x: number; y: number };
|
|
|
2995
3035
|
* 丸めた角は `L (手前の切り口) Q (角), (向こうの切り口)` の形で書かれており、 `Q` の制御点が
|
|
2996
3036
|
* 角そのものになる。 折れずに続く `L` は角ではないので点にしない。
|
|
2997
3037
|
*
|
|
2998
|
-
* 開けない形は 3 つ。 部分経路が 2 つ以上ある (`M` が複数)、
|
|
3038
|
+
* 開けない形は 3 つ。 部分経路が 2 つ以上ある (`M` が複数)、 弓か円弧 (`C` / `A`) を含む、 `Q` に制御点が
|
|
2999
3039
|
* 無い。 いずれも触らずに返す = 形そのものが意味を持つため、 組み直すと別の図になる。
|
|
3000
3040
|
*/
|
|
3001
3041
|
function pathVertices(d: string): Vertex[] | null {
|
|
@@ -3006,7 +3046,7 @@ function pathVertices(d: string): Vertex[] | null {
|
|
|
3006
3046
|
const out: Vertex[] = [{ x: first.x1, y: first.y1 }];
|
|
3007
3047
|
for (let i = 0; i < segs.length; i++) {
|
|
3008
3048
|
const s = segs[i]!;
|
|
3009
|
-
if (s.cmd === "C") return null;
|
|
3049
|
+
if (s.cmd === "C" || s.cmd === "A") return null;
|
|
3010
3050
|
if (s.cmd === "Q") {
|
|
3011
3051
|
const c = s.ctrl?.[0];
|
|
3012
3052
|
if (!c) return null;
|
|
@@ -3106,7 +3146,7 @@ export function separateSharedStraightRuns(edges: LaidEdge[], nodes: readonly La
|
|
|
3106
3146
|
const v = pathVertices(e.d);
|
|
3107
3147
|
if (v && v.length >= 2) verts.set(e.id, v);
|
|
3108
3148
|
}
|
|
3109
|
-
if (verts.size
|
|
3149
|
+
if (verts.size === 0) return;
|
|
3110
3150
|
|
|
3111
3151
|
const moved = new Set<string>();
|
|
3112
3152
|
|
|
@@ -3129,36 +3169,50 @@ export function separateSharedStraightRuns(edges: LaidEdge[], nodes: readonly La
|
|
|
3129
3169
|
for (let j = i + 1; j < edges.length; j++) {
|
|
3130
3170
|
const 前 = edges[i]!;
|
|
3131
3171
|
const 後 = edges[j]!;
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3172
|
+
if (前.routing === "metro" && 後.routing === "metro") continue;
|
|
3173
|
+
// 書き手が形を決めた路線図の線と重なる時は、自動線の方を動かす。
|
|
3174
|
+
const 固定 = 後.routing === "metro" ? 後 : 前;
|
|
3175
|
+
const 動かす = 後.routing === "metro" ? 前 : 後;
|
|
3176
|
+
const B = verts.get(動かす.id);
|
|
3177
|
+
if (!B) continue;
|
|
3178
|
+
const 固定の角 = verts.get(固定.id);
|
|
3179
|
+
const 固定の経路 = 固定の角
|
|
3180
|
+
? [固定の角]
|
|
3181
|
+
: 固定.routing === "metro"
|
|
3182
|
+
? extractPathSegments(固定.d)
|
|
3183
|
+
.filter((s) => s.cmd === "L")
|
|
3184
|
+
.map((s) => [{ x: s.x1, y: s.y1 }, { x: s.x2, y: s.y2 }])
|
|
3185
|
+
: [];
|
|
3186
|
+
if (固定の経路.length === 0) continue;
|
|
3135
3187
|
// 同じ節から出る線は幹線として重ねる (#211)。
|
|
3136
|
-
if (
|
|
3188
|
+
if (固定.from === 動かす.from) continue;
|
|
3137
3189
|
for (const ax of [0, 1] as const) {
|
|
3138
|
-
for (
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3190
|
+
for (const A of 固定の経路) {
|
|
3191
|
+
for (let ka = 0; ka + 1 < A.length; ka++) {
|
|
3192
|
+
if (!runIsAligned(A, ka, ax)) continue;
|
|
3193
|
+
for (let kb = 0; kb + 1 < B.length; kb++) {
|
|
3194
|
+
if (!runIsAligned(B, kb, ax)) continue;
|
|
3195
|
+
// 端点を含む区間は動かさない (線の根元が辺の中央から外れる)。
|
|
3196
|
+
if (kb === 0 || kb + 1 === B.length - 1) continue;
|
|
3197
|
+
const 座標A = ax === 0 ? A[ka]!.x : A[ka]!.y;
|
|
3198
|
+
const 座標B = ax === 0 ? B[kb]!.x : B[kb]!.y;
|
|
3199
|
+
if (Math.abs(座標A - 座標B) > SHARED_RUN_EPS) continue;
|
|
3200
|
+
const sa = runSpan(A, ka, ax);
|
|
3201
|
+
const sb = runSpan(B, kb, ax);
|
|
3202
|
+
if (Math.min(sa.hi, sb.hi) - Math.max(sa.lo, sb.lo) < SHARED_RUN_MIN) continue;
|
|
3203
|
+
|
|
3204
|
+
for (const delta of [SHARED_RUN_SEPARATION, -SHARED_RUN_SEPARATION]) {
|
|
3205
|
+
if (!fits(動かす, B, kb, ax, delta)) continue;
|
|
3206
|
+
if (ax === 0) {
|
|
3207
|
+
B[kb]!.x += delta;
|
|
3208
|
+
B[kb + 1]!.x += delta;
|
|
3209
|
+
} else {
|
|
3210
|
+
B[kb]!.y += delta;
|
|
3211
|
+
B[kb + 1]!.y += delta;
|
|
3212
|
+
}
|
|
3213
|
+
moved.add(動かす.id);
|
|
3214
|
+
break;
|
|
3159
3215
|
}
|
|
3160
|
-
moved.add(後.id);
|
|
3161
|
-
break;
|
|
3162
3216
|
}
|
|
3163
3217
|
}
|
|
3164
3218
|
}
|
|
@@ -3195,16 +3249,25 @@ export function pullOutgoingFirstCorner(edges: LaidEdge[], nodes: readonly LaidN
|
|
|
3195
3249
|
let 札を旧経路へ追従済み = false;
|
|
3196
3250
|
for (const list of 束.values()) {
|
|
3197
3251
|
if (list.length < 2) continue;
|
|
3252
|
+
type 最初の折れ候補 = { e: LaidEdge; pts: Vertex[] | null; 最初の長さ: number };
|
|
3198
3253
|
const 並び = list
|
|
3199
|
-
.
|
|
3254
|
+
.map((e): 最初の折れ候補 | null => {
|
|
3200
3255
|
const pts = pathVertices(e.d);
|
|
3201
|
-
if (
|
|
3202
|
-
|
|
3256
|
+
if (pts && pts.length >= 2) {
|
|
3257
|
+
return { e, pts, 最初の長さ: Math.hypot(pts[1]!.x - pts[0]!.x, pts[1]!.y - pts[0]!.y) };
|
|
3258
|
+
}
|
|
3259
|
+
if (e.routing !== "metro") return null;
|
|
3260
|
+
const first = extractPathSegments(e.d)[0];
|
|
3261
|
+
if (!first) return null;
|
|
3262
|
+
return { e, pts: null, 最初の長さ: Math.hypot(first.x2 - first.x1, first.y2 - first.y1) };
|
|
3203
3263
|
})
|
|
3264
|
+
.filter((x): x is 最初の折れ候補 => x !== null)
|
|
3204
3265
|
.sort((a, b) => b.最初の長さ - a.最初の長さ);
|
|
3205
3266
|
|
|
3206
3267
|
// 最長の線を引いても共有区間は縮まらないので、 2 本目以降だけを動かす。
|
|
3207
3268
|
for (const { e, pts, 最初の長さ } of 並び.slice(1)) {
|
|
3269
|
+
if (e.routing === "metro") continue;
|
|
3270
|
+
if (!pts) continue;
|
|
3208
3271
|
// 同じ 2 節を結ぶ線の分離は `bowSharedStraightPaths` の範囲。 迂回経路は重ねて残す。
|
|
3209
3272
|
if ((同じ組の数.get(`${e.from}->${e.to}`) ?? 0) > 1) continue;
|
|
3210
3273
|
if (pts.length < 3 || 最初の長さ <= EDGE_STUB_OUT + VERTICAL_DETOUR_CORNER) continue;
|
|
@@ -3306,9 +3369,16 @@ const MIXED_ROOT_OVERLAP_MIN = 24;
|
|
|
3306
3369
|
* 分かれるので、 分かれる点が交差として数えられる。 端点の接触を除く数え方も試したが、
|
|
3307
3370
|
* 離れる本数は増えないまま別の図で交差が出た。 収束する前の段階で分けるには経路の引き方
|
|
3308
3371
|
* そのものを変える必要があり、 それは後処理では届かない。
|
|
3372
|
+
*
|
|
3373
|
+
* @returns 差し替えを残した線ごとの終点移動量。 札は経路を動かす pass が全て終わった後に
|
|
3374
|
+
* `followLabelsToFannedTails` がこの量へ追従させる (#1000)。
|
|
3309
3375
|
*/
|
|
3310
|
-
export function fanIncomingEndpoints(
|
|
3311
|
-
|
|
3376
|
+
export function fanIncomingEndpoints(
|
|
3377
|
+
edges: LaidEdge[],
|
|
3378
|
+
nodes: readonly LaidNode[],
|
|
3379
|
+
): ReadonlyMap<string, { dx: number; dy: number }> {
|
|
3380
|
+
const 終点の移動 = new Map<string, { dx: number; dy: number }>();
|
|
3381
|
+
if (edges.length < 2) return 終点の移動;
|
|
3312
3382
|
const nmap = new Map(nodes.map((n) => [n.id, n]));
|
|
3313
3383
|
const 平坦な区間 = new Map(edges.map((e) => [e.id, flattenPathSegments(extractPathSegments(e.d))] as const));
|
|
3314
3384
|
|
|
@@ -3399,6 +3469,7 @@ export function fanIncomingEndpoints(edges: LaidEdge[], nodes: readonly LaidNode
|
|
|
3399
3469
|
: k * 間隔 - 中心;
|
|
3400
3470
|
if (Math.abs(d) < 0.001) continue;
|
|
3401
3471
|
const e = 並び[k]!.e;
|
|
3472
|
+
if (e.routing === "metro") continue;
|
|
3402
3473
|
// 交差が増えるなら戻す。 判定は軸と同じ数え方を使う = 独自の数え方は軸と食い違い、
|
|
3403
3474
|
// 軸が 3 件を数えている状況で 0 件と判定して通してしまった (実測)。
|
|
3404
3475
|
const 前 = e.d;
|
|
@@ -3412,6 +3483,103 @@ export function fanIncomingEndpoints(edges: LaidEdge[], nodes: readonly LaidNode
|
|
|
3412
3483
|
if (countEdgeCrossings(edges).count <= 前の交差) break;
|
|
3413
3484
|
e.d = 前;
|
|
3414
3485
|
}
|
|
3486
|
+
if (e.d !== 前) 終点の移動.set(e.id, 横 ? { dx: 0, dy: d } : { dx: d, dy: 0 });
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
return 終点の移動;
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
/**
|
|
3494
|
+
* 同じ辺へ入るために終点をずらした線の札を、 最後の区間から離れた位置へ追従させる (#1000)。
|
|
3495
|
+
*
|
|
3496
|
+
* 経路を動かす pass が全て終わった後の形で、 最後の区間を覆っている札だけを見る。
|
|
3497
|
+
* まず終点と同じ量だけ動かし、 置けなければ最後の区間の反対側へ折り返す。
|
|
3498
|
+
* 追従側で足す条件は、 候補が最後の区間と交わらないことだけ。
|
|
3499
|
+
*
|
|
3500
|
+
* 置けるかは後段の `resolveLabelClearanceAfterPathPasses` と同じ
|
|
3501
|
+
* `札の移動を塞ぐ相手を探す` で決める。 別の判定で余白を割る位置へ置くと、 後段が同じ札を
|
|
3502
|
+
* 押し直すため (#1002)。
|
|
3503
|
+
*
|
|
3504
|
+
* 自分の線上に載っている overlay は判定の先頭で外す。 線から押し出された overlay は追従させる =
|
|
3505
|
+
* 一律に外すと、 #1000 で直した「押し出された overlay の札が最後の区間を覆う」 形が戻る。
|
|
3506
|
+
*
|
|
3507
|
+
* @param obstacles 箱と縦列の題の矩形。 後段の逃がし pass に渡すものと同じ集まり。
|
|
3508
|
+
*/
|
|
3509
|
+
export function followLabelsToFannedTails(
|
|
3510
|
+
edges: LaidEdge[],
|
|
3511
|
+
obstacles: readonly BBox[],
|
|
3512
|
+
shifts: ReadonlyMap<string, { dx: number; dy: number }>,
|
|
3513
|
+
): void {
|
|
3514
|
+
if (shifts.size === 0) return;
|
|
3515
|
+
const 測り方 = 線の測り方を作る();
|
|
3516
|
+
const labels: BBox[] = edges.filter(hasRenderedLabel).map((e) => ({
|
|
3517
|
+
kind: "edge-label",
|
|
3518
|
+
id: e.id,
|
|
3519
|
+
...computeLabelBBoxWorld(e),
|
|
3520
|
+
}));
|
|
3521
|
+
const labelIndexByEdgeId = new Map(labels.map((label, index) => [label.id, index] as const));
|
|
3522
|
+
const edgeById = new Map(edges.map((e) => [e.id, e] as const));
|
|
3523
|
+
const 周り: 札の周り = {
|
|
3524
|
+
箱たち: obstacles.filter((o) => o.kind === "node" || o.kind === "lane-label"),
|
|
3525
|
+
labels,
|
|
3526
|
+
edges,
|
|
3527
|
+
edgeById,
|
|
3528
|
+
測り方,
|
|
3529
|
+
};
|
|
3530
|
+
|
|
3531
|
+
for (const e of edges) {
|
|
3532
|
+
const 移動 = shifts.get(e.id);
|
|
3533
|
+
if (!移動 || !hasRenderedLabel(e)) continue;
|
|
3534
|
+
|
|
3535
|
+
const labelIndex = labelIndexByEdgeId.get(e.id);
|
|
3536
|
+
if (labelIndex === undefined) continue;
|
|
3537
|
+
const 元の札 = labels[labelIndex]!;
|
|
3538
|
+
const 自分の区間 = 測り方.区間(e.id, e.d);
|
|
3539
|
+
if (
|
|
3540
|
+
e.overlay &&
|
|
3541
|
+
measurePathLabelGapSegs(自分の区間, {
|
|
3542
|
+
x: 元の札.x + 元の札.w / 2,
|
|
3543
|
+
y: 元の札.y + 元の札.h / 2,
|
|
3544
|
+
w: 0,
|
|
3545
|
+
h: 0,
|
|
3546
|
+
}) <= OVERLAY_LABEL_ON_PATH_TOLERANCE
|
|
3547
|
+
) continue;
|
|
3548
|
+
|
|
3549
|
+
const 最後 = 自分の区間.at(-1);
|
|
3550
|
+
if (!最後) continue;
|
|
3551
|
+
if (!segmentsCrossRect([最後], 元の札)) continue;
|
|
3552
|
+
|
|
3553
|
+
const 候補a = {
|
|
3554
|
+
labelX: e.labelX + 移動.dx,
|
|
3555
|
+
labelY: e.labelY + 移動.dy,
|
|
3556
|
+
labelAnchor: e.labelAnchor,
|
|
3557
|
+
};
|
|
3558
|
+
const 候補aの箱 = computeLabelBBoxWorld({ ...e, ...候補a });
|
|
3559
|
+
const 最後は縦 = Math.abs(最後.x2 - 最後.x1) <= Math.abs(最後.y2 - 最後.y1);
|
|
3560
|
+
const 候補 = [
|
|
3561
|
+
候補a,
|
|
3562
|
+
{
|
|
3563
|
+
labelX: 最後は縦
|
|
3564
|
+
? 最後.x1 * 2 - (候補aの箱.x + 候補aの箱.w / 2)
|
|
3565
|
+
: 候補aの箱.x + 候補aの箱.w / 2,
|
|
3566
|
+
labelY: 最後は縦
|
|
3567
|
+
? 候補aの箱.y + 候補aの箱.h / 2
|
|
3568
|
+
: 最後.y1 * 2 - (候補aの箱.y + 候補aの箱.h / 2),
|
|
3569
|
+
labelAnchor: "middle" as const,
|
|
3570
|
+
},
|
|
3571
|
+
];
|
|
3572
|
+
|
|
3573
|
+
for (const c of 候補) {
|
|
3574
|
+
const 札: BBox = { kind: "edge-label", id: e.id, ...computeLabelBBoxWorld({ ...e, ...c }) };
|
|
3575
|
+
if (segmentsCrossRect([最後], 札)) continue;
|
|
3576
|
+
if (札の移動を塞ぐ相手を探す(周り, 元の札, 札) !== null) continue;
|
|
3577
|
+
|
|
3578
|
+
e.labelX = c.labelX;
|
|
3579
|
+
e.labelY = c.labelY;
|
|
3580
|
+
e.labelAnchor = c.labelAnchor;
|
|
3581
|
+
labels[labelIndex] = 札;
|
|
3582
|
+
break;
|
|
3415
3583
|
}
|
|
3416
3584
|
}
|
|
3417
3585
|
}
|
|
@@ -3437,7 +3605,7 @@ function 辺から離れた側の位置(e: LaidEdge, 横: boolean): number | nul
|
|
|
3437
3605
|
/**
|
|
3438
3606
|
* 経路の末尾を「辺に沿って助走 → 辺へ垂直に入る」 の形に差し替える。
|
|
3439
3607
|
*
|
|
3440
|
-
* 差し替えられない形なら `null` を返す。
|
|
3608
|
+
* 差し替えられない形なら `null` を返す。 弓か円弧 (`C` / `A`) を含む経路、 部分経路が 2 つ以上ある形、
|
|
3441
3609
|
* 最後が辺へ垂直に入っていない形、 助走を取れないほど短い経路、 差し替えた先が箱に当たる
|
|
3442
3610
|
* 場合が該当する。
|
|
3443
3611
|
*/
|
|
@@ -3451,7 +3619,7 @@ function 末尾を辺に沿ってずらす(
|
|
|
3451
3619
|
if ((e.d.match(/M/g) ?? []).length !== 1) return null;
|
|
3452
3620
|
const segs = extractPathSegments(e.d);
|
|
3453
3621
|
if (segs.length === 0) return null;
|
|
3454
|
-
if (segs.some((s) => s.cmd === "C")) return null;
|
|
3622
|
+
if (segs.some((s) => s.cmd === "C" || s.cmd === "A")) return null;
|
|
3455
3623
|
|
|
3456
3624
|
const last = segs[segs.length - 1]!;
|
|
3457
3625
|
if (last.cmd !== "L") return null;
|
package/src/layout/head-label.ts
CHANGED
|
@@ -22,8 +22,15 @@ import { extractPathSegments } from "./collisions";
|
|
|
22
22
|
import { hasRenderedLabel } from "./spec";
|
|
23
23
|
import { measureTextWidth } from "./text-width";
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* 端の字の大きさ (world)。
|
|
27
|
+
*
|
|
28
|
+
* **札の下の行とは別に持つ** (#994)。 元は `EDGE_LABEL_TEXT.sub.fontSize` を引いていたが、
|
|
29
|
+
* 札は箱の間に置くので縮めても読め、端の字は線の脇で単独に立つので同じ大きさでは負ける。
|
|
30
|
+
* 見本帳はクラス図を 0.42 倍で出しており、19 では画面 8 画素と、箱の中の字 (11 画素) より
|
|
31
|
+
* 一段小さく見えた。 26 にすると画面で 11 画素になり、箱の中の字と並ぶ。
|
|
32
|
+
*/
|
|
33
|
+
export const 端の字の大きさ = 26;
|
|
27
34
|
|
|
28
35
|
/** 字の箱の高さ。 字の大きさに上下の余白を足す */
|
|
29
36
|
const 箱の高さ = Math.round(端の字の大きさ * 1.2);
|
|
@@ -31,14 +38,17 @@ const 箱の高さ = Math.round(端の字の大きさ * 1.2);
|
|
|
31
38
|
/**
|
|
32
39
|
* 端の印と端の字の間に空ける余白 (world)。 線に沿う向きと直角の向きで同じ値を使う。
|
|
33
40
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
41
|
+
* **字の大きさに連れて広げない** (#994)。 #989 では字の箱の半分から導いていたが、字を 26 に
|
|
42
|
+
* 上げると余白まで 16 に広がり、印の大きさは変わらないのに字だけが線から遠ざかった。
|
|
43
|
+
* 実機で見た利用者の言葉は「間伸びしてる」 で、図も縦に伸びていた。
|
|
44
|
+
*
|
|
45
|
+
* この余白が避ける相手は **印** で、印は字の大きさに連れて変わらない (`EDGE_HEAD_MARKER_SIZE`
|
|
46
|
+
* は固定)。 避ける相手が動かないなら、避ける幅も動かさない。
|
|
37
47
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
48
|
+
* 値は #989 が字 19 で導いた 12 をそのまま据え置く。 見本帳の 0.42 倍では 5 画素で、
|
|
49
|
+
* 印の縁と字の箱の間に 1 行の半分ほどが残る。
|
|
40
50
|
*/
|
|
41
|
-
export const 端の字と印の余白 =
|
|
51
|
+
export const 端の字と印の余白 = 12;
|
|
42
52
|
|
|
43
53
|
/** 端の印が線の芯から左右へ張り出す長さ (world)。 印の縦の半分 */
|
|
44
54
|
export const 端の印の張り出し = EDGE_HEAD_MARKER_SIZE.既定 / 2;
|
|
@@ -122,12 +132,12 @@ function 向きを取る(end: 点, 候補: ReadonlyArray<点 | undefined>): { en
|
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
/**
|
|
125
|
-
* 端の点と、線がその端へ入ってくる向き。
|
|
135
|
+
* 端の点と、線がその端へ入ってくる向き。 SVG が端の印を回す向きと同じ規則で取る。
|
|
126
136
|
*
|
|
127
137
|
* 行き先の端は最後の区間から、出どころの端は最初の区間を逆にたどって取る。 端の区間の長さが
|
|
128
138
|
* 0 の時は隣の区間へ進む。 向きが取れない (区間が無い / 全て長さ 0) 時は `null`。
|
|
129
139
|
*/
|
|
130
|
-
function
|
|
140
|
+
export function 線の端の向き(d: string, 端: 線の端): { end: 点; ux: number; uy: number } | null {
|
|
131
141
|
const segs = extractPathSegments(d);
|
|
132
142
|
if (segs.length === 0) return null;
|
|
133
143
|
if (端 === "head") {
|
|
@@ -136,7 +146,11 @@ function 端での向き(d: string, 端: 線の端): { end: 点; ux: number; uy:
|
|
|
136
146
|
const 候補 = segs
|
|
137
147
|
.slice()
|
|
138
148
|
.reverse()
|
|
139
|
-
.flatMap((s) =>
|
|
149
|
+
.flatMap((s) => {
|
|
150
|
+
const 制御点 =
|
|
151
|
+
s.cmd === "C" ? [s.ctrl?.[1], s.ctrl?.[0]] : s.cmd === "Q" ? [s.ctrl?.[0]] : [];
|
|
152
|
+
return [...制御点, { x: s.x1, y: s.y1 }];
|
|
153
|
+
});
|
|
140
154
|
return 向きを取る(end, 候補);
|
|
141
155
|
}
|
|
142
156
|
const first = segs[0]!;
|
|
@@ -160,7 +174,7 @@ export function 端の字を置く(
|
|
|
160
174
|
): 端の字の置き場所 | null {
|
|
161
175
|
const text = (端 === "head" ? edge.headLabel : edge.tailLabel)?.trim();
|
|
162
176
|
if (!text) return null;
|
|
163
|
-
const 向き =
|
|
177
|
+
const 向き = 線の端の向き(edge.d, 端);
|
|
164
178
|
if (!向き) return null;
|
|
165
179
|
const { end, ux, uy } = 向き;
|
|
166
180
|
const bx = end.x - ux * 端の字を戻す距離;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { CdlEdge, 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
|
+
): 路線図の線の結果 {
|
|
53
|
+
const fromLeft = from.cx - from.w / 2;
|
|
54
|
+
const fromRight = from.cx + from.w / 2;
|
|
55
|
+
const toLeft = to.cx - to.w / 2;
|
|
56
|
+
const toRight = to.cx + to.w / 2;
|
|
57
|
+
|
|
58
|
+
let fromSide: Side;
|
|
59
|
+
let toSide: Side;
|
|
60
|
+
let x1: number;
|
|
61
|
+
let x2: number;
|
|
62
|
+
let dir: 1 | -1;
|
|
63
|
+
|
|
64
|
+
if (toLeft >= fromRight) {
|
|
65
|
+
fromSide = "right";
|
|
66
|
+
toSide = "left";
|
|
67
|
+
x1 = fromRight;
|
|
68
|
+
x2 = toLeft;
|
|
69
|
+
dir = 1;
|
|
70
|
+
} else if (toRight <= fromLeft) {
|
|
71
|
+
fromSide = "left";
|
|
72
|
+
toSide = "right";
|
|
73
|
+
x1 = fromLeft;
|
|
74
|
+
x2 = toRight;
|
|
75
|
+
dir = -1;
|
|
76
|
+
} else {
|
|
77
|
+
fromSide = "right";
|
|
78
|
+
toSide = "right";
|
|
79
|
+
x1 = fromRight;
|
|
80
|
+
x2 = toRight;
|
|
81
|
+
dir = 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const y1 = from.cy;
|
|
85
|
+
const y2 = to.cy;
|
|
86
|
+
const dy = y2 - y1;
|
|
87
|
+
const sgn = Math.sign(dy);
|
|
88
|
+
let d: string;
|
|
89
|
+
let mid: 点;
|
|
90
|
+
let tangent: 点;
|
|
91
|
+
|
|
92
|
+
if (Math.abs(dy) < SAME_HEIGHT_THRESHOLD) {
|
|
93
|
+
d = `M ${point({ x: x1, y: y1 })} L ${point({ x: x2, y: y2 })}`;
|
|
94
|
+
mid = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
|
|
95
|
+
tangent = { x: x2 - x1, y: y2 - y1 };
|
|
96
|
+
} else if (fromSide !== toSide && Math.abs(x2 - x1) >= Math.abs(dy)) {
|
|
97
|
+
const horizontal = Math.abs(x2 - x1);
|
|
98
|
+
const s = (horizontal - Math.abs(dy)) / 2;
|
|
99
|
+
const p0 = { x: x1, y: y1 };
|
|
100
|
+
const p1 = { x: x1 + dir * s, y: y1 };
|
|
101
|
+
const p2 = { x: x2 - dir * s, y: y2 };
|
|
102
|
+
const p3 = { x: x2, y: y2 };
|
|
103
|
+
const u = { x: dir, y: 0 };
|
|
104
|
+
const w = { x: dir / Math.SQRT2, y: sgn / Math.SQRT2 };
|
|
105
|
+
const r = Math.min(
|
|
106
|
+
METRO_CORNER_RADIUS,
|
|
107
|
+
s / T,
|
|
108
|
+
(Math.SQRT2 * Math.abs(dy)) / (2 * T),
|
|
109
|
+
);
|
|
110
|
+
const t = r * T;
|
|
111
|
+
if (r >= 0.5) {
|
|
112
|
+
const f1 = dir * sgn > 0 ? 1 : 0;
|
|
113
|
+
d = [
|
|
114
|
+
`M ${point(p0)}`,
|
|
115
|
+
`L ${point(add(p1, u, -t))}`,
|
|
116
|
+
`A ${round2(r)} ${round2(r)} 0 0 ${f1} ${point(add(p1, w, t))}`,
|
|
117
|
+
`L ${point(add(p2, w, -t))}`,
|
|
118
|
+
`A ${round2(r)} ${round2(r)} 0 0 ${1 - f1} ${point(add(p2, u, t))}`,
|
|
119
|
+
`L ${point(p3)}`,
|
|
120
|
+
].join(" ");
|
|
121
|
+
} else {
|
|
122
|
+
d = polyline([p0, p1, p2, p3]);
|
|
123
|
+
}
|
|
124
|
+
mid = { x: (x1 + x2) / 2, y: (y1 + y2) / 2 };
|
|
125
|
+
tangent = w;
|
|
126
|
+
} else {
|
|
127
|
+
fromSide = "right";
|
|
128
|
+
toSide = "right";
|
|
129
|
+
x1 = fromRight;
|
|
130
|
+
x2 = toRight;
|
|
131
|
+
const xa = Math.max(x1, x2) + EDGE_STUB_OUT;
|
|
132
|
+
const h = Math.abs(dy) / 2;
|
|
133
|
+
const p0 = { x: x1, y: y1 };
|
|
134
|
+
const p1 = { x: xa, y: y1 };
|
|
135
|
+
const p2 = { x: xa + h, y: y1 + sgn * h };
|
|
136
|
+
const p3 = { x: xa, y: y2 };
|
|
137
|
+
const p4 = { x: x2, y: y2 };
|
|
138
|
+
const u = { x: 1, y: 0 };
|
|
139
|
+
const w1 = { x: 1 / Math.SQRT2, y: sgn / Math.SQRT2 };
|
|
140
|
+
const w2 = { x: -1 / Math.SQRT2, y: sgn / Math.SQRT2 };
|
|
141
|
+
const r = Math.min(METRO_CORNER_RADIUS, h);
|
|
142
|
+
const t = r * T;
|
|
143
|
+
const sweep = sgn > 0 ? 1 : 0;
|
|
144
|
+
d = [
|
|
145
|
+
`M ${point(p0)}`,
|
|
146
|
+
`L ${point(add(p1, u, -t))}`,
|
|
147
|
+
`A ${round2(r)} ${round2(r)} 0 0 ${sweep} ${point(add(p1, w1, t))}`,
|
|
148
|
+
`L ${point(add(p2, w1, -r))}`,
|
|
149
|
+
`A ${round2(r)} ${round2(r)} 0 0 ${sweep} ${point(add(p2, w2, r))}`,
|
|
150
|
+
`L ${point(add(p3, w2, -t))}`,
|
|
151
|
+
`A ${round2(r)} ${round2(r)} 0 0 ${sweep} ${point(add(p3, u, -t))}`,
|
|
152
|
+
`L ${point(p4)}`,
|
|
153
|
+
].join(" ");
|
|
154
|
+
mid = { x: p2.x - T * r, y: p2.y };
|
|
155
|
+
tangent = { x: 0, y: sgn };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (edge?.overlay) {
|
|
159
|
+
return { d, fromSide, toSide, labelX: mid.x, labelY: mid.y, labelAnchor: "middle" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const tangentLength = Math.hypot(tangent.x, tangent.y);
|
|
163
|
+
let normalX = tangent.y / tangentLength;
|
|
164
|
+
let normalY = -tangent.x / tangentLength;
|
|
165
|
+
if (normalY > Number.EPSILON || (Math.abs(normalY) <= Number.EPSILON && normalX < 0)) {
|
|
166
|
+
normalX *= -1;
|
|
167
|
+
normalY *= -1;
|
|
168
|
+
}
|
|
169
|
+
const labelW = computeLabelBoxW(edge?.label, edge?.sub);
|
|
170
|
+
const labelH = labelPillH(edge?.sub);
|
|
171
|
+
const distance =
|
|
172
|
+
labelPathClearanceHorizontal(edge) +
|
|
173
|
+
(Math.abs(normalX) * labelW + Math.abs(normalY) * labelH) / 2 +
|
|
174
|
+
labelMarginExtra;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
d,
|
|
178
|
+
fromSide,
|
|
179
|
+
toSide,
|
|
180
|
+
labelX: mid.x + normalX * distance,
|
|
181
|
+
labelY: mid.y + normalY * distance,
|
|
182
|
+
labelAnchor: "middle",
|
|
183
|
+
};
|
|
184
|
+
}
|