@cardenelabs/cdl 0.78.0 → 0.79.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.78.0",
3
+ "version": "0.79.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",
@@ -74,41 +74,180 @@ export function collectBBoxes(lanes: LaidLane[], nodes: LaidNode[], edges: LaidE
74
74
  }
75
75
 
76
76
  for (const e of edges) {
77
- const points = extractPathPoints(e.d);
78
- if (points.length === 0) continue;
79
- const xs = points.map((p) => p.x);
80
- const ys = points.map((p) => p.y);
81
- const minX = Math.min(...xs);
82
- const maxX = Math.max(...xs);
83
- const minY = Math.min(...ys);
84
- const maxY = Math.max(...ys);
77
+ const box = pathBBox(e.d);
78
+ if (!box) continue;
85
79
  out.push({
86
80
  kind: "edge-path",
87
81
  id: e.id,
88
- x: minX - 2,
89
- y: minY - 2,
90
- w: maxX - minX + 4,
91
- h: maxY - minY + 4,
82
+ x: box.minX - 2,
83
+ y: box.minY - 2,
84
+ w: box.maxX - box.minX + 4,
85
+ h: box.maxY - box.minY + 4,
92
86
  });
93
87
  }
94
88
 
95
89
  return out;
96
90
  }
97
91
 
98
- /** SVG path d 文字列から M/L/C/Q コマンドの座標点を抽出する軽量 parser */
92
+ /** SVG path の命令が取る引数の数。 命令を区切る唯一の根拠 */
93
+ const PATH_ARITY: Record<string, number> = {
94
+ M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0,
95
+ };
96
+
97
+ /** 1 つの命令と、その命令だけが取る引数 */
98
+ type PathCommand = { cmd: string; args: number[] };
99
+
100
+ /** 小文字の命令は 1 つ前の点からの差分で書かれている */
101
+ function isRelative(cmd: string): boolean {
102
+ return cmd >= "a" && cmd <= "z";
103
+ }
104
+
105
+ /**
106
+ * 命令が持つ座標の位置を `[x の位置, y の位置]` の組で、出る順に返す。
107
+ *
108
+ * `A` は `rx ry 回転 大きい弧か 向き x y` の 7 個で、座標は末尾の 2 個だけ。
109
+ * 前の 5 個は半径と向きの指定なので、平行移動しても値が変わらない。
110
+ * `H` / `V` は片方しか持たないため -1 を置き、読む側が今の点で補う。
111
+ */
112
+ function coordSlots(cmd: string): Array<[number, number]> {
113
+ switch (cmd.toUpperCase()) {
114
+ case "M":
115
+ case "L":
116
+ case "T":
117
+ return [[0, 1]];
118
+ case "H":
119
+ return [[0, -1]];
120
+ case "V":
121
+ return [[-1, 0]];
122
+ case "Q":
123
+ case "S":
124
+ return [[0, 1], [2, 3]];
125
+ case "C":
126
+ return [[0, 1], [2, 3], [4, 5]];
127
+ case "A":
128
+ return [[5, 6]];
129
+ default:
130
+ return [];
131
+ }
132
+ }
133
+
134
+ /**
135
+ * `d` を命令 1 つずつに切り出す。
136
+ *
137
+ * **引数の数を命令ごとに数えることが本関数の役目**。 文字を全部消して数を 2 つずつ組にする
138
+ * 読み方は、円弧 (`A`) の引数が 7 個 = 奇数のため組にする位置が 1 つずれ、その後ろの点が
139
+ * 全部ずれる (#872 実測 = 道筋のどこにも無い `(11,11)` と `(0,0)` が点として出て、
140
+ * 外接矩形が図の左上まで伸びた)。
141
+ *
142
+ * 同じ命令を続けて書く省略形 (`L 1 2 3 4` = `L 1 2 L 3 4`) も 1 つずつに開く。
143
+ * `M` の 2 組目以降は `L` になる (SVG の決まり)。
144
+ *
145
+ * 引数が足りない命令は読み飛ばす。 埋めると座標に undefined が入り、以降が全て NaN になって
146
+ * 判定が黙って対象外になる (#370 と同じ判断)。
147
+ */
148
+ function parsePathCommands(d: string): PathCommand[] {
149
+ const out: PathCommand[] = [];
150
+ const commandRegex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
151
+ let m: RegExpExecArray | null;
152
+ while ((m = commandRegex.exec(d)) !== null) {
153
+ const cmd = m[1]!;
154
+ const arity = PATH_ARITY[cmd.toUpperCase()] ?? 0;
155
+ if (arity === 0) {
156
+ out.push({ cmd, args: [] });
157
+ continue;
158
+ }
159
+ const nums = (m[2] ?? "").trim().split(/[\s,]+/).filter(Boolean).map(parseFloat);
160
+ for (let i = 0; i + arity <= nums.length; i += arity) {
161
+ const args = nums.slice(i, i + arity);
162
+ if (!args.every(Number.isFinite)) break;
163
+ const 繰り返し = i > 0;
164
+ const c = 繰り返し && cmd === "M" ? "L" : 繰り返し && cmd === "m" ? "l" : cmd;
165
+ out.push({ cmd: c, args });
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * `d` を命令ごとに読み、座標点を出る順に返す。
173
+ *
174
+ * 返すのは **道筋の上に在る点** で、曲線の制御点も含む (外接矩形を安全側に出すため)。
175
+ * 円弧は終点だけを返す = 途中の膨らみは点として書かれていない。 膨らみを含めた外接矩形が
176
+ * 要るなら `pathBBox` を使う。
177
+ *
178
+ * 小文字の命令 (1 つ前の点からの差分) は絶対座標に直して返す。
179
+ */
99
180
  export function extractPathPoints(d: string): Array<{ x: number; y: number }> {
100
181
  const out: Array<{ x: number; y: number }> = [];
101
- const tokens = d.replace(/[A-Za-z]/g, " ").trim().split(/[\s,]+/).filter(Boolean);
102
- for (let i = 0; i + 1 < tokens.length; i += 2) {
103
- const x = parseFloat(tokens[i]!);
104
- const y = parseFloat(tokens[i + 1]!);
105
- if (Number.isFinite(x) && Number.isFinite(y)) {
182
+ let cur = { x: 0, y: 0 };
183
+ let started = false;
184
+ for (const { cmd, args } of parsePathCommands(d)) {
185
+ // 先頭の命令は小文字でも絶対座標として読む (SVG の決まり)
186
+ const 相対 = started && isRelative(cmd);
187
+ const 基点 = cur;
188
+ for (const [xi, yi] of coordSlots(cmd)) {
189
+ const x = xi >= 0 ? (相対 ? 基点.x + args[xi]! : args[xi]!) : 基点.x;
190
+ const y = yi >= 0 ? (相対 ? 基点.y + args[yi]! : args[yi]!) : 基点.y;
106
191
  out.push({ x, y });
192
+ cur = { x, y };
107
193
  }
194
+ started = true;
108
195
  }
109
196
  return out;
110
197
  }
111
198
 
199
+ /**
200
+ * `d` の外接矩形を返す。 点が 1 つも無ければ `null`。
201
+ *
202
+ * `extractPathPoints` との違いは **円弧の膨らみを足すこと**。 円弧は終点しか点を持たないが、
203
+ * 実際に描かれる線は弦から膨らむ (経路の飛び越しは半径 11 の半円)。 終点だけで矩形を出すと
204
+ * 膨らんだ側が矩形の外に出て、本当の重なりを見落とす側に倒れる。
205
+ *
206
+ * 膨らみの上限は半径と弦の長さで決まる。 半径 `r` の円弧が長さ `L` の弦から離れる距離は、
207
+ * 短い側を通るなら `r - sqrt(r² - (L/2)²)`、長い側 (大きい弧) を通るなら
208
+ * `r + sqrt(r² - (L/2)²)`。 楕円は長い方の半径を使って安全側に倒す。
209
+ */
210
+ export function pathBBox(
211
+ d: string,
212
+ ): { minX: number; minY: number; maxX: number; maxY: number } | null {
213
+ let minX = Infinity;
214
+ let minY = Infinity;
215
+ let maxX = -Infinity;
216
+ let maxY = -Infinity;
217
+ let 点あり = false;
218
+ const 取る = (x: number, y: number): void => {
219
+ minX = Math.min(minX, x);
220
+ minY = Math.min(minY, y);
221
+ maxX = Math.max(maxX, x);
222
+ maxY = Math.max(maxY, y);
223
+ 点あり = true;
224
+ };
225
+
226
+ let cur = { x: 0, y: 0 };
227
+ let started = false;
228
+ for (const { cmd, args } of parsePathCommands(d)) {
229
+ const 相対 = started && isRelative(cmd);
230
+ const 基点 = cur;
231
+ for (const [xi, yi] of coordSlots(cmd)) {
232
+ const x = xi >= 0 ? (相対 ? 基点.x + args[xi]! : args[xi]!) : 基点.x;
233
+ const y = yi >= 0 ? (相対 ? 基点.y + args[yi]! : args[yi]!) : 基点.y;
234
+ 取る(x, y);
235
+ cur = { x, y };
236
+ }
237
+ if (cmd.toUpperCase() === "A") {
238
+ const r = Math.max(Math.abs(args[0]!), Math.abs(args[1]!));
239
+ const 弦 = Math.hypot(cur.x - 基点.x, cur.y - 基点.y);
240
+ const 半弦 = Math.min(弦 / 2, r);
241
+ const 余 = Math.sqrt(Math.max(0, r * r - 半弦 * 半弦));
242
+ const 膨らみ = args[3] !== 0 ? r + 余 : r - 余;
243
+ 取る(Math.min(基点.x, cur.x) - 膨らみ, Math.min(基点.y, cur.y) - 膨らみ);
244
+ 取る(Math.max(基点.x, cur.x) + 膨らみ, Math.max(基点.y, cur.y) + 膨らみ);
245
+ }
246
+ started = true;
247
+ }
248
+ return 点あり ? { minX, minY, maxX, maxY } : null;
249
+ }
250
+
112
251
  /**
113
252
  * SVG path d 文字列を (dx, dy) 平行移動する (CAR-485 導入)。
114
253
  *
@@ -117,9 +256,9 @@ export function extractPathPoints(d: string): Array<{ x: number; y: number }> {
117
256
  * (dx, dy) を加算する必要がある。 edges.ts の routePath() は M / L / Q コマンドで
118
257
  * 全 coord を absolute 表記するため、 command tokenizer で数値 pair を舐めて加算する。
119
258
  *
120
- * 対応 command = M / L / Q (Q は controlX/Y + endX/Y の 2 pair)。 C は 3 pair (control1 +
121
- * control2 + end)。 Z (closepath) は座標なしで pass through。 大文字 / 小文字は routePath()
122
- * の出力に合わせて大文字のみ想定。
259
+ * 命令ごとの引数の数は `parsePathCommands` が持つ。 円弧 (`A`) は末尾 2 個だけが座標で、
260
+ * 半径と向きの指定は動かさない。 小文字の命令は 1 つ前の点からの差分なので、平行移動しても
261
+ * 値が変わらない = そのまま通す (先頭の絶対座標が動けば道筋全体が動く)。
123
262
  *
124
263
  * dx = 0 かつ dy = 0 は no-op (元 d を返す)。
125
264
  */
@@ -134,25 +273,35 @@ export function translatePathD(d: string, dx: number, dy: number): string {
134
273
  * `idx` は path 先頭からの座標 pair の通し番号 (Q の control 点も 1 pair として数える)。
135
274
  */
136
275
  function rebuildPathD(d: string, map: (x: number, y: number, idx: number) => [number, number]): string {
137
- const commandRegex = /([MLCQZmlcqz])([^MLCQZmlcqz]*)/g;
138
276
  let out = "";
139
277
  let idx = 0;
140
- let m: RegExpExecArray | null;
141
- while ((m = commandRegex.exec(d)) !== null) {
142
- const cmd = m[1]!;
143
- const argsStr = (m[2] ?? "").trim();
144
- if (argsStr === "" || cmd === "Z" || cmd === "z") {
278
+ let cur = { x: 0, y: 0 };
279
+ let started = false;
280
+ for (const { cmd, args } of parsePathCommands(d)) {
281
+ const slots = coordSlots(cmd);
282
+ if (slots.length === 0) {
145
283
  out += cmd + " ";
284
+ started = true;
146
285
  continue;
147
286
  }
148
- const args = argsStr.split(/[\s,]+/).filter(Boolean).map(parseFloat);
149
- const translated: number[] = [];
150
- for (let i = 0; i + 1 < args.length; i += 2) {
151
- const [nx, ny] = map(args[i]!, args[i + 1]!, idx);
152
- translated.push(nx, ny);
287
+ const 相対 = started && isRelative(cmd);
288
+ const 基点 = cur;
289
+ const 次の引数 = args.slice();
290
+ for (const [xi, yi] of slots) {
291
+ const x = xi >= 0 ? (相対 ? 基点.x + args[xi]! : args[xi]!) : 基点.x;
292
+ const y = yi >= 0 ? (相対 ? 基点.y + args[yi]! : args[yi]!) : 基点.y;
293
+ const [nx, ny] = map(x, y, idx);
294
+ if (!相対) {
295
+ if (xi >= 0) 次の引数[xi] = nx;
296
+ if (yi >= 0) 次の引数[yi] = ny;
297
+ }
298
+ // 位置の通し番号は `extractPathPoints` が返す点と 1 対 1 で揃える。
299
+ // 揃っていないと `translatePathDPreservingEndpoints` が別の点を固定する。
153
300
  idx += 1;
301
+ cur = { x, y };
154
302
  }
155
- out += cmd + " " + translated.join(" ") + " ";
303
+ out += cmd + " " + 次の引数.join(" ") + " ";
304
+ started = true;
156
305
  }
157
306
  return out.trim();
158
307
  }
@@ -163,17 +312,12 @@ function rebuildPathD(d: string, map: (x: number, y: number, idx: number) => [nu
163
312
  * Q は `control` と `end` の 2 pair を持つ。 corner が無ければ両方 -1。
164
313
  */
165
314
  function cornerBoundaryIndices(d: string): { firstCornerEnd: number; lastCornerStart: number } {
166
- const commandRegex = /([MLCQZmlcqz])([^MLCQZmlcqz]*)/g;
167
315
  let idx = 0;
168
316
  let firstCornerEnd = -1;
169
317
  let lastCornerStart = -1;
170
- let m: RegExpExecArray | null;
171
- while ((m = commandRegex.exec(d)) !== null) {
172
- const cmd = m[1]!;
173
- const argsStr = (m[2] ?? "").trim();
174
- if (argsStr === "" || cmd === "Z" || cmd === "z") continue;
175
- const pairs = argsStr.split(/[\s,]+/).filter(Boolean).length >> 1;
176
- if ((cmd === "Q" || cmd === "q") && pairs >= 2) {
318
+ for (const { cmd } of parsePathCommands(d)) {
319
+ const pairs = coordSlots(cmd).length;
320
+ if (cmd.toUpperCase() === "Q" && pairs >= 2) {
177
321
  if (firstCornerEnd < 0) firstCornerEnd = idx + 1;
178
322
  lastCornerStart = idx;
179
323
  }