@cardenelabs/cdl 0.78.0 → 0.80.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/CHANGELOG.md +54 -1
- package/dist/index.cjs +139 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +139 -39
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +139 -39
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +139 -39
- package/dist/react.js.map +1 -1
- package/package.json +1 -1
- package/src/layout/collisions.ts +185 -41
- package/src/render/nodes.tsx +16 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cardenelabs/cdl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.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/layout/collisions.ts
CHANGED
|
@@ -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
|
|
78
|
-
if (
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
*
|
|
121
|
-
*
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
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
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 + " " +
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
}
|
package/src/render/nodes.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { JSX, KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
1
|
+
import type { CSSProperties, JSX, KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
2
2
|
import { ActorNode } from "../kinds/actor";
|
|
3
3
|
import { CardNode } from "../kinds/card";
|
|
4
4
|
import { SequenceBoardNode } from "../kinds/sequence-board";
|
|
@@ -461,6 +461,20 @@ export function CdlNodeView({
|
|
|
461
461
|
const offsetX = node.renderOffsetX !== undefined ? resolveNumericAttr(node.renderOffsetX, stateValues, 0) : 0;
|
|
462
462
|
const offsetY = node.renderOffsetY !== undefined ? resolveNumericAttr(node.renderOffsetY, stateValues, 0) : 0;
|
|
463
463
|
const hasOffset = offsetX !== 0 || offsetY !== 0;
|
|
464
|
+
/**
|
|
465
|
+
* 箱の `style` は **1 箇所でまとめて作る**。
|
|
466
|
+
*
|
|
467
|
+
* React は同じ要素に `style` を 2 回渡すと後の値が前を丸ごと置き換える。 押せる箱の
|
|
468
|
+
* `cursor: pointer` を条件つきで広げる側に置いていたため、後ろの `transition` に
|
|
469
|
+
* 置き換えられて消えていた (#869 実測 = 押せる箱 4 件の `style` が `transition` だけで、
|
|
470
|
+
* 計算済みの cursor は親から継承した値だった)。
|
|
471
|
+
*
|
|
472
|
+
* 押せない箱に `cursor` を入れない。 親から継承するのが正しい (編集画面では `grab`)。
|
|
473
|
+
*/
|
|
474
|
+
const 箱のstyle: CSSProperties = {
|
|
475
|
+
transition: "opacity 120ms ease-out, transform 200ms ease-out",
|
|
476
|
+
...(関係の受け口 === undefined ? {} : { cursor: "pointer" as const }),
|
|
477
|
+
};
|
|
464
478
|
return (
|
|
465
479
|
<g
|
|
466
480
|
data-cdl-node={node.id}
|
|
@@ -483,7 +497,6 @@ export function CdlNodeView({
|
|
|
483
497
|
role: "button" as const,
|
|
484
498
|
tabIndex: 0,
|
|
485
499
|
"aria-pressed": 関係の受け口.焦点 === node.id,
|
|
486
|
-
style: { cursor: "pointer" as const },
|
|
487
500
|
onPointerEnter: () => 関係の受け口.触れた(node.id),
|
|
488
501
|
onPointerLeave: () => 関係の受け口.離れた(),
|
|
489
502
|
onFocus: () => 関係の受け口.触れた(node.id),
|
|
@@ -498,7 +511,7 @@ export function CdlNodeView({
|
|
|
498
511
|
transform={hasOffset ? `translate(${offsetX}, ${offsetY})` : undefined}
|
|
499
512
|
opacity={boundOpacity}
|
|
500
513
|
data-cdl-tone={node.tone}
|
|
501
|
-
style={
|
|
514
|
+
style={箱のstyle}
|
|
502
515
|
>
|
|
503
516
|
<rect
|
|
504
517
|
// 実寸の枠 (#390)。 `data-cdl-w/h` は layout の静的値で、 `wBind` / `hBind` の縮小や
|