@cardenelabs/cdl 0.51.0 → 0.53.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.
@@ -693,6 +693,12 @@ type CdlEdge = {
693
693
  * 置き場所は線の最後の区間から決まる (`layout/head-label.ts`)。
694
694
  */
695
695
  headLabel?: string;
696
+ /**
697
+ * 出どころの端に添える短い字 (#825)。 `headLabel` と対。 クラス図の出どころ側の多重度に使う。
698
+ *
699
+ * UML は関係の両端に多重度を書く。 置き場所の計算は `headLabel` と同じ (`layout/head-label.ts`)。
700
+ */
701
+ tailLabel?: string;
696
702
  /** label の x 方向 fine offset (default 0)、 著者が細かく位置調整するための逃げ道 */
697
703
  labelOffsetX?: number;
698
704
  /** label の y 方向 fine offset (default 0、 + で下、 - で上) */
@@ -693,6 +693,12 @@ type CdlEdge = {
693
693
  * 置き場所は線の最後の区間から決まる (`layout/head-label.ts`)。
694
694
  */
695
695
  headLabel?: string;
696
+ /**
697
+ * 出どころの端に添える短い字 (#825)。 `headLabel` と対。 クラス図の出どころ側の多重度に使う。
698
+ *
699
+ * UML は関係の両端に多重度を書く。 置き場所の計算は `headLabel` と同じ (`layout/head-label.ts`)。
700
+ */
701
+ tailLabel?: string;
696
702
  /** label の x 方向 fine offset (default 0)、 著者が細かく位置調整するための逃げ道 */
697
703
  labelOffsetX?: number;
698
704
  /** label の y 方向 fine offset (default 0、 + で下、 - で上) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardenelabs/cdl",
3
- "version": "0.51.0",
3
+ "version": "0.53.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",
package/src/builder.ts CHANGED
@@ -1454,6 +1454,8 @@ export type DiagramBuilder = {
1454
1454
  cardinality?: string;
1455
1455
  /** 行き先の端に添える字 (#821)。 クラス図の多重度に使う */
1456
1456
  headLabel?: string;
1457
+ /** 出どころの端に添える字 (#825)。 クラス図の出どころ側の多重度に使う */
1458
+ tailLabel?: string;
1457
1459
  labelOffsetX?: number;
1458
1460
  labelOffsetY?: number;
1459
1461
  routing?: "default" | "back-detour";
@@ -1564,6 +1566,8 @@ export type DiagramBuilder = {
1564
1566
  cardinality?: string;
1565
1567
  /** 行き先の端に添える字 (#821)。 クラス図の多重度に使う */
1566
1568
  headLabel?: string;
1569
+ /** 出どころの端に添える字 (#825)。 クラス図の出どころ側の多重度に使う */
1570
+ tailLabel?: string;
1567
1571
  labelOffsetX?: number;
1568
1572
  labelOffsetY?: number;
1569
1573
  routing?: "default" | "back-detour";
@@ -1781,6 +1785,7 @@ export function diagram(
1781
1785
  guard: opts.guard,
1782
1786
  cardinality: opts.cardinality,
1783
1787
  headLabel: opts.headLabel,
1788
+ tailLabel: opts.tailLabel,
1784
1789
  labelOffsetX: opts.labelOffsetX,
1785
1790
  labelOffsetY: opts.labelOffsetY,
1786
1791
  routing: opts.routing,
@@ -1916,6 +1921,7 @@ export function diagram(
1916
1921
  guard: d.guard,
1917
1922
  cardinality: d.cardinality,
1918
1923
  headLabel: d.headLabel,
1924
+ tailLabel: d.tailLabel,
1919
1925
  labelOffsetX: d.labelOffsetX,
1920
1926
  labelOffsetY: d.labelOffsetY,
1921
1927
  routing: d.routing,
@@ -1,11 +1,11 @@
1
1
  /**
2
- * 行き先の端に添える字 (`headLabel`) の置き場所 (#821)。
2
+ * 線の端に添える字 (`headLabel` / `tailLabel`) の置き場所 (#821 / #825)。
3
3
  *
4
4
  * 描く側 (`render/edges.tsx`) と検査が同じ値を使うため、計算をここに 1 つだけ置く。
5
+ * 行き先の端 (`head`) と出どころの端 (`tail`) は、端での線の向きの取り方だけが違う。
5
6
  *
6
- * 線の最後の区間の向きで決める。 端から `端の字を戻す距離` だけ線を戻った点を基準に、
7
- * 線の脇へ置く。 字は端に近い側の縁を基準点に揃える = 字の長さが変わっても、端から字までの
8
- * 隙間は変わらない。
7
+ * 端での線の向きで決める。 端から `端の字を戻す距離` だけ線を戻った点を基準に、線の脇へ置く。
8
+ * 字は端に近い側の縁を基準点に揃える = 字の長さが変わっても、端から字までの隙間は変わらない。
9
9
  *
10
10
  * **線の札の反対側に置く**。 札と同じ側に置くと、札の語と多重度が続けて読まれる (「持つ」 の
11
11
  * 隣に `1..*`、 英字の頃は「HAS1」)。 実測で、札と同じ側 (横向きなら上、縦向きなら右) に
@@ -13,8 +13,8 @@
13
13
  * 札を持たない線は、横向きなら下、縦向きなら左に置く (engine が札を置く既定の側の反対)。
14
14
  *
15
15
  * 斜めの線と曲線は、向きの大きいほうの軸で横向き / 縦向きに振り分ける。 曲線の向きは
16
- * 終点の接線 (最後の制御点から終点へ) を使う。 弦の向きを使うと、大きく膨らむ曲線で
17
- * 線が端へ入ってくる向きと別の向きが出る。
16
+ * 端の接線 (行き先の端なら最後の制御点から終点へ、出どころの端なら最初の制御点から始点へ) を
17
+ * 使う。 弦の向きを使うと、大きく膨らむ曲線で線が端へ入ってくる向きと別の向きが出る。
18
18
  */
19
19
  import { EDGE_LABEL_TEXT } from "../label-text";
20
20
  import { extractPathSegments } from "./collisions";
@@ -25,7 +25,7 @@ import { measureTextWidth } from "./text-width";
25
25
  * 端から字までの、線に沿った距離 (world)。
26
26
  *
27
27
  * 端の印の長さの最大 (光っている時の 14、 `render/stage.tsx` の marker の大きさ) に隙間 8 を足す。
28
- * 印を持たない端 (菱が出どころ側にある「持つ」 「抱える」) でも同じ距離にする = 端ごとに
28
+ * 印を持たない端 (菱が出どころ側にある「持つ」 「抱える」 の行き先) でも同じ距離にする = 端ごとに
29
29
  * 距離が変わると、図の中で多重度の位置が揃わない。
30
30
  */
31
31
  export const 端の字を戻す距離 = 22;
@@ -42,6 +42,33 @@ const 箱の高さ = Math.round(端の字の大きさ * 1.2);
42
42
  /** 字の大きさに対する baseline の深さ。 等幅の字の ascent が字の大きさの 8 割ほど */
43
43
  const BASELINE_RATIO = 0.8;
44
44
 
45
+ /**
46
+ * 両端に字がある線で、2 つの字の間に空ける長さ (world)。
47
+ *
48
+ * 字と自分の端の間 (`端の字を戻す距離`) の 2 倍にする = 字が自分の端より相手の字の近くに
49
+ * 見えなくなる。 実測で、間が 10 だった短い縦の線 (長さ 100) は 2 つの字が 2 行の札のように
50
+ * 読まれ、どちらの端の数かが分からなかった (#829)。
51
+ */
52
+ export const 両端の字の間 = 端の字を戻す距離 * 2;
53
+
54
+ /**
55
+ * 端の字を線の同じ側に並べた時、線に沿って要る長さ (world)。 字が無い線は 0。
56
+ *
57
+ * 字は端から `端の字を戻す距離` 戻った所から、線に沿って `箱の高さ` だけ占める (縦向きの時)。
58
+ * 両端にあれば、その 2 つの間に `両端の字の間` を足す。 縦に隣り合う箱の間隔を決める
59
+ * `layout/nodes.ts` が使う = 間隔が線の長さそのものになる形だけが対象。
60
+ */
61
+ export function 端の字に要る線の長さ(edge: { headLabel?: string; tailLabel?: string }): number {
62
+ const 一つ分 = 端の字を戻す距離 + 箱の高さ;
63
+ const 数 = [edge.headLabel, edge.tailLabel].filter((t) => t?.trim()).length;
64
+ if (数 === 0) return 0;
65
+ if (数 === 1) return 一つ分;
66
+ return 一つ分 * 2 + 両端の字の間;
67
+ }
68
+
69
+ /** どちらの端か。 `head` = 行き先 (`to`)、 `tail` = 出どころ (`from`) */
70
+ export type 線の端 = "head" | "tail";
71
+
45
72
  export interface 端の字の置き場所 {
46
73
  /** `text` の x。 どちらの縁かは `anchor` が決める */
47
74
  x: number;
@@ -52,43 +79,60 @@ export interface 端の字の置き場所 {
52
79
  box: { x: number; y: number; w: number; h: number };
53
80
  }
54
81
 
82
+ type 点 = { x: number; y: number };
83
+
84
+ /** 端の点と、線がその端へ入ってくる向き (単位ベクトル)。 取れない時は `null` */
85
+ function 向きを取る(end: 点, 候補: ReadonlyArray<点 | undefined>): { end: 点; ux: number; uy: number } | null {
86
+ for (const p of 候補) {
87
+ if (!p) continue;
88
+ const dx = end.x - p.x;
89
+ const dy = end.y - p.y;
90
+ const len = Math.hypot(dx, dy);
91
+ if (len > 1e-6) return { end, ux: dx / len, uy: dy / len };
92
+ }
93
+ return null;
94
+ }
95
+
55
96
  /**
56
- * 線の終点と、線が終点へ入ってくる向き (単位ベクトル)。
97
+ * 端の点と、線がその端へ入ってくる向き。
57
98
  *
58
- * 最後の区間の長さが 0 の時は 1 つ前の区間へ戻る。 向きが取れない (区間が無い / 全て長さ 0)
59
- * 時は `null`。
99
+ * 行き先の端は最後の区間から、出どころの端は最初の区間を逆にたどって取る。 端の区間の長さが
100
+ * 0 の時は隣の区間へ進む。 向きが取れない (区間が無い / 全て長さ 0) 時は `null`。
60
101
  */
61
- function 最後の向き(d: string): { end: { x: number; y: number }; ux: number; uy: number } | null {
102
+ function 端での向き(d: string, 端: 線の端): { end: 点; ux: number; uy: number } | null {
62
103
  const segs = extractPathSegments(d);
63
- const last = segs[segs.length - 1];
64
- if (!last) return null;
65
- const end = { x: last.x2, y: last.y2 };
66
- for (let i = segs.length - 1; i >= 0; i--) {
67
- const s = segs[i]!;
68
- const 制御点 = s.cmd === "Q" ? s.ctrl?.[0] : s.cmd === "C" ? s.ctrl?.[1] : undefined;
69
- for (const p of [制御点, { x: s.x1, y: s.y1 }]) {
70
- if (!p) continue;
71
- const dx = end.x - p.x;
72
- const dy = end.y - p.y;
73
- const len = Math.hypot(dx, dy);
74
- if (len > 1e-6) return { end, ux: dx / len, uy: dy / len };
75
- }
104
+ if (segs.length === 0) return null;
105
+ if (端 === "head") {
106
+ const last = segs[segs.length - 1]!;
107
+ const end = { x: last.x2, y: last.y2 };
108
+ const 候補 = segs
109
+ .slice()
110
+ .reverse()
111
+ .flatMap((s) => [s.cmd === "Q" ? s.ctrl?.[0] : s.cmd === "C" ? s.ctrl?.[1] : undefined, { x: s.x1, y: s.y1 }]);
112
+ return 向きを取る(end, 候補);
76
113
  }
77
- return null;
114
+ const first = segs[0]!;
115
+ const end = { x: first.x1, y: first.y1 };
116
+ const 候補 = segs.flatMap((s) => [...(s.ctrl ?? []), { x: s.x2, y: s.y2 }]);
117
+ return 向きを取る(end, 候補);
78
118
  }
79
119
 
80
- /** `headLabel` を描く位置。 字が空か、線の向きが取れない時は `null` (描かない) */
81
- export function 端の字を置く(edge: {
82
- d: string;
83
- headLabel?: string;
84
- label?: string;
85
- sub?: string;
86
- labelX?: number;
87
- labelY?: number;
88
- }): 端の字の置き場所 | null {
89
- const text = edge.headLabel?.trim();
120
+ /** 端の字を描く位置。 字が空か、線の向きが取れない時は `null` (描かない) */
121
+ export function 端の字を置く(
122
+ edge: {
123
+ d: string;
124
+ headLabel?: string;
125
+ tailLabel?: string;
126
+ label?: string;
127
+ sub?: string;
128
+ labelX?: number;
129
+ labelY?: number;
130
+ },
131
+ 端: 線の端 = "head",
132
+ ): 端の字の置き場所 | null {
133
+ const text = (端 === "head" ? edge.headLabel : edge.tailLabel)?.trim();
90
134
  if (!text) return null;
91
- const 向き = 最後の向き(edge.d);
135
+ const 向き = 端での向き(edge.d, 端);
92
136
  if (!向き) return null;
93
137
  const { end, ux, uy } = 向き;
94
138
  const bx = end.x - ux * 端の字を戻す距離;
@@ -1,5 +1,6 @@
1
1
  import { requiredRowsWidth } from "../kinds/row-align";
2
2
  import type { CdlDiagram, LaidLane, LaidNode } from "../types";
3
+ import { 端の字に要る線の長さ } from "./head-label";
3
4
  import {
4
5
  hasRenderedLabel,
5
6
  labelPillH,
@@ -138,9 +139,9 @@ function requiredStackGapForEdgeLabels(diag: CdlDiagram, allStacks: readonly num
138
139
 
139
140
  // 同じ隙間 (lane + 上側 row の rank) を通る label の pill 高を集める。
140
141
  const pillHsByGap = new Map<string, number[]>();
142
+ // 端の字 (#829) は線の端から測るので、隙間ごとに集めず線 1 本ずつの長さで足りる
143
+ let 端の字の要求 = 0;
141
144
  for (const e of diag.edges) {
142
- // `label` が空でも `sub` があれば pill は描かれ、 その高さ (68) の分だけ隙間が要る (Issue #224)。
143
- if (!hasRenderedLabel(e)) continue;
144
145
  const from = byId.get(e.from);
145
146
  const to = byId.get(e.to);
146
147
  if (!from || !to) continue;
@@ -149,11 +150,14 @@ function requiredStackGapForEdgeLabels(diag: CdlDiagram, allStacks: readonly num
149
150
  const toRank = rankOf.get(to.stack);
150
151
  if (fromRank === undefined || toRank === undefined) continue;
151
152
  if (Math.abs(fromRank - toRank) !== 1) continue;
153
+ 端の字の要求 = Math.max(端の字の要求, 端の字に要る線の長さ(e));
154
+ // `label` が空でも `sub` があれば pill は描かれ、 その高さ (68) の分だけ隙間が要る (Issue #224)。
155
+ if (!hasRenderedLabel(e)) continue;
152
156
  const key = `${from.lane}::${Math.min(fromRank, toRank)}`;
153
157
  pillHsByGap.set(key, [...(pillHsByGap.get(key) ?? []), labelPillH(e.sub)]);
154
158
  }
155
159
 
156
- let required = 0;
160
+ let required = 端の字の要求;
157
161
  for (const pillHs of pillHsByGap.values()) {
158
162
  required = Math.max(required, requiredVerticalLabelGap(pillHs));
159
163
  }
package/src/presets.ts CHANGED
@@ -1432,6 +1432,8 @@ export type UmlRelation = {
1432
1432
  label?: string;
1433
1433
  /** 多重度 (例 "1..*" / "0..1")。 行き先 (`to`) の端に添える (#821) */
1434
1434
  cardinality?: string;
1435
+ /** 出どころ (`from`) 側の多重度 (例 "1")。 出どころの端に添える (#825) */
1436
+ tailCardinality?: string;
1435
1437
  tone?: Tone;
1436
1438
  /**
1437
1439
  * 矢印の先の形 (cdl#574)。 書かなければ種類から決まる (`CLASS_RELATION_LOOK`)。
@@ -1576,6 +1578,7 @@ export function classDiagram(preset: ClassDiagramPreset): ClassDiagramBuilder {
1576
1578
  // 多重度は行き先の端に添える (#821)。 札の下の行に入れると語と続けて読まれ
1577
1579
  // (英字の頃は「HAS 1..*」 が「HAS1」)、 どちらの端の数かも読めない
1578
1580
  ...(r.cardinality ? { headLabel: r.cardinality } : {}),
1581
+ ...(r.tailCardinality ? { tailLabel: r.tailCardinality } : {}),
1579
1582
  tone: r.tone ?? 図の既定 ?? CLASS_RELATION_TONE[r.type],
1580
1583
  // 関係の種類から線と端の形を決める (cdl#574 / #578)。 書いた値が勝つ
1581
1584
  style: r.style ?? 見た目.style,
@@ -1,7 +1,7 @@
1
1
  import { 等幅の字 } from "./mono-text";
2
2
  import type { JSX } from "react";
3
3
  import { computeLabelBBoxWorld, hasRenderedLabel, LABEL_PILL_PAD_X } from "../layout/spec";
4
- import { 端の字の大きさ, 端の字を置く } from "../layout/head-label";
4
+ import { 端の字の大きさ, 端の字を置く, type 線の端 } from "../layout/head-label";
5
5
  import type { EdgeRole, LaidEdge, Tone } from "../types";
6
6
  import { edgeHeadMarkerName } from "./edge-head";
7
7
  import { toneColor } from "./tone";
@@ -397,25 +397,28 @@ export function CdlEdgeLabelView({
397
397
  }
398
398
 
399
399
  /**
400
- * 行き先の端に添える字 (#821)。 札と同じく最前面の pass で描く。
400
+ * 線の端に添える字 (#821 / #825)。 札と同じく最前面の pass で描く。 `端` で行き先か出どころかを選ぶ。
401
401
  *
402
402
  * 下地の四角は敷かず、字の周りに下地色の縁取りを付ける (`paint-order: stroke`)。 字は線の脇、
403
403
  * 箱の角の近くに置くので、四角い下地だと箱の角や線に被って小さな箱に見える。
404
404
  */
405
- export function CdlEdgeHeadLabelView({
405
+ export function CdlEdgeEndLabelView({
406
406
  edge,
407
407
  active,
408
+ 端,
408
409
  }: {
409
410
  edge: LaidEdge;
410
411
  active: boolean;
412
+ 端: 線の端;
411
413
  }): JSX.Element | null {
412
- const 置き場所 = 端の字を置く(edge);
414
+ const 置き場所 = 端の字を置く(edge, 端);
413
415
  if (!置き場所) return null;
414
416
  const color = edgeColor(edge);
415
417
  return (
416
418
  <text
417
419
  data-cdl-role="edge-end-label"
418
420
  data-cdl-edge-end-label-for={edge.id}
421
+ data-cdl-edge-end={端}
419
422
  x={置き場所.x}
420
423
  y={置き場所.y}
421
424
  fontSize={端の字の大きさ}
@@ -428,7 +431,7 @@ export function CdlEdgeHeadLabelView({
428
431
  paintOrder="stroke"
429
432
  style={{ fontFamily: fontFamilyAttr("sub") }}
430
433
  >
431
- {等幅の字(edge.headLabel, 端の字の大きさ)}
434
+ {等幅の字(端 === "head" ? edge.headLabel : edge.tailLabel, 端の字の大きさ)}
432
435
  </text>
433
436
  );
434
437
  }
@@ -20,7 +20,7 @@ import {
20
20
  edgeHeadMarkerName,
21
21
  edgeHeadPaint,
22
22
  } from "./edge-head";
23
- import { CdlEdgeView, CdlEdgeGlowView, CdlEdgeHeadLabelView, CdlEdgeLabelView } from "./edges";
23
+ import { CdlEdgeView, CdlEdgeGlowView, CdlEdgeEndLabelView, CdlEdgeLabelView } from "./edges";
24
24
  import { CdlNodeView } from "./nodes";
25
25
  import { resolveChartData } from "./payload-binding";
26
26
  import { relatedFocusSet } from "./relation-focus";
@@ -453,16 +453,19 @@ export function CdlStage({
453
453
  />
454
454
  ))}
455
455
 
456
- {/* 行き先の端に添える字 (#821)。 札と同じ最前面の pass */}
456
+ {/* 線の端に添える字 (#821 / #825)。 札と同じ最前面の pass */}
457
457
  {laid.edges
458
- .filter((edge) => edge.headLabel && 矢印の出番.出るか(edge.id))
459
- .map((edge) => (
460
- <CdlEdgeHeadLabelView
461
- key={`head-label-${edge.id}`}
462
- edge={edge}
463
- active={activeSet.has(edge.id)}
464
- />
465
- ))}
458
+ .filter((edge) => (edge.headLabel || edge.tailLabel) && 矢印の出番.出るか(edge.id))
459
+ .flatMap((edge) =>
460
+ (["head", "tail"] as const).map((端) => (
461
+ <CdlEdgeEndLabelView
462
+ key={`${端}-label-${edge.id}`}
463
+ edge={edge}
464
+ active={activeSet.has(edge.id)}
465
+ 端={端}
466
+ />
467
+ )),
468
+ )}
466
469
 
467
470
  {/* debug overlay = engine が把握している bbox + collision を半透明で重ね描き */}
468
471
  {debug && (
package/src/types.ts CHANGED
@@ -765,6 +765,12 @@ export type CdlEdge = {
765
765
  * 置き場所は線の最後の区間から決まる (`layout/head-label.ts`)。
766
766
  */
767
767
  headLabel?: string;
768
+ /**
769
+ * 出どころの端に添える短い字 (#825)。 `headLabel` と対。 クラス図の出どころ側の多重度に使う。
770
+ *
771
+ * UML は関係の両端に多重度を書く。 置き場所の計算は `headLabel` と同じ (`layout/head-label.ts`)。
772
+ */
773
+ tailLabel?: string;
768
774
  /** label の x 方向 fine offset (default 0)、 著者が細かく位置調整するための逃げ道 */
769
775
  labelOffsetX?: number;
770
776
  /** label の y 方向 fine offset (default 0、 + で下、 - で上) */