@cardenelabs/cdl 0.48.0 → 0.49.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardenelabs/cdl",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
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",
@@ -2212,57 +2212,17 @@ export function resolveEdgeLabelOverlapsWithChainAndPropagate(
2212
2212
  };
2213
2213
  }
2214
2214
 
2215
+ /** 札の逃がし先を測るのに使う、 線の区間と外接矩形。 同じ図の中で使い回す (#810)。 */
2216
+ interface 線の測り方 {
2217
+ readonly 区間: (id: string, d: string) => Seg[];
2218
+ readonly 遠い: (box: BBox, edgeId: string, d: string, 余裕: number) => boolean;
2219
+ }
2220
+
2215
2221
  /**
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
- * 読みの前提で、 離しすぎるとどの線の札か判らなくなる。
2222
+ * 線の測り方を作る。 逃がし pass と、 門が「なぜ動かせなかったか」 を説明する側
2223
+ * (`札が動けない理由`) の両方が使う = 2 箇所で別に書くと、 片方だけ直してずれる。
2250
2224
  */
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);
2225
+ function 線の測り方を作る(): 線の測り方 {
2266
2226
  /*
2267
2227
  * 線の区間は **曲線を開いた形** で持つ (`detectCollisions` の札 branch と同じ)。
2268
2228
  *
@@ -2313,12 +2273,287 @@ export function resolveLabelClearanceAfterPathPasses(
2313
2273
  return Math.hypot(dx, dy) >= 余裕;
2314
2274
  };
2315
2275
 
2276
+ return { 区間, 遠い };
2277
+ }
2278
+
2279
+ /*
2280
+ * 動かす量の頭。 読みの上限は「自分の線から離れすぎない」 ことで、 それは `安全か` が
2281
+ * 距離そのもので見る。 ここは念のための頭で、 chain 伝搬の頭 (`CHAIN_SHIFT_MAX_ACCUMULATED`)
2282
+ * より緩くしてよい = この pass は札しか動かさず、 箱や経路を引きずらない。
2283
+ */
2284
+ const 札の最大移動 = CHAIN_SHIFT_MAX_ACCUMULATED * 2;
2285
+
2286
+ /** 軸ごとの離れ量。 重なっている時は負になる */
2287
+ const 離れ量 = (a: BBox, b: BBox): { x: number; y: number } => ({
2288
+ x: Math.max(a.x - (b.x + b.w), b.x - (a.x + a.w)),
2289
+ y: Math.max(a.y - (b.y + b.h), b.y - (a.y + a.h)),
2290
+ });
2291
+
2292
+ const 動かした箱 = (box: BBox, axis: "x" | "y", sign: 1 | -1, amount: number): BBox => ({
2293
+ ...box,
2294
+ x: box.x + (axis === "x" ? sign * amount : 0),
2295
+ y: box.y + (axis === "y" ? sign * amount : 0),
2296
+ });
2297
+
2298
+ /** 札の逃がし候補。 軸 ・ 向き ・ 量 */
2299
+ type 逃がし候補 = { axis: "x" | "y"; sign: 1 | -1; amount: number };
2300
+
2301
+ /** 札を 1 つ動かした先で、 いまより近づいてしまう相手 (#810)。 */
2302
+ export interface 札の移動を塞ぐ相手 {
2303
+ /** `種類:id`。 自分の線から離れすぎる時は `自分の線` */
2304
+ readonly 相手: string;
2305
+ /** 動かす前の隙間 */
2306
+ readonly 前: number;
2307
+ /** 動かした後の隙間 */
2308
+ readonly 後: number;
2309
+ }
2310
+
2311
+ /** 札を動かす判定に使う、 図の中の相手 */
2312
+ interface 札の周り {
2313
+ readonly 箱たち: readonly BBox[];
2314
+ readonly labels: readonly BBox[];
2315
+ readonly edges: readonly LaidEdge[];
2316
+ readonly edgeById: ReadonlyMap<string, LaidEdge>;
2317
+ readonly 測り方: 線の測り方;
2318
+ }
2319
+
2320
+ /**
2321
+ * 札 1 つを動かした先が、 いまより近づく相手を作らないか。 作るなら最初の 1 つを返す。
2322
+ *
2323
+ * 逃がし pass の `安全か` と、 門の説明 (`札が動けない理由`) が同じものを引く (#810)。
2324
+ * 2 箇所で別に書くと、 「pass は動かせないのに説明は動かせると言う」 ずれが生まれる。
2325
+ */
2326
+ function 札の移動を塞ぐ相手を探す(
2327
+ 周り: 札の周り,
2328
+ label: BBox,
2329
+ 行き先: BBox,
2330
+ ): 札の移動を塞ぐ相手 | null {
2331
+ const { 箱たち, labels, edges, edgeById, 測り方 } = 周り;
2332
+ for (const obs of 箱たち) {
2333
+ const 前 = computeGap(label, obs);
2334
+ const 後 = computeGap(行き先, obs);
2335
+ if (後 < Math.min(前, requiredNearClearance("edge-label", obs.kind))) return { 相手: `${obs.kind}:${obs.id}`, 前, 後 };
2336
+ }
2337
+ const 札の要求 = requiredNearClearance("edge-label", "edge-label");
2338
+ for (const 他 of labels) {
2339
+ if (他.id === label.id) continue;
2340
+ const 前 = computeGap(label, 他);
2341
+ const 後 = computeGap(行き先, 他);
2342
+ if (後 < Math.min(前, 札の要求)) return { 相手: `edge-label:${他.id}`, 前, 後 };
2343
+ }
2344
+ // 線との距離は重いので最後に見る。 ここまでで落ちる候補が大半。
2345
+ const 自分 = edgeById.get(label.id);
2346
+ const 自分の区間 = 自分 ? 測り方.区間(自分.id, 自分.d) : [];
2316
2347
  /*
2317
- * 動かす量の頭。 読みの上限は「自分の線から離れすぎない」 ことで、 それは `安全か` が
2318
- * 距離そのもので見る。 ここは念のための頭で、 chain 伝搬の頭 (`CHAIN_SHIFT_MAX_ACCUMULATED`)
2319
- * より緩くしてよい = この pass は札しか動かさず、 箱や経路を引きずらない。
2348
+ * **自分の線から離れすぎない**。 札は自分の線の近くに在ることが読みの前提で、
2349
+ * 離すと どの線の説明なのかが判らなくなる。 いま既に離れている札はそれ以上離さない。
2320
2350
  */
2321
- const 最大移動 = CHAIN_SHIFT_MAX_ACCUMULATED * 2;
2351
+ if (自分の区間.length > 0) {
2352
+ const 前 = measurePathLabelGapSegs(自分の区間, label);
2353
+ const 後 = measurePathLabelGapSegs(自分の区間, 行き先);
2354
+ if (後 > Math.max(前, DIST_LABEL_PATH_MAX)) return { 相手: "自分の線", 前, 後 };
2355
+ }
2356
+ for (const 相手 of edges) {
2357
+ if (相手.id === label.id) continue;
2358
+ // 動かす前も後も要求ぶん離れている線は、 測らずに落とす。
2359
+ if (測り方.遠い(label, 相手.id, 相手.d, LABEL_TO_PATH_CLEARANCE) && 測り方.遠い(行き先, 相手.id, 相手.d, LABEL_TO_PATH_CLEARANCE)) continue;
2360
+ const segs = segmentsExcludingOwnPath(測り方.区間(相手.id, 相手.d), 自分の区間);
2361
+ if (segs.length === 0) continue;
2362
+ const 前 = measurePathLabelGapSegs(segs, label);
2363
+ const 後 = measurePathLabelGapSegs(segs, 行き先);
2364
+ if (後 < Math.min(前, LABEL_TO_PATH_CLEARANCE)) return { 相手: `edge-path:${相手.id}`, 前, 後 };
2365
+ }
2366
+ return null;
2367
+ }
2368
+
2369
+ /** 矩形どうしが要求を満たしていない時の、 軸ごとの逃がし候補 */
2370
+ function 矩形の候補(
2371
+ label: BBox,
2372
+ static箱: BBox,
2373
+ required: number,
2374
+ ): 逃がし候補[] {
2375
+ const sep = 離れ量(label, static箱);
2376
+ const 向き = (axis: "x" | "y"): 1 | -1 => {
2377
+ const a = axis === "x" ? label.x + label.w / 2 : label.y + label.h / 2;
2378
+ const b = axis === "x" ? static箱.x + static箱.w / 2 : static箱.y + static箱.h / 2;
2379
+ return a >= b ? 1 : -1;
2380
+ };
2381
+ return [
2382
+ { axis: "x" as const, sign: 向き("x"), amount: required - sep.x + NEAR_MISS_MARGIN },
2383
+ { axis: "y" as const, sign: 向き("y"), amount: required - sep.y + NEAR_MISS_MARGIN },
2384
+ ];
2385
+ }
2386
+
2387
+ /**
2388
+ * 札を軸の向きへ動かして、 線から `LABEL_TO_PATH_CLEARANCE` 離れるのに要る量。
2389
+ * 上限 (`札の最大移動`) まで伸ばしても満たせない向きは `null`。
2390
+ */
2391
+ function 線から離れる量(
2392
+ segs: Seg[],
2393
+ label: BBox,
2394
+ gap: number,
2395
+ axis: "x" | "y",
2396
+ sign: 1 | -1,
2397
+ ): number | null {
2398
+ const 最大移動 = 札の最大移動;
2399
+ const 満たす = (量: number): boolean =>
2400
+ measurePathLabelGapSegs(segs, 動かした箱(label, axis, sign, 量)) >= LABEL_TO_PATH_CLEARANCE;
2401
+ let lo = 0;
2402
+ let hi = Math.max(4, LABEL_TO_PATH_CLEARANCE - gap);
2403
+ // **上限そのものを試してから諦める**。 2 倍で伸ばした値が上限を跨いだ所で打ち切ると、
2404
+ // 上限内に満たす量があっても見つけられない (実測 = 83 で足りるのに 128 で諦めていた)。
2405
+ while (!満たす(hi)) {
2406
+ if (hi >= 最大移動) return null;
2407
+ lo = hi;
2408
+ hi = Math.min(hi * 2, 最大移動);
2409
+ }
2410
+ for (let k = 0; k < 12; k += 1) {
2411
+ const mid = (lo + hi) / 2;
2412
+ if (満たす(mid)) hi = mid;
2413
+ else lo = mid;
2414
+ }
2415
+ return hi + NEAR_MISS_MARGIN;
2416
+ }
2417
+
2418
+ /** 報告に出す向きの呼び名。 並べる順もこの順にする */
2419
+ const 向きの順: ReadonlyArray<{ axis: "x" | "y"; sign: 1 | -1; 名前: string }> = [
2420
+ { axis: "y", sign: -1, 名前: "上" },
2421
+ { axis: "y", sign: 1, 名前: "下" },
2422
+ { axis: "x", sign: -1, 名前: "左" },
2423
+ { axis: "x", sign: 1, 名前: "右" },
2424
+ ];
2425
+
2426
+ /**
2427
+ * 門が `clearance` の違反を報告する時に、 札が **なぜ動けなかったか** を 1 行で返す (#810)。
2428
+ *
2429
+ * 違反の報告は「どの 2 つが何 px で、 要求が何 px か」 しか持たない。 読んだ人が次に知りたいのは
2430
+ * 「なぜ engine はこれを直せなかったのか」 で、 それを探すのに層を 3 つ読む必要があった。
2431
+ * 理由は逃がし pass の中で分かっている = 向きごとに行き先を作り、 塞ぐ相手が居る向きを捨てている。
2432
+ *
2433
+ * **最終の位置から測り直す**。 layout の途中の記録を運ぶと、 途中で逃がせたが別の pass で押し戻された
2434
+ * 形や、 走査を打ち切った形で、 古い理由が残る。 逃がし pass と同じ候補 ・ 同じ判定
2435
+ * (`札の移動を塞ぐ相手を探す`) を最終の位置で試せば、 報告と実物がずれない。
2436
+ *
2437
+ * どれか 1 つの向きでも安全なら `null` を返す = 動けるのに動いていない形について、
2438
+ * 「塞がれている」 と言うのは誤り。
2439
+ *
2440
+ * @param labelId 動かせなかった札の辺 id
2441
+ * @param 相手 違反の相手 (箱 / 縦列の題 / 別の札 / 別の線)
2442
+ * @param edges 図の全ての辺 (最終の位置)
2443
+ * @param obstacles 箱と縦列の題の矩形
2444
+ */
2445
+ export function 札が動けない理由(
2446
+ labelId: string,
2447
+ 相手: BBox,
2448
+ edges: readonly LaidEdge[],
2449
+ obstacles: readonly BBox[],
2450
+ ): string | null {
2451
+ const edgeById = new Map(edges.map((e) => [e.id, e] as const));
2452
+ const 自分 = edgeById.get(labelId);
2453
+ if (!自分) return null;
2454
+ const labels: BBox[] = edges
2455
+ .filter((e) => e.label?.trim() || e.sub?.trim())
2456
+ .map((e) => ({ kind: "edge-label" as const, id: e.id, ...computeLabelBBoxWorld(e) }));
2457
+ const label = labels.find((l) => l.id === labelId);
2458
+ if (!label) return null;
2459
+ const 測り方 = 線の測り方を作る();
2460
+ const 周り: 札の周り = {
2461
+ 箱たち: obstacles.filter((o) => o.kind === "node" || o.kind === "lane-label"),
2462
+ labels,
2463
+ edges,
2464
+ edgeById,
2465
+ 測り方,
2466
+ };
2467
+
2468
+ // 逃がし pass と同じ候補を作る。 線が相手なら 4 つの向き、 矩形が相手なら離れる側の 2 つ。
2469
+ const 候補: Array<{ axis: "x" | "y"; sign: 1 | -1; amount: number | null }> = [];
2470
+ if (相手.kind === "edge-path") {
2471
+ const 線 = edgeById.get(相手.id);
2472
+ if (!線) return null;
2473
+ const segs = segmentsExcludingOwnPath(測り方.区間(線.id, 線.d), 測り方.区間(自分.id, 自分.d));
2474
+ if (segs.length === 0) return null;
2475
+ const gap = measurePathLabelGapSegs(segs, label);
2476
+ for (const 向き of 向きの順) {
2477
+ 候補.push({ axis: 向き.axis, sign: 向き.sign, amount: 線から離れる量(segs, label, gap, 向き.axis, 向き.sign) });
2478
+ }
2479
+ } else {
2480
+ const required = requiredNearClearance("edge-label", 相手.kind);
2481
+ for (const c of 矩形の候補(label, 相手, required)) {
2482
+ // 既に離れている軸は候補にしない (逃がし pass も量 0 以下を飛ばす)
2483
+ if (c.amount > 0) 候補.push(c);
2484
+ }
2485
+ }
2486
+ if (候補.length === 0) return null;
2487
+
2488
+ const 理由: Array<{ 順: number; 文: string }> = [];
2489
+ for (const c of 候補) {
2490
+ const 順 = 向きの順.findIndex((v) => v.axis === c.axis && v.sign === c.sign);
2491
+ const 名前 = 向きの順[順]!.名前;
2492
+ if (c.amount === null || c.amount > 札の最大移動) {
2493
+ 理由.push({ 順, 文: `${名前}=上限 ${札の最大移動} を越える` });
2494
+ continue;
2495
+ }
2496
+ const 塞ぐ = 札の移動を塞ぐ相手を探す(周り, label, 動かした箱(label, c.axis, c.sign, c.amount));
2497
+ if (!塞ぐ) return null;
2498
+ 理由.push({ 順, 文: `${名前}=${塞ぐ.相手} (${塞ぐ.前.toFixed(0)}→${塞ぐ.後.toFixed(0)})` });
2499
+ }
2500
+ return `逃げ場なし: ${理由.sort((a, b) => a.順 - b.順).map((r) => r.文).join(" / ")}`;
2501
+ }
2502
+
2503
+ /**
2504
+ * 経路を動かす pass の **後** に、 札だけを箱 ・ 札 ・ 線から離す (#659)。
2505
+ *
2506
+ * ## なぜ最終 pass が要るか
2507
+ *
2508
+ * `resolveEdgeLabelOverlapsWithChainAndPropagate` は経路を動かす 4 つの pass
2509
+ * (`bowSharedStraightPaths` / `pullOutgoingFirstCorner` / `fanIncomingEndpoints` /
2510
+ * `separateSharedStraightRuns`) の **前** に走る。 札の初期位置は経路から決まるため、
2511
+ * 後の pass が経路を動かすと札も一緒に動く。 動いた後を見る者が誰も居なかった。
2512
+ *
2513
+ * 0.30.0 で `pullOutgoingFirstCorner` (#651) と `fanIncomingEndpoints` (#649) が経路を
2514
+ * 大きく動かすようになり、 消費側の見本帳で札が 11 件動いた (実測 = 最大 217 world)。
2515
+ * 動いた先が箱の近くだと `clearance` 軸の要求 (箱と札で 32) を割ったまま残る。
2516
+ * 実測 = 0.29.0 で 0 件だった違反が 0.30.1 で 9 件。
2517
+ *
2518
+ * ## 何を動かすか
2519
+ *
2520
+ * **札だけを動かす**。 箱と経路は動かさない = ここで経路を動かすと、 直前の 4 pass が
2521
+ * 確定させた形 (根元の重なり分け / 起点の直進) を崩すことになる。
2522
+ *
2523
+ * 判定は門 (`detectNearCollisions`) と同じ `requiredNearClearance` ・ `computeGap` ・
2524
+ * `measurePathLabelGapSegs` を引く。 別の式で近似すると、 門は落ちるのに押し出しは動かない
2525
+ * 状態に戻る。
2526
+ *
2527
+ * ## 行き先を確かめてから動かす
2528
+ *
2529
+ * 一番安く済む軸へ出すだけだと、 上下や左右の箱に挟まれた札が往復して収束しない
2530
+ * (実測 = 箱 2 つの間で 16 下げて 4 上げるのを繰り返す)。
2531
+ * 軸ごとに行き先を作り、 **他のどの箱 ・ 札 ・ 線とも今より近づかない** 向きだけを採る。
2532
+ * どの向きも安全でなければ動かさない = 直せなかったことは門が引き続き報告する。
2533
+ *
2534
+ * ## 動かしすぎない
2535
+ *
2536
+ * 累積は `CHAIN_SHIFT_MAX_ACCUMULATED` で頭を打つ。 札は自分の線の近くに居ることが
2537
+ * 読みの前提で、 離しすぎるとどの線の札か判らなくなる。
2538
+ */
2539
+ export function resolveLabelClearanceAfterPathPasses(
2540
+ edges: LaidEdge[],
2541
+ obstacles: BBox[],
2542
+ maxIter = 8,
2543
+ ): LaidEdge[] {
2544
+ if (edges.length === 0) return edges;
2545
+
2546
+ const labeledIds = new Set(
2547
+ edges.filter((e) => e.label?.trim() || e.sub?.trim()).map((e) => e.id),
2548
+ );
2549
+ if (labeledIds.size === 0) return edges;
2550
+
2551
+ const 箱たち = obstacles.filter((o) => o.kind === "node" || o.kind === "lane-label");
2552
+ const edgeById = new Map<string, LaidEdge>();
2553
+ for (const e of edges) edgeById.set(e.id, e);
2554
+ const 測り方 = 線の測り方を作る();
2555
+ const { 区間, 遠い } = 測り方;
2556
+ const 最大移動 = 札の最大移動;
2322
2557
 
2323
2558
  const shiftMap = new Map<string, { dx: number; dy: number }>();
2324
2559
  const getShift = (id: string): { dx: number; dy: number } =>
@@ -2335,18 +2570,6 @@ export function resolveLabelClearanceAfterPathPasses(
2335
2570
  return out;
2336
2571
  };
2337
2572
 
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
2573
  for (let iter = 0; iter < maxIter; iter += 1) {
2351
2574
  const labels = labelBBoxes();
2352
2575
  const 札の要求 = requiredNearClearance("edge-label", "edge-label");
@@ -2360,47 +2583,13 @@ export function resolveLabelClearanceAfterPathPasses(
2360
2583
  const 今回動かした = new Set<string>();
2361
2584
 
2362
2585
  /** 札 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
- };
2586
+ const 安全か = (label: BBox, 行き先: BBox): boolean =>
2587
+ 札の移動を塞ぐ相手を探す({ 箱たち, labels, edges, edgeById, 測り方 }, label, 行き先) === null;
2399
2588
 
2400
2589
  /** 候補を安い順に試し、 行き先が安全な最初の 1 つを積む */
2401
2590
  const 逃がす = (
2402
2591
  label: BBox,
2403
- 候補: Array<{ axis: "x" | "y"; sign: 1 | -1; amount: number }>,
2592
+ 候補: 逃がし候補[],
2404
2593
  ): boolean => {
2405
2594
  for (const c of 候補.slice().sort((a, b) => a.amount - b.amount)) {
2406
2595
  if (c.amount <= 0) continue;
@@ -2417,24 +2606,6 @@ export function resolveLabelClearanceAfterPathPasses(
2417
2606
  return false;
2418
2607
  };
2419
2608
 
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
2609
  // 札 ↔ 箱 / 縦列の題。 障害物は動かさないので札だけを逃がす。
2439
2610
  for (const label of labels) {
2440
2611
  if (今回動かした.has(label.id)) continue;
@@ -2506,25 +2677,8 @@ export function resolveLabelClearanceAfterPathPasses(
2506
2677
  * 動かす量を 2 倍ずつ伸ばして満たす所を見つけ、 二分で詰める。 測り方は門と同じ
2507
2678
  * `measurePathLabelGapSegs` を使う。 上限まで伸ばしても満たせない向きは候補にしない。
2508
2679
  */
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
- };
2680
+ const 必要量 = (axis: "x" | "y", sign: 1 | -1): number | null =>
2681
+ 線から離れる量(segs, label, gap, axis, sign);
2528
2682
  const dx = 芯.x - 近い点.x;
2529
2683
  const dy = 芯.y - 近い点.y;
2530
2684
  const 候補: Array<{ axis: "x" | "y"; sign: 1 | -1; amount: number }> = [];
@@ -62,10 +62,12 @@ export function layoutWithValidation(
62
62
 
63
63
  if (opts.emitConsole) {
64
64
  for (const v of report.violations) {
65
+ // 理由は末尾に足す = `(diagram=...)` までの形を読む側を崩さない (#810)
66
+ const 理由 = v.reason !== undefined ? ` ${v.reason}` : "";
65
67
  if (v.severity === "error") {
66
- console.error(`[cdl geometry ${v.axis}] ${v.detail} (diagram=${v.diagramId})`);
68
+ console.error(`[cdl geometry ${v.axis}] ${v.detail} (diagram=${v.diagramId})${理由}`);
67
69
  } else {
68
- console.warn(`[cdl geometry ${v.axis}] ${v.detail} (diagram=${v.diagramId})`);
70
+ console.warn(`[cdl geometry ${v.axis}] ${v.detail} (diagram=${v.diagramId})${理由}`);
69
71
  }
70
72
  }
71
73
  }
@@ -88,6 +88,7 @@ import {
88
88
  flattenPathSegments,
89
89
  measurePathLabelGapSegs,
90
90
  segmentsCrossRect,
91
+ 札が動けない理由,
91
92
  } from "./layout/collisions";
92
93
  import { measureTextWidth } from "./layout/text-width";
93
94
  import { droppedLineIsHidden, labelPlan } from "./kinds/shape-region";
@@ -276,6 +277,14 @@ export interface Violation {
276
277
  diagramId: string;
277
278
  detail: string;
278
279
  severity: "error" | "warn";
280
+ /**
281
+ * engine がなぜ直せなかったか (#810)。 分かる時だけ付く。
282
+ *
283
+ * `detail` とは別の欄に置く = `detail` は「どの 2 つが何 px か」 を固定の形で持ち、
284
+ * 消費側がそこから id を読み取っている。 理由を `detail` に足すと、 理由の中の id まで
285
+ * 違反の対象として拾われる。
286
+ */
287
+ reason?: string;
279
288
  }
280
289
 
281
290
  export type VisualAxis =
@@ -1447,12 +1456,23 @@ export function visualValidateLaid(
1447
1456
  const counts = emptyCounts();
1448
1457
  const profile = opts?.profile ?? "production";
1449
1458
  const skipped = SKIPPED_AXES[profile];
1450
- const push = (axis: VisualAxis, detail: string, severity: "error" | "warn" = "error") => {
1459
+ const push = (
1460
+ axis: VisualAxis,
1461
+ detail: string,
1462
+ severity: "error" | "warn" = "error",
1463
+ reason?: string,
1464
+ ) => {
1451
1465
  // 用途に合わない軸は数えない。
1452
1466
  if (skipped.has(axis)) return;
1453
1467
  // `diag` は壊れた形でも渡りうる (`#393`)。 報告そのものが例外になると、 壊れている
1454
1468
  // ことを伝える手段が無くなる。
1455
- violations.push({ axis, diagramId: safeDiagramId(diag), detail, severity });
1469
+ violations.push({
1470
+ axis,
1471
+ diagramId: safeDiagramId(diag),
1472
+ detail,
1473
+ severity,
1474
+ ...(reason !== undefined ? { reason } : {}),
1475
+ });
1456
1476
  counts[axis]++;
1457
1477
  };
1458
1478
 
@@ -1479,10 +1499,21 @@ export function visualValidate(diag: CdlDiagram, opts?: ValidateOptions): Visual
1479
1499
  const counts = emptyCounts();
1480
1500
  const profile = opts?.profile ?? "production";
1481
1501
  const skipped = SKIPPED_AXES[profile];
1482
- const push = (axis: VisualAxis, detail: string, severity: "error" | "warn" = "error") => {
1502
+ const push = (
1503
+ axis: VisualAxis,
1504
+ detail: string,
1505
+ severity: "error" | "warn" = "error",
1506
+ reason?: string,
1507
+ ) => {
1483
1508
  // 用途に合わない軸は数えない。 見なかった軸は report の `skippedAxes` に載せる。
1484
1509
  if (skipped.has(axis)) return;
1485
- violations.push({ axis, diagramId: safeDiagramId(diag), detail, severity });
1510
+ violations.push({
1511
+ axis,
1512
+ diagramId: safeDiagramId(diag),
1513
+ detail,
1514
+ severity,
1515
+ ...(reason !== undefined ? { reason } : {}),
1516
+ });
1486
1517
  counts[axis]++;
1487
1518
  };
1488
1519
 
@@ -1852,7 +1883,7 @@ function visualValidateInner(
1852
1883
  diag: CdlDiagram,
1853
1884
  violations: Violation[],
1854
1885
  counts: Record<VisualAxis, number>,
1855
- push: (axis: VisualAxis, detail: string, severity?: "error" | "warn") => void,
1886
+ push: (axis: VisualAxis, detail: string, severity?: "error" | "warn", reason?: string) => void,
1856
1887
  profile: ValidationProfile,
1857
1888
  skippedAxes: VisualAxis[],
1858
1889
  /** 軸 23 が基準にする器。 入口で確かめ済 (`器を確かめる`)。 高さ `undefined` は上限なし。 */
@@ -1909,7 +1940,7 @@ function runAxes(
1909
1940
  diag: CdlDiagram,
1910
1941
  violations: Violation[],
1911
1942
  counts: Record<VisualAxis, number>,
1912
- push: (axis: VisualAxis, detail: string, severity?: "error" | "warn") => void,
1943
+ push: (axis: VisualAxis, detail: string, severity?: "error" | "warn", reason?: string) => void,
1913
1944
  profile: ValidationProfile,
1914
1945
  skippedAxes: VisualAxis[],
1915
1946
  /** 軸 23 が基準にする器。 入口で確かめ済 (`器を確かめる`)。 高さ `undefined` は上限なし。 */
@@ -2046,10 +2077,35 @@ function runAxes(
2046
2077
  // Axis 6: clearance (near collision 0 件、 CLEARANCE_POLICY 違反)
2047
2078
  // ───────────────────────────────────────────────────────────
2048
2079
  const nears = detectNearCollisions(bboxes, laid.edges);
2080
+ /*
2081
+ * 札が絡む違反には、 engine がなぜ動かせなかったかを添える (#810)。
2082
+ *
2083
+ * 動かす側は逃がし pass と同じ = 札どうしなら後に書いた方 (`resolveLabelClearanceAfterPathPasses`
2084
+ * が後の方を動かす)、 札と別の物なら札。 札の絡まない違反 (箱どうし等) には添えない。
2085
+ */
2086
+ const 札の障害物 = bboxes.filter((b) => b.kind === "node" || b.kind === "lane-label");
2087
+ const 辺の順 = new Map(laid.edges.map((e, i) => [e.id, i] as const));
2049
2088
  for (const n of nears) {
2089
+ const aは札 = n.a.kind === "edge-label";
2090
+ const bは札 = n.b.kind === "edge-label";
2091
+ const 動かす =
2092
+ aは札 && bは札
2093
+ ? (辺の順.get(n.a.id) ?? 0) > (辺の順.get(n.b.id) ?? 0)
2094
+ ? n.a
2095
+ : n.b
2096
+ : aは札
2097
+ ? n.a
2098
+ : bは札
2099
+ ? n.b
2100
+ : null;
2101
+ const 相手 = 動かす === n.a ? n.b : n.a;
2102
+ const reason =
2103
+ 動かす !== null ? (札が動けない理由(動かす.id, 相手, laid.edges, 札の障害物) ?? undefined) : undefined;
2050
2104
  push(
2051
2105
  "clearance",
2052
2106
  `${n.a.kind}:${n.a.id} ↔ ${n.b.kind}:${n.b.id} gap=${n.gap.toFixed(1)}px (need ${n.required}px)`,
2107
+ "error",
2108
+ reason,
2053
2109
  );
2054
2110
  }
2055
2111