@cardenelabs/cdl 0.30.1 → 0.30.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardenelabs/cdl",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
4
4
  "description": "CDL (Chainome Diagram Language). Mermaid-like declarative DSL that compiles to animated SVG diagrams. Built for blockchain / Solidity flows, generic enough for sequence / flow / state / ER / topology diagrams.",
5
5
  "license": "MIT",
6
6
  "author": "cardene777",
package/src/index.ts CHANGED
@@ -187,6 +187,7 @@ export { requiredRowsWidth } from "./kinds/row-align";
187
187
  export {
188
188
  resolveOverlapsWithChain,
189
189
  resolveEdgeLabelOverlapsWithChain,
190
+ resolveLabelClearanceAfterPathPasses,
190
191
  resolveEdgeLabelOverlapsWithChainAndPropagate,
191
192
  } from "./layout/collisions";
192
193
  // predict-bbox = engine 予測 bbox の world 単位計算 SSOT (副次係数 COEFF 撤廃用)
@@ -1,5 +1,6 @@
1
1
  import type { BBox, LaidEdge, LaidLane, LaidNode } from "../types";
2
2
  import { CHAIN_SHIFT_MAX_ACCUMULATED, computeLabelBBoxWorld, EDGE_STUB_OUT, hasRenderedLabel, LABEL_TO_PATH_CLEARANCE, requiredNearClearance } from "./spec";
3
+ import { DIST_LABEL_PATH_MAX } from "./clearance-constants";
3
4
  import { footerShapeDrops } from "./footer-shape";
4
5
  import { measureTextWidth } from "./text-width";
5
6
 
@@ -2211,6 +2212,350 @@ export function resolveEdgeLabelOverlapsWithChainAndPropagate(
2211
2212
  };
2212
2213
  }
2213
2214
 
2215
+ /**
2216
+ * 経路を動かす pass の **後** に、 札だけを箱 ・ 札 ・ 線から離す (#659)。
2217
+ *
2218
+ * ## なぜ最終 pass が要るか
2219
+ *
2220
+ * `resolveEdgeLabelOverlapsWithChainAndPropagate` は経路を動かす 4 つの pass
2221
+ * (`bowSharedStraightPaths` / `pullOutgoingFirstCorner` / `fanIncomingEndpoints` /
2222
+ * `separateSharedStraightRuns`) の **前** に走る。 札の初期位置は経路から決まるため、
2223
+ * 後の pass が経路を動かすと札も一緒に動く。 動いた後を見る者が誰も居なかった。
2224
+ *
2225
+ * 0.30.0 で `pullOutgoingFirstCorner` (#651) と `fanIncomingEndpoints` (#649) が経路を
2226
+ * 大きく動かすようになり、 消費側の見本帳で札が 11 件動いた (実測 = 最大 217 world)。
2227
+ * 動いた先が箱の近くだと `clearance` 軸の要求 (箱と札で 32) を割ったまま残る。
2228
+ * 実測 = 0.29.0 で 0 件だった違反が 0.30.1 で 9 件。
2229
+ *
2230
+ * ## 何を動かすか
2231
+ *
2232
+ * **札だけを動かす**。 箱と経路は動かさない = ここで経路を動かすと、 直前の 4 pass が
2233
+ * 確定させた形 (根元の重なり分け / 起点の直進) を崩すことになる。
2234
+ *
2235
+ * 判定は門 (`detectNearCollisions`) と同じ `requiredNearClearance` ・ `computeGap` ・
2236
+ * `measurePathLabelGapSegs` を引く。 別の式で近似すると、 門は落ちるのに押し出しは動かない
2237
+ * 状態に戻る。
2238
+ *
2239
+ * ## 行き先を確かめてから動かす
2240
+ *
2241
+ * 一番安く済む軸へ出すだけだと、 上下や左右の箱に挟まれた札が往復して収束しない
2242
+ * (実測 = 箱 2 つの間で 16 下げて 4 上げるのを繰り返す)。
2243
+ * 軸ごとに行き先を作り、 **他のどの箱 ・ 札 ・ 線とも今より近づかない** 向きだけを採る。
2244
+ * どの向きも安全でなければ動かさない = 直せなかったことは門が引き続き報告する。
2245
+ *
2246
+ * ## 動かしすぎない
2247
+ *
2248
+ * 累積は `CHAIN_SHIFT_MAX_ACCUMULATED` で頭を打つ。 札は自分の線の近くに居ることが
2249
+ * 読みの前提で、 離しすぎるとどの線の札か判らなくなる。
2250
+ */
2251
+ export function resolveLabelClearanceAfterPathPasses(
2252
+ edges: LaidEdge[],
2253
+ obstacles: BBox[],
2254
+ maxIter = 8,
2255
+ ): LaidEdge[] {
2256
+ if (edges.length === 0) return edges;
2257
+
2258
+ const labeledIds = new Set(
2259
+ edges.filter((e) => e.label?.trim() || e.sub?.trim()).map((e) => e.id),
2260
+ );
2261
+ if (labeledIds.size === 0) return edges;
2262
+
2263
+ const 箱たち = obstacles.filter((o) => o.kind === "node" || o.kind === "lane-label");
2264
+ const edgeById = new Map<string, LaidEdge>();
2265
+ for (const e of edges) edgeById.set(e.id, e);
2266
+ /*
2267
+ * 線の区間は **曲線を開いた形** で持つ (`detectCollisions` の札 branch と同じ)。
2268
+ *
2269
+ * 弦のままだと、 膨らんだ弧が札を貫いても弦が札の外なら見えない。 門はこの形で
2270
+ * `edge-label-overlap` を出すため、 押し出し側が弦で測ると「門は落ちるのに動かない」 に戻る。
2271
+ */
2272
+ const 区間cache = new Map<string, Seg[]>();
2273
+ const 区間 = (id: string, d: string): Seg[] => {
2274
+ const hit = 区間cache.get(id);
2275
+ if (hit) return hit;
2276
+ const segs = flattenPathSegments(extractPathSegments(d));
2277
+ 区間cache.set(id, segs);
2278
+ return segs;
2279
+ };
2280
+ /*
2281
+ * 線の外接矩形。 **測る前に遠い相手を落とすために持つ**。
2282
+ *
2283
+ * 札 1 つにつき全ての線と区間距離を測ると、 線が増えるほど二乗で効く
2284
+ * (実測 = 曲線 380 本の図で配置が 100ms の予算を超えた)。 外接矩形が要求ぶん離れていれば
2285
+ * 区間距離も必ず要求以上なので、 測らずに落としてよい。
2286
+ */
2287
+ const 外接cache = new Map<string, { x: number; y: number; w: number; h: number } | null>();
2288
+ const 外接 = (id: string, d: string) => {
2289
+ const hit = 外接cache.get(id);
2290
+ if (hit !== undefined) return hit;
2291
+ const segs = 区間(id, d);
2292
+ if (segs.length === 0) {
2293
+ 外接cache.set(id, null);
2294
+ return null;
2295
+ }
2296
+ let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
2297
+ for (const g of segs) {
2298
+ x1 = Math.min(x1, g.x1, g.x2);
2299
+ y1 = Math.min(y1, g.y1, g.y2);
2300
+ x2 = Math.max(x2, g.x1, g.x2);
2301
+ y2 = Math.max(y2, g.y1, g.y2);
2302
+ }
2303
+ const box = { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
2304
+ 外接cache.set(id, box);
2305
+ return box;
2306
+ };
2307
+ /** 札の矩形と線の外接矩形が、 要求より離れているか */
2308
+ const 遠い = (box: BBox, edgeId: string, d: string, 余裕: number): boolean => {
2309
+ const b = 外接(edgeId, d);
2310
+ if (!b) return true;
2311
+ const dx = Math.max(0, Math.max(box.x - (b.x + b.w), b.x - (box.x + box.w)));
2312
+ const dy = Math.max(0, Math.max(box.y - (b.y + b.h), b.y - (box.y + box.h)));
2313
+ return Math.hypot(dx, dy) >= 余裕;
2314
+ };
2315
+
2316
+ /*
2317
+ * 動かす量の頭。 読みの上限は「自分の線から離れすぎない」 ことで、 それは `安全か` が
2318
+ * 距離そのもので見る。 ここは念のための頭で、 chain 伝搬の頭 (`CHAIN_SHIFT_MAX_ACCUMULATED`)
2319
+ * より緩くしてよい = この pass は札しか動かさず、 箱や経路を引きずらない。
2320
+ */
2321
+ const 最大移動 = CHAIN_SHIFT_MAX_ACCUMULATED * 2;
2322
+
2323
+ const shiftMap = new Map<string, { dx: number; dy: number }>();
2324
+ const getShift = (id: string): { dx: number; dy: number } =>
2325
+ shiftMap.get(id) ?? { dx: 0, dy: 0 };
2326
+
2327
+ const labelBBoxes = (): BBox[] => {
2328
+ const out: BBox[] = [];
2329
+ for (const e of edges) {
2330
+ if (!labeledIds.has(e.id)) continue;
2331
+ const base = computeLabelBBoxWorld(e);
2332
+ const s = getShift(e.id);
2333
+ out.push({ kind: "edge-label", id: e.id, x: base.x + s.dx, y: base.y + s.dy, w: base.w, h: base.h });
2334
+ }
2335
+ return out;
2336
+ };
2337
+
2338
+ /** 軸ごとの離れ量。 重なっている時は負になる */
2339
+ const 離れ量 = (a: BBox, b: BBox): { x: number; y: number } => ({
2340
+ x: Math.max(a.x - (b.x + b.w), b.x - (a.x + a.w)),
2341
+ y: Math.max(a.y - (b.y + b.h), b.y - (a.y + a.h)),
2342
+ });
2343
+
2344
+ const 動かした箱 = (box: BBox, axis: "x" | "y", sign: 1 | -1, amount: number): BBox => ({
2345
+ ...box,
2346
+ x: box.x + (axis === "x" ? sign * amount : 0),
2347
+ y: box.y + (axis === "y" ? sign * amount : 0),
2348
+ });
2349
+
2350
+ for (let iter = 0; iter < maxIter; iter += 1) {
2351
+ const labels = labelBBoxes();
2352
+ const 札の要求 = requiredNearClearance("edge-label", "edge-label");
2353
+ let 動いた = false;
2354
+ /*
2355
+ * **1 回の走査で同じ札を 2 度動かさない**。 `labels` は走査の頭で 1 度だけ作るため、
2356
+ * 2 度目の判定は動かす前の位置を基準にしてしまう。 その状態で行き先を確かめても、
2357
+ * 実際に着く場所とは違う所を見ることになる (実測 = 札が箱の中へ 228px² 入った)。
2358
+ * 動かした札は次の走査で作り直した位置から見る。
2359
+ */
2360
+ const 今回動かした = new Set<string>();
2361
+
2362
+ /** 札 1 つを動かした先が、 いまより近づく相手を作らないか */
2363
+ const 安全か = (label: BBox, 行き先: BBox): boolean => {
2364
+ for (const obs of 箱たち) {
2365
+ const 前 = computeGap(label, obs);
2366
+ const 後 = computeGap(行き先, obs);
2367
+ if (後 < Math.min(前, requiredNearClearance("edge-label", obs.kind))) return false;
2368
+ }
2369
+ for (const 他 of labels) {
2370
+ if (他.id === label.id) continue;
2371
+ const 前 = computeGap(label, 他);
2372
+ const 後 = computeGap(行き先, 他);
2373
+ if (後 < Math.min(前, 札の要求)) return false;
2374
+ }
2375
+ // 線との距離は重いので最後に見る。 ここまでで落ちる候補が大半。
2376
+ const 自分 = edgeById.get(label.id);
2377
+ const 自分の区間 = 自分 ? 区間(自分.id, 自分.d) : [];
2378
+ /*
2379
+ * **自分の線から離れすぎない**。 札は自分の線の近くに在ることが読みの前提で、
2380
+ * 離すと どの線の説明なのかが判らなくなる。 いま既に離れている札はそれ以上離さない。
2381
+ */
2382
+ if (自分の区間.length > 0) {
2383
+ const 前 = measurePathLabelGapSegs(自分の区間, label);
2384
+ const 後 = measurePathLabelGapSegs(自分の区間, 行き先);
2385
+ if (後 > Math.max(前, DIST_LABEL_PATH_MAX)) return false;
2386
+ }
2387
+ for (const 相手 of edges) {
2388
+ if (相手.id === label.id) continue;
2389
+ // 動かす前も後も要求ぶん離れている線は、 測らずに落とす。
2390
+ if (遠い(label, 相手.id, 相手.d, LABEL_TO_PATH_CLEARANCE) && 遠い(行き先, 相手.id, 相手.d, LABEL_TO_PATH_CLEARANCE)) continue;
2391
+ const segs = segmentsExcludingOwnPath(区間(相手.id, 相手.d), 自分の区間);
2392
+ if (segs.length === 0) continue;
2393
+ const 前 = measurePathLabelGapSegs(segs, label);
2394
+ const 後 = measurePathLabelGapSegs(segs, 行き先);
2395
+ if (後 < Math.min(前, LABEL_TO_PATH_CLEARANCE)) return false;
2396
+ }
2397
+ return true;
2398
+ };
2399
+
2400
+ /** 候補を安い順に試し、 行き先が安全な最初の 1 つを積む */
2401
+ const 逃がす = (
2402
+ label: BBox,
2403
+ 候補: Array<{ axis: "x" | "y"; sign: 1 | -1; amount: number }>,
2404
+ ): boolean => {
2405
+ for (const c of 候補.slice().sort((a, b) => a.amount - b.amount)) {
2406
+ if (c.amount <= 0) continue;
2407
+ const 行き先 = 動かした箱(label, c.axis, c.sign, c.amount);
2408
+ if (!安全か(label, 行き先)) continue;
2409
+ const cur = getShift(label.id);
2410
+ const dx = c.axis === "x" ? cur.dx + c.sign * c.amount : cur.dx;
2411
+ const dy = c.axis === "y" ? cur.dy + c.sign * c.amount : cur.dy;
2412
+ if (Math.hypot(dx, dy) > 最大移動) continue;
2413
+ shiftMap.set(label.id, { dx, dy });
2414
+ 今回動かした.add(label.id);
2415
+ return true;
2416
+ }
2417
+ return false;
2418
+ };
2419
+
2420
+ /** 矩形どうしが要求を満たしていない時の、 軸ごとの逃がし候補 */
2421
+ const 矩形の候補 = (
2422
+ label: BBox,
2423
+ static箱: BBox,
2424
+ required: number,
2425
+ ): Array<{ axis: "x" | "y"; sign: 1 | -1; amount: number }> => {
2426
+ const sep = 離れ量(label, static箱);
2427
+ const 向き = (axis: "x" | "y"): 1 | -1 => {
2428
+ const a = axis === "x" ? label.x + label.w / 2 : label.y + label.h / 2;
2429
+ const b = axis === "x" ? static箱.x + static箱.w / 2 : static箱.y + static箱.h / 2;
2430
+ return a >= b ? 1 : -1;
2431
+ };
2432
+ return [
2433
+ { axis: "x" as const, sign: 向き("x"), amount: required - sep.x + NEAR_MISS_MARGIN },
2434
+ { axis: "y" as const, sign: 向き("y"), amount: required - sep.y + NEAR_MISS_MARGIN },
2435
+ ];
2436
+ };
2437
+
2438
+ // 札 ↔ 箱 / 縦列の題。 障害物は動かさないので札だけを逃がす。
2439
+ for (const label of labels) {
2440
+ if (今回動かした.has(label.id)) continue;
2441
+ for (const obs of 箱たち) {
2442
+ const required = requiredNearClearance(label.kind, obs.kind);
2443
+ if (required <= 0) continue;
2444
+ if (computeGap(label, obs) >= required) continue;
2445
+ if (逃がす(label, 矩形の候補(label, obs, required))) {
2446
+ 動いた = true;
2447
+ break;
2448
+ }
2449
+ }
2450
+ }
2451
+
2452
+ // 札 ↔ 札。 後に書いた方を動かす = 先に書いた方を基準にすると向きが安定する。
2453
+ const 札順 = new Map<string, number>();
2454
+ edges.forEach((e, i) => 札順.set(e.id, i));
2455
+ for (let i = 0; i < labels.length; i += 1) {
2456
+ for (let j = i + 1; j < labels.length; j += 1) {
2457
+ const a = labels[i]!;
2458
+ const b = labels[j]!;
2459
+ if (computeGap(a, b) >= 札の要求) continue;
2460
+ const aIsLater = 札順.get(a.id)! > 札順.get(b.id)!;
2461
+ const moveBox = aIsLater ? a : b;
2462
+ const staticBox = aIsLater ? b : a;
2463
+ if (今回動かした.has(moveBox.id)) continue;
2464
+ if (逃がす(moveBox, 矩形の候補(moveBox, staticBox, 札の要求))) 動いた = true;
2465
+ }
2466
+ }
2467
+
2468
+ // 札 ↔ 別の線。 線は動かさないので札だけを逃がす。
2469
+ // 向きは、 線の最も近い点から札の芯へ向かう向きの縦横 2 つ。
2470
+ for (const label of labels) {
2471
+ if (今回動かした.has(label.id)) continue;
2472
+ const 自分 = edgeById.get(label.id);
2473
+ if (!自分) continue;
2474
+ const 自分の区間 = 区間(自分.id, 自分.d);
2475
+ for (const 他 of edges) {
2476
+ if (他.id === label.id) continue;
2477
+ // 同じ 2 点を結ぶ線は経路が重なるのが設計 (CAR-429)。 逃がす相手にしない。
2478
+ if (自分.from === 他.from && 自分.to === 他.to) continue;
2479
+ if (遠い(label, 他.id, 他.d, LABEL_TO_PATH_CLEARANCE)) continue;
2480
+ const segs = segmentsExcludingOwnPath(区間(他.id, 他.d), 自分の区間);
2481
+ if (segs.length === 0) continue;
2482
+ const gap = measurePathLabelGapSegs(segs, label);
2483
+ if (gap >= LABEL_TO_PATH_CLEARANCE) continue;
2484
+ const 芯 = { x: label.x + label.w / 2, y: label.y + label.h / 2 };
2485
+ let 近い点: { x: number; y: number } | null = null;
2486
+ let 最短 = Infinity;
2487
+ for (const seg of segs) {
2488
+ for (const 点 of [
2489
+ { x: seg.x1, y: seg.y1 },
2490
+ { x: seg.x2, y: seg.y2 },
2491
+ { x: (seg.x1 + seg.x2) / 2, y: (seg.y1 + seg.y2) / 2 },
2492
+ ]) {
2493
+ const d = Math.hypot(点.x - 芯.x, 点.y - 芯.y);
2494
+ if (d < 最短) {
2495
+ 最短 = d;
2496
+ 近い点 = 点;
2497
+ }
2498
+ }
2499
+ }
2500
+ if (!近い点) continue;
2501
+ /*
2502
+ * **要る量を測って決める**。 `要求 - いまの距離` で出すと、 線が札を横切っている形で
2503
+ * 足りない = 距離は 0 で止まるが、 実際には札の端が線を越えるまで動かす必要がある。
2504
+ * 実測 = 札の幅 132 の真ん中を縦の線が貫く形で、 18 動かしても 7 しか離れなかった。
2505
+ *
2506
+ * 動かす量を 2 倍ずつ伸ばして満たす所を見つけ、 二分で詰める。 測り方は門と同じ
2507
+ * `measurePathLabelGapSegs` を使う。 上限まで伸ばしても満たせない向きは候補にしない。
2508
+ */
2509
+ const 必要量 = (axis: "x" | "y", sign: 1 | -1): number | null => {
2510
+ const 満たす = (量: number): boolean =>
2511
+ measurePathLabelGapSegs(segs, 動かした箱(label, axis, sign, 量)) >= LABEL_TO_PATH_CLEARANCE;
2512
+ let lo = 0;
2513
+ let hi = Math.max(4, LABEL_TO_PATH_CLEARANCE - gap);
2514
+ // **上限そのものを試してから諦める**。 2 倍で伸ばした値が上限を跨いだ所で打ち切ると、
2515
+ // 上限内に満たす量があっても見つけられない (実測 = 83 で足りるのに 128 で諦めていた)。
2516
+ while (!満たす(hi)) {
2517
+ if (hi >= 最大移動) return null;
2518
+ lo = hi;
2519
+ hi = Math.min(hi * 2, 最大移動);
2520
+ }
2521
+ for (let k = 0; k < 12; k += 1) {
2522
+ const mid = (lo + hi) / 2;
2523
+ if (満たす(mid)) hi = mid;
2524
+ else lo = mid;
2525
+ }
2526
+ return hi + NEAR_MISS_MARGIN;
2527
+ };
2528
+ const dx = 芯.x - 近い点.x;
2529
+ const dy = 芯.y - 近い点.y;
2530
+ const 候補: Array<{ axis: "x" | "y"; sign: 1 | -1; amount: number }> = [];
2531
+ for (const c of [
2532
+ { axis: "x" as const, sign: (dx >= 0 ? 1 : -1) as 1 | -1 },
2533
+ { axis: "y" as const, sign: (dy >= 0 ? 1 : -1) as 1 | -1 },
2534
+ // 近い点から離れる向きが塞がれていることがある。 反対向きも候補に入れる。
2535
+ { axis: "x" as const, sign: (dx >= 0 ? -1 : 1) as 1 | -1 },
2536
+ { axis: "y" as const, sign: (dy >= 0 ? -1 : 1) as 1 | -1 },
2537
+ ]) {
2538
+ const 量 = 必要量(c.axis, c.sign);
2539
+ if (量 !== null) 候補.push({ ...c, amount: 量 });
2540
+ }
2541
+ if (逃がす(label, 候補)) {
2542
+ 動いた = true;
2543
+ break;
2544
+ }
2545
+ }
2546
+ }
2547
+
2548
+ if (!動いた) break;
2549
+ }
2550
+
2551
+ if (shiftMap.size === 0) return edges;
2552
+ return edges.map((e) => {
2553
+ const s = shiftMap.get(e.id);
2554
+ if (!s || (s.dx === 0 && s.dy === 0)) return e;
2555
+ return { ...e, labelX: e.labelX + s.dx, labelY: e.labelY + s.dy };
2556
+ });
2557
+ }
2558
+
2214
2559
  /**
2215
2560
  * CAR-485 = label shift + edge-path shift を同時 apply する helper。
2216
2561
  * label shift は labelX / labelY 加算、 edge-path shift は path.d の shift。
package/src/layout.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  detectNearCollisions,
10
10
  resolveEdgeLabelOverlapsWithChainAndPropagate,
11
11
  resolveOverlaps,
12
+ resolveLabelClearanceAfterPathPasses,
12
13
  } from "./layout/collisions";
13
14
  import {
14
15
  bowSharedStraightPaths,
@@ -367,6 +368,19 @@ export function layout(diag: CdlDiagram): LaidDiagram {
367
368
  // 置き直しは全て縦方向で、 線に沿って動かす経路が無かった。 いま被っている札だけを
368
369
  // 対象にするので、 落ち着いている図の座標は動かない。
369
370
  slideLabelsAlongOwnPath(edges, nodesFinal);
371
+ // 経路を動かす pass が全て終わってから、 札を箱と札から離し直す (#659)。
372
+ //
373
+ // 上の chain 伝搬はここより前に走るため、 その後で `pullOutgoingFirstCorner` /
374
+ // `fanIncomingEndpoints` が経路を動かすと札も一緒に動く。 動いた後を見る者が居なかった。
375
+ // ここで動かすのは札だけで、 箱も経路も触らない = 触ると直前の pass が確定させた形を崩す。
376
+ if (!isChainEdgeLabelDisabled()) {
377
+ edges = resolveLabelClearanceAfterPathPasses(
378
+ edges,
379
+ collectBBoxes(containedLanesFinal, nodesFinal, []).filter(
380
+ (b) => b.kind === "node" || b.kind === "lane-label",
381
+ ),
382
+ );
383
+ }
370
384
 
371
385
  const bboxes = collectBBoxes(containedLanesFinal, nodesFinal, edges);
372
386
  const collisions = detectCollisions(bboxes, edges);