@cardenelabs/cdl 0.21.2 → 0.23.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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * 関係から lane と stack の並びを決める (#599)。
3
+ *
4
+ * cdl は交差を `countEdgeCrossings` で数えるだけで、減らす手段を持っていなかった。
5
+ * 書き手が lane / stack を手で指定する仕様のため、交差が出ても engine 側に打つ手がない。
6
+ * 6 表 5 関係の図を人が並べた実測で交差 3 箇所、本関数の並びでは 0 箇所になる。
7
+ *
8
+ * ## 3 段で決める
9
+ *
10
+ * | 段 | やること |
11
+ * |---|---|
12
+ * | 1 | 親からの深さで lane 番号を決める (最長路) |
13
+ * | 2 | lane を跨ぐ関係に中継点を挟み、通り道を確保する |
14
+ * | 3 | 各 lane の並びを中央値法で往復させ、交差を減らす |
15
+ *
16
+ * ## `compile` からは呼ばない
17
+ *
18
+ * 書き手が明示した lane / stack を engine が黙って動かすと、既存の全図の配置が変わる。
19
+ * 本関数は入力を受けて割当を返すだけで、呼ぶかどうかは上流が決める。
20
+ *
21
+ * ## 中継点を挟む理由
22
+ *
23
+ * lane を 2 つ以上跨ぐ関係は、途中の lane で場所を取らないと他の節点と重なる経路になる。
24
+ * 中継点を置くと、その lane の並べ替えに参加して通り道が確保される。
25
+ * 返す結果に中継点は含まない (呼出側が知る必要のない内部の足場)。
26
+ */
27
+
28
+ /** 並べたい節点。 大きさは見ないので id だけでよい */
29
+ export type OrderNode = { id: string };
30
+
31
+ /** 親から子への関係。 向きが lane の深さを決める */
32
+ export type OrderEdge = { from: string; to: string };
33
+
34
+ /** 決まった並び。 lane が列、 stack が列の中の位置 */
35
+ export type OrderResult = { id: string; lane: number; stack: number };
36
+
37
+ /** 中央値法を往復させる回数。 4 往復で並びが落ち着くことを test で確かめている */
38
+ const SWEEPS = 4;
39
+
40
+ /** 中継点かどうかは id の接頭辞で分ける。 呼出側の id と衝突しない形にする */
41
+ const RELAY = "__relay:";
42
+
43
+ /**
44
+ * 親からの深さを決める。
45
+ *
46
+ * 環状の関係 (a→b→c→a) があると最長路は定義できないため、節点数を上限に打ち切る。
47
+ * 打ち切った時点の深さをそのまま使う = 環の中では順序が付かないが、結果は返る。
48
+ */
49
+ function ranks(nodes: readonly OrderNode[], edges: readonly OrderEdge[]): Map<string, number> {
50
+ const rank = new Map<string, number>();
51
+ for (const n of nodes) rank.set(n.id, 0);
52
+ for (let pass = 0; pass < nodes.length; pass += 1) {
53
+ let 動いた = false;
54
+ for (const e of edges) {
55
+ if (e.from === e.to) continue;
56
+ const a = rank.get(e.from);
57
+ const b = rank.get(e.to);
58
+ if (a === undefined || b === undefined) continue;
59
+ if (b < a + 1) {
60
+ rank.set(e.to, a + 1);
61
+ 動いた = true;
62
+ }
63
+ }
64
+ if (!動いた) break;
65
+ }
66
+ return rank;
67
+ }
68
+
69
+ /** 中央値。 参照先が無い節点は -1 を返し、並べ替えの対象から外す */
70
+ function median(values: readonly number[]): number {
71
+ if (values.length === 0) return -1;
72
+ const v = [...values].sort((a, b) => a - b);
73
+ return v[Math.floor((v.length - 1) / 2)] ?? -1;
74
+ }
75
+
76
+ /**
77
+ * 関係から lane と stack を決める。
78
+ *
79
+ * 同じ入力からは常に同じ並びが返る (乱数を使わず、同点は入力順で解く)。
80
+ *
81
+ * @param nodes 並べたい節点
82
+ * @param edges 親から子への関係。 自己参照は深さに影響しない
83
+ * @returns 節点ごとの lane と stack。 入力に無い id は返らない
84
+ */
85
+ export function orderNodes(nodes: readonly OrderNode[], edges: readonly OrderEdge[]): OrderResult[] {
86
+ if (nodes.length === 0) return [];
87
+ const ある = new Set(nodes.map((n) => n.id));
88
+ const 有効 = edges.filter((e) => ある.has(e.from) && ある.has(e.to));
89
+
90
+ const rank = ranks(nodes, 有効);
91
+
92
+ // 中継点を挟む。 lane を 2 つ以上跨ぐ関係だけが対象
93
+ type 節 = { id: string; lane: number };
94
+ const 節点: 節[] = nodes.map((n) => ({ id: n.id, lane: rank.get(n.id) ?? 0 }));
95
+ const 辺: OrderEdge[] = [];
96
+ 有効.forEach((e, i) => {
97
+ if (e.from === e.to) return;
98
+ const 始 = rank.get(e.from) ?? 0;
99
+ const 終 = rank.get(e.to) ?? 0;
100
+ let 前 = e.from;
101
+ for (let s = 始 + 1; s < 終; s += 1) {
102
+ const id = `${RELAY}${i}:${s}`;
103
+ 節点.push({ id, lane: s });
104
+ 辺.push({ from: 前, to: id });
105
+ 前 = id;
106
+ }
107
+ 辺.push({ from: 前, to: e.to });
108
+ });
109
+
110
+ // lane ごとに並べる。 初期の並びは入力順
111
+ const 深さ = Math.max(...節点.map((n) => n.lane)) + 1;
112
+ const 列: 節[][] = Array.from({ length: 深さ }, () => []);
113
+ for (const n of 節点) 列[n.lane]?.push(n);
114
+
115
+ const 位置 = (): Map<string, number> => {
116
+ const m = new Map<string, number>();
117
+ for (const 並 of 列) 並.forEach((n, i) => m.set(n.id, i));
118
+ return m;
119
+ };
120
+ const 親 = new Map<string, string[]>();
121
+ const 子 = new Map<string, string[]>();
122
+ const 足す = (m: Map<string, string[]>, key: string, value: string): void => {
123
+ const 既 = m.get(key);
124
+ if (既) 既.push(value);
125
+ else m.set(key, [value]);
126
+ };
127
+ for (const e of 辺) {
128
+ 足す(親, e.to, e.from);
129
+ 足す(子, e.from, e.to);
130
+ }
131
+
132
+ for (let pass = 0; pass < SWEEPS; pass += 1) {
133
+ const pos = 位置();
134
+ const 下向き = pass % 2 === 0;
135
+ const 順 = 下向き ? [...列.keys()] : [...列.keys()].reverse();
136
+ for (const s of 順) {
137
+ if ((下向き && s === 0) || (!下向き && s === 列.length - 1)) continue;
138
+ const 並 = 列[s];
139
+ if (!並) continue;
140
+ const 元順 = new Map(並.map((n, i) => [n.id, i]));
141
+ const 重み = new Map(
142
+ 並.map((n) => {
143
+ const 相手 = (下向き ? 親.get(n.id) : 子.get(n.id)) ?? [];
144
+ const m = median(相手.map((id) => pos.get(id) ?? -1).filter((v) => v >= 0));
145
+ return [n.id, m];
146
+ }),
147
+ );
148
+ 並.sort((a, b) => {
149
+ const A = 重み.get(a.id) ?? -1;
150
+ const B = 重み.get(b.id) ?? -1;
151
+ // 参照先を持たない節点は動かさない (元の位置を保つ)
152
+ if (A < 0 && B < 0) return (元順.get(a.id) ?? 0) - (元順.get(b.id) ?? 0);
153
+ if (A < 0) return 1;
154
+ if (B < 0) return -1;
155
+ if (A !== B) return A - B;
156
+ return (元順.get(a.id) ?? 0) - (元順.get(b.id) ?? 0);
157
+ });
158
+ }
159
+ }
160
+
161
+ // 中継点を落とし、残りに stack を振り直す
162
+ const 結果: OrderResult[] = [];
163
+ 列.forEach((並, lane) => {
164
+ let stack = 0;
165
+ for (const n of 並) {
166
+ if (n.id.startsWith(RELAY)) continue;
167
+ 結果.push({ id: n.id, lane, stack });
168
+ stack += 1;
169
+ }
170
+ });
171
+ return 結果;
172
+ }
173
+
174
+ /**
175
+ * 並びの上で交差の数を数える。
176
+ *
177
+ * 節点の実寸を見ず、lane と stack だけで数える近似。
178
+ * 同じ lane 間を渡る 2 本が、始点と終点の上下関係を入れ替えていれば 1 交差と数える。
179
+ * 配置を比べる用途にはこれで足り、実寸を要する判定は `countEdgeCrossings` が担う。
180
+ */
181
+ export function countOrderCrossings(order: readonly OrderResult[], edges: readonly OrderEdge[]): number {
182
+ const 場所 = new Map(order.map((o) => [o.id, o]));
183
+ const 渡り = edges
184
+ .filter((e) => e.from !== e.to)
185
+ .map((e) => ({ a: 場所.get(e.from), b: 場所.get(e.to) }))
186
+ .filter((e): e is { a: OrderResult; b: OrderResult } => e.a !== undefined && e.b !== undefined);
187
+
188
+ let 数 = 0;
189
+ for (let i = 0; i < 渡り.length; i += 1) {
190
+ for (let j = i + 1; j < 渡り.length; j += 1) {
191
+ const x = 渡り[i];
192
+ const y = 渡り[j];
193
+ if (!x || !y) continue;
194
+ // 同じ lane から同じ lane へ渡る 2 本だけを比べる
195
+ if (x.a.lane !== y.a.lane || x.b.lane !== y.b.lane) continue;
196
+ const 上下 = (x.a.stack - y.a.stack) * (x.b.stack - y.b.stack);
197
+ if (上下 < 0) 数 += 1;
198
+ }
199
+ }
200
+ return 数;
201
+ }
@@ -251,6 +251,7 @@ export function genericTitleBaselineY(kind: string | undefined, h: number, hasRo
251
251
  export const ROW_TOP_STORAGE = 152;
252
252
  export const ROW_PITCH_STORAGE = 56;
253
253
 
254
+
254
255
  /** `kinds/generic.tsx` の 1 行目 baseline と行送り。 */
255
256
  export const ROW_TOP_GENERIC = 100;
256
257
  export const ROW_PITCH_GENERIC = 28;
@@ -1155,3 +1156,29 @@ export const CLOUD_ART_CENTER_RISE = (() => {
1155
1156
  const 下 = Math.max(...CLOUD_CIRCLES.map((c) => c.cy + c.r));
1156
1157
  return -(上 + 下) / 2;
1157
1158
  })();
1159
+
1160
+ /**
1161
+ * 箱が中身より高い時に、字を下へずらす量 (#597)。
1162
+ *
1163
+ * 段の高さは行内の最大値で決まるので、行に高い箱が 1 つあると同じ行の箱もそこまで伸びる。
1164
+ * 字は上から置かれるため、伸びたぶんがそのまま下に空く (実測 = 240 の箱に 67 ぶんの字が
1165
+ * 上端に貼り付いていた)。
1166
+ *
1167
+ * 余りを上下で分ける = 中身を箱の中央に置く。 行を持つ形と値を持つ形は対象外
1168
+ * (行は上端から数え、値は下端から置くので、動かすと重なる)。
1169
+ *
1170
+ * @param 満載h 3 段すべてを書いた時の高さ (`NODE_SIZE[kind].h`)
1171
+ * @param 実際のh layout が決めた高さ
1172
+ */
1173
+ export function 余りを分ける(
1174
+ kind: string | undefined,
1175
+ 満載h: number,
1176
+ 実際のh: number,
1177
+ 中身: { hasEyebrow: boolean; hasSubtitle: boolean; hasValue: boolean; hasRows: boolean },
1178
+ ): number {
1179
+ if (!段を積む箱か(kind, 中身.hasRows)) return 0;
1180
+ if (中身.hasValue) return 0;
1181
+ const 要る高さ = 中身から出す高さ(kind, 満載h, 中身);
1182
+ const 余り = 実際のh - 要る高さ;
1183
+ return 余り > 0 ? Math.round(余り / 2) : 0;
1184
+ }
package/src/layout.ts CHANGED
@@ -10,7 +10,15 @@ import {
10
10
  resolveEdgeLabelOverlapsWithChainAndPropagate,
11
11
  resolveOverlaps,
12
12
  } from "./layout/collisions";
13
- import { bowSharedStraightPaths, layoutEdges, repositionParallelLabels } from "./layout/edges";
13
+ import {
14
+ bowSharedStraightPaths,
15
+ fanIncomingEndpoints,
16
+ hopEdgeCrossings,
17
+ layoutEdges,
18
+ repositionParallelLabels,
19
+ separateSharedStraightRuns,
20
+ slideLabelsAlongOwnPath,
21
+ } from "./layout/edges";
14
22
  import { applyFooterShapeDrops } from "./layout/footer-shape";
15
23
  import {
16
24
  expandLaneGapsForEdgeLabels,
@@ -338,7 +346,23 @@ export function layout(diag: CdlDiagram): LaidDiagram {
338
346
  // 弧が確定してから、 同じ節の組を結ぶ辺の label を「兄弟の線を跨がない位置」 へ置き直す (#376)。
339
347
  // 順序が逆だと、 直線を前提に置いた label を弧が貫く。 label を先に線から離すと、 今度は弧を
340
348
  // 膨らませる判定が label を障害物と見なして弧が消える。
349
+ // 同じ辺に入る線の終点を辺に沿って離す (#611)。 経路を引き終えてから当てる = 終点を先に
350
+ // 動かすと引き方の分岐が変わり、 迂回していた線が箱を貫通する (実測で 0 件が 7 件になった)。
351
+ fanIncomingEndpoints(edges, nodesFinal);
352
+ // 同じ通り道を並んで走る区間をずらす (#610)。 弓の後に当てる = 弓で分かれた線を数え直さない。
353
+ // 弧より先に当てる = ずらした後の形で交点を数えないと、 消えた交点に弧が残る。
354
+ //
355
+ // 終点を離した **後** に当てる。 先に当てると、 終点を離したことで新しくできた並走が
356
+ // 残る (実測で ER 4 表に長さ 95 の並走が戻った)。
357
+ separateSharedStraightRuns(edges, nodesFinal);
358
+ // 交わる所で上を通る側に弧を差し込む (#608)。 経路が確定してから当てる = 弓や迂回で
359
+ // 形が変わった後の交点を見る。 札を置く前に当てるので、 札は弧を含んだ経路の上に載る。
360
+ hopEdgeCrossings(edges);
341
361
  repositionParallelLabels(edges, nodesFinal);
362
+ // 縦にずらすだけでは逃げ場が無い札を、 自分の線に沿って動かす (#601)。 ここまでの
363
+ // 置き直しは全て縦方向で、 線に沿って動かす経路が無かった。 いま被っている札だけを
364
+ // 対象にするので、 落ち着いている図の座標は動かない。
365
+ slideLabelsAlongOwnPath(edges, nodesFinal);
342
366
 
343
367
  const bboxes = collectBBoxes(containedLanesFinal, nodesFinal, edges);
344
368
  const collisions = detectCollisions(bboxes, edges);
package/src/presets.ts CHANGED
@@ -794,8 +794,9 @@ export function er(preset: ErPreset): ErBuilder {
794
794
  const api: ErBuilder = {
795
795
  entity(e) {
796
796
  const laneId = `lane-${e.id}`;
797
- // 鍵の群を上にまとめ、間を空の行 1 つで開ける (#578)。 区切りの線を引く代わりに余白で
798
- // 分けるので、`rowMarks` を持つ箱では描き手が線を引かない
797
+ // 鍵の群を上にまとめる (#578)。 群の区切りは **行頭の印** が持つ = 鍵は名前に下線が付く。
798
+ // 空の行で開ける形は #606 でやめた = 下線と重複した 2 つ目の手掛かりで、
799
+ // 代わりに行の間隔を不揃いにしていた (鍵と値を持つ箱だけ境目が広がる)
799
800
  const 鍵 = (e.columns ?? []).filter((c) => c.pk === true);
800
801
  const 値 = (e.columns ?? []).filter((c) => c.pk !== true);
801
802
  const 行: string[] = [];
@@ -809,10 +810,6 @@ export function er(preset: ErPreset): ErBuilder {
809
810
  });
810
811
  };
811
812
  鍵.forEach(足す);
812
- if (鍵.length > 0 && 値.length > 0) {
813
- 行.push("");
814
- 印.push(null);
815
- }
816
813
  値.forEach(足す);
817
814
  const 設計の形 = 行.length > 0;
818
815
  const rows = 設計の形 ? 行 : (e.rows ?? []);
@@ -1442,8 +1439,8 @@ export function classDiagram(preset: ClassDiagramPreset): ClassDiagramBuilder {
1442
1439
  return api;
1443
1440
  },
1444
1441
  build() {
1445
- // 行頭の印で持ち物と振る舞いを分ける (#578)。 群の間は **空の行** で開ける = 区切りの
1446
- // 線を引く代わりに余白で分ける。 印を持つ行は描き手が線を引かない
1442
+ // 行頭の印で持ち物と振る舞いを分ける (#578)。 持ち物は四角、振る舞いは山形なので、
1443
+ // 群の区切りは行ごとに読める。 空の行で開ける形は #606 でやめた
1447
1444
  const 中身 = 箱.map((c) => {
1448
1445
  const rows: string[] = [];
1449
1446
  const rowMarks: (RowMark | null)[] = [];
@@ -1451,10 +1448,6 @@ export function classDiagram(preset: ClassDiagramPreset): ClassDiagramBuilder {
1451
1448
  rows.push(stripAccess(a));
1452
1449
  rowMarks.push({ shape: "square", filled: isPublic(a) });
1453
1450
  }
1454
- if ((c.attributes?.length ?? 0) > 0 && (c.methods?.length ?? 0) > 0) {
1455
- rows.push("");
1456
- rowMarks.push(null);
1457
- }
1458
1451
  for (const m of c.methods ?? []) {
1459
1452
  rows.push(stripAccess(m));
1460
1453
  rowMarks.push({ shape: "chevron", filled: isPublic(m) });