@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.
@@ -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
  /**
@@ -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 を "back-detour" に切替
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 originalEdges = edges;
156
- edges = edges.map((e): CdlEdge =>
157
- h3Edges.has(e.id) && e.routing !== "back-detour" ? { ...e, routing: "back-detour" } : e,
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 (edges !== originalEdges) {
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
  }
package/src/layout.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  bowSharedStraightPaths,
17
17
  fanIncomingEndpoints,
18
+ followLabelsToFannedTails,
18
19
  hopEdgeCrossings,
19
20
  layoutEdges,
20
21
  pullOutgoingFirstCorner,
@@ -212,7 +213,8 @@ export function applyRowLevelNodeShifts<
212
213
  * - node は lane 内 stack 順で縦並び、 各 stack 間 gap 80px、 上端 80px
213
214
  * - lane label は lane 上部 30px 高
214
215
  * - contain: true の lane は内部 node 全部を 24px padding で囲む boundary
215
- * - edge routing は orthogonal L 字 (起点 side → 中継 → 終点 side)、 左右 lane 跨ぎは S 字 bezier
216
+ * - edge routing は orthogonal L 字 (起点 side → 中継 → 終点 side)。`curve` は 3 次曲線、
217
+ * `metro` は横線と傾き ±1 の斜線を円弧で結ぶ
216
218
  * - edge label は path 中点から path 進行方向の垂直方向に 50-80px、 node bbox を避ける
217
219
  * - viewBox は cdl が auto 計算 (右端 +80 / 下端 +80)
218
220
  */
@@ -339,10 +341,15 @@ export function layout(diag: CdlDiagram): LaidDiagram {
339
341
 
340
342
  // 同じ 2 点を結ぶ複数の線を弓なりに分ける (dragon #941)。 label と path が確定した後に動かす =
341
343
  // 確定前に判定すると、 後から動く label の位置を見て分離を諦める。 座標を明示した edge
342
- // (canvas pivot) は「指定した直線が最終 path」 が公開契約なので対象から外す。
344
+ // (canvas pivot)、`curve`、`metro` は「指定した形が最終 path」 が公開契約なので対象から外す。
343
345
  const positionedEdgeIds = new Set(
344
346
  diag.edges
345
- .filter((e) => e.posX1 !== undefined && e.posY1 !== undefined && e.posX2 !== undefined && e.posY2 !== undefined)
347
+ .filter(
348
+ (e) =>
349
+ e.routing === "curve" ||
350
+ e.routing === "metro" ||
351
+ (e.posX1 !== undefined && e.posY1 !== undefined && e.posX2 !== undefined && e.posY2 !== undefined),
352
+ )
346
353
  .map((e) => e.id),
347
354
  );
348
355
  bowSharedStraightPaths(edges, nodesFinal, positionedEdgeIds);
@@ -354,7 +361,7 @@ export function layout(diag: CdlDiagram): LaidDiagram {
354
361
  pullOutgoingFirstCorner(edges, nodesFinal);
355
362
  // 同じ辺に入る線の終点を辺に沿って離す (#611)。 経路を引き終えてから当てる = 終点を先に
356
363
  // 動かすと引き方の分岐が変わり、 迂回していた線が箱を貫通する (実測で 0 件が 7 件になった)。
357
- fanIncomingEndpoints(edges, nodesFinal);
364
+ const fannedTailShifts = fanIncomingEndpoints(edges, nodesFinal);
358
365
  // 同じ通り道を並んで走る区間をずらす (#610)。 弓の後に当てる = 弓で分かれた線を数え直さない。
359
366
  // 弧より先に当てる = ずらした後の形で交点を数えないと、 消えた交点に弧が残る。
360
367
  //
@@ -364,6 +371,14 @@ export function layout(diag: CdlDiagram): LaidDiagram {
364
371
  // 交わる所で上を通る側に弧を差し込む (#608)。 経路が確定してから当てる = 弓や迂回で
365
372
  // 形が変わった後の交点を見る。 札を置く前に当てるので、 札は弧を含んだ経路の上に載る。
366
373
  hopEdgeCrossings(edges);
374
+ // 終点を離す / 共有区間を分ける / 交差へ弧を入れる pass が全て終わった座標で札を見る (#1000)。
375
+ // 先に見ると、 その後に経路が動いて札が線を覆う形が戻る。
376
+ // 追従と最後の逃がし pass は、 `collectBBoxes` が含める下端の名札の箱上へのはみ出しと
377
+ // 縦列の題を同じ矩形で測る。 追従だけ箱本体で測ると、 置いた札を最後の pass が押し直す (#1002)。
378
+ const labelClearanceObstacles = collectBBoxes(containedLanesFinal, nodesFinal, []).filter(
379
+ (b) => b.kind === "node" || b.kind === "lane-label",
380
+ );
381
+ followLabelsToFannedTails(edges, labelClearanceObstacles, fannedTailShifts);
367
382
  repositionParallelLabels(edges, nodesFinal);
368
383
  // 縦にずらすだけでは逃げ場が無い札を、 自分の線に沿って動かす (#601)。 ここまでの
369
384
  // 置き直しは全て縦方向で、 線に沿って動かす経路が無かった。 いま被っている札だけを
@@ -371,16 +386,11 @@ export function layout(diag: CdlDiagram): LaidDiagram {
371
386
  slideLabelsAlongOwnPath(edges, nodesFinal);
372
387
  // 経路を動かす pass が全て終わってから、 札を箱と札から離し直す (#659)。
373
388
  //
374
- // 上の chain 伝搬はここより前に走るため、 その後で `pullOutgoingFirstCorner` /
375
- // `fanIncomingEndpoints` が経路を動かすと札も一緒に動く。 動いた後を見る者が居なかった。
389
+ // 上の chain 伝搬はここより前に走るため、 その後の経路 pass が動かした最終経路との隙間を
390
+ // ここで見直す。
376
391
  // ここで動かすのは札だけで、 箱も経路も触らない = 触ると直前の pass が確定させた形を崩す。
377
392
  if (!isChainEdgeLabelDisabled()) {
378
- edges = resolveLabelClearanceAfterPathPasses(
379
- edges,
380
- collectBBoxes(containedLanesFinal, nodesFinal, []).filter(
381
- (b) => b.kind === "node" || b.kind === "lane-label",
382
- ),
383
- );
393
+ edges = resolveLabelClearanceAfterPathPasses(edges, labelClearanceObstacles);
384
394
  }
385
395
 
386
396
  const bboxes = collectBBoxes(containedLanesFinal, nodesFinal, edges);
@@ -110,14 +110,16 @@ export function CdlEdgeView({
110
110
  // 貫通効果が必要な場合は別途 phase activation で表現する。
111
111
  // 丸の通り道は線の両端から印の長さだけ縮める (#988)。 縮めないと丸が端の印の中まで
112
112
  // 入り、輪郭だけの三角に濃い丸が重なって印に穴が開いたように見える。
113
- // 点線の粒を `shrinkPathEnd` で隠しているのと同じ理由で、向きが両端にあるぶん対で縮める
114
- const 通り道を縮める = EDGE_HEAD_MARKER_SIZE.既定 + 走る丸の半径.芯;
113
+ // 点線の粒を `shrinkPathEnd` で隠しているのと同じ理由で、向きが両端にあるぶん対で縮める。
114
+ // 縮める幅は芯ではなく **玉の一番外の縁** で測る (#994) = 使う側が縁を足すと芯だけでは足りない
115
+ const 通り道を縮める = EDGE_HEAD_MARKER_SIZE.既定 + 走る丸の外側;
115
116
  const glowPathD = shrinkPathStart(shrinkPathEnd(edge.d, 通り道を縮める), 通り道を縮める);
116
117
  void isPassThrough;
117
118
 
118
119
  return (
119
120
  <g
120
121
  data-cdl-edge={edge.id}
122
+ data-cdl-routing={edge.routing === "metro" ? "metro" : undefined}
121
123
  data-cdl-from={edge.from}
122
124
  data-cdl-to={edge.to}
123
125
  data-cdl-active={active ? "true" : "false"}
@@ -267,7 +269,21 @@ function SolidEdgePath({
267
269
  * 見えるので、印の長さに芯の半径を足した分だけ通り道を縮める。 ここに数を直に書くと、
268
270
  * 丸を大きくした時に芯だけが印へはみ出す。
269
271
  */
270
- export const 走る丸の半径 = { 外: 28, 中: 18, 芯: 11 } as const;
272
+ export const 走る丸の半径 = { 外: 28, 中: 18, 芯: 12 } as const;
273
+
274
+ /**
275
+ * 使う側が芯に足す縁の太さの見込み (world、半分の値)。
276
+ *
277
+ * 描く側は芯を塗りだけで描くが、使う側は紙の色の縁を回して線から浮かせることがある
278
+ * (見本帳は 3.5 の縁を足しており、外側は 12 + 1.75 = 13.75 になる)。
279
+ * その縁は CSS で足されるので描く側からは見えない = 見込みとして持つしかない。
280
+ *
281
+ * 2 にするのは見本帳の 1.75 を丸めた値。 **これより太い縁を足すと玉が印に入る**。
282
+ */
283
+ export const 走る丸の縁の見込み = 2;
284
+
285
+ /** 玉の一番外の縁までの長さ (world)。 通り道を端から縮める幅の基準になる */
286
+ export const 走る丸の外側 = 走る丸の半径.芯 + 走る丸の縁の見込み;
271
287
 
272
288
  function EdgeGlowCircles({ color, edgeId }: { color: string; edgeId: string }): JSX.Element {
273
289
  const ref = `#cdl-edge-${edgeId}`;
@@ -7,6 +7,7 @@ import { FunctionNode } from "../kinds/function";
7
7
  import { StorageNode } from "../kinds/storage";
8
8
  import { GenericNode } from "../kinds/generic";
9
9
  import { MarkStartNode, MarkEndNode } from "../kinds/terminal-mark";
10
+ import { StationNode } from "../kinds/station";
10
11
  import { DynShapeNode } from "../kinds/dyn-shape";
11
12
  import { ChartLineNode, type ChartLineScale } from "../kinds/chart-line";
12
13
  import { ChartPieNode } from "../kinds/chart-pie";
@@ -449,6 +450,8 @@ export function CdlNodeView({
449
450
  return <MarkStartNode node={resolvedNode} active={active} />;
450
451
  case "mark-end":
451
452
  return <MarkEndNode node={resolvedNode} active={active} />;
453
+ case "station":
454
+ return <StationNode node={resolvedNode} active={active} />;
452
455
  default:
453
456
  // 新 NodeKind 25 種は GenericNode で汎用描画 (kind 別 icon / color / shape)
454
457
  return <GenericNode node={resolvedNode} active={active} value={node.value} stateValues={stateValues} 値の名前={値の名前} />;
package/src/types.ts CHANGED
@@ -186,6 +186,8 @@ export const NODE_KINDS = [
186
186
  // 状態遷移図が使う。 印そのものが意味を持つので文字は描かない
187
187
  "mark-start",
188
188
  "mark-end",
189
+ // 路線図の駅 (#996) ... 線路上の点を小さな丸で示し、名前と説明は丸の外に置く
190
+ "station",
189
191
  // 時系列のやり取り (#578) ... 1 つの箱が図を丸ごと描く。 縦が時間で、参加者は箱を持たない
190
192
  "sequence-board",
191
193
  ] as const;
@@ -207,6 +209,18 @@ export type Side = "top" | "right" | "bottom" | "left";
207
209
  */
208
210
  export type EdgeStyle = "solid" | "dotted-flow" | "dashed";
209
211
 
212
+ /**
213
+ * 線の道筋。
214
+ *
215
+ * - `"default"` ... 自動。 箱を避け、角を丸めた直角の線を引く。 書かない時も同じ
216
+ * - `"back-detour"` ... 箱の上を回って前の箱へ戻る
217
+ * - `"curve"` ... 始点から水平に出て終点へ水平に入る 3 次曲線。 途中の箱は避けず、
218
+ * 2 点が同じ高さなら直線になる。 終点の矢印は水平を向く
219
+ * - `"metro"` ... 横線と傾き ±1 の斜線だけで結び、角を半径 r の円弧で丸める。
220
+ * 途中の箱は避けず、2 点が同じ高さなら横 1 本になる
221
+ */
222
+ export type EdgeRouting = "default" | "back-detour" | "curve" | "metro";
223
+
210
224
  /** 辺の役目 (#618)。 `main` = 図の主となる道 */
211
225
  export type EdgeRole = "main";
212
226
 
@@ -506,8 +520,8 @@ export type CdlNode = {
506
520
  * 変わるのは kind の既定色を使って描かれる部分で、 枠線 / eyebrow の文字色 / icon /
507
521
  * 強調表示中の行の値。 本文の文字色と背景は変わらない。
508
522
  *
509
- * 効くのは `actor` / `function` / `storage` / `event` / `card` と `person` 以降の汎用 kind の
510
- * 計 26 種。 それ以外は自前の配色を持つため指定しても変わらず、 検査も通す。 効く kind の
523
+ * 効くのは `actor` / `function` / `storage` / `event` / `card`、`person` 以降の汎用 kind、
524
+ * 流れの印、路線図の駅。 それ以外は自前の配色を持つため指定しても変わらず、検査も通す。
511
525
  * 一覧は `test/node-styling.test.tsx` が実装から計算した集合と突き合わせている。
512
526
  *
513
527
  * 既定色と同じ名前を明示指定しても色が変わる kind がある (既定色の hex が `render/tone.ts`
@@ -867,8 +881,12 @@ export type CdlEdge = {
867
881
  labelOffsetX?: number;
868
882
  /** label の y 方向 fine offset (default 0、 + で下、 - で上) */
869
883
  labelOffsetY?: number;
870
- /** routing mode (default "default" = 自動 routing、 "back-detour" = back transition 用の上方 detour 強制) */
871
- routing?: "default" | "back-detour";
884
+ /**
885
+ * 線の道筋。 `default` は箱を避ける自動経路、 `back-detour` は箱の上を回る戻り線、
886
+ * `curve` は途中の箱を避けず、両端で水平になる 3 次曲線。 `metro` は途中の箱を避けず、
887
+ * 横線と傾き ±1 の斜線を半径 r の円弧で結ぶ。2 点が同じ高さなら横 1 本。書かなければ `default`。
888
+ */
889
+ routing?: EdgeRouting;
872
890
  /**
873
891
  * CAR-549 SSOT ... label を edge path 上に「被せる」 か「離す」 かの制御 flag。
874
892
  * default (false or undefined) = LABEL_INIT_CLEARANCE (34 world、 CAR-630 で 26 → 34 復帰) 離して配置。
@@ -143,6 +143,7 @@ import {
143
143
  TITLE_ROW_MIN_GAP,
144
144
  rendersTitleText,
145
145
  fitsOwnTitleWidth,
146
+ skipsNodeTitleWidthCheck,
146
147
  drawsTitleOutsideBox,
147
148
  titleScalesToFit,
148
149
  MIN_TITLE_FONT_SIZE,
@@ -1980,6 +1981,8 @@ function runAxes(
1980
1981
  const LIFELINE_W_THRESHOLD = 8;
1981
1982
  for (const n of laid.nodes) {
1982
1983
  if (n.w < LIFELINE_W_THRESHOLD) continue;
1984
+ // 駅は文字を収める箱ではなく線路上の印なので、箱向けの最小寸法を要求しない。
1985
+ if (n.kind === "station") continue;
1983
1986
  if (n.w < MIN_NODE_W || n.h < MIN_NODE_H) {
1984
1987
  push(
1985
1988
  "node-visibility",
@@ -2016,6 +2019,8 @@ function runAxes(
2016
2019
  // 自分で幅に合わせて切る種別も同じ。 字数から幅を出す下の近似は字の大きさ 22 を前提に
2017
2020
  // しており、 それより小さい字で描く種別に当てると偽陽性になる (#469)
2018
2021
  if (fitsOwnTitleWidth(n.kind)) continue;
2022
+ // 箱の外に出す駅名は小さな丸の幅で切らないため、箱幅を上限にする前提が成り立たない。
2023
+ if (skipsNodeTitleWidthCheck(n.kind)) continue;
2019
2024
  // 常に非表示の node の文字は画面に出ないので、 切れようがない。
2020
2025
  if (!isRenderedNode(n)) continue;
2021
2026
  const titleTrimmed = n.title?.trim() ?? "";
@@ -3111,6 +3116,13 @@ function runAxes(
3111
3116
  for (let i = 0; i + 1 < sorted.length; i++) {
3112
3117
  const upper = sorted[i]!;
3113
3118
  const lower = sorted[i + 1]!;
3119
+ // 横の範囲が重ならない箱は同じ lane に横並びで置かれた組なので、縦の隙間を測らない。
3120
+ if (
3121
+ upper.cx + upper.w / 2 <= lower.cx - lower.w / 2 ||
3122
+ lower.cx + lower.w / 2 <= upper.cx - upper.w / 2
3123
+ ) {
3124
+ continue;
3125
+ }
3114
3126
  const upperBottom = upper.cy + upper.h / 2;
3115
3127
  const lowerTop = lower.cy - lower.h / 2;
3116
3128
  const gap = lowerTop - upperBottom;
@@ -4214,17 +4226,25 @@ function runAxes(
4214
4226
  y1: number;
4215
4227
  x2: number;
4216
4228
  y2: number;
4217
- cmd: "L" | "Q" | "C";
4229
+ cmd: "L" | "Q" | "C" | "A";
4218
4230
  ctrl?: ReadonlyArray<{ x: number; y: number }>;
4219
4231
  }>,
4220
4232
  peakY: number,
4221
4233
  ): Array<{ minX: number; maxX: number }> => {
4222
4234
  const out: Array<{ minX: number; maxX: number }> = [];
4235
+ let 円弧の始点: { x: number; y: number } | null = null;
4223
4236
  for (const s of segs) {
4224
- if (Math.abs(s.y1 - s.y2) > 1) continue;
4225
- if (Math.abs(s.y1 - peakY) > 1) continue;
4237
+ if (s.cmd === "A") {
4238
+ // 飛び越しの弧は同じ高さの通り道の上の小さな山で、山の前後は 1 本の通り道になる。
4239
+ 円弧の始点 ??= { x: s.x1, y: s.y1 };
4240
+ continue;
4241
+ }
4242
+ const 始点 = 円弧の始点 ?? { x: s.x1, y: s.y1 };
4243
+ 円弧の始点 = null;
4244
+ if (Math.abs(始点.y - s.y2) > 1) continue;
4245
+ if (Math.abs(始点.y - peakY) > 1) continue;
4226
4246
  if (s.cmd !== "L" && !(s.ctrl ?? []).every((c) => Math.abs(c.y - peakY) <= 1)) continue;
4227
- out.push({ minX: Math.min(s.x1, s.x2), maxX: Math.max(s.x1, s.x2) });
4247
+ out.push({ minX: Math.min(始点.x, s.x2), maxX: Math.max(始点.x, s.x2) });
4228
4248
  }
4229
4249
  return out;
4230
4250
  };