@cardenelabs/cdl 0.64.0 → 0.65.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.64.0",
3
+ "version": "0.65.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",
@@ -18,9 +18,10 @@ import {
18
18
  EDGE_STUB_OUT,
19
19
  hasRenderedLabel,
20
20
  labelInitClearance,
21
- LANE_BORDER_CLEARANCE_TOL,
21
+ laneVerticalRange,
22
22
  labelPathClearanceHorizontal,
23
23
  labelPillH,
24
+ rectCrossesLaneBorder,
24
25
  spreadWithMinimalShift,
25
26
  PARALLEL_EDGE_BOW,
26
27
  SYMMETRIC_PAIR_SHIFT,
@@ -47,6 +48,25 @@ const VERTICAL_DETOUR_CORNER = 14;
47
48
  export function verticalCornerDir(from: number, to: number): 1 | -1 {
48
49
  return to < from ? -1 : 1;
49
50
  }
51
+
52
+ /**
53
+ * 角の丸みを、その角が使える長さで頭打ちにする (`#865`)。
54
+ *
55
+ * 丸めた角は「手前で半径だけ直線を止め、弧で半径だけ先へ抜ける」 形で作る。 走りがその分を
56
+ * 持っていないと、止めた点が走りの向こう側へ回り込み、直線が逆走して角の外に突起が出る。
57
+ *
58
+ * `走り` には **その走りを 1 つの角が使える長さ** を渡す。 両端の 2 角で使う走り (L 字の縦)
59
+ * は半分を渡し、片端の 1 角しか使わない走り (L 字の横) はそのまま渡す。
60
+ *
61
+ * 0 を返す時は丸める余地が無い。 呼び出し側は弧の切り口が角そのものに重なった形を出すので、
62
+ * 見た目は折れ線のまま繋がる。 `verticesToPath` が同じ規則を別の経路で持っている。
63
+ *
64
+ * 走りの向きは見ない = 経路は右にも左にも上にも下にも走るため、呼び出し側は符号付きの差を
65
+ * そのまま渡せる。 返り値が負にならないのは、半径を負で呼ばない限り長さが下限になるため。
66
+ */
67
+ export function cornerRadiusFor(半径: number, ...走り: readonly number[]): number {
68
+ return Math.min(半径, ...走り.map((v) => Math.abs(v)));
69
+ }
50
70
  /** 縦 detour が source から曲がるまで直進する距離 (world unit)。 EDGE_STUB_OUT を上回る。 */
51
71
  const VERTICAL_DETOUR_EXIT = 45;
52
72
 
@@ -172,6 +192,8 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
172
192
  (diag.viewport?.labelMargin ?? diag.viewport?.gap ?? DEFAULT_LABEL_MARGIN) - DEFAULT_LABEL_MARGIN;
173
193
  const nmap = new Map(nodes.map((n) => [n.id, n]));
174
194
  const lmap = new Map(lanes.map((l) => [l.id, l]));
195
+ // label が lane の枠線を跨ぐかを見る 2 か所 (左への迂回と label の反転) が使う縦の範囲 (#863)。
196
+ const laneRangeById = new Map(lanes.map((l) => [l.id, laneVerticalRange(l, nodes)]));
175
197
 
176
198
  // canvas pivot 新 spec (dragon canvas pivot spec §layout-role-conversion)。
177
199
  // posX1 / posY1 / posX2 / posY2 全て set 済 edge は「user が固定した絶対 path」 = 直線 path
@@ -614,14 +636,12 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
614
636
  const pill = { x: centerX - boxW / 2, y: (exitY + returnY) / 2 - boxH / 2, w: boxW, h: boxH };
615
637
  if (others.some((n) => rectsOverlap(pill, nodeRect(n)))) return true;
616
638
 
617
- // 発着 lane の枠線は跨いでよい (visual-validate の lane-border-clearance と同一規約)。
639
+ // 発着 lane の枠線は跨いでよい。 跨ぐかは visual-validate の lane-border-clearance と同じ関数で見る。
618
640
  const endpointLanes = new Set(
619
641
  nodes.filter((n) => n.id === c.from || n.id === c.to).map((n) => n.lane),
620
642
  );
621
643
  return lanes.some(
622
- (l) =>
623
- !endpointLanes.has(l.id) &&
624
- (crossesVerticalLine(pill, l.x) || crossesVerticalLine(pill, l.x + l.width)),
644
+ (l) => !endpointLanes.has(l.id) && rectCrossesLaneBorder(pill, l, laneRangeById.get(l.id)) !== undefined,
625
645
  );
626
646
  };
627
647
 
@@ -968,13 +988,13 @@ export function layoutEdges(diag: CdlDiagram, nodes: LaidNode[], lanes: LaidLane
968
988
  nodes.reduce((acc, n) => Math.min(acc, rectGap(box, nodeRect(n))), Infinity);
969
989
  if (minNodeGap(alt) < Math.min(CLEARANCE_NODE_LABEL, minNodeGap(cur)) - 1e-6) continue;
970
990
 
971
- // 発着 lane の枠線は跨いでよい (visual-validate の lane-border-clearance と同一規約)。
991
+ // 発着 lane の枠線は跨いでよい。 跨ぐかは visual-validate の lane-border-clearance と同じ関数で見る。
972
992
  const endpointLanes = new Set(
973
993
  [laneOfNode.get(r.e.from), laneOfNode.get(r.e.to)].filter((v): v is string => v !== undefined),
974
994
  );
975
995
  const crossesLaneBorder = lanes.some((l) => {
976
996
  if (endpointLanes.has(l.id)) return false;
977
- return crossesVerticalLine(alt, l.x) || crossesVerticalLine(alt, l.x + l.width);
997
+ return rectCrossesLaneBorder(alt, l, laneRangeById.get(l.id)) !== undefined;
978
998
  });
979
999
  if (crossesLaneBorder) continue;
980
1000
 
@@ -1465,14 +1485,6 @@ function segmentsBBox(segs: Array<{ x1: number; y1: number; x2: number; y2: numb
1465
1485
  return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1466
1486
  }
1467
1487
 
1468
- /**
1469
- * rect が縦線 x = lineX を跨ぐか。 `visual-validate` の lane-border-clearance と同一規約で、
1470
- * 端が線に触れるだけ (許容差 LANE_BORDER_CLEARANCE_TOL 以内) は跨いだ扱いにしない。
1471
- */
1472
- function crossesVerticalLine(rect: Rect, lineX: number): boolean {
1473
- return rect.x < lineX - LANE_BORDER_CLEARANCE_TOL && rect.x + rect.w > lineX + LANE_BORDER_CLEARANCE_TOL;
1474
- }
1475
-
1476
1488
  function distributeBySides(
1477
1489
  list: Array<{
1478
1490
  e: CdlEdge;
@@ -1940,6 +1952,13 @@ function routePath(
1940
1952
  iter++;
1941
1953
  }
1942
1954
  }
1955
+ // 角の丸みは `midX` が決まってから頭打ちにする (`#865`)。 上の繰り返しが札を避けて `midX` を
1956
+ // 中央から動かすため、中央のつもりで先に計算すると、動いた後の横の走りを測り損ねる。
1957
+ //
1958
+ // 縦の走りは両端の 2 角で使うので半分を渡し、横の走りは片端の 1 角しか使わないのでそのまま
1959
+ // 渡す。 頭打ちにしないと、中心の高さの差が 28 (丸み 14 の 2 倍) より近い対で縦の走りが
1960
+ // 逆走する = 実測で 506 → 516 の対が `500 520` から `500 502` へ 18 戻る形を出していた。
1961
+ const lCorner = cornerRadiusFor(corner, (B.y - A.y) / 2, midX - A.x, B.x - midX);
1943
1962
  // label 初期位置 = L 字 corner の垂直 segment 中点、 A.y と B.y の中間 高さ (routing v6)。
1944
1963
  // 旧実装は labelX=(A.x+midX)/2, labelY=A.y の水平 segment 上に置いていたが、 label 幅の広い
1945
1964
  // edge (幅 230px 等) では A 側 node bbox に食い込んで overlap 発生 (rollback pattern e3 の
@@ -1997,10 +2016,10 @@ function routePath(
1997
2016
  // enter 水平 (539.11→400) 139 world、 最長 = mid 垂直、 中点 = (553.11, 410)、
1998
2017
  // pill (label 幅 102 × 高さ 36) 中心 = (553.11, 410) で垂直 segment 上 straddle。
1999
2018
  if (edge?.overlay) {
2000
- const exitEndX = midX - xDir * corner;
2001
- const enterStartX = midX + xDir * corner;
2002
- const midStartY = A.y + yDir * corner;
2003
- const midEndY = B.y - yDir * corner;
2019
+ const exitEndX = midX - xDir * lCorner;
2020
+ const enterStartX = midX + xDir * lCorner;
2021
+ const midStartY = A.y + yDir * lCorner;
2022
+ const midEndY = B.y - yDir * lCorner;
2004
2023
  const segments = [
2005
2024
  { len: Math.abs(exitEndX - A.x), midXVal: (A.x + exitEndX) / 2, midYVal: A.y },
2006
2025
  { len: Math.abs(midEndY - midStartY), midXVal: midX, midYVal: (midStartY + midEndY) / 2 },
@@ -2009,10 +2028,10 @@ function routePath(
2009
2028
  const longest = segments.reduce((a, b) => (b.len > a.len ? b : a));
2010
2029
  return {
2011
2030
  d: `M ${A.x} ${A.y}
2012
- L ${midX - xDir * corner} ${A.y}
2013
- Q ${midX} ${A.y}, ${midX} ${A.y + yDir * corner}
2014
- L ${midX} ${B.y - yDir * corner}
2015
- Q ${midX} ${B.y}, ${midX + xDir * corner} ${B.y}
2031
+ L ${midX - xDir * lCorner} ${A.y}
2032
+ Q ${midX} ${A.y}, ${midX} ${A.y + yDir * lCorner}
2033
+ L ${midX} ${B.y - yDir * lCorner}
2034
+ Q ${midX} ${B.y}, ${midX + xDir * lCorner} ${B.y}
2016
2035
  L ${B.x} ${B.y}`,
2017
2036
  labelX: longest.midXVal,
2018
2037
  labelY: longest.midYVal,
@@ -2021,10 +2040,10 @@ function routePath(
2021
2040
  }
2022
2041
  return {
2023
2042
  d: `M ${A.x} ${A.y}
2024
- L ${midX - xDir * corner} ${A.y}
2025
- Q ${midX} ${A.y}, ${midX} ${A.y + yDir * corner}
2026
- L ${midX} ${B.y - yDir * corner}
2027
- Q ${midX} ${B.y}, ${midX + xDir * corner} ${B.y}
2043
+ L ${midX - xDir * lCorner} ${A.y}
2044
+ Q ${midX} ${A.y}, ${midX} ${A.y + yDir * lCorner}
2045
+ L ${midX} ${B.y - yDir * lCorner}
2046
+ Q ${midX} ${B.y}, ${midX + xDir * lCorner} ${B.y}
2028
2047
  L ${B.x} ${B.y}`,
2029
2048
  labelX: midX,
2030
2049
  labelY: verticalMidY,
@@ -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 } from "../types";
21
+ import type { CdlEdge, LaidEdge, LaidLane, LaidNode } from "../types";
22
22
 
23
23
  // ─── edge label pill bbox SSOT (CAR-431 で 3 定義箇所を集約) ──
24
24
 
@@ -951,6 +951,64 @@ export const COLUMN_GAP_VARIANCE_TOL = 40;
951
951
  */
952
952
  export const LANE_BORDER_CLEARANCE_TOL = 2;
953
953
 
954
+ /** lane が占める縦の範囲 (world unit)。 `laneVerticalRange` が返す。 */
955
+ export type LaneVerticalRange = { top: number; bottom: number };
956
+
957
+ /**
958
+ * 縦の範囲で比べてよい lane の上端と下端。 上下いっぱいの列として比べる lane は `undefined` (#863)。
959
+ *
960
+ * 範囲を持つのは 2 種だけ。
961
+ *
962
+ * | lane | 範囲 |
963
+ * |---|---|
964
+ * | `contain` | 中の node を囲む枠 (`recomputeContainedLanes` が決める `y` / `height`)。 描画もこの枠を描く |
965
+ * | `posX` / `posY` / `posH` を揃えた lane | 著者が置いた `posY` から `posH` まで |
966
+ *
967
+ * それ以外の lane の `height` は名札の帯 (`LANE_LABEL_H`) の高さで、 列の縦の広がりではない。
968
+ * 列は中の node が段の位置に並ぶ下端まで続く。 `posH` を持たない固定の lane も、 `posX` と `posY` が
969
+ * 揃わず layout が座標を採らなかった lane も同じ。 lifeline の lane は縦線が図の下まで伸びる。
970
+ * どれも上下いっぱいの列として比べないと、 列の中にある説明文を取りこぼす。
971
+ *
972
+ * 範囲を持つ lane でも、 中の node が範囲の外にあれば、 その node の上下まで広げる。 高さを指定した
973
+ * lane に座標を持たない node を入れると、 node は段の位置に並んで範囲の外に出るため。
974
+ */
975
+ export function laneVerticalRange(lane: LaidLane, nodes: readonly LaidNode[]): LaneVerticalRange | undefined {
976
+ const pinnedWithHeight = lane.posX !== undefined && lane.posY !== undefined && lane.posH !== undefined;
977
+ if (!lane.contain && !pinnedWithHeight) return undefined;
978
+ let top = lane.y;
979
+ let bottom = lane.y + lane.height;
980
+ for (const n of nodes) {
981
+ if (n.lane !== lane.id) continue;
982
+ top = Math.min(top, n.cy - n.h / 2);
983
+ bottom = Math.max(bottom, n.cy + n.h / 2);
984
+ }
985
+ return { top, bottom };
986
+ }
987
+
988
+ /**
989
+ * rect が lane の左右の枠線 (`lane.x` / `lane.x + lane.width`) を跨ぐ側。 跨がなければ `undefined` (#863)。
990
+ *
991
+ * 検証器の軸 64 (`lane-border-clearance`) と、 engine が説明文の置き場所を選ぶ 2 か所
992
+ * (`layout/edges.ts` の左への迂回と説明文の反転) がこの関数で判定する。 規則を 1 か所に置き、
993
+ * engine が避ける枠線と検証器が指摘する枠線を食い違わせない。
994
+ *
995
+ * `range` (`laneVerticalRange` の値) があれば、 rect と縦に重なる時だけ比べる。 接するだけ
996
+ * (重なりの高さ 0) は重ならない扱い。 横は枠線から `LANE_BORDER_CLEARANCE_TOL` 以内に端が
997
+ * 触れるだけなら跨がない扱い。 左右の両方を跨ぐ rect は左を返す。
998
+ */
999
+ export function rectCrossesLaneBorder(
1000
+ rect: { x: number; y: number; w: number; h: number },
1001
+ lane: Pick<LaidLane, "x" | "width">,
1002
+ range: LaneVerticalRange | undefined,
1003
+ ): "left" | "right" | undefined {
1004
+ if (range !== undefined && (rect.y + rect.h <= range.top || rect.y >= range.bottom)) return undefined;
1005
+ const crosses = (lineX: number) =>
1006
+ rect.x < lineX - LANE_BORDER_CLEARANCE_TOL && rect.x + rect.w > lineX + LANE_BORDER_CLEARANCE_TOL;
1007
+ if (crosses(lane.x)) return "left";
1008
+ if (crosses(lane.x + lane.width)) return "right";
1009
+ return undefined;
1010
+ }
1011
+
954
1012
  // ─── near-collision policy SSOT (CAR-424 で collisions.ts から集約) ──
955
1013
 
956
1014
  /**
@@ -131,7 +131,6 @@ import {
131
131
  DETOUR_SLOT_GAP,
132
132
  EDGE_STUB_OUT,
133
133
  LABEL_PILL_PAD_X,
134
- computeLabelBoxW,
135
134
  buildDiagramAltText,
136
135
  computeLabelBBoxWorld,
137
136
  hasRenderedLabel,
@@ -143,7 +142,8 @@ import {
143
142
  fitsOwnTitleWidth,
144
143
  rowBaselineY,
145
144
  rowGlyphDepth,
146
- LANE_BORDER_CLEARANCE_TOL,
145
+ laneVerticalRange,
146
+ rectCrossesLaneBorder,
147
147
  MIN_COLUMN_ALIGNMENT_TOLERANCE,
148
148
  MIN_ROW_ALIGNMENT_TOLERANCE,
149
149
  ROW_GAP_VARIANCE_TOL,
@@ -4246,8 +4246,14 @@ function runAxes(
4246
4246
  //
4247
4247
  // 例外 = edge 発着 node の lane (from lane / to lane) 内は判定対象外、 その 2 lane 内なら
4248
4248
  // label が lane 縁近くにあっても違和感なし。
4249
+ //
4250
+ // 縦の範囲を持つ lane (`contain` の枠と、 `posX` / `posY` / `posH` を揃えた lane) は、 label と
4251
+ // 縦に重なる時だけ比べる (#863)。 別の段に置いた lane の枠線の延長上に label が来ても、 枠線は
4252
+ // そこに描かれていない。 判定の規則は `rectCrossesLaneBorder` (`layout/spec.ts`) が持ち、
4253
+ // engine が label の置き場所を選ぶ時も同じ関数を使う。
4249
4254
  // ───────────────────────────────────────────────────────────
4250
4255
  {
4256
+ const rangeByLane = new Map(laid.lanes.map((lane) => [lane.id, laneVerticalRange(lane, laid.nodes)]));
4251
4257
  for (const e of laid.edges) {
4252
4258
  if (!hasRenderedLabel(e)) continue;
4253
4259
  const fromNode = nodeById.get(e.from);
@@ -4258,29 +4264,14 @@ function runAxes(
4258
4264
  const allowedLanes = new Set<string>();
4259
4265
  if (fromNode) allowedLanes.add(fromNode.lane);
4260
4266
  if (toNode) allowedLanes.add(toNode.lane);
4261
- // label bbox 復元 (Axis 59 と同 logic、 lane border 判定は x 軸のみ使うので高さ不要)
4262
- const boxW = computeLabelBoxW(e.label, e.sub);
4263
- const anchor = e.labelAnchor;
4264
- const boxLeftX = anchor === "start" ? 0 : anchor === "end" ? -boxW : -boxW / 2;
4265
- const bx = e.labelX + boxLeftX;
4266
- const bxRight = bx + boxW;
4267
+ const box = computeLabelBBoxWorld(e);
4267
4268
  for (const lane of laid.lanes) {
4268
4269
  if (allowedLanes.has(lane.id)) continue;
4269
- const laneLeft = lane.x;
4270
- const laneRight = lane.x + lane.width;
4271
- // border 貫通 = bbox が border 線と交差 (border 左端 と bbox 左/右端 の関係)
4272
- // 左 border 貫通 = bx < laneLeft && bxRight > laneLeft + TOL
4273
- // 右 border 貫通 = bx < laneRight - TOL && bxRight > laneRight
4274
- const crossesLeft =
4275
- bx < laneLeft - LANE_BORDER_CLEARANCE_TOL &&
4276
- bxRight > laneLeft + LANE_BORDER_CLEARANCE_TOL;
4277
- const crossesRight =
4278
- bx < laneRight - LANE_BORDER_CLEARANCE_TOL &&
4279
- bxRight > laneRight + LANE_BORDER_CLEARANCE_TOL;
4280
- if (crossesLeft || crossesRight) {
4270
+ const side = rectCrossesLaneBorder(box, lane, rangeByLane.get(lane.id));
4271
+ if (side !== undefined) {
4281
4272
  push(
4282
4273
  "lane-border-clearance",
4283
- `edge "${e.id}" label bbox (${bx.toFixed(0)}..${bxRight.toFixed(0)}) が非発着 lane "${lane.id}" (${laneLeft.toFixed(0)}..${laneRight.toFixed(0)}) の ${crossesLeft ? "左" : "右"} border を貫通`,
4274
+ `edge "${e.id}" label bbox (${box.x.toFixed(0)}..${(box.x + box.w).toFixed(0)}) が非発着 lane "${lane.id}" (${lane.x.toFixed(0)}..${(lane.x + lane.width).toFixed(0)}) の ${side === "left" ? "左" : "右"} border を貫通`,
4284
4275
  );
4285
4276
  break; // 1 edge 1 発火に留める
4286
4277
  }