@wscytz/dsl 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +81 -2
- package/dist/canvas-dsl.d.ts +62 -0
- package/dist/canvas-dsl.js +215 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +389 -0
- package/dist/dsl-compute.d.ts +48 -0
- package/dist/dsl-compute.js +248 -0
- package/dist/dsl-grammar.d.ts +134 -0
- package/dist/dsl-grammar.js +227 -0
- package/dist/dsl-parser.d.ts +199 -0
- package/dist/dsl-parser.gen.d.ts +4 -0
- package/dist/dsl-parser.gen.js +3365 -0
- package/dist/dsl-parser.js +683 -0
- package/dist/dsl-relational-solver.d.ts +47 -0
- package/dist/dsl-relational-solver.js +221 -0
- package/dist/dsl-sanitize.d.ts +56 -0
- package/dist/dsl-sanitize.js +257 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +26 -0
- package/dist/routing.d.ts +73 -0
- package/dist/routing.js +617 -0
- package/dist/to-json-canvas.d.ts +74 -0
- package/dist/to-json-canvas.js +149 -0
- package/dist/to-svg.d.ts +48 -0
- package/dist/to-svg.js +542 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.js +12 -0
- package/package.json +53 -3
package/dist/to-svg.js
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { planRoute } from './routing.js';
|
|
2
|
+
/** legacy 6 色名 → 设计 token 实值(浅色主题)。grey 是 gray 的冻结别名。 */
|
|
3
|
+
const COLOR_HEX = {
|
|
4
|
+
red: '#b51b17',
|
|
5
|
+
yellow: '#8f706b',
|
|
6
|
+
blue: '#006480',
|
|
7
|
+
black: '#1b1c1a',
|
|
8
|
+
white: '#fbf9f6',
|
|
9
|
+
gray: '#5e5e5c',
|
|
10
|
+
grey: '#5e5e5c',
|
|
11
|
+
};
|
|
12
|
+
const INK = '#1b1c1a';
|
|
13
|
+
const SURFACE = '#fbf9f6';
|
|
14
|
+
const PAD = 40;
|
|
15
|
+
const FONT = 'ui-sans-serif, system-ui, -apple-system, sans-serif';
|
|
16
|
+
const CARD_DEFAULT_W = 240;
|
|
17
|
+
const CARD_DEFAULT_H = 120;
|
|
18
|
+
const TEXT_DEFAULT_W = 200;
|
|
19
|
+
const TEXT_DEFAULT_H = 60;
|
|
20
|
+
function esc(s) {
|
|
21
|
+
return s
|
|
22
|
+
.replaceAll('&', '&')
|
|
23
|
+
.replaceAll('<', '<')
|
|
24
|
+
.replaceAll('>', '>')
|
|
25
|
+
.replaceAll('"', '"');
|
|
26
|
+
}
|
|
27
|
+
function hex(color) {
|
|
28
|
+
return (color !== undefined && COLOR_HEX[color]) || INK;
|
|
29
|
+
}
|
|
30
|
+
/** 贪心折行:分词 token(CJK 单字 / 英数连串 / 空白 / 其它标点),英文单词不拦腰断;
|
|
31
|
+
* 单 token 超行宽按字强拆;超 maxLines 末行截 …。 */
|
|
32
|
+
function wrapLines(s, maxW, fontSize, maxLines) {
|
|
33
|
+
const charPx = (ch) => (ch.charCodeAt(0) > 0x2e7f ? fontSize : fontSize * 0.55);
|
|
34
|
+
const tokens = s.match(/[ -鿿-]|[A-Za-z0-9]+|\s+|[^A-Za-z0-9\s -鿿-]/g) ?? [s];
|
|
35
|
+
const out = [];
|
|
36
|
+
let line = '';
|
|
37
|
+
let w = 0;
|
|
38
|
+
let truncated = false;
|
|
39
|
+
for (const tok of tokens) {
|
|
40
|
+
// 显式换行:含 \n 的空白 token 直接断行(首段并入当前行)
|
|
41
|
+
if (/\s/.test(tok) && tok.includes('\n')) {
|
|
42
|
+
const head = tok.replace(/\n[\s\S]*$/, '');
|
|
43
|
+
if (head)
|
|
44
|
+
line += head;
|
|
45
|
+
if (line.trim())
|
|
46
|
+
out.push(line.trimEnd());
|
|
47
|
+
line = '';
|
|
48
|
+
w = 0;
|
|
49
|
+
if (out.length === maxLines) {
|
|
50
|
+
truncated = true;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const tw = [...tok].reduce((a, c) => a + charPx(c), 0);
|
|
56
|
+
if (w + tw > maxW && line.trim()) {
|
|
57
|
+
out.push(line.trimEnd());
|
|
58
|
+
line = '';
|
|
59
|
+
w = 0;
|
|
60
|
+
if (out.length === maxLines) {
|
|
61
|
+
truncated = true;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
if (/^\s+$/.test(tok))
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (tw > maxW && !line.trim()) {
|
|
68
|
+
for (const ch of tok) {
|
|
69
|
+
const cw = charPx(ch);
|
|
70
|
+
if (w + cw > maxW && line) {
|
|
71
|
+
out.push(line);
|
|
72
|
+
line = '';
|
|
73
|
+
w = 0;
|
|
74
|
+
if (out.length === maxLines) {
|
|
75
|
+
truncated = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
line += ch;
|
|
80
|
+
w += cw;
|
|
81
|
+
}
|
|
82
|
+
if (truncated)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
line += tok;
|
|
87
|
+
w += tw;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!truncated && line.trim())
|
|
91
|
+
out.push(line.trimEnd());
|
|
92
|
+
if (truncated)
|
|
93
|
+
out[out.length - 1] = out[out.length - 1].replace(/.$/, '') + '…';
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** 矩形中心到 (tx,ty) 方向与矩形边界的交点(箭头贴边)。 */
|
|
97
|
+
function borderPoint(r, tx, ty) {
|
|
98
|
+
const cx = r.x + r.w / 2;
|
|
99
|
+
const cy = r.y + r.h / 2;
|
|
100
|
+
const dx = tx - cx;
|
|
101
|
+
const dy = ty - cy;
|
|
102
|
+
if (dx === 0 && dy === 0)
|
|
103
|
+
return { x: cx, y: cy };
|
|
104
|
+
const sx = dx !== 0 ? r.w / 2 / Math.abs(dx) : Infinity;
|
|
105
|
+
const sy = dy !== 0 ? r.h / 2 / Math.abs(dy) : Infinity;
|
|
106
|
+
const s = Math.min(sx, sy);
|
|
107
|
+
return { x: cx + dx * s, y: cy + dy * s };
|
|
108
|
+
}
|
|
109
|
+
/** 箭头头部:tip 在线终点,方向由上一段决定。arrow=开口 V;triangle=实心三角。 */
|
|
110
|
+
function arrowHead(tip, from, kind, color) {
|
|
111
|
+
const ang = Math.atan2(tip.y - from.y, tip.x - from.x);
|
|
112
|
+
const len = 12;
|
|
113
|
+
const spread = 0.42;
|
|
114
|
+
const w1 = { x: tip.x - len * Math.cos(ang - spread), y: tip.y - len * Math.sin(ang - spread) };
|
|
115
|
+
const w2 = { x: tip.x - len * Math.cos(ang + spread), y: tip.y - len * Math.sin(ang + spread) };
|
|
116
|
+
if (kind === 'triangle') {
|
|
117
|
+
return `<polygon points="${tip.x},${tip.y} ${w1.x},${w1.y} ${w2.x},${w2.y}" fill="${color}"/>`;
|
|
118
|
+
}
|
|
119
|
+
return `<polyline points="${w1.x},${w1.y} ${tip.x},${tip.y} ${w2.x},${w2.y}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`;
|
|
120
|
+
}
|
|
121
|
+
/** 线段与矩形内域是否相交(Liang-Barsky;相交段要有实长,贴边擦过不算)。 */
|
|
122
|
+
function segHitsRect(a, b, r) {
|
|
123
|
+
let t0 = 0;
|
|
124
|
+
let t1 = 1;
|
|
125
|
+
const dx = b.x - a.x;
|
|
126
|
+
const dy = b.y - a.y;
|
|
127
|
+
const edges = [
|
|
128
|
+
[-dx, a.x - r.x],
|
|
129
|
+
[dx, r.x + r.w - a.x],
|
|
130
|
+
[-dy, a.y - r.y],
|
|
131
|
+
[dy, r.y + r.h - a.y],
|
|
132
|
+
];
|
|
133
|
+
for (const [p, q] of edges) {
|
|
134
|
+
if (p === 0) {
|
|
135
|
+
if (q < 0)
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const t = q / p;
|
|
140
|
+
if (p < 0) {
|
|
141
|
+
if (t > t1)
|
|
142
|
+
return false;
|
|
143
|
+
if (t > t0)
|
|
144
|
+
t0 = t;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
if (t < t0)
|
|
148
|
+
return false;
|
|
149
|
+
if (t < t1)
|
|
150
|
+
t1 = t;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return t1 - t0 > 0.01;
|
|
155
|
+
}
|
|
156
|
+
function inRect(p, r) {
|
|
157
|
+
return p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h;
|
|
158
|
+
}
|
|
159
|
+
function polyClear(pts, rects) {
|
|
160
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
161
|
+
for (const r of rects)
|
|
162
|
+
if (segHitsRect(pts[i], pts[i + 1], r))
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
for (const p of pts) {
|
|
166
|
+
for (const r of rects)
|
|
167
|
+
if (inRect(p, r))
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
function polyLen(pts) {
|
|
173
|
+
let len = 0;
|
|
174
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
175
|
+
len += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
|
176
|
+
}
|
|
177
|
+
return len;
|
|
178
|
+
}
|
|
179
|
+
/** 折线中点(按弧长走一半;label 锚点用)。 */
|
|
180
|
+
function polyMid(pts) {
|
|
181
|
+
const half = polyLen(pts) / 2;
|
|
182
|
+
let acc = 0;
|
|
183
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
184
|
+
const seg = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
|
185
|
+
if (acc + seg >= half && seg > 0) {
|
|
186
|
+
const t = (half - acc) / seg;
|
|
187
|
+
return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * t, y: pts[i].y + (pts[i + 1].y - pts[i].y) * t };
|
|
188
|
+
}
|
|
189
|
+
acc += seg;
|
|
190
|
+
}
|
|
191
|
+
return pts[pts.length - 1];
|
|
192
|
+
}
|
|
193
|
+
/** 一轴上障碍区间并集的空隙中点(空隙 ≥ minGap 才算可行走廊)。 */
|
|
194
|
+
function corridors(rects, axis, minGap) {
|
|
195
|
+
const ivals = rects
|
|
196
|
+
.map((r) => (axis === 'x' ? [r.x, r.x + r.w] : [r.y, r.y + r.h]))
|
|
197
|
+
.sort((a, b) => a[0] - b[0]);
|
|
198
|
+
const merged = [];
|
|
199
|
+
for (const iv of ivals) {
|
|
200
|
+
const last = merged[merged.length - 1];
|
|
201
|
+
if (last && iv[0] <= last[1])
|
|
202
|
+
last[1] = Math.max(last[1], iv[1]);
|
|
203
|
+
else
|
|
204
|
+
merged.push([iv[0], iv[1]]);
|
|
205
|
+
}
|
|
206
|
+
const mids = [];
|
|
207
|
+
for (let i = 0; i < merged.length - 1; i++) {
|
|
208
|
+
const start = merged[i][1];
|
|
209
|
+
const end = merged[i + 1][0];
|
|
210
|
+
if (end - start >= minGap)
|
|
211
|
+
mids.push((start + end) / 2);
|
|
212
|
+
}
|
|
213
|
+
return mids;
|
|
214
|
+
}
|
|
215
|
+
/** 正交路由的无障碍缺省折点:按主方向取中线 Z;已对齐则直连(空)。 */
|
|
216
|
+
function midZWaypoints(p1, p2) {
|
|
217
|
+
if (p1.y === p2.y || p1.x === p2.x)
|
|
218
|
+
return [];
|
|
219
|
+
if (Math.abs(p2.x - p1.x) >= Math.abs(p2.y - p1.y)) {
|
|
220
|
+
const mx = (p1.x + p2.x) / 2;
|
|
221
|
+
return [{ x: mx, y: p1.y }, { x: mx, y: p2.y }];
|
|
222
|
+
}
|
|
223
|
+
const my = (p1.y + p2.y) / 2;
|
|
224
|
+
return [{ x: p1.x, y: my }, { x: p2.x, y: my }];
|
|
225
|
+
}
|
|
226
|
+
/** 折线路径转圆角路径:每个内点用二次贝塞尔切角(半径 ≤8 且 ≤两侧半段长;
|
|
227
|
+
* 相邻重复点先去重)。端点与首末线段方向不变,箭头朝向不受影响;点数 <3 原样直连。 */
|
|
228
|
+
function roundedPolyPath(pts) {
|
|
229
|
+
const uniq = pts.filter((p, i) => i === 0 || p.x !== pts[i - 1].x || p.y !== pts[i - 1].y);
|
|
230
|
+
if (uniq.length < 3)
|
|
231
|
+
return `M ${uniq.map((p) => `${p.x} ${p.y}`).join(' L ')}`;
|
|
232
|
+
const R = 8;
|
|
233
|
+
let d = `M ${uniq[0].x} ${uniq[0].y}`;
|
|
234
|
+
for (let i = 1; i < uniq.length - 1; i++) {
|
|
235
|
+
const prev = uniq[i - 1];
|
|
236
|
+
const cur = uniq[i];
|
|
237
|
+
const next = uniq[i + 1];
|
|
238
|
+
const lin = Math.hypot(cur.x - prev.x, cur.y - prev.y);
|
|
239
|
+
const lout = Math.hypot(next.x - cur.x, next.y - cur.y);
|
|
240
|
+
const r = Math.min(R, lin / 2, lout / 2);
|
|
241
|
+
const inPt = { x: cur.x - ((cur.x - prev.x) / lin) * r, y: cur.y - ((cur.y - prev.y) / lin) * r };
|
|
242
|
+
const outPt = { x: cur.x + ((next.x - cur.x) / lout) * r, y: cur.y + ((next.y - cur.y) / lout) * r };
|
|
243
|
+
d += ` L ${inPt.x} ${inPt.y} Q ${cur.x} ${cur.y} ${outPt.x} ${outPt.y}`;
|
|
244
|
+
}
|
|
245
|
+
d += ` L ${uniq[uniq.length - 1].x} ${uniq[uniq.length - 1].y}`;
|
|
246
|
+
return d;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 默认路由自动避障:直连段穿过障碍时,生成绕行路标再整段验证。候选四族:
|
|
250
|
+
* L 形 2 个 / 命中包围盒外扩 Z 形 4 个 / 障碍间隙走廊 Z 形(两轴各若干——
|
|
251
|
+
* 直连线同时擦到两簇障碍时,包围盒外扩走廊会全撞墙,列间走廊才是活路)/
|
|
252
|
+
* 全部障碍外包盒四向兜底(前面全灭时宁可绕远不可穿模)。
|
|
253
|
+
* 每个候选都把端点按新方向重新贴边(borderPoint),返回**整条路径**(含新端点)——
|
|
254
|
+
* 调用方直接用,不再拼原端点(否则末端沿卡边滑行,箭头顺着边飘)。
|
|
255
|
+
* 全部候选撞墙则退回 null(直连,诚实降级不硬凑)。margin=走廊与障碍的间距。
|
|
256
|
+
*/
|
|
257
|
+
export function routeAround(p1, p2, src, dst, obstacles, margin = 16) {
|
|
258
|
+
// 内缩 2px:贴边端点(恰在原边界上)落进内缩矩形之外,不误报穿越
|
|
259
|
+
const rects = obstacles
|
|
260
|
+
.filter((r) => r.w > 12 && r.h > 12)
|
|
261
|
+
.map((r) => ({ x: r.x + 2, y: r.y + 2, w: r.w - 4, h: r.h - 4 }));
|
|
262
|
+
if (rects.length === 0)
|
|
263
|
+
return null;
|
|
264
|
+
const hits = rects.filter((r) => segHitsRect(p1, p2, r));
|
|
265
|
+
if (hits.length === 0)
|
|
266
|
+
return null;
|
|
267
|
+
const minX = Math.min(...hits.map((r) => r.x));
|
|
268
|
+
const minY = Math.min(...hits.map((r) => r.y));
|
|
269
|
+
const maxX = Math.max(...hits.map((r) => r.x + r.w));
|
|
270
|
+
const maxY = Math.max(...hits.map((r) => r.y + r.h));
|
|
271
|
+
const cands = [
|
|
272
|
+
[{ x: p2.x, y: p1.y }],
|
|
273
|
+
[{ x: p1.x, y: p2.y }],
|
|
274
|
+
[{ x: p1.x, y: minY - margin }, { x: p2.x, y: minY - margin }],
|
|
275
|
+
[{ x: p1.x, y: maxY + margin }, { x: p2.x, y: maxY + margin }],
|
|
276
|
+
[{ x: minX - margin, y: p1.y }, { x: minX - margin, y: p2.y }],
|
|
277
|
+
[{ x: maxX + margin, y: p1.y }, { x: maxX + margin, y: p2.y }],
|
|
278
|
+
];
|
|
279
|
+
for (const c of corridors(rects, 'x', 8))
|
|
280
|
+
cands.push([{ x: c, y: p1.y }, { x: c, y: p2.y }]);
|
|
281
|
+
for (const c of corridors(rects, 'y', 8))
|
|
282
|
+
cands.push([{ x: p1.x, y: c }, { x: p2.x, y: c }]);
|
|
283
|
+
// 兜底族:绕开全部障碍的外包盒四向——前几族全灭时宁可绕远不可穿模
|
|
284
|
+
// (长度天然最大,只在短候选都失败时胜出;源/目标被围死才算真无解)
|
|
285
|
+
const gMinX = Math.min(...rects.map((r) => r.x));
|
|
286
|
+
const gMinY = Math.min(...rects.map((r) => r.y));
|
|
287
|
+
const gMaxX = Math.max(...rects.map((r) => r.x + r.w));
|
|
288
|
+
const gMaxY = Math.max(...rects.map((r) => r.y + r.h));
|
|
289
|
+
cands.push([{ x: p1.x, y: gMinY - margin }, { x: p2.x, y: gMinY - margin }], [{ x: p1.x, y: gMaxY + margin }, { x: p2.x, y: gMaxY + margin }], [{ x: gMinX - margin, y: p1.y }, { x: gMinX - margin, y: p2.y }], [{ x: gMaxX + margin, y: p1.y }, { x: gMaxX + margin, y: p2.y }]);
|
|
290
|
+
let best = null;
|
|
291
|
+
let bestScore = Infinity;
|
|
292
|
+
for (const w of cands) {
|
|
293
|
+
const head = w[0];
|
|
294
|
+
const tail = w[w.length - 1];
|
|
295
|
+
const path = [borderPoint(src, head.x, head.y), ...w, borderPoint(dst, tail.x, tail.y)];
|
|
296
|
+
if (!polyClear(path, rects))
|
|
297
|
+
continue;
|
|
298
|
+
const score = polyLen(path) + w.length * 8;
|
|
299
|
+
if (score < bestScore) {
|
|
300
|
+
bestScore = score;
|
|
301
|
+
best = path;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return best;
|
|
305
|
+
}
|
|
306
|
+
function dashAttr(dash) {
|
|
307
|
+
if (dash === 'dashed')
|
|
308
|
+
return ' stroke-dasharray="8 6"';
|
|
309
|
+
if (dash === 'dotted')
|
|
310
|
+
return ' stroke-dasharray="2 5"';
|
|
311
|
+
return '';
|
|
312
|
+
}
|
|
313
|
+
export function toSvg(ops) {
|
|
314
|
+
const skipped = [];
|
|
315
|
+
const bodies = [];
|
|
316
|
+
let minX = Infinity;
|
|
317
|
+
let minY = Infinity;
|
|
318
|
+
let maxX = -Infinity;
|
|
319
|
+
let maxY = -Infinity;
|
|
320
|
+
const grow = (r) => {
|
|
321
|
+
if (r.x < minX)
|
|
322
|
+
minX = r.x;
|
|
323
|
+
if (r.y < minY)
|
|
324
|
+
minY = r.y;
|
|
325
|
+
if (r.x + r.w > maxX)
|
|
326
|
+
maxX = r.x + r.w;
|
|
327
|
+
if (r.y + r.h > maxY)
|
|
328
|
+
maxY = r.y + r.h;
|
|
329
|
+
};
|
|
330
|
+
// 第一遍:登记几何(箭头端点解析用);卡 + rect 同时登记为避障障碍(frame 是容器、text 无边界,不算)
|
|
331
|
+
const geom = new Map();
|
|
332
|
+
const obstacles = [];
|
|
333
|
+
// 对抗性契约:坐标非有限(NaN/Infinity,可经 JSON 1e999 / 上游数值 bug 进入)→ 记 skipped
|
|
334
|
+
// 不登记 —— 绝不让 "NaN"/"Infinity" 字面量落进 svg 属性毒化 viewBox 与整个渲染
|
|
335
|
+
const finiteRect = (r) => Number.isFinite(r.x) && Number.isFinite(r.y) && Number.isFinite(r.w) && Number.isFinite(r.h);
|
|
336
|
+
for (let i = 0; i < ops.length; i++) {
|
|
337
|
+
const op = ops[i];
|
|
338
|
+
if (op.type === 'card') {
|
|
339
|
+
const r = { x: op.x, y: op.y, w: op.w ?? CARD_DEFAULT_W, h: op.h ?? CARD_DEFAULT_H };
|
|
340
|
+
if (!finiteRect(r)) {
|
|
341
|
+
skipped.push({ opIndex: i, op: `card ${op.cardId}`, reason: '坐标含 NaN/Infinity,不渲染' });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
geom.set(String(op.cardId), r);
|
|
345
|
+
obstacles.push(r);
|
|
346
|
+
}
|
|
347
|
+
else if (op.type === 'free' && op.id) {
|
|
348
|
+
const isText = op.shape === 'text';
|
|
349
|
+
const r = {
|
|
350
|
+
x: op.x,
|
|
351
|
+
y: op.y,
|
|
352
|
+
w: op.w ?? (isText ? TEXT_DEFAULT_W : CARD_DEFAULT_W),
|
|
353
|
+
h: op.h ?? (isText ? TEXT_DEFAULT_H : CARD_DEFAULT_H),
|
|
354
|
+
};
|
|
355
|
+
if (!finiteRect(r)) {
|
|
356
|
+
skipped.push({ opIndex: i, op: `free ${op.id}`, reason: '坐标含 NaN/Infinity,不渲染' });
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
geom.set(op.id, r);
|
|
360
|
+
if (op.shape === 'rect')
|
|
361
|
+
obstacles.push(r);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// 第二遍:逐 op 出 SVG 片段(顺序即 DSL 顺序;箭头画在卡之下——先箭头后卡更干净)
|
|
365
|
+
const arrowBodies = [];
|
|
366
|
+
const nodeBodies = [];
|
|
367
|
+
for (let i = 0; i < ops.length; i++) {
|
|
368
|
+
const op = ops[i];
|
|
369
|
+
if (op.type === 'card') {
|
|
370
|
+
const r = geom.get(String(op.cardId));
|
|
371
|
+
if (!r)
|
|
372
|
+
continue; // 首遍判非有限已记 skipped
|
|
373
|
+
grow(r);
|
|
374
|
+
const stroke = hex(op.color === 'white' ? 'black' : op.color); // 白卡白描边不可见 → 墨描边
|
|
375
|
+
nodeBodies.push(`<rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="8" fill="${SURFACE}" stroke="${stroke}" stroke-width="2"/>`);
|
|
376
|
+
if (op.title !== undefined) {
|
|
377
|
+
nodeBodies.push(`<text x="${r.x + 14}" y="${r.y + 26}" font-family="${FONT}" font-size="14" font-weight="700" fill="${INK}">${esc(op.title)}</text>`);
|
|
378
|
+
}
|
|
379
|
+
if (op.content !== undefined) {
|
|
380
|
+
const top = r.y + (op.title !== undefined ? 46 : 20);
|
|
381
|
+
const lines = wrapLines(op.content, r.w - 28, 12, 3);
|
|
382
|
+
lines.forEach((ln, k) => {
|
|
383
|
+
nodeBodies.push(`<text x="${r.x + 14}" y="${top + k * 17}" font-family="${FONT}" font-size="12" fill="#5e5e5c">${esc(ln)}</text>`);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (op.type === 'free') {
|
|
389
|
+
let r = op.id != null ? geom.get(op.id) : undefined;
|
|
390
|
+
if (op.id != null && !r)
|
|
391
|
+
continue; // 首遍判非有限已记 skipped(避免二次入 skipped)
|
|
392
|
+
if (!r) {
|
|
393
|
+
r = {
|
|
394
|
+
x: op.x,
|
|
395
|
+
y: op.y,
|
|
396
|
+
w: op.w ?? (op.shape === 'text' ? TEXT_DEFAULT_W : CARD_DEFAULT_W),
|
|
397
|
+
h: op.h ?? (op.shape === 'text' ? TEXT_DEFAULT_H : CARD_DEFAULT_H),
|
|
398
|
+
};
|
|
399
|
+
if (!finiteRect(r)) {
|
|
400
|
+
skipped.push({ opIndex: i, op: `free ${i}`, reason: '坐标含 NaN/Infinity,不渲染' });
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
grow(r);
|
|
405
|
+
if (op.shape === 'text') {
|
|
406
|
+
const fill = hex(op.color);
|
|
407
|
+
const src = op.text ?? (op.compute !== undefined ? `=${op.compute}` : '');
|
|
408
|
+
const lines = wrapLines(src, r.w - 8, 13, Math.max(1, Math.floor(r.h / 18)));
|
|
409
|
+
lines.forEach((ln, k) => {
|
|
410
|
+
nodeBodies.push(`<text x="${r.x + 4}" y="${r.y + 15 + k * 18}" font-family="${FONT}" font-size="13" fill="${fill}">${esc(ln)}</text>`);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
else if (op.shape === 'rect') {
|
|
414
|
+
nodeBodies.push(`<rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" fill="none" stroke="${hex(op.color)}" stroke-width="2"/>`);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
// frame:主题容器 = 描边矩形 + 左上标题
|
|
418
|
+
nodeBodies.push(`<rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" fill="none" stroke="${hex(op.color)}" stroke-width="2" stroke-dasharray="10 6"/>`);
|
|
419
|
+
if (op.text !== undefined) {
|
|
420
|
+
nodeBodies.push(`<text x="${r.x + 10}" y="${r.y + 20}" font-family="${FONT}" font-size="13" font-weight="700" fill="${hex(op.color)}">${esc(op.text)}</text>`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
// arrow
|
|
426
|
+
const color = hex(op.color);
|
|
427
|
+
let p1;
|
|
428
|
+
let p2;
|
|
429
|
+
let mid = [];
|
|
430
|
+
let srcRect;
|
|
431
|
+
let dstRect;
|
|
432
|
+
if (op.freeArrow === true) {
|
|
433
|
+
p1 = { x: op.x ?? 0, y: op.y ?? 0 };
|
|
434
|
+
p2 = { x: (op.x ?? 0) + (op.w ?? 0), y: (op.y ?? 0) + (op.h ?? 0) };
|
|
435
|
+
if (![p1.x, p1.y, p2.x, p2.y].every(Number.isFinite)) {
|
|
436
|
+
skipped.push({ opIndex: i, op: `arrow ${op.id ?? ''}`.trim(), reason: '坐标含 NaN/Infinity,不渲染' });
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
const src = geom.get(op.from);
|
|
442
|
+
const dst = geom.get(op.to);
|
|
443
|
+
if (!src || !dst) {
|
|
444
|
+
skipped.push({
|
|
445
|
+
opIndex: i,
|
|
446
|
+
op: `arrow ${op.id ?? `${op.from}→${op.to}`}`,
|
|
447
|
+
reason: `端点 ${!src ? `#${op.from}` : `#${op.to}`} 不存在,不渲染`,
|
|
448
|
+
});
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
srcRect = src;
|
|
452
|
+
dstRect = dst;
|
|
453
|
+
p1 = borderPoint(src, dst.x + dst.w / 2, dst.y + dst.h / 2);
|
|
454
|
+
p2 = borderPoint(dst, src.x + src.w / 2, src.y + src.h / 2);
|
|
455
|
+
if (op.route === 'elbow' && op.elbow !== undefined) {
|
|
456
|
+
mid = op.elbow;
|
|
457
|
+
}
|
|
458
|
+
else if (op.route === 'elbow') {
|
|
459
|
+
// route=elbow 未给折点:正交自动路由——求解器(A* 正交可见图)优先,
|
|
460
|
+
// 无解退启发式绕行,再退主方向中线 Z(已对齐则直连);端点随路径重贴边
|
|
461
|
+
const planned = planRoute(p1, p2, src, dst, obstacles, 'orthogonal');
|
|
462
|
+
if (planned && planned.length >= 2) {
|
|
463
|
+
p1 = planned[0];
|
|
464
|
+
p2 = planned[planned.length - 1];
|
|
465
|
+
mid = planned.slice(1, -1);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
const detour = routeAround(p1, p2, src, dst, obstacles);
|
|
469
|
+
const w = detour ? detour.slice(1, -1) : midZWaypoints(p1, p2);
|
|
470
|
+
if (w.length > 0) {
|
|
471
|
+
p1 = borderPoint(src, w[0].x, w[0].y);
|
|
472
|
+
p2 = borderPoint(dst, w[w.length - 1].x, w[w.length - 1].y);
|
|
473
|
+
mid = w;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
else if (op.route === undefined && op.curve === undefined) {
|
|
478
|
+
// 默认路由自动避障:求解器规划(free 模式直连已清返回 null=保持直连),
|
|
479
|
+
// null 且确有障碍挡路时退启发式候选族;显式 route/curve 交给作者意图。
|
|
480
|
+
// 返回整条重贴边路径,端点一并替换(箭头方向跟着新末端走,不沿卡边滑)
|
|
481
|
+
const planned = planRoute(p1, p2, src, dst, obstacles, 'free');
|
|
482
|
+
if (planned && planned.length >= 2) {
|
|
483
|
+
p1 = planned[0];
|
|
484
|
+
p2 = planned[planned.length - 1];
|
|
485
|
+
mid = planned.slice(1, -1);
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
const detour = routeAround(p1, p2, src, dst, obstacles);
|
|
489
|
+
if (detour) {
|
|
490
|
+
p1 = detour[0];
|
|
491
|
+
p2 = detour[detour.length - 1];
|
|
492
|
+
mid = detour.slice(1, -1);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const route = op.route ?? (op.curve !== undefined ? 'curve' : 'straight');
|
|
498
|
+
let d;
|
|
499
|
+
let headFrom;
|
|
500
|
+
let labelAnchor;
|
|
501
|
+
if (route === 'curve') {
|
|
502
|
+
const c = op.curve ?? { cx: (p1.x + p2.x) / 2, cy: (p1.y + p2.y) / 2 - 40 };
|
|
503
|
+
d = `M ${p1.x} ${p1.y} Q ${c.cx} ${c.cy} ${p2.x} ${p2.y}`;
|
|
504
|
+
headFrom = { x: c.cx, y: c.cy };
|
|
505
|
+
labelAnchor = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
|
506
|
+
grow({ x: p1.x, y: p1.y, w: 0, h: 0 });
|
|
507
|
+
grow({ x: p2.x, y: p2.y, w: 0, h: 0 });
|
|
508
|
+
grow({ x: c.cx, y: c.cy, w: 0, h: 0 });
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
const pts = [p1, ...mid, p2];
|
|
512
|
+
d = roundedPolyPath(pts);
|
|
513
|
+
headFrom = pts[pts.length - 2];
|
|
514
|
+
labelAnchor = polyMid(pts);
|
|
515
|
+
for (const p of pts)
|
|
516
|
+
grow({ x: p.x, y: p.y, w: 0, h: 0 });
|
|
517
|
+
}
|
|
518
|
+
arrowBodies.push(`<path d="${d}" fill="none" stroke="${color}" stroke-width="2"${dashAttr(op.dash)}/>`);
|
|
519
|
+
if (op.arrowhead !== 'none') {
|
|
520
|
+
arrowBodies.push(arrowHead(p2, headFrom, op.arrowhead ?? 'arrow', color));
|
|
521
|
+
}
|
|
522
|
+
if (op.label !== undefined) {
|
|
523
|
+
arrowBodies.push(`<text x="${labelAnchor.x}" y="${labelAnchor.y - 6}" font-family="${FONT}" font-size="11" fill="${INK}" text-anchor="middle" stroke="${SURFACE}" stroke-width="3" paint-order="stroke">${esc(op.label)}</text>`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!Number.isFinite(minX)) {
|
|
527
|
+
minX = 0;
|
|
528
|
+
minY = 0;
|
|
529
|
+
maxX = 1;
|
|
530
|
+
maxY = 1;
|
|
531
|
+
}
|
|
532
|
+
const vx = Math.floor(minX - PAD);
|
|
533
|
+
const vy = Math.floor(minY - PAD);
|
|
534
|
+
const vw = Math.ceil(maxX - minX) + PAD * 2;
|
|
535
|
+
const vh = Math.ceil(maxY - minY) + PAD * 2;
|
|
536
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vx} ${vy} ${vw} ${vh}" width="${vw}" height="${vh}">\n` +
|
|
537
|
+
` <rect x="${vx}" y="${vy}" width="${vw}" height="${vh}" fill="${SURFACE}"/>\n` +
|
|
538
|
+
` <g>\n${arrowBodies.map((b) => ` ${b}`).join('\n')}\n </g>\n` +
|
|
539
|
+
` <g>\n${nodeBodies.map((b) => ` ${b}`).join('\n')}\n </g>\n` +
|
|
540
|
+
`</svg>\n`;
|
|
541
|
+
return { svg, skipped };
|
|
542
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types — 包内自持的边界类型(发布自持,零 workspace 依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 结构性复制自应用侧权威源:
|
|
5
|
+
* - CanvasElement / CanvasElementKind ← @cys-stift/canvas-engine(canvas-host.ts)
|
|
6
|
+
* - CardId / CardType / TagColor / TagRef / LinkPreview / CodeBlock / Quote ← @cys-stift/domain(types.ts)
|
|
7
|
+
*
|
|
8
|
+
* 同步由 `__tests__/types-compat.test.ts` 的编译期双向可赋值断言钉住——任何一侧漂移,
|
|
9
|
+
* `tsc --noEmit`(lint)即红。本包是画布的「文字视图」契约:只需要元素/字段的形状,
|
|
10
|
+
* 不关心应用侧的存储、渲染与交互。
|
|
11
|
+
*/
|
|
12
|
+
/** 主动支持的元素种类(用户可见 6 种;DSL 只序列化 active 集合)。 */
|
|
13
|
+
export type ActiveCanvasKind = 'card' | 'arrow' | 'freedraw' | 'text' | 'rect' | 'frame';
|
|
14
|
+
/** Legacy 种类——接口仍能表示(读旧画布),但 DSL 不序列化。 */
|
|
15
|
+
export type LegacyCanvasKind = 'ellipse' | 'line' | 'note' | 'image';
|
|
16
|
+
export type CanvasElementKind = ActiveCanvasKind | LegacyCanvasKind;
|
|
17
|
+
/** 通用画布元素模型(字段语义详见引擎侧权威源;此处为发布自持的结构性复制)。 */
|
|
18
|
+
export interface CanvasElement {
|
|
19
|
+
id: string;
|
|
20
|
+
kind: CanvasElementKind;
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
w: number;
|
|
24
|
+
h: number;
|
|
25
|
+
rotation: number;
|
|
26
|
+
/** 样式色(token 名或引擎色名,如 'blue');freedraw 不用。 */
|
|
27
|
+
color?: string;
|
|
28
|
+
/** arrow 线型(语义关系签名的一维):solid/dashed/dotted。缺省按 solid 渲染。 */
|
|
29
|
+
dash?: 'solid' | 'dashed' | 'dotted';
|
|
30
|
+
/** arrow 终点箭头形:arrow=开口V / triangle=实心三角 / none=无。缺省 arrow。 */
|
|
31
|
+
arrowhead?: 'arrow' | 'triangle' | 'none';
|
|
32
|
+
/** note/text 的文本;arrow 的 label。 */
|
|
33
|
+
text?: string;
|
|
34
|
+
/** arrow 端点(id 引用,无 '#' 前缀)。 */
|
|
35
|
+
from?: string;
|
|
36
|
+
to?: string;
|
|
37
|
+
/** arrow 弯曲控制点(二次贝塞尔,绝对页坐标)。无则直线。 */
|
|
38
|
+
curve?: {
|
|
39
|
+
cx: number;
|
|
40
|
+
cy: number;
|
|
41
|
+
};
|
|
42
|
+
/** 箭头路由形态:straight(直线)/ curve(单控制点)/ elbow(折线)。缺省 straight。 */
|
|
43
|
+
route?: 'straight' | 'curve' | 'elbow';
|
|
44
|
+
/** 折线折点(1-2 个,绝对页坐标)。route='elbow' 时用。 */
|
|
45
|
+
elbow?: {
|
|
46
|
+
x: number;
|
|
47
|
+
y: number;
|
|
48
|
+
}[];
|
|
49
|
+
/** freedraw/image 只在此层带 metadata;原始点序列/二进制不进 DSL。 */
|
|
50
|
+
meta?: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
export type CardId = string & {
|
|
53
|
+
readonly __brand: 'CardId';
|
|
54
|
+
};
|
|
55
|
+
export type CardType = 'note' | 'image' | 'link' | 'code' | 'quote';
|
|
56
|
+
/** 规范 tag 色 token(与 domain TAG_COLORS 逐字一致)。 */
|
|
57
|
+
export type CanonicalTagColor = 'var(--color-red)' | 'var(--color-blue)' | 'var(--color-yellow)' | 'var(--color-black)' | 'var(--color-white)' | 'var(--color-gray)';
|
|
58
|
+
/** 历史 preview 写入的旧 token(读兼容;持久化边界统一 normalize)。 */
|
|
59
|
+
export type LegacyTagColor = 'var(--color-teal)' | 'var(--color-pink)' | 'var(--color-orange)' | 'var(--color-purple)' | 'var(--color-green)';
|
|
60
|
+
export type TagColor = CanonicalTagColor | LegacyTagColor;
|
|
61
|
+
export interface TagRef {
|
|
62
|
+
value: string;
|
|
63
|
+
color: TagColor;
|
|
64
|
+
}
|
|
65
|
+
export interface LinkPreview {
|
|
66
|
+
url: string;
|
|
67
|
+
title?: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
ogImageUrl?: string;
|
|
70
|
+
fetchedAt: Date;
|
|
71
|
+
}
|
|
72
|
+
export interface CodeBlock {
|
|
73
|
+
language: string;
|
|
74
|
+
code: string;
|
|
75
|
+
caption?: string;
|
|
76
|
+
}
|
|
77
|
+
export interface Quote {
|
|
78
|
+
text: string;
|
|
79
|
+
attribution?: string;
|
|
80
|
+
sourceUrl?: string;
|
|
81
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types — 包内自持的边界类型(发布自持,零 workspace 依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 结构性复制自应用侧权威源:
|
|
5
|
+
* - CanvasElement / CanvasElementKind ← @cys-stift/canvas-engine(canvas-host.ts)
|
|
6
|
+
* - CardId / CardType / TagColor / TagRef / LinkPreview / CodeBlock / Quote ← @cys-stift/domain(types.ts)
|
|
7
|
+
*
|
|
8
|
+
* 同步由 `__tests__/types-compat.test.ts` 的编译期双向可赋值断言钉住——任何一侧漂移,
|
|
9
|
+
* `tsc --noEmit`(lint)即红。本包是画布的「文字视图」契约:只需要元素/字段的形状,
|
|
10
|
+
* 不关心应用侧的存储、渲染与交互。
|
|
11
|
+
*/
|
|
12
|
+
export {};
|