sketchmark 0.1.2 → 0.1.4
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.
Potentially problematic release.
This version of sketchmark might be problematic. Click here for more details.
- package/README.md +461 -200
- package/dist/ast/types.d.ts +17 -0
- package/dist/ast/types.d.ts.map +1 -1
- package/dist/index.cjs +708 -188
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +706 -189
- package/dist/index.js.map +1 -1
- package/dist/layout/index.d.ts +1 -1
- package/dist/layout/index.d.ts.map +1 -1
- package/dist/markdown/parser.d.ts +16 -0
- package/dist/markdown/parser.d.ts.map +1 -0
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/tokenizer.d.ts +1 -1
- package/dist/parser/tokenizer.d.ts.map +1 -1
- package/dist/renderer/canvas/index.d.ts.map +1 -1
- package/dist/renderer/svg/index.d.ts.map +1 -1
- package/dist/scene/index.d.ts +15 -0
- package/dist/scene/index.d.ts.map +1 -1
- package/dist/share/encrypted.d.ts +11 -0
- package/dist/share/encrypted.d.ts.map +1 -0
- package/dist/sketchmark.iife.js +708 -188
- package/package.json +69 -66
package/dist/index.cjs
CHANGED
|
@@ -24,6 +24,7 @@ const KEYWORDS = new Set([
|
|
|
24
24
|
"step",
|
|
25
25
|
"config",
|
|
26
26
|
"theme",
|
|
27
|
+
"bare",
|
|
27
28
|
"bar-chart",
|
|
28
29
|
"line-chart",
|
|
29
30
|
"pie-chart",
|
|
@@ -63,6 +64,7 @@ const KEYWORDS = new Set([
|
|
|
63
64
|
"dag",
|
|
64
65
|
"tree",
|
|
65
66
|
"force",
|
|
67
|
+
"markdown",
|
|
66
68
|
]);
|
|
67
69
|
const ARROW_PATTERNS = ["<-->", "<->", "-->", "<--", "->", "<-", "---", "--"];
|
|
68
70
|
// Characters that can start an arrow pattern — used to decide whether a '-'
|
|
@@ -100,6 +102,23 @@ function tokenize(src) {
|
|
|
100
102
|
i++;
|
|
101
103
|
continue;
|
|
102
104
|
}
|
|
105
|
+
if (ch === '"' && peek(1) === '"' && peek(2) === '"') {
|
|
106
|
+
i += 3; // skip opening """
|
|
107
|
+
let raw = "";
|
|
108
|
+
while (i < src.length) {
|
|
109
|
+
if (src[i] === '"' && src[i + 1] === '"' && src[i + 2] === '"') {
|
|
110
|
+
i += 3; // skip closing """
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
if (src[i] === "\n") {
|
|
114
|
+
line++;
|
|
115
|
+
lineStart = i + 1;
|
|
116
|
+
}
|
|
117
|
+
raw += src[i++];
|
|
118
|
+
}
|
|
119
|
+
add("STRING_BLOCK", raw);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
103
122
|
// Strings
|
|
104
123
|
if (ch === '"' || ch === "'") {
|
|
105
124
|
const q = ch;
|
|
@@ -250,14 +269,16 @@ function propsToStyle(p) {
|
|
|
250
269
|
s.fontSize = parseFloat(p["font-size"]);
|
|
251
270
|
if (p["font-weight"])
|
|
252
271
|
s.fontWeight = p["font-weight"];
|
|
253
|
-
if (p[
|
|
254
|
-
s.textAlign = p[
|
|
255
|
-
if (p
|
|
256
|
-
s.
|
|
257
|
-
if (p[
|
|
258
|
-
s.
|
|
259
|
-
if (p[
|
|
260
|
-
s.
|
|
272
|
+
if (p["text-align"])
|
|
273
|
+
s.textAlign = p["text-align"];
|
|
274
|
+
if (p.padding)
|
|
275
|
+
s.padding = parseFloat(p.padding);
|
|
276
|
+
if (p["vertical-align"])
|
|
277
|
+
s.verticalAlign = p["vertical-align"];
|
|
278
|
+
if (p["line-height"])
|
|
279
|
+
s.lineHeight = parseFloat(p["line-height"]);
|
|
280
|
+
if (p["letter-spacing"])
|
|
281
|
+
s.letterSpacing = parseFloat(p["letter-spacing"]);
|
|
261
282
|
if (p.font)
|
|
262
283
|
s.font = p.font;
|
|
263
284
|
if (p["dash"]) {
|
|
@@ -297,6 +318,7 @@ function parse(src) {
|
|
|
297
318
|
notes: [],
|
|
298
319
|
charts: [],
|
|
299
320
|
tables: [],
|
|
321
|
+
markdowns: [],
|
|
300
322
|
styles: {},
|
|
301
323
|
themes: {},
|
|
302
324
|
config: {},
|
|
@@ -307,6 +329,7 @@ function parse(src) {
|
|
|
307
329
|
const noteIds = new Set();
|
|
308
330
|
const chartIds = new Set();
|
|
309
331
|
const groupIds = new Set();
|
|
332
|
+
const markdownIds = new Set();
|
|
310
333
|
let i = 0;
|
|
311
334
|
const cur = () => flat[i] ?? flat[flat.length - 1];
|
|
312
335
|
const peek1 = () => flat[i + 1] ?? flat[flat.length - 1];
|
|
@@ -520,7 +543,7 @@ function parse(src) {
|
|
|
520
543
|
const group = {
|
|
521
544
|
kind: "group",
|
|
522
545
|
id,
|
|
523
|
-
label: props.label ??
|
|
546
|
+
label: props.label ?? "",
|
|
524
547
|
children: [],
|
|
525
548
|
layout: props.layout,
|
|
526
549
|
columns: props.columns !== undefined ? parseInt(props.columns, 10) : undefined,
|
|
@@ -546,8 +569,18 @@ function parse(src) {
|
|
|
546
569
|
break;
|
|
547
570
|
const v = cur().value;
|
|
548
571
|
// ── Nested group ──────────────────────────────────
|
|
549
|
-
if (v === "group") {
|
|
572
|
+
if (v === "group" || v === "bare") {
|
|
573
|
+
const isBare = v === "bare";
|
|
550
574
|
const nested = parseGroup();
|
|
575
|
+
if (isBare) {
|
|
576
|
+
nested.label = "";
|
|
577
|
+
nested.style = {
|
|
578
|
+
...nested.style,
|
|
579
|
+
fill: nested.style?.fill ?? "none",
|
|
580
|
+
stroke: nested.style?.stroke ?? "none",
|
|
581
|
+
strokeWidth: nested.style?.strokeWidth ?? 0,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
551
584
|
ast.groups.push(nested);
|
|
552
585
|
groupIds.add(nested.id);
|
|
553
586
|
group.children.push({ kind: "group", id: nested.id });
|
|
@@ -569,6 +602,21 @@ function parse(src) {
|
|
|
569
602
|
group.children.push({ kind: "note", id: note.id });
|
|
570
603
|
continue;
|
|
571
604
|
}
|
|
605
|
+
// ── Markdown ───────────────────────────────────────
|
|
606
|
+
if (v === "markdown") {
|
|
607
|
+
const md = parseMarkdown(id);
|
|
608
|
+
ast.markdowns.push(md);
|
|
609
|
+
markdownIds.add(md.id);
|
|
610
|
+
group.children.push({ kind: "markdown", id: md.id });
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (v === "bare") {
|
|
614
|
+
// treat exactly like 'group' but inject defaults
|
|
615
|
+
const grp = parseGroup(); // reuse parseGroup
|
|
616
|
+
grp.label = "";
|
|
617
|
+
grp.style = { ...grp.style, stroke: "none", fill: "none" };
|
|
618
|
+
// rest is identical to group handling
|
|
619
|
+
}
|
|
572
620
|
// ── Chart ──────────────────────────────────────────
|
|
573
621
|
if (CHART_TYPES.includes(v)) {
|
|
574
622
|
const chart = parseChart(v);
|
|
@@ -612,71 +660,71 @@ function parse(src) {
|
|
|
612
660
|
function parseStep() {
|
|
613
661
|
skip();
|
|
614
662
|
const toks = lineTokens();
|
|
615
|
-
const action = (toks[0]?.value ??
|
|
616
|
-
let target = toks[1]?.value ??
|
|
617
|
-
if (toks[2]?.type ===
|
|
663
|
+
const action = (toks[0]?.value ?? "highlight");
|
|
664
|
+
let target = toks[1]?.value ?? "";
|
|
665
|
+
if (toks[2]?.type === "ARROW" && toks[3]) {
|
|
618
666
|
target = `${toks[1].value}${toks[2].value}${toks[3].value}`;
|
|
619
667
|
}
|
|
620
|
-
const step = { kind:
|
|
668
|
+
const step = { kind: "step", action, target };
|
|
621
669
|
for (let j = 2; j < toks.length; j++) {
|
|
622
670
|
const k = toks[j]?.value;
|
|
623
671
|
const eq = toks[j + 1];
|
|
624
672
|
const vt = toks[j + 2];
|
|
625
673
|
// key=value form
|
|
626
|
-
if (eq?.type ===
|
|
627
|
-
if (k ===
|
|
674
|
+
if (eq?.type === "EQUALS" && vt) {
|
|
675
|
+
if (k === "dx") {
|
|
628
676
|
step.dx = parseFloat(vt.value);
|
|
629
677
|
j += 2;
|
|
630
678
|
continue;
|
|
631
679
|
}
|
|
632
|
-
if (k ===
|
|
680
|
+
if (k === "dy") {
|
|
633
681
|
step.dy = parseFloat(vt.value);
|
|
634
682
|
j += 2;
|
|
635
683
|
continue;
|
|
636
684
|
}
|
|
637
|
-
if (k ===
|
|
685
|
+
if (k === "duration") {
|
|
638
686
|
step.duration = parseFloat(vt.value);
|
|
639
687
|
j += 2;
|
|
640
688
|
continue;
|
|
641
689
|
}
|
|
642
|
-
if (k ===
|
|
690
|
+
if (k === "delay") {
|
|
643
691
|
step.delay = parseFloat(vt.value);
|
|
644
692
|
j += 2;
|
|
645
693
|
continue;
|
|
646
694
|
}
|
|
647
|
-
if (k ===
|
|
695
|
+
if (k === "factor") {
|
|
648
696
|
step.factor = parseFloat(vt.value);
|
|
649
697
|
j += 2;
|
|
650
698
|
continue;
|
|
651
699
|
}
|
|
652
|
-
if (k ===
|
|
700
|
+
if (k === "deg") {
|
|
653
701
|
step.deg = parseFloat(vt.value);
|
|
654
702
|
j += 2;
|
|
655
703
|
continue;
|
|
656
704
|
}
|
|
657
|
-
if (k ===
|
|
705
|
+
if (k === "fill") {
|
|
658
706
|
step.value = vt.value;
|
|
659
707
|
j += 2;
|
|
660
708
|
continue;
|
|
661
709
|
}
|
|
662
|
-
if (k ===
|
|
710
|
+
if (k === "color") {
|
|
663
711
|
step.value = vt.value;
|
|
664
712
|
j += 2;
|
|
665
713
|
continue;
|
|
666
714
|
}
|
|
667
715
|
}
|
|
668
716
|
// bare key value (legacy)
|
|
669
|
-
if (k ===
|
|
717
|
+
if (k === "delay" && eq?.type === "NUMBER") {
|
|
670
718
|
step.delay = parseFloat(eq.value);
|
|
671
719
|
j++;
|
|
672
720
|
continue;
|
|
673
721
|
}
|
|
674
|
-
if (k ===
|
|
722
|
+
if (k === "duration" && eq?.type === "NUMBER") {
|
|
675
723
|
step.duration = parseFloat(eq.value);
|
|
676
724
|
j++;
|
|
677
725
|
continue;
|
|
678
726
|
}
|
|
679
|
-
if (k ===
|
|
727
|
+
if (k === "trigger") {
|
|
680
728
|
step.trigger = eq?.value;
|
|
681
729
|
j++;
|
|
682
730
|
continue;
|
|
@@ -876,6 +924,40 @@ function parse(src) {
|
|
|
876
924
|
skip();
|
|
877
925
|
return table;
|
|
878
926
|
}
|
|
927
|
+
// ── parseMarkdown ─────────────────────────────────────────
|
|
928
|
+
function parseMarkdown(groupId) {
|
|
929
|
+
skip(); // 'markdown'
|
|
930
|
+
const toks = lineTokens();
|
|
931
|
+
let id = groupId ? groupId + "_" + uid("md") : uid("md");
|
|
932
|
+
if (toks[0])
|
|
933
|
+
id = toks[0].value;
|
|
934
|
+
const props = {};
|
|
935
|
+
let j = 1;
|
|
936
|
+
while (j < toks.length - 1) {
|
|
937
|
+
const k = toks[j], eq = toks[j + 1];
|
|
938
|
+
if (eq?.type === "EQUALS" && j + 2 < toks.length) {
|
|
939
|
+
props[k.value] = toks[j + 2].value;
|
|
940
|
+
j += 3;
|
|
941
|
+
}
|
|
942
|
+
else
|
|
943
|
+
j++;
|
|
944
|
+
}
|
|
945
|
+
skipNL();
|
|
946
|
+
let content = "";
|
|
947
|
+
if (cur().type === "STRING_BLOCK") {
|
|
948
|
+
content = cur().value;
|
|
949
|
+
skip();
|
|
950
|
+
}
|
|
951
|
+
return {
|
|
952
|
+
kind: "markdown",
|
|
953
|
+
id,
|
|
954
|
+
content: content.trim(),
|
|
955
|
+
width: props.width ? parseFloat(props.width) : undefined,
|
|
956
|
+
height: props.height ? parseFloat(props.height) : undefined,
|
|
957
|
+
theme: props.theme,
|
|
958
|
+
style: propsToStyle(props),
|
|
959
|
+
};
|
|
960
|
+
}
|
|
879
961
|
// ── Main parse loop ─────────────────────────────────────
|
|
880
962
|
skipNL();
|
|
881
963
|
if (cur().value === "diagram")
|
|
@@ -989,8 +1071,18 @@ function parse(src) {
|
|
|
989
1071
|
continue;
|
|
990
1072
|
}
|
|
991
1073
|
// group
|
|
992
|
-
if (v === "group") {
|
|
1074
|
+
if (v === "group" || v === "bare") {
|
|
1075
|
+
const isBare = v === "bare";
|
|
993
1076
|
const grp = parseGroup();
|
|
1077
|
+
if (isBare) {
|
|
1078
|
+
grp.label = "";
|
|
1079
|
+
grp.style = {
|
|
1080
|
+
...grp.style,
|
|
1081
|
+
fill: grp.style?.fill ?? "none",
|
|
1082
|
+
stroke: grp.style?.stroke ?? "none",
|
|
1083
|
+
strokeWidth: grp.style?.strokeWidth ?? 0,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
994
1086
|
ast.groups.push(grp);
|
|
995
1087
|
groupIds.add(grp.id);
|
|
996
1088
|
ast.rootOrder.push({ kind: "group", id: grp.id });
|
|
@@ -1025,6 +1117,13 @@ function parse(src) {
|
|
|
1025
1117
|
ast.rootOrder.push({ kind: "chart", id: chart.id }); // ← ADD
|
|
1026
1118
|
continue;
|
|
1027
1119
|
}
|
|
1120
|
+
if (v === "markdown") {
|
|
1121
|
+
const md = parseMarkdown();
|
|
1122
|
+
ast.markdowns.push(md);
|
|
1123
|
+
markdownIds.add(md.id);
|
|
1124
|
+
ast.rootOrder.push({ kind: "markdown", id: md.id });
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1028
1127
|
// edge: A -> B (MUST come before shape check)
|
|
1029
1128
|
if (t.type === "IDENT" || t.type === "STRING" || t.type === "KEYWORD") {
|
|
1030
1129
|
const nextTok = flat[i + 1];
|
|
@@ -1077,6 +1176,91 @@ function parse(src) {
|
|
|
1077
1176
|
return ast;
|
|
1078
1177
|
}
|
|
1079
1178
|
|
|
1179
|
+
// ============================================================
|
|
1180
|
+
// sketchmark — Markdown inline parser
|
|
1181
|
+
// Supports: # h1 ## h2 ### h3 **bold** *italic* blank lines
|
|
1182
|
+
// ============================================================
|
|
1183
|
+
// ── Font sizes per line kind ──────────────────────────────
|
|
1184
|
+
const LINE_FONT_SIZE = {
|
|
1185
|
+
h1: 40,
|
|
1186
|
+
h2: 28,
|
|
1187
|
+
h3: 20,
|
|
1188
|
+
p: 15,
|
|
1189
|
+
blank: 0,
|
|
1190
|
+
};
|
|
1191
|
+
const LINE_FONT_WEIGHT = {
|
|
1192
|
+
h1: 700,
|
|
1193
|
+
h2: 600,
|
|
1194
|
+
h3: 600,
|
|
1195
|
+
p: 400,
|
|
1196
|
+
blank: 400,
|
|
1197
|
+
};
|
|
1198
|
+
// Spacing below each line kind (px)
|
|
1199
|
+
const LINE_SPACING = {
|
|
1200
|
+
h1: 52,
|
|
1201
|
+
h2: 38,
|
|
1202
|
+
h3: 28,
|
|
1203
|
+
p: 22,
|
|
1204
|
+
blank: 10,
|
|
1205
|
+
};
|
|
1206
|
+
// ── Parse a full markdown string into lines ───────────────
|
|
1207
|
+
function parseMarkdownContent(content) {
|
|
1208
|
+
const raw = content.split('\n');
|
|
1209
|
+
const lines = [];
|
|
1210
|
+
for (const line of raw) {
|
|
1211
|
+
const t = line.trim();
|
|
1212
|
+
if (!t) {
|
|
1213
|
+
lines.push({ kind: 'blank', runs: [] });
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (t.startsWith('### ')) {
|
|
1217
|
+
lines.push({ kind: 'h3', runs: parseInline(t.slice(4)) });
|
|
1218
|
+
}
|
|
1219
|
+
else if (t.startsWith('## ')) {
|
|
1220
|
+
lines.push({ kind: 'h2', runs: parseInline(t.slice(3)) });
|
|
1221
|
+
}
|
|
1222
|
+
else if (t.startsWith('# ')) {
|
|
1223
|
+
lines.push({ kind: 'h1', runs: parseInline(t.slice(2)) });
|
|
1224
|
+
}
|
|
1225
|
+
else {
|
|
1226
|
+
lines.push({ kind: 'p', runs: parseInline(t) });
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
// strip leading/trailing blank lines
|
|
1230
|
+
while (lines.length && lines[0].kind === 'blank')
|
|
1231
|
+
lines.shift();
|
|
1232
|
+
while (lines.length && lines[lines.length - 1].kind === 'blank')
|
|
1233
|
+
lines.pop();
|
|
1234
|
+
return lines;
|
|
1235
|
+
}
|
|
1236
|
+
// ── Parse inline bold/italic spans ───────────────────────
|
|
1237
|
+
function parseInline(text) {
|
|
1238
|
+
const runs = [];
|
|
1239
|
+
// Order matters: check ** before *
|
|
1240
|
+
const re = /(\*\*(.+?)\*\*|\*(.+?)\*|[^*]+)/g;
|
|
1241
|
+
let m;
|
|
1242
|
+
while ((m = re.exec(text)) !== null) {
|
|
1243
|
+
if (m[0].startsWith('**')) {
|
|
1244
|
+
runs.push({ text: m[2], bold: true });
|
|
1245
|
+
}
|
|
1246
|
+
else if (m[0].startsWith('*')) {
|
|
1247
|
+
runs.push({ text: m[3], italic: true });
|
|
1248
|
+
}
|
|
1249
|
+
else {
|
|
1250
|
+
if (m[0])
|
|
1251
|
+
runs.push({ text: m[0] });
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return runs;
|
|
1255
|
+
}
|
|
1256
|
+
// ── Calculate natural height of a parsed block ────────────
|
|
1257
|
+
function calcMarkdownHeight(lines, pad = 16) {
|
|
1258
|
+
let h = pad * 2; // top + bottom
|
|
1259
|
+
for (const line of lines)
|
|
1260
|
+
h += LINE_SPACING[line.kind];
|
|
1261
|
+
return h;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1080
1264
|
// ============================================================
|
|
1081
1265
|
// sketchmark — Scene Graph
|
|
1082
1266
|
// ============================================================
|
|
@@ -1167,6 +1351,18 @@ function buildSceneGraph(ast) {
|
|
|
1167
1351
|
h: c.height ?? 240,
|
|
1168
1352
|
};
|
|
1169
1353
|
});
|
|
1354
|
+
const markdowns = (ast.markdowns ?? []).map(m => {
|
|
1355
|
+
const themeStyle = m.theme ? (ast.themes[m.theme] ?? {}) : {};
|
|
1356
|
+
return {
|
|
1357
|
+
id: m.id,
|
|
1358
|
+
content: m.content,
|
|
1359
|
+
lines: parseMarkdownContent(m.content),
|
|
1360
|
+
style: { ...themeStyle, ...m.style },
|
|
1361
|
+
width: m.width,
|
|
1362
|
+
height: m.height,
|
|
1363
|
+
x: 0, y: 0, w: 0, h: 0,
|
|
1364
|
+
};
|
|
1365
|
+
});
|
|
1170
1366
|
// Set parentId for nested groups
|
|
1171
1367
|
for (const g of groups) {
|
|
1172
1368
|
for (const child of g.children) {
|
|
@@ -1197,6 +1393,7 @@ function buildSceneGraph(ast) {
|
|
|
1197
1393
|
tables,
|
|
1198
1394
|
notes,
|
|
1199
1395
|
charts,
|
|
1396
|
+
markdowns,
|
|
1200
1397
|
animation: { steps: ast.steps, currentStep: -1 },
|
|
1201
1398
|
styles: ast.styles,
|
|
1202
1399
|
config: ast.config,
|
|
@@ -1221,6 +1418,9 @@ function noteMap(sg) {
|
|
|
1221
1418
|
function chartMap(sg) {
|
|
1222
1419
|
return new Map(sg.charts.map((c) => [c.id, c]));
|
|
1223
1420
|
}
|
|
1421
|
+
function markdownMap(sg) {
|
|
1422
|
+
return new Map((sg.markdowns ?? []).map(m => [m.id, m]));
|
|
1423
|
+
}
|
|
1224
1424
|
|
|
1225
1425
|
// ============================================================
|
|
1226
1426
|
// sketchmark — Layout Engine (Flexbox-style, recursive)
|
|
@@ -1261,30 +1461,40 @@ function sizeNode(n) {
|
|
|
1261
1461
|
n.h = n.height;
|
|
1262
1462
|
const labelW = Math.round(n.label.length * FONT_PX_PER_CHAR + BASE_PAD);
|
|
1263
1463
|
switch (n.shape) {
|
|
1264
|
-
case
|
|
1464
|
+
case "circle":
|
|
1265
1465
|
n.w = n.w || Math.max(84, Math.min(MAX_W, labelW));
|
|
1266
1466
|
n.h = n.h || n.w;
|
|
1267
1467
|
break;
|
|
1268
|
-
case
|
|
1468
|
+
case "diamond":
|
|
1269
1469
|
n.w = n.w || Math.max(130, Math.min(MAX_W, labelW + 30));
|
|
1270
1470
|
n.h = n.h || Math.max(62, n.w * 0.46);
|
|
1271
1471
|
break;
|
|
1272
|
-
case
|
|
1472
|
+
case "hexagon":
|
|
1273
1473
|
n.w = n.w || Math.max(126, Math.min(MAX_W, labelW + 20));
|
|
1274
1474
|
n.h = n.h || Math.max(54, n.w * 0.44);
|
|
1275
1475
|
break;
|
|
1276
|
-
case
|
|
1476
|
+
case "triangle":
|
|
1277
1477
|
n.w = n.w || Math.max(108, Math.min(MAX_W, labelW + 10));
|
|
1278
|
-
n.h = n.h || Math.max(64, n.w * 0.
|
|
1478
|
+
n.h = n.h || Math.max(64, n.w * 0.6);
|
|
1279
1479
|
break;
|
|
1280
|
-
case
|
|
1480
|
+
case "cylinder":
|
|
1281
1481
|
n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
|
|
1282
1482
|
n.h = n.h || 66;
|
|
1283
1483
|
break;
|
|
1284
|
-
case
|
|
1484
|
+
case "parallelogram":
|
|
1285
1485
|
n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW + 28));
|
|
1286
1486
|
n.h = n.h || 50;
|
|
1287
1487
|
break;
|
|
1488
|
+
case "text": {
|
|
1489
|
+
// read fontSize from style if set, otherwise use default
|
|
1490
|
+
const fontSize = Number(n.style?.fontSize ?? 13);
|
|
1491
|
+
const charWidth = fontSize * 0.55;
|
|
1492
|
+
const maxW = n.width ?? 400;
|
|
1493
|
+
const approxLines = Math.ceil((n.label.length * charWidth) / (maxW - 16));
|
|
1494
|
+
n.w = maxW;
|
|
1495
|
+
n.h = n.height ?? Math.max(24, approxLines * fontSize * 1.5 + 8);
|
|
1496
|
+
break;
|
|
1497
|
+
}
|
|
1288
1498
|
default:
|
|
1289
1499
|
n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
|
|
1290
1500
|
n.h = n.h || 52;
|
|
@@ -1292,7 +1502,7 @@ function sizeNode(n) {
|
|
|
1292
1502
|
}
|
|
1293
1503
|
}
|
|
1294
1504
|
function sizeNote(n) {
|
|
1295
|
-
const maxChars = Math.max(...n.lines.map(l => l.length));
|
|
1505
|
+
const maxChars = Math.max(...n.lines.map((l) => l.length));
|
|
1296
1506
|
n.w = Math.max(120, Math.ceil(maxChars * NOTE_FONT) + NOTE_PAD_X * 2);
|
|
1297
1507
|
n.h = n.lines.length * NOTE_LINE_H + NOTE_PAD_Y * 2;
|
|
1298
1508
|
if (n.width && n.w < n.width)
|
|
@@ -1308,7 +1518,7 @@ function sizeTable(t) {
|
|
|
1308
1518
|
t.h = labelH + rowH;
|
|
1309
1519
|
return;
|
|
1310
1520
|
}
|
|
1311
|
-
const numCols = Math.max(...rows.map(r => r.cells.length));
|
|
1521
|
+
const numCols = Math.max(...rows.map((r) => r.cells.length));
|
|
1312
1522
|
const colW = Array(numCols).fill(MIN_COL_W);
|
|
1313
1523
|
for (const row of rows) {
|
|
1314
1524
|
row.cells.forEach((cell, i) => {
|
|
@@ -1317,110 +1527,128 @@ function sizeTable(t) {
|
|
|
1317
1527
|
}
|
|
1318
1528
|
t.colWidths = colW;
|
|
1319
1529
|
t.w = colW.reduce((s, w) => s + w, 0);
|
|
1320
|
-
const nHeader = rows.filter(r => r.kind ===
|
|
1321
|
-
const nData = rows.filter(r => r.kind ===
|
|
1530
|
+
const nHeader = rows.filter((r) => r.kind === "header").length;
|
|
1531
|
+
const nData = rows.filter((r) => r.kind === "data").length;
|
|
1322
1532
|
t.h = labelH + nHeader * headerH + nData * rowH;
|
|
1323
1533
|
}
|
|
1324
1534
|
function sizeChart(c) {
|
|
1325
1535
|
c.w = c.w || 320;
|
|
1326
1536
|
c.h = c.h || 240;
|
|
1327
1537
|
}
|
|
1538
|
+
function sizeMarkdown(m) {
|
|
1539
|
+
const pad = Number(m.style?.padding ?? 16);
|
|
1540
|
+
m.w = m.width ?? 400;
|
|
1541
|
+
m.h = m.height ?? calcMarkdownHeight(m.lines, pad);
|
|
1542
|
+
}
|
|
1328
1543
|
// ── Item size helpers ─────────────────────────────────────
|
|
1329
|
-
function iW(r, nm, gm, tm, ntm, cm) {
|
|
1330
|
-
if (r.kind ===
|
|
1544
|
+
function iW(r, nm, gm, tm, ntm, cm, mdm) {
|
|
1545
|
+
if (r.kind === "node")
|
|
1331
1546
|
return nm.get(r.id).w;
|
|
1332
|
-
if (r.kind ===
|
|
1547
|
+
if (r.kind === "table")
|
|
1333
1548
|
return tm.get(r.id).w;
|
|
1334
|
-
if (r.kind ===
|
|
1549
|
+
if (r.kind === "note")
|
|
1335
1550
|
return ntm.get(r.id).w;
|
|
1336
|
-
if (r.kind ===
|
|
1551
|
+
if (r.kind === "chart")
|
|
1337
1552
|
return cm.get(r.id).w;
|
|
1553
|
+
if (r.kind === "markdown")
|
|
1554
|
+
return mdm.get(r.id).w;
|
|
1338
1555
|
return gm.get(r.id).w;
|
|
1339
1556
|
}
|
|
1340
|
-
function iH(r, nm, gm, tm, ntm, cm) {
|
|
1341
|
-
if (r.kind ===
|
|
1557
|
+
function iH(r, nm, gm, tm, ntm, cm, mdm) {
|
|
1558
|
+
if (r.kind === "node")
|
|
1342
1559
|
return nm.get(r.id).h;
|
|
1343
|
-
if (r.kind ===
|
|
1560
|
+
if (r.kind === "table")
|
|
1344
1561
|
return tm.get(r.id).h;
|
|
1345
|
-
if (r.kind ===
|
|
1562
|
+
if (r.kind === "note")
|
|
1346
1563
|
return ntm.get(r.id).h;
|
|
1347
|
-
if (r.kind ===
|
|
1564
|
+
if (r.kind === "chart")
|
|
1348
1565
|
return cm.get(r.id).h;
|
|
1566
|
+
if (r.kind === "markdown")
|
|
1567
|
+
return mdm.get(r.id).h;
|
|
1349
1568
|
return gm.get(r.id).h;
|
|
1350
1569
|
}
|
|
1351
|
-
function setPos(r, x, y, nm, gm, tm, ntm, cm) {
|
|
1352
|
-
if (r.kind ===
|
|
1570
|
+
function setPos(r, x, y, nm, gm, tm, ntm, cm, mdm) {
|
|
1571
|
+
if (r.kind === "node") {
|
|
1353
1572
|
const n = nm.get(r.id);
|
|
1354
1573
|
n.x = Math.round(x);
|
|
1355
1574
|
n.y = Math.round(y);
|
|
1356
1575
|
return;
|
|
1357
1576
|
}
|
|
1358
|
-
if (r.kind ===
|
|
1577
|
+
if (r.kind === "table") {
|
|
1359
1578
|
const t = tm.get(r.id);
|
|
1360
1579
|
t.x = Math.round(x);
|
|
1361
1580
|
t.y = Math.round(y);
|
|
1362
1581
|
return;
|
|
1363
1582
|
}
|
|
1364
|
-
if (r.kind ===
|
|
1583
|
+
if (r.kind === "note") {
|
|
1365
1584
|
const nt = ntm.get(r.id);
|
|
1366
1585
|
nt.x = Math.round(x);
|
|
1367
1586
|
nt.y = Math.round(y);
|
|
1368
1587
|
return;
|
|
1369
1588
|
}
|
|
1370
|
-
if (r.kind ===
|
|
1589
|
+
if (r.kind === "chart") {
|
|
1371
1590
|
const c = cm.get(r.id);
|
|
1372
1591
|
c.x = Math.round(x);
|
|
1373
1592
|
c.y = Math.round(y);
|
|
1374
1593
|
return;
|
|
1375
1594
|
}
|
|
1595
|
+
if (r.kind === "markdown") {
|
|
1596
|
+
const md = mdm.get(r.id);
|
|
1597
|
+
md.x = Math.round(x);
|
|
1598
|
+
md.y = Math.round(y);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1376
1601
|
const g = gm.get(r.id);
|
|
1377
1602
|
g.x = Math.round(x);
|
|
1378
1603
|
g.y = Math.round(y);
|
|
1379
1604
|
}
|
|
1380
1605
|
// ── Pass 1: Measure (bottom-up) ───────────────────────────
|
|
1381
1606
|
// Recursively computes w, h for a group from its children's sizes.
|
|
1382
|
-
function measure(g, nm, gm, tm, ntm, cm) {
|
|
1607
|
+
function measure(g, nm, gm, tm, ntm, cm, mdm) {
|
|
1383
1608
|
// Recurse into nested groups first; size tables before reading their dims
|
|
1384
1609
|
for (const r of g.children) {
|
|
1385
|
-
if (r.kind ===
|
|
1386
|
-
measure(gm.get(r.id), nm, gm, tm, ntm, cm);
|
|
1387
|
-
if (r.kind ===
|
|
1610
|
+
if (r.kind === "group")
|
|
1611
|
+
measure(gm.get(r.id), nm, gm, tm, ntm, cm, mdm);
|
|
1612
|
+
if (r.kind === "table")
|
|
1388
1613
|
sizeTable(tm.get(r.id));
|
|
1389
|
-
if (r.kind ===
|
|
1614
|
+
if (r.kind === "note")
|
|
1390
1615
|
sizeNote(ntm.get(r.id));
|
|
1391
|
-
if (r.kind ===
|
|
1616
|
+
if (r.kind === "chart")
|
|
1392
1617
|
sizeChart(cm.get(r.id));
|
|
1618
|
+
if (r.kind === "markdown")
|
|
1619
|
+
sizeMarkdown(mdm.get(r.id));
|
|
1393
1620
|
}
|
|
1394
1621
|
const { padding: pad, gap, columns, layout } = g;
|
|
1395
1622
|
const kids = g.children;
|
|
1623
|
+
const labelH = g.label ? GROUP_LABEL_H : 0;
|
|
1396
1624
|
if (!kids.length) {
|
|
1397
1625
|
g.w = pad * 2;
|
|
1398
|
-
g.h = pad * 2 +
|
|
1626
|
+
g.h = pad * 2 + labelH;
|
|
1399
1627
|
if (g.width && g.w < g.width)
|
|
1400
1628
|
g.w = g.width;
|
|
1401
1629
|
if (g.height && g.h < g.height)
|
|
1402
1630
|
g.h = g.height;
|
|
1403
1631
|
return;
|
|
1404
1632
|
}
|
|
1405
|
-
const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
|
|
1406
|
-
const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
|
|
1633
|
+
const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
|
|
1634
|
+
const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
|
|
1407
1635
|
const n = kids.length;
|
|
1408
|
-
if (layout ===
|
|
1636
|
+
if (layout === "row") {
|
|
1409
1637
|
g.w = ws.reduce((s, w) => s + w, 0) + gap * (n - 1) + pad * 2;
|
|
1410
|
-
g.h = Math.max(...hs) + pad * 2 +
|
|
1638
|
+
g.h = Math.max(...hs) + pad * 2 + labelH;
|
|
1411
1639
|
}
|
|
1412
|
-
else if (layout ===
|
|
1640
|
+
else if (layout === "grid") {
|
|
1413
1641
|
const cols = Math.max(1, columns);
|
|
1414
1642
|
const rows = Math.ceil(n / cols);
|
|
1415
1643
|
const cellW = Math.max(...ws);
|
|
1416
1644
|
const cellH = Math.max(...hs);
|
|
1417
1645
|
g.w = cols * cellW + (cols - 1) * gap + pad * 2;
|
|
1418
|
-
g.h = rows * cellH + (rows - 1) * gap + pad * 2 +
|
|
1646
|
+
g.h = rows * cellH + (rows - 1) * gap + pad * 2 + labelH;
|
|
1419
1647
|
}
|
|
1420
1648
|
else {
|
|
1421
1649
|
// column (default)
|
|
1422
1650
|
g.w = Math.max(...ws) + pad * 2;
|
|
1423
|
-
g.h = hs.reduce((s, h) => s + h, 0) + gap * (n - 1) + pad * 2 +
|
|
1651
|
+
g.h = hs.reduce((s, h) => s + h, 0) + gap * (n - 1) + pad * 2 + labelH;
|
|
1424
1652
|
}
|
|
1425
1653
|
// Clamp to minWidth / minHeight — this is what gives distribute() free
|
|
1426
1654
|
// space to work with for justify=center/end/space-between/space-around
|
|
@@ -1435,21 +1663,32 @@ function distribute(sizes, contentSize, gap, justify) {
|
|
|
1435
1663
|
const totalSize = sizes.reduce((s, v) => s + v, 0);
|
|
1436
1664
|
const gapCount = n - 1;
|
|
1437
1665
|
switch (justify) {
|
|
1438
|
-
case
|
|
1666
|
+
case "center": {
|
|
1439
1667
|
const total = totalSize + gap * gapCount;
|
|
1440
|
-
return {
|
|
1668
|
+
return {
|
|
1669
|
+
start: Math.max(0, (contentSize - total) / 2),
|
|
1670
|
+
gaps: Array(gapCount).fill(gap),
|
|
1671
|
+
};
|
|
1441
1672
|
}
|
|
1442
|
-
case
|
|
1673
|
+
case "end": {
|
|
1443
1674
|
const total = totalSize + gap * gapCount;
|
|
1444
|
-
return {
|
|
1675
|
+
return {
|
|
1676
|
+
start: Math.max(0, contentSize - total),
|
|
1677
|
+
gaps: Array(gapCount).fill(gap),
|
|
1678
|
+
};
|
|
1445
1679
|
}
|
|
1446
|
-
case
|
|
1447
|
-
const g2 = gapCount > 0
|
|
1680
|
+
case "space-between": {
|
|
1681
|
+
const g2 = gapCount > 0
|
|
1682
|
+
? Math.max(gap, (contentSize - totalSize) / gapCount)
|
|
1683
|
+
: gap;
|
|
1448
1684
|
return { start: 0, gaps: Array(gapCount).fill(g2) };
|
|
1449
1685
|
}
|
|
1450
|
-
case
|
|
1686
|
+
case "space-around": {
|
|
1451
1687
|
const space = n > 0 ? (contentSize - totalSize) / n : gap;
|
|
1452
|
-
return {
|
|
1688
|
+
return {
|
|
1689
|
+
start: Math.max(0, space / 2),
|
|
1690
|
+
gaps: Array(gapCount).fill(Math.max(gap, space)),
|
|
1691
|
+
};
|
|
1453
1692
|
}
|
|
1454
1693
|
default: // start
|
|
1455
1694
|
return { start: 0, gaps: Array(gapCount).fill(gap) };
|
|
@@ -1457,70 +1696,73 @@ function distribute(sizes, contentSize, gap, justify) {
|
|
|
1457
1696
|
}
|
|
1458
1697
|
// ── Pass 2: Place (top-down) ──────────────────────────────
|
|
1459
1698
|
// Assigns x, y to each child. Assumes g.x / g.y already set by parent.
|
|
1460
|
-
function place(g, nm, gm, tm, ntm, cm) {
|
|
1699
|
+
function place(g, nm, gm, tm, ntm, cm, mdm) {
|
|
1461
1700
|
const { padding: pad, gap, columns, layout, align, justify } = g;
|
|
1701
|
+
const labelH = g.label ? GROUP_LABEL_H : 0;
|
|
1462
1702
|
const contentX = g.x + pad;
|
|
1463
|
-
const contentY = g.y +
|
|
1703
|
+
const contentY = g.y + labelH + pad;
|
|
1464
1704
|
const contentW = g.w - pad * 2;
|
|
1465
|
-
const contentH = g.h - pad * 2 -
|
|
1705
|
+
const contentH = g.h - pad * 2 - labelH;
|
|
1466
1706
|
const kids = g.children;
|
|
1467
1707
|
if (!kids.length)
|
|
1468
1708
|
return;
|
|
1469
|
-
if (layout ===
|
|
1470
|
-
const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
|
|
1471
|
-
const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
|
|
1709
|
+
if (layout === "row") {
|
|
1710
|
+
const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
|
|
1711
|
+
const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
|
|
1472
1712
|
const maxH = Math.max(...hs);
|
|
1473
1713
|
const { start, gaps } = distribute(ws, contentW, gap, justify);
|
|
1474
1714
|
let x = contentX + start;
|
|
1475
1715
|
for (let i = 0; i < kids.length; i++) {
|
|
1476
1716
|
let y;
|
|
1477
1717
|
switch (align) {
|
|
1478
|
-
case
|
|
1718
|
+
case "center":
|
|
1479
1719
|
y = contentY + (maxH - hs[i]) / 2;
|
|
1480
1720
|
break;
|
|
1481
|
-
case
|
|
1721
|
+
case "end":
|
|
1482
1722
|
y = contentY + maxH - hs[i];
|
|
1483
1723
|
break;
|
|
1484
|
-
default:
|
|
1724
|
+
default:
|
|
1725
|
+
y = contentY;
|
|
1485
1726
|
}
|
|
1486
|
-
setPos(kids[i], x, y, nm, gm, tm, ntm, cm);
|
|
1727
|
+
setPos(kids[i], x, y, nm, gm, tm, ntm, cm, mdm);
|
|
1487
1728
|
x += ws[i] + (i < gaps.length ? gaps[i] : 0);
|
|
1488
1729
|
}
|
|
1489
1730
|
}
|
|
1490
|
-
else if (layout ===
|
|
1731
|
+
else if (layout === "grid") {
|
|
1491
1732
|
const cols = Math.max(1, columns);
|
|
1492
|
-
const cellW = Math.max(...kids.map(r => iW(r, nm, gm, tm, ntm, cm)));
|
|
1493
|
-
const cellH = Math.max(...kids.map(r => iH(r, nm, gm, tm, ntm, cm)));
|
|
1733
|
+
const cellW = Math.max(...kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm)));
|
|
1734
|
+
const cellH = Math.max(...kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm)));
|
|
1494
1735
|
kids.forEach((ref, i) => {
|
|
1495
|
-
setPos(ref, contentX + (i % cols) * (cellW + gap), contentY + Math.floor(i / cols) * (cellH + gap), nm, gm, tm, ntm, cm);
|
|
1736
|
+
setPos(ref, contentX + (i % cols) * (cellW + gap), contentY + Math.floor(i / cols) * (cellH + gap), nm, gm, tm, ntm, cm, mdm);
|
|
1496
1737
|
});
|
|
1497
1738
|
}
|
|
1498
1739
|
else {
|
|
1499
1740
|
// column (default)
|
|
1500
|
-
const ws = kids.map(r => iW(r, nm, gm, tm, ntm, cm));
|
|
1501
|
-
const hs = kids.map(r => iH(r, nm, gm, tm, ntm, cm));
|
|
1741
|
+
const ws = kids.map((r) => iW(r, nm, gm, tm, ntm, cm, mdm));
|
|
1742
|
+
const hs = kids.map((r) => iH(r, nm, gm, tm, ntm, cm, mdm));
|
|
1502
1743
|
const maxW = Math.max(...ws);
|
|
1503
1744
|
const { start, gaps } = distribute(hs, contentH, gap, justify);
|
|
1504
1745
|
let y = contentY + start;
|
|
1505
1746
|
for (let i = 0; i < kids.length; i++) {
|
|
1506
1747
|
let x;
|
|
1507
1748
|
switch (align) {
|
|
1508
|
-
case
|
|
1749
|
+
case "center":
|
|
1509
1750
|
x = contentX + (maxW - ws[i]) / 2;
|
|
1510
1751
|
break;
|
|
1511
|
-
case
|
|
1752
|
+
case "end":
|
|
1512
1753
|
x = contentX + maxW - ws[i];
|
|
1513
1754
|
break;
|
|
1514
|
-
default:
|
|
1755
|
+
default:
|
|
1756
|
+
x = contentX;
|
|
1515
1757
|
}
|
|
1516
|
-
setPos(kids[i], x, y, nm, gm, tm, ntm, cm);
|
|
1758
|
+
setPos(kids[i], x, y, nm, gm, tm, ntm, cm, mdm);
|
|
1517
1759
|
y += hs[i] + (i < gaps.length ? gaps[i] : 0);
|
|
1518
1760
|
}
|
|
1519
1761
|
}
|
|
1520
1762
|
// Recurse into nested groups
|
|
1521
1763
|
for (const r of kids) {
|
|
1522
|
-
if (r.kind ===
|
|
1523
|
-
place(gm.get(r.id), nm, gm, tm, ntm, cm);
|
|
1764
|
+
if (r.kind === "group")
|
|
1765
|
+
place(gm.get(r.id), nm, gm, tm, ntm, cm, mdm);
|
|
1524
1766
|
}
|
|
1525
1767
|
}
|
|
1526
1768
|
// ── Edge routing ──────────────────────────────────────────
|
|
@@ -1530,9 +1772,9 @@ function connPoint(n, other) {
|
|
|
1530
1772
|
const dx = ox - cx, dy = oy - cy;
|
|
1531
1773
|
if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01)
|
|
1532
1774
|
return [cx, cy];
|
|
1533
|
-
if (n.shape ===
|
|
1775
|
+
if (n.shape === "circle") {
|
|
1534
1776
|
const r = n.w * 0.44, len = Math.sqrt(dx * dx + dy * dy);
|
|
1535
|
-
return [cx + dx / len * r, cy + dy / len * r];
|
|
1777
|
+
return [cx + (dx / len) * r, cy + (dy / len) * r];
|
|
1536
1778
|
}
|
|
1537
1779
|
const hw = n.w / 2 - 2, hh = n.h / 2 - 2;
|
|
1538
1780
|
const tx = Math.abs(dx) > 0.01 ? hw / Math.abs(dx) : 1e9;
|
|
@@ -1577,9 +1819,12 @@ function routeEdges(sg) {
|
|
|
1577
1819
|
}
|
|
1578
1820
|
function connPt(src, dstCX, dstCY) {
|
|
1579
1821
|
// SceneNode has a .shape field; use the existing connPoint for it
|
|
1580
|
-
if (
|
|
1822
|
+
if ("shape" in src && src.shape) {
|
|
1581
1823
|
return connPoint(src, {
|
|
1582
|
-
x: dstCX - 1,
|
|
1824
|
+
x: dstCX - 1,
|
|
1825
|
+
y: dstCY - 1,
|
|
1826
|
+
w: 2,
|
|
1827
|
+
h: 2});
|
|
1583
1828
|
}
|
|
1584
1829
|
return rectConnPoint$2(src.x, src.y, src.w, src.h, dstCX, dstCY);
|
|
1585
1830
|
}
|
|
@@ -1592,84 +1837,84 @@ function routeEdges(sg) {
|
|
|
1592
1837
|
}
|
|
1593
1838
|
const dstCX = dst.x + dst.w / 2, dstCY = dst.y + dst.h / 2;
|
|
1594
1839
|
const srcCX = src.x + src.w / 2, srcCY = src.y + src.h / 2;
|
|
1595
|
-
e.points = [
|
|
1596
|
-
connPt(src, dstCX, dstCY),
|
|
1597
|
-
connPt(dst, srcCX, srcCY),
|
|
1598
|
-
];
|
|
1840
|
+
e.points = [connPt(src, dstCX, dstCY), connPt(dst, srcCX, srcCY)];
|
|
1599
1841
|
}
|
|
1600
1842
|
}
|
|
1601
1843
|
function computeBounds(sg, margin) {
|
|
1602
1844
|
const allX = [
|
|
1603
|
-
...sg.nodes.map(n => n.x + n.w),
|
|
1604
|
-
...sg.groups.filter(g => g.w).map(g => g.x + g.w),
|
|
1605
|
-
...sg.tables.map(t => t.x + t.w),
|
|
1606
|
-
...sg.notes.map(n => n.x + n.w),
|
|
1607
|
-
...sg.charts.map(c => c.x + c.w)
|
|
1845
|
+
...sg.nodes.map((n) => n.x + n.w),
|
|
1846
|
+
...sg.groups.filter((g) => g.w).map((g) => g.x + g.w),
|
|
1847
|
+
...sg.tables.map((t) => t.x + t.w),
|
|
1848
|
+
...sg.notes.map((n) => n.x + n.w),
|
|
1849
|
+
...sg.charts.map((c) => c.x + c.w),
|
|
1850
|
+
...sg.markdowns.map((m) => m.x + m.w),
|
|
1608
1851
|
];
|
|
1609
1852
|
const allY = [
|
|
1610
|
-
...sg.nodes.map(n => n.y + n.h),
|
|
1611
|
-
...sg.groups.filter(g => g.h).map(g => g.y + g.h),
|
|
1612
|
-
...sg.tables.map(t => t.y + t.h),
|
|
1613
|
-
...sg.notes.map(n => n.y + n.h),
|
|
1614
|
-
...sg.charts.map(c => c.y + c.h)
|
|
1853
|
+
...sg.nodes.map((n) => n.y + n.h),
|
|
1854
|
+
...sg.groups.filter((g) => g.h).map((g) => g.y + g.h),
|
|
1855
|
+
...sg.tables.map((t) => t.y + t.h),
|
|
1856
|
+
...sg.notes.map((n) => n.y + n.h),
|
|
1857
|
+
...sg.charts.map((c) => c.y + c.h),
|
|
1858
|
+
...sg.markdowns.map((m) => m.y + m.h),
|
|
1615
1859
|
];
|
|
1616
1860
|
sg.width = (allX.length ? Math.max(...allX) : 400) + margin;
|
|
1617
1861
|
sg.height = (allY.length ? Math.max(...allY) : 300) + margin;
|
|
1618
1862
|
}
|
|
1619
1863
|
// ── Public entry point ────────────────────────────────────
|
|
1620
1864
|
function layout(sg) {
|
|
1621
|
-
const GAP_MAIN = Number(sg.config[
|
|
1622
|
-
const MARGIN = Number(sg.config[
|
|
1865
|
+
const GAP_MAIN = Number(sg.config["gap"] ?? DEFAULT_GAP_MAIN);
|
|
1866
|
+
const MARGIN = Number(sg.config["margin"] ?? DEFAULT_MARGIN);
|
|
1623
1867
|
const nm = nodeMap(sg);
|
|
1624
1868
|
const gm = groupMap(sg);
|
|
1625
1869
|
const tm = tableMap(sg);
|
|
1626
1870
|
const ntm = noteMap(sg);
|
|
1627
1871
|
const cm = chartMap(sg);
|
|
1628
|
-
|
|
1629
|
-
console.log('[layout] sg.rootOrder:', sg.rootOrder.map(r => r.kind + ':' + r.id));
|
|
1872
|
+
const mdm = markdownMap(sg);
|
|
1630
1873
|
// 1. Size all nodes and tables
|
|
1631
1874
|
sg.nodes.forEach(sizeNode);
|
|
1632
1875
|
sg.tables.forEach(sizeTable);
|
|
1633
1876
|
sg.notes.forEach(sizeNote);
|
|
1634
1877
|
sg.charts.forEach(sizeChart);
|
|
1878
|
+
sg.markdowns.forEach(sizeMarkdown);
|
|
1635
1879
|
// src/layout/index.ts — after sg.charts.forEach(sizeChart);
|
|
1636
1880
|
// 2. Identify root vs nested items
|
|
1637
|
-
const nestedGroupIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind ===
|
|
1638
|
-
const groupedNodeIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind ===
|
|
1639
|
-
const groupedTableIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind ===
|
|
1640
|
-
const groupedNoteIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind ===
|
|
1641
|
-
const groupedChartIds = new Set(sg.groups.flatMap(g => g.children.filter(c => c.kind ===
|
|
1642
|
-
const
|
|
1643
|
-
const
|
|
1644
|
-
const
|
|
1645
|
-
const
|
|
1646
|
-
const
|
|
1881
|
+
const nestedGroupIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "group").map((c) => c.id)));
|
|
1882
|
+
const groupedNodeIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "node").map((c) => c.id)));
|
|
1883
|
+
const groupedTableIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "table").map((c) => c.id)));
|
|
1884
|
+
const groupedNoteIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "note").map((c) => c.id)));
|
|
1885
|
+
const groupedChartIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "chart").map((c) => c.id)));
|
|
1886
|
+
const groupedMarkdownIds = new Set(sg.groups.flatMap((g) => g.children.filter((c) => c.kind === "markdown").map((c) => c.id)));
|
|
1887
|
+
const rootGroups = sg.groups.filter((g) => !nestedGroupIds.has(g.id));
|
|
1888
|
+
const rootNodes = sg.nodes.filter((n) => !groupedNodeIds.has(n.id));
|
|
1889
|
+
const rootTables = sg.tables.filter((t) => !groupedTableIds.has(t.id));
|
|
1890
|
+
const rootNotes = sg.notes.filter((n) => !groupedNoteIds.has(n.id));
|
|
1891
|
+
const rootCharts = sg.charts.filter((c) => !groupedChartIds.has(c.id));
|
|
1892
|
+
const rootMarkdowns = sg.markdowns.filter((m) => !groupedMarkdownIds.has(m.id));
|
|
1647
1893
|
// 3. Measure root groups bottom-up
|
|
1648
1894
|
for (const g of rootGroups)
|
|
1649
|
-
measure(g, nm, gm, tm, ntm, cm);
|
|
1895
|
+
measure(g, nm, gm, tm, ntm, cm, mdm);
|
|
1650
1896
|
// 4. Build root order
|
|
1651
1897
|
// sg.rootOrder preserves DSL declaration order.
|
|
1652
1898
|
// Fall back: groups, then nodes, then tables.
|
|
1653
1899
|
const rootOrder = sg.rootOrder?.length
|
|
1654
1900
|
? sg.rootOrder
|
|
1655
1901
|
: [
|
|
1656
|
-
...rootGroups.map(g => ({ kind:
|
|
1657
|
-
...rootNodes.map(n => ({ kind:
|
|
1658
|
-
...rootTables.map(t => ({ kind:
|
|
1659
|
-
...rootNotes.map(n => ({ kind:
|
|
1660
|
-
...rootCharts.map(c => ({ kind:
|
|
1902
|
+
...rootGroups.map((g) => ({ kind: "group", id: g.id })),
|
|
1903
|
+
...rootNodes.map((n) => ({ kind: "node", id: n.id })),
|
|
1904
|
+
...rootTables.map((t) => ({ kind: "table", id: t.id })),
|
|
1905
|
+
...rootNotes.map((n) => ({ kind: "note", id: n.id })),
|
|
1906
|
+
...rootCharts.map((c) => ({ kind: "chart", id: c.id })),
|
|
1907
|
+
...rootMarkdowns.map((m) => ({ kind: "markdown", id: m.id })),
|
|
1661
1908
|
];
|
|
1662
1909
|
// 5. Root-level layout
|
|
1663
1910
|
// sg.layout:
|
|
1664
1911
|
// 'row' → items flow left to right (default)
|
|
1665
1912
|
// 'column' → items flow top to bottom
|
|
1666
1913
|
// 'grid' → config columns=N grid
|
|
1667
|
-
const rootLayout = (sg.layout ??
|
|
1668
|
-
const rootCols = Number(sg.config[
|
|
1669
|
-
const useGrid = rootLayout ===
|
|
1670
|
-
const useColumn = rootLayout ===
|
|
1671
|
-
console.log('[layout] sized charts:', sg.charts.map(c => `${c.id} w=${c.w} h=${c.h}`));
|
|
1672
|
-
console.log('[layout] rootOrder chart refs:', rootOrder.filter(r => r.kind === 'chart'));
|
|
1914
|
+
const rootLayout = (sg.layout ?? "row");
|
|
1915
|
+
const rootCols = Number(sg.config["columns"] ?? 1);
|
|
1916
|
+
const useGrid = rootLayout === "grid" && rootCols > 0;
|
|
1917
|
+
const useColumn = rootLayout === "column";
|
|
1673
1918
|
if (useGrid) {
|
|
1674
1919
|
// ── Grid: per-row heights, per-column widths (no wasted space) ──
|
|
1675
1920
|
const cols = rootCols;
|
|
@@ -1680,22 +1925,26 @@ function layout(sg) {
|
|
|
1680
1925
|
const col = idx % cols;
|
|
1681
1926
|
const row = Math.floor(idx / cols);
|
|
1682
1927
|
let w = 0, h = 0;
|
|
1683
|
-
if (ref.kind ===
|
|
1928
|
+
if (ref.kind === "group") {
|
|
1684
1929
|
w = gm.get(ref.id).w;
|
|
1685
1930
|
h = gm.get(ref.id).h;
|
|
1686
1931
|
}
|
|
1687
|
-
else if (ref.kind ===
|
|
1932
|
+
else if (ref.kind === "table") {
|
|
1688
1933
|
w = tm.get(ref.id).w;
|
|
1689
1934
|
h = tm.get(ref.id).h;
|
|
1690
1935
|
}
|
|
1691
|
-
else if (ref.kind ===
|
|
1936
|
+
else if (ref.kind === "note") {
|
|
1692
1937
|
w = ntm.get(ref.id).w;
|
|
1693
1938
|
h = ntm.get(ref.id).h;
|
|
1694
1939
|
}
|
|
1695
|
-
else if (ref.kind ===
|
|
1940
|
+
else if (ref.kind === "chart") {
|
|
1696
1941
|
w = cm.get(ref.id).w;
|
|
1697
1942
|
h = cm.get(ref.id).h;
|
|
1698
1943
|
}
|
|
1944
|
+
else if (ref.kind === "markdown") {
|
|
1945
|
+
w = mdm.get(ref.id).w;
|
|
1946
|
+
h = mdm.get(ref.id).h;
|
|
1947
|
+
}
|
|
1699
1948
|
else {
|
|
1700
1949
|
w = nm.get(ref.id).w;
|
|
1701
1950
|
h = nm.get(ref.id).h;
|
|
@@ -1718,22 +1967,26 @@ function layout(sg) {
|
|
|
1718
1967
|
rootOrder.forEach((ref, idx) => {
|
|
1719
1968
|
const x = colX[idx % cols];
|
|
1720
1969
|
const y = rowY[Math.floor(idx / cols)];
|
|
1721
|
-
if (ref.kind ===
|
|
1970
|
+
if (ref.kind === "group") {
|
|
1722
1971
|
gm.get(ref.id).x = x;
|
|
1723
1972
|
gm.get(ref.id).y = y;
|
|
1724
1973
|
}
|
|
1725
|
-
else if (ref.kind ===
|
|
1974
|
+
else if (ref.kind === "table") {
|
|
1726
1975
|
tm.get(ref.id).x = x;
|
|
1727
1976
|
tm.get(ref.id).y = y;
|
|
1728
1977
|
}
|
|
1729
|
-
else if (ref.kind ===
|
|
1978
|
+
else if (ref.kind === "note") {
|
|
1730
1979
|
ntm.get(ref.id).x = x;
|
|
1731
1980
|
ntm.get(ref.id).y = y;
|
|
1732
1981
|
}
|
|
1733
|
-
else if (ref.kind ===
|
|
1982
|
+
else if (ref.kind === "chart") {
|
|
1734
1983
|
cm.get(ref.id).x = x;
|
|
1735
1984
|
cm.get(ref.id).y = y;
|
|
1736
1985
|
}
|
|
1986
|
+
else if (ref.kind === "markdown") {
|
|
1987
|
+
mdm.get(ref.id).x = x;
|
|
1988
|
+
mdm.get(ref.id).y = y;
|
|
1989
|
+
}
|
|
1737
1990
|
else {
|
|
1738
1991
|
nm.get(ref.id).x = x;
|
|
1739
1992
|
nm.get(ref.id).y = y;
|
|
@@ -1745,44 +1998,52 @@ function layout(sg) {
|
|
|
1745
1998
|
let pos = MARGIN;
|
|
1746
1999
|
for (const ref of rootOrder) {
|
|
1747
2000
|
let w = 0, h = 0;
|
|
1748
|
-
if (ref.kind ===
|
|
2001
|
+
if (ref.kind === "group") {
|
|
1749
2002
|
w = gm.get(ref.id).w;
|
|
1750
2003
|
h = gm.get(ref.id).h;
|
|
1751
2004
|
}
|
|
1752
|
-
else if (ref.kind ===
|
|
2005
|
+
else if (ref.kind === "table") {
|
|
1753
2006
|
w = tm.get(ref.id).w;
|
|
1754
2007
|
h = tm.get(ref.id).h;
|
|
1755
2008
|
}
|
|
1756
|
-
else if (ref.kind ===
|
|
2009
|
+
else if (ref.kind === "note") {
|
|
1757
2010
|
w = ntm.get(ref.id).w;
|
|
1758
2011
|
h = ntm.get(ref.id).h;
|
|
1759
2012
|
}
|
|
1760
|
-
else if (ref.kind ===
|
|
2013
|
+
else if (ref.kind === "chart") {
|
|
1761
2014
|
w = cm.get(ref.id).w;
|
|
1762
2015
|
h = cm.get(ref.id).h;
|
|
1763
2016
|
}
|
|
2017
|
+
else if (ref.kind === "markdown") {
|
|
2018
|
+
w = mdm.get(ref.id).w;
|
|
2019
|
+
h = mdm.get(ref.id).h;
|
|
2020
|
+
}
|
|
1764
2021
|
else {
|
|
1765
2022
|
w = nm.get(ref.id).w;
|
|
1766
2023
|
h = nm.get(ref.id).h;
|
|
1767
2024
|
}
|
|
1768
2025
|
const x = useColumn ? MARGIN : pos;
|
|
1769
2026
|
const y = useColumn ? pos : MARGIN;
|
|
1770
|
-
if (ref.kind ===
|
|
2027
|
+
if (ref.kind === "group") {
|
|
1771
2028
|
gm.get(ref.id).x = x;
|
|
1772
2029
|
gm.get(ref.id).y = y;
|
|
1773
2030
|
}
|
|
1774
|
-
else if (ref.kind ===
|
|
2031
|
+
else if (ref.kind === "table") {
|
|
1775
2032
|
tm.get(ref.id).x = x;
|
|
1776
2033
|
tm.get(ref.id).y = y;
|
|
1777
2034
|
}
|
|
1778
|
-
else if (ref.kind ===
|
|
2035
|
+
else if (ref.kind === "note") {
|
|
1779
2036
|
ntm.get(ref.id).x = x;
|
|
1780
2037
|
ntm.get(ref.id).y = y;
|
|
1781
2038
|
}
|
|
1782
|
-
else if (ref.kind ===
|
|
2039
|
+
else if (ref.kind === "chart") {
|
|
1783
2040
|
cm.get(ref.id).x = x;
|
|
1784
2041
|
cm.get(ref.id).y = y;
|
|
1785
2042
|
}
|
|
2043
|
+
else if (ref.kind === "markdown") {
|
|
2044
|
+
mdm.get(ref.id).x = x;
|
|
2045
|
+
mdm.get(ref.id).y = y;
|
|
2046
|
+
}
|
|
1786
2047
|
else {
|
|
1787
2048
|
nm.get(ref.id).x = x;
|
|
1788
2049
|
nm.get(ref.id).y = y;
|
|
@@ -1792,10 +2053,9 @@ function layout(sg) {
|
|
|
1792
2053
|
}
|
|
1793
2054
|
// 6. Place children within each root group (top-down, recursive)
|
|
1794
2055
|
for (const g of rootGroups)
|
|
1795
|
-
place(g, nm, gm, tm, ntm, cm);
|
|
2056
|
+
place(g, nm, gm, tm, ntm, cm, mdm);
|
|
1796
2057
|
// 7. Route edges and compute canvas size
|
|
1797
2058
|
routeEdges(sg);
|
|
1798
|
-
console.log('[layout] chart positions:', sg.charts.map(c => `${c.id} x=${c.x} y=${c.y}`));
|
|
1799
2059
|
computeBounds(sg, MARGIN);
|
|
1800
2060
|
return sg;
|
|
1801
2061
|
}
|
|
@@ -2524,6 +2784,26 @@ function resolveStyleFont$1(style, fallback) {
|
|
|
2524
2784
|
loadFont(raw);
|
|
2525
2785
|
return resolveFont(raw);
|
|
2526
2786
|
}
|
|
2787
|
+
function wrapText$1(text, maxWidth, fontSize) {
|
|
2788
|
+
const words = text.split(' ');
|
|
2789
|
+
const charsPerPx = fontSize * 0.55; // approximate
|
|
2790
|
+
const maxChars = Math.floor(maxWidth / charsPerPx);
|
|
2791
|
+
const lines = [];
|
|
2792
|
+
let current = '';
|
|
2793
|
+
for (const word of words) {
|
|
2794
|
+
const test = current ? `${current} ${word}` : word;
|
|
2795
|
+
if (test.length > maxChars && current) {
|
|
2796
|
+
lines.push(current);
|
|
2797
|
+
current = word;
|
|
2798
|
+
}
|
|
2799
|
+
else {
|
|
2800
|
+
current = test;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
if (current)
|
|
2804
|
+
lines.push(current);
|
|
2805
|
+
return lines;
|
|
2806
|
+
}
|
|
2527
2807
|
// ── SVG text helpers ──────────────────────────────────────────────────────
|
|
2528
2808
|
/**
|
|
2529
2809
|
* Single-line SVG text element.
|
|
@@ -2866,7 +3146,9 @@ function renderToSVG(sg, container, options = {}) {
|
|
|
2866
3146
|
const gFontSize = Number(gs.fontSize ?? 12);
|
|
2867
3147
|
const gFont = resolveStyleFont$1(gs, diagramFont);
|
|
2868
3148
|
const gLetterSpacing = gs.letterSpacing;
|
|
2869
|
-
|
|
3149
|
+
if (g.label) {
|
|
3150
|
+
gg.appendChild(mkText(g.label, g.x + 14, g.y + 14, gFontSize, 500, gLabelColor, "start", gFont, gLetterSpacing));
|
|
3151
|
+
}
|
|
2870
3152
|
GL.appendChild(gg);
|
|
2871
3153
|
}
|
|
2872
3154
|
svg.appendChild(GL);
|
|
@@ -2959,7 +3241,9 @@ function renderToSVG(sg, container, options = {}) {
|
|
|
2959
3241
|
: textAlign === "right"
|
|
2960
3242
|
? n.x + n.w - 8
|
|
2961
3243
|
: n.x + n.w / 2;
|
|
2962
|
-
const lines = n.label.
|
|
3244
|
+
const lines = n.shape === 'text' && !n.label.includes('\n')
|
|
3245
|
+
? wrapText$1(n.label, n.w - 16, fontSize)
|
|
3246
|
+
: n.label.split('\n');
|
|
2963
3247
|
const verticalAlign = String(n.style?.verticalAlign ?? "middle");
|
|
2964
3248
|
const nodeBodyTop = n.y + 6;
|
|
2965
3249
|
const nodeBodyBottom = n.y + n.h - 6;
|
|
@@ -3156,6 +3440,54 @@ function renderToSVG(sg, container, options = {}) {
|
|
|
3156
3440
|
NoteL.appendChild(ng);
|
|
3157
3441
|
}
|
|
3158
3442
|
svg.appendChild(NoteL);
|
|
3443
|
+
markdownMap(sg);
|
|
3444
|
+
const MDL = mkGroup('markdown-layer');
|
|
3445
|
+
for (const m of sg.markdowns) {
|
|
3446
|
+
const mg = mkGroup(`markdown-${m.id}`, 'mdg');
|
|
3447
|
+
const mFont = resolveStyleFont$1(m.style, diagramFont);
|
|
3448
|
+
const baseColor = String(m.style?.color ?? palette.nodeText);
|
|
3449
|
+
const textAlign = String(m.style?.textAlign ?? 'left');
|
|
3450
|
+
const anchor = textAlign === 'right' ? 'end'
|
|
3451
|
+
: textAlign === 'center' ? 'middle'
|
|
3452
|
+
: 'start';
|
|
3453
|
+
const PAD = Number(m.style?.padding ?? 16);
|
|
3454
|
+
const textX = textAlign === 'right' ? m.x + m.w - PAD
|
|
3455
|
+
: textAlign === 'center' ? m.x + m.w / 2
|
|
3456
|
+
: m.x + PAD;
|
|
3457
|
+
let y = m.y + PAD;
|
|
3458
|
+
for (const line of m.lines) {
|
|
3459
|
+
if (line.kind === 'blank') {
|
|
3460
|
+
y += LINE_SPACING.blank;
|
|
3461
|
+
continue;
|
|
3462
|
+
}
|
|
3463
|
+
const fontSize = LINE_FONT_SIZE[line.kind];
|
|
3464
|
+
const fontWeight = LINE_FONT_WEIGHT[line.kind];
|
|
3465
|
+
const t = se('text');
|
|
3466
|
+
t.setAttribute('x', String(textX));
|
|
3467
|
+
t.setAttribute('y', String(y + fontSize / 2));
|
|
3468
|
+
t.setAttribute('text-anchor', anchor);
|
|
3469
|
+
t.setAttribute('dominant-baseline', 'middle');
|
|
3470
|
+
t.setAttribute('font-family', mFont);
|
|
3471
|
+
t.setAttribute('font-size', String(fontSize));
|
|
3472
|
+
t.setAttribute('font-weight', String(fontWeight));
|
|
3473
|
+
t.setAttribute('fill', baseColor);
|
|
3474
|
+
t.setAttribute('pointer-events', 'none');
|
|
3475
|
+
t.setAttribute('user-select', 'none');
|
|
3476
|
+
for (const run of line.runs) {
|
|
3477
|
+
const span = se('tspan');
|
|
3478
|
+
span.textContent = run.text;
|
|
3479
|
+
if (run.bold)
|
|
3480
|
+
span.setAttribute('font-weight', '700');
|
|
3481
|
+
if (run.italic)
|
|
3482
|
+
span.setAttribute('font-style', 'italic');
|
|
3483
|
+
t.appendChild(span);
|
|
3484
|
+
}
|
|
3485
|
+
mg.appendChild(t);
|
|
3486
|
+
y += LINE_SPACING[line.kind];
|
|
3487
|
+
}
|
|
3488
|
+
MDL.appendChild(mg);
|
|
3489
|
+
}
|
|
3490
|
+
svg.appendChild(MDL);
|
|
3159
3491
|
// ── Charts ────────────────────────────────────────────────
|
|
3160
3492
|
const CL = mkGroup("chart-layer");
|
|
3161
3493
|
for (const c of sg.charts) {
|
|
@@ -3438,10 +3770,6 @@ function resolveStyleFont(style, fallback) {
|
|
|
3438
3770
|
return resolveFont(raw);
|
|
3439
3771
|
}
|
|
3440
3772
|
// ── Canvas text helpers ────────────────────────────────────────────────────
|
|
3441
|
-
/**
|
|
3442
|
-
* Draw a single line of text.
|
|
3443
|
-
* align: 'left' | 'center' | 'right' (maps to ctx.textAlign)
|
|
3444
|
-
*/
|
|
3445
3773
|
function drawText(ctx, txt, x, y, sz = 14, wt = 500, col = '#1a1208', align = 'center', font = 'system-ui, sans-serif', letterSpacing) {
|
|
3446
3774
|
ctx.save();
|
|
3447
3775
|
ctx.font = `${wt} ${sz}px ${font}`;
|
|
@@ -3466,9 +3794,6 @@ function drawText(ctx, txt, x, y, sz = 14, wt = 500, col = '#1a1208', align = 'c
|
|
|
3466
3794
|
}
|
|
3467
3795
|
ctx.restore();
|
|
3468
3796
|
}
|
|
3469
|
-
/**
|
|
3470
|
-
* Draw multiple lines of text, vertically centred around cy.
|
|
3471
|
-
*/
|
|
3472
3797
|
function drawMultilineText(ctx, lines, x, cy, sz = 14, wt = 500, col = '#1a1208', align = 'center', lineH = 18, font = 'system-ui, sans-serif', letterSpacing) {
|
|
3473
3798
|
const totalH = (lines.length - 1) * lineH;
|
|
3474
3799
|
const startY = cy - totalH / 2;
|
|
@@ -3476,6 +3801,27 @@ function drawMultilineText(ctx, lines, x, cy, sz = 14, wt = 500, col = '#1a1208'
|
|
|
3476
3801
|
drawText(ctx, line, x, startY + i * lineH, sz, wt, col, align, font, letterSpacing);
|
|
3477
3802
|
});
|
|
3478
3803
|
}
|
|
3804
|
+
// Soft word-wrap for `text` shape nodes
|
|
3805
|
+
function wrapText(text, maxWidth, fontSize) {
|
|
3806
|
+
const charWidth = fontSize * 0.55;
|
|
3807
|
+
const maxChars = Math.floor(maxWidth / charWidth);
|
|
3808
|
+
const words = text.split(' ');
|
|
3809
|
+
const lines = [];
|
|
3810
|
+
let current = '';
|
|
3811
|
+
for (const word of words) {
|
|
3812
|
+
const test = current ? `${current} ${word}` : word;
|
|
3813
|
+
if (test.length > maxChars && current) {
|
|
3814
|
+
lines.push(current);
|
|
3815
|
+
current = word;
|
|
3816
|
+
}
|
|
3817
|
+
else {
|
|
3818
|
+
current = test;
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
if (current)
|
|
3822
|
+
lines.push(current);
|
|
3823
|
+
return lines.length ? lines : [text];
|
|
3824
|
+
}
|
|
3479
3825
|
// ── Arrow direction ────────────────────────────────────────────────────────
|
|
3480
3826
|
function connMeta(connector) {
|
|
3481
3827
|
if (connector === '--')
|
|
@@ -3567,7 +3913,7 @@ function renderShape(rc, ctx, n, palette, R) {
|
|
|
3567
3913
|
], opts);
|
|
3568
3914
|
break;
|
|
3569
3915
|
case 'text':
|
|
3570
|
-
break;
|
|
3916
|
+
break; // no shape drawn
|
|
3571
3917
|
case 'image': {
|
|
3572
3918
|
if (n.imageUrl) {
|
|
3573
3919
|
const img = new Image();
|
|
@@ -3657,7 +4003,9 @@ function renderToCanvas(sg, canvas, options = {}) {
|
|
|
3657
4003
|
// ── Title ────────────────────────────────────────────────
|
|
3658
4004
|
if (sg.title) {
|
|
3659
4005
|
const titleSize = Number(sg.config['title-size'] ?? 18);
|
|
3660
|
-
|
|
4006
|
+
const titleWeight = Number(sg.config['title-weight'] ?? 600);
|
|
4007
|
+
const titleColor = String(sg.config['title-color'] ?? palette.titleText);
|
|
4008
|
+
drawText(ctx, sg.title, sg.width / 2, 28, titleSize, titleWeight, titleColor, 'center', diagramFont);
|
|
3661
4009
|
}
|
|
3662
4010
|
// ── Groups (outermost first) ─────────────────────────────
|
|
3663
4011
|
const sortedGroups = [...sg.groups].sort((a, b) => groupDepth(a, gm) - groupDepth(b, gm));
|
|
@@ -3673,13 +4021,16 @@ function renderToCanvas(sg, canvas, options = {}) {
|
|
|
3673
4021
|
strokeWidth: Number(gs.strokeWidth ?? 1.2),
|
|
3674
4022
|
strokeLineDash: gs.strokeDash ?? palette.groupDash,
|
|
3675
4023
|
});
|
|
3676
|
-
// ── Group label
|
|
3677
|
-
//
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
4024
|
+
// ── Group label ──────────────────────────────────────
|
|
4025
|
+
// Only render when label has content — empty label = no reserved space
|
|
4026
|
+
// supports: font, font-size, letter-spacing (always left-anchored)
|
|
4027
|
+
if (g.label) {
|
|
4028
|
+
const gFontSize = Number(gs.fontSize ?? 12);
|
|
4029
|
+
const gFont = resolveStyleFont(gs, diagramFont);
|
|
4030
|
+
const gLetterSpacing = gs.letterSpacing;
|
|
4031
|
+
const gLabelColor = gs.color ? String(gs.color) : palette.groupLabel;
|
|
4032
|
+
drawText(ctx, g.label, g.x + 14, g.y + 16, gFontSize, 500, gLabelColor, 'left', gFont, gLetterSpacing);
|
|
4033
|
+
}
|
|
3683
4034
|
}
|
|
3684
4035
|
// ── Edges ─────────────────────────────────────────────────
|
|
3685
4036
|
for (const e of sg.edges) {
|
|
@@ -3732,7 +4083,8 @@ function renderToCanvas(sg, canvas, options = {}) {
|
|
|
3732
4083
|
for (const n of sg.nodes) {
|
|
3733
4084
|
renderShape(rc, ctx, n, palette, R);
|
|
3734
4085
|
// ── Node / text typography ─────────────────────────
|
|
3735
|
-
// supports: font, font-size, letter-spacing, text-align,
|
|
4086
|
+
// supports: font, font-size, letter-spacing, text-align,
|
|
4087
|
+
// vertical-align, line-height, word-wrap (text shape)
|
|
3736
4088
|
const fontSize = Number(n.style?.fontSize ?? (n.shape === 'text' ? 13 : 14));
|
|
3737
4089
|
const fontWeight = n.style?.fontWeight ?? (n.shape === 'text' ? 400 : 500);
|
|
3738
4090
|
const textColor = String(n.style?.color ??
|
|
@@ -3741,15 +4093,28 @@ function renderToCanvas(sg, canvas, options = {}) {
|
|
|
3741
4093
|
const textAlign = String(n.style?.textAlign ?? 'center');
|
|
3742
4094
|
const lineHeight = Number(n.style?.lineHeight ?? 1.3) * fontSize;
|
|
3743
4095
|
const letterSpacing = n.style?.letterSpacing;
|
|
4096
|
+
const vertAlign = String(n.style?.verticalAlign ?? 'middle');
|
|
4097
|
+
// x shifts for left/right alignment
|
|
3744
4098
|
const textX = textAlign === 'left' ? n.x + 8
|
|
3745
4099
|
: textAlign === 'right' ? n.x + n.w - 8
|
|
3746
4100
|
: n.x + n.w / 2;
|
|
3747
|
-
|
|
4101
|
+
// word-wrap for text shape; explicit \n for all others
|
|
4102
|
+
const rawLines = n.label.split('\n');
|
|
4103
|
+
const lines = n.shape === 'text' && rawLines.length === 1
|
|
4104
|
+
? wrapText(n.label, n.w - 16, fontSize)
|
|
4105
|
+
: rawLines;
|
|
4106
|
+
// vertical-align: compute textCY from top/middle/bottom
|
|
4107
|
+
const nodeBodyTop = n.y + 6;
|
|
4108
|
+
const nodeBodyBottom = n.y + n.h - 6;
|
|
4109
|
+
const blockH = (lines.length - 1) * lineHeight;
|
|
4110
|
+
const textCY = vertAlign === 'top' ? nodeBodyTop + blockH / 2
|
|
4111
|
+
: vertAlign === 'bottom' ? nodeBodyBottom - blockH / 2
|
|
4112
|
+
: n.y + n.h / 2; // middle (default)
|
|
3748
4113
|
if (lines.length > 1) {
|
|
3749
|
-
drawMultilineText(ctx, lines, textX,
|
|
4114
|
+
drawMultilineText(ctx, lines, textX, textCY, fontSize, fontWeight, textColor, textAlign, lineHeight, nodeFont, letterSpacing);
|
|
3750
4115
|
}
|
|
3751
4116
|
else {
|
|
3752
|
-
drawText(ctx,
|
|
4117
|
+
drawText(ctx, lines[0] ?? '', textX, textCY, fontSize, fontWeight, textColor, textAlign, nodeFont, letterSpacing);
|
|
3753
4118
|
}
|
|
3754
4119
|
}
|
|
3755
4120
|
// ── Tables ────────────────────────────────────────────────
|
|
@@ -3760,7 +4125,8 @@ function renderToCanvas(sg, canvas, options = {}) {
|
|
|
3760
4125
|
const textCol = String(gs.color ?? palette.tableText);
|
|
3761
4126
|
const pad = t.labelH;
|
|
3762
4127
|
// ── Table-level font ────────────────────────────────
|
|
3763
|
-
// supports: font, font-size, letter-spacing
|
|
4128
|
+
// supports: font, font-size, letter-spacing
|
|
4129
|
+
// cells also support text-align
|
|
3764
4130
|
const tFontSize = Number(gs.fontSize ?? 12);
|
|
3765
4131
|
const tFont = resolveStyleFont(gs, diagramFont);
|
|
3766
4132
|
const tLetterSpacing = gs.letterSpacing;
|
|
@@ -3771,8 +4137,7 @@ function renderToCanvas(sg, canvas, options = {}) {
|
|
|
3771
4137
|
rc.line(t.x, t.y + pad, t.x + t.w, t.y + pad, {
|
|
3772
4138
|
roughness: 0.6, seed: hashStr$1(t.id + 'l'), stroke: strk, strokeWidth: 1,
|
|
3773
4139
|
});
|
|
3774
|
-
// ── Table label:
|
|
3775
|
-
// always left-anchored
|
|
4140
|
+
// ── Table label: always left-anchored ───────────────
|
|
3776
4141
|
drawText(ctx, t.label, t.x + 10, t.y + pad / 2, tFontSize, 500, textCol, 'left', tFont, tLetterSpacing);
|
|
3777
4142
|
let rowY = t.y + pad;
|
|
3778
4143
|
for (const row of t.rows) {
|
|
@@ -3786,7 +4151,7 @@ function renderToCanvas(sg, canvas, options = {}) {
|
|
|
3786
4151
|
stroke: row.kind === 'header' ? strk : palette.tableDivider,
|
|
3787
4152
|
strokeWidth: row.kind === 'header' ? 1.2 : 0.6,
|
|
3788
4153
|
});
|
|
3789
|
-
// ── Cell text
|
|
4154
|
+
// ── Cell text ───────────────────────────────────
|
|
3790
4155
|
// header always centered; data rows respect gs.textAlign
|
|
3791
4156
|
const cellAlignProp = (row.kind === 'header'
|
|
3792
4157
|
? 'center'
|
|
@@ -3834,22 +4199,91 @@ function renderToCanvas(sg, canvas, options = {}) {
|
|
|
3834
4199
|
], { roughness: 0.4, seed: hashStr$1(n.id + 'f'),
|
|
3835
4200
|
fill: palette.noteFold, fillStyle: 'solid', stroke: strk, strokeWidth: 0.8 });
|
|
3836
4201
|
// ── Note typography ─────────────────────────────────
|
|
3837
|
-
// supports: font, font-size, letter-spacing, text-align,
|
|
4202
|
+
// supports: font, font-size, letter-spacing, text-align,
|
|
4203
|
+
// vertical-align, line-height
|
|
3838
4204
|
const nFontSize = Number(gs.fontSize ?? 12);
|
|
3839
4205
|
const nFont = resolveStyleFont(gs, diagramFont);
|
|
3840
4206
|
const nLetterSpacing = gs.letterSpacing;
|
|
3841
4207
|
const nLineHeight = Number(gs.lineHeight ?? 1.4) * nFontSize;
|
|
3842
4208
|
const nTextAlign = String(gs.textAlign ?? 'left');
|
|
4209
|
+
const nVertAlign = String(gs.verticalAlign ?? 'top');
|
|
3843
4210
|
const nColor = String(gs.color ?? palette.noteText);
|
|
3844
4211
|
const nTextX = nTextAlign === 'right' ? x + w - fold - 6
|
|
3845
4212
|
: nTextAlign === 'center' ? x + (w - fold) / 2
|
|
3846
4213
|
: x + 12;
|
|
4214
|
+
// vertical-align inside note body (below fold)
|
|
4215
|
+
const bodyTop = y + fold + 8;
|
|
4216
|
+
const bodyBottom = y + h - 8;
|
|
4217
|
+
const blockH = (n.lines.length - 1) * nLineHeight;
|
|
4218
|
+
const blockCY = nVertAlign === 'bottom' ? bodyBottom - blockH / 2
|
|
4219
|
+
: nVertAlign === 'middle' ? (bodyTop + bodyBottom) / 2
|
|
4220
|
+
: bodyTop + blockH / 2; // top (default)
|
|
3847
4221
|
if (n.lines.length > 1) {
|
|
3848
|
-
const blockCY = y + fold / 2 + (h - fold) / 2;
|
|
3849
4222
|
drawMultilineText(ctx, n.lines, nTextX, blockCY, nFontSize, 400, nColor, nTextAlign, nLineHeight, nFont, nLetterSpacing);
|
|
3850
4223
|
}
|
|
3851
4224
|
else {
|
|
3852
|
-
drawText(ctx, n.lines[0] ?? '', nTextX,
|
|
4225
|
+
drawText(ctx, n.lines[0] ?? '', nTextX, blockCY, nFontSize, 400, nColor, nTextAlign, nFont, nLetterSpacing);
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
// ── Markdown blocks ────────────────────────────────────────
|
|
4229
|
+
// Renders prose with Markdown headings and bold/italic inline spans.
|
|
4230
|
+
// Canvas has no native bold-within-a-run, so each run is drawn
|
|
4231
|
+
// individually with its own ctx.font setting.
|
|
4232
|
+
for (const m of (sg.markdowns ?? [])) {
|
|
4233
|
+
const mFont = resolveStyleFont(m.style, diagramFont);
|
|
4234
|
+
const baseColor = String(m.style?.color ?? palette.nodeText);
|
|
4235
|
+
const textAlign = String(m.style?.textAlign ?? 'left');
|
|
4236
|
+
const PAD = Number(m.style?.padding ?? 16);
|
|
4237
|
+
const anchorX = textAlign === 'right' ? m.x + m.w - PAD
|
|
4238
|
+
: textAlign === 'center' ? m.x + m.w / 2
|
|
4239
|
+
: m.x + PAD;
|
|
4240
|
+
let y = m.y + PAD;
|
|
4241
|
+
for (const line of m.lines) {
|
|
4242
|
+
if (line.kind === 'blank') {
|
|
4243
|
+
y += LINE_SPACING.blank;
|
|
4244
|
+
continue;
|
|
4245
|
+
}
|
|
4246
|
+
const fontSize = LINE_FONT_SIZE[line.kind];
|
|
4247
|
+
const fontWeight = LINE_FONT_WEIGHT[line.kind];
|
|
4248
|
+
const lineY = y + fontSize / 2;
|
|
4249
|
+
// Measure total run width for left-offset when runs mix bold/italic
|
|
4250
|
+
// Simple: draw each run consecutively from a computed start x
|
|
4251
|
+
ctx.save();
|
|
4252
|
+
ctx.textBaseline = 'middle';
|
|
4253
|
+
ctx.fillStyle = baseColor;
|
|
4254
|
+
if (textAlign === 'center' || textAlign === 'right') {
|
|
4255
|
+
// Measure full line width first
|
|
4256
|
+
let totalW = 0;
|
|
4257
|
+
for (const run of line.runs) {
|
|
4258
|
+
const runStyle = run.italic ? 'italic ' : '';
|
|
4259
|
+
const runWeight = run.bold ? 700 : fontWeight;
|
|
4260
|
+
ctx.font = `${runStyle}${runWeight} ${fontSize}px ${mFont}`;
|
|
4261
|
+
totalW += ctx.measureText(run.text).width;
|
|
4262
|
+
}
|
|
4263
|
+
let runX = textAlign === 'center' ? anchorX - totalW / 2 : anchorX - totalW;
|
|
4264
|
+
ctx.textAlign = 'left';
|
|
4265
|
+
for (const run of line.runs) {
|
|
4266
|
+
const runStyle = run.italic ? 'italic ' : '';
|
|
4267
|
+
const runWeight = run.bold ? 700 : fontWeight;
|
|
4268
|
+
ctx.font = `${runStyle}${runWeight} ${fontSize}px ${mFont}`;
|
|
4269
|
+
ctx.fillText(run.text, runX, lineY);
|
|
4270
|
+
runX += ctx.measureText(run.text).width;
|
|
4271
|
+
}
|
|
4272
|
+
}
|
|
4273
|
+
else {
|
|
4274
|
+
// left-aligned — draw runs left to right from anchorX
|
|
4275
|
+
let runX = anchorX;
|
|
4276
|
+
ctx.textAlign = 'left';
|
|
4277
|
+
for (const run of line.runs) {
|
|
4278
|
+
const runStyle = run.italic ? 'italic ' : '';
|
|
4279
|
+
const runWeight = run.bold ? 700 : fontWeight;
|
|
4280
|
+
ctx.font = `${runStyle}${runWeight} ${fontSize}px ${mFont}`;
|
|
4281
|
+
ctx.fillText(run.text, runX, lineY);
|
|
4282
|
+
runX += ctx.measureText(run.text).width;
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
ctx.restore();
|
|
4286
|
+
y += LINE_SPACING[line.kind];
|
|
3853
4287
|
}
|
|
3854
4288
|
}
|
|
3855
4289
|
// ── Charts ────────────────────────────────────────────────
|
|
@@ -4809,6 +5243,89 @@ class EventEmitter {
|
|
|
4809
5243
|
}
|
|
4810
5244
|
}
|
|
4811
5245
|
|
|
5246
|
+
// ============================================================
|
|
5247
|
+
// sketchmark — Encrypted sharing
|
|
5248
|
+
// Diagram DSL is encrypted in the browser.
|
|
5249
|
+
// The server stores an opaque blob it cannot read.
|
|
5250
|
+
// The decryption key lives only in the URL fragment (#key=...).
|
|
5251
|
+
// ============================================================
|
|
5252
|
+
const WORKER_URL = 'https://sketchmark.anmism.workers.dev';
|
|
5253
|
+
// ── Crypto helpers ────────────────────────────────────────
|
|
5254
|
+
async function generateKey() {
|
|
5255
|
+
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, // extractable so we can export to URL
|
|
5256
|
+
['encrypt', 'decrypt']);
|
|
5257
|
+
}
|
|
5258
|
+
async function keyToBase64(key) {
|
|
5259
|
+
const raw = await crypto.subtle.exportKey('raw', key);
|
|
5260
|
+
return btoa(String.fromCharCode(...new Uint8Array(raw)));
|
|
5261
|
+
}
|
|
5262
|
+
async function base64ToKey(b64) {
|
|
5263
|
+
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
5264
|
+
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, // not extractable on the receiving end
|
|
5265
|
+
['decrypt']);
|
|
5266
|
+
}
|
|
5267
|
+
// ── Encrypt ───────────────────────────────────────────────
|
|
5268
|
+
async function encryptDSL(dsl, key) {
|
|
5269
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
5270
|
+
const encoded = new TextEncoder().encode(dsl);
|
|
5271
|
+
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
|
|
5272
|
+
// prepend iv to the blob: [ iv (12 bytes) | ciphertext ]
|
|
5273
|
+
const result = new Uint8Array(12 + encrypted.byteLength);
|
|
5274
|
+
result.set(iv, 0);
|
|
5275
|
+
result.set(new Uint8Array(encrypted), 12);
|
|
5276
|
+
return result;
|
|
5277
|
+
}
|
|
5278
|
+
// ── Decrypt ───────────────────────────────────────────────
|
|
5279
|
+
async function decryptBlob(blob, key) {
|
|
5280
|
+
const iv = blob.slice(0, 12);
|
|
5281
|
+
const ciphertext = blob.slice(12);
|
|
5282
|
+
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
|
|
5283
|
+
return new TextDecoder().decode(decrypted);
|
|
5284
|
+
}
|
|
5285
|
+
// ── Public API ────────────────────────────────────────────
|
|
5286
|
+
/**
|
|
5287
|
+
* Encrypt DSL, upload to worker, return shareable URL.
|
|
5288
|
+
* The URL fragment (#key=...) never reaches the server.
|
|
5289
|
+
*/
|
|
5290
|
+
async function shareDiagram(dsl) {
|
|
5291
|
+
const key = await generateKey();
|
|
5292
|
+
const blob = await encryptDSL(dsl, key);
|
|
5293
|
+
const keyB64 = await keyToBase64(key);
|
|
5294
|
+
const res = await fetch(`${WORKER_URL}/api/blob`, {
|
|
5295
|
+
method: 'POST',
|
|
5296
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
5297
|
+
body: blob.buffer,
|
|
5298
|
+
});
|
|
5299
|
+
if (!res.ok)
|
|
5300
|
+
throw new Error(`Upload failed: ${res.status}`);
|
|
5301
|
+
const { id } = await res.json();
|
|
5302
|
+
// key goes into the fragment — browser never sends this to any server
|
|
5303
|
+
return `${window.location.origin}/playground.html?s=${id}#key=${keyB64}`;
|
|
5304
|
+
}
|
|
5305
|
+
/**
|
|
5306
|
+
* Read ?s= and #key= from the current URL, fetch + decrypt the diagram.
|
|
5307
|
+
* Returns null if no share params found.
|
|
5308
|
+
*/
|
|
5309
|
+
async function loadSharedDiagram() {
|
|
5310
|
+
const params = new URLSearchParams(window.location.search);
|
|
5311
|
+
const id = params.get('s');
|
|
5312
|
+
if (!id)
|
|
5313
|
+
return null;
|
|
5314
|
+
// key is in the fragment — parse manually, not via URLSearchParams
|
|
5315
|
+
// (URLSearchParams on hash strips the #)
|
|
5316
|
+
const fragment = window.location.hash.slice(1);
|
|
5317
|
+
const keyMatch = fragment.match(/key=([^&]+)/);
|
|
5318
|
+
if (!keyMatch)
|
|
5319
|
+
return null;
|
|
5320
|
+
const keyB64 = keyMatch[1];
|
|
5321
|
+
const res = await fetch(`${WORKER_URL}/api/blob/${id}`);
|
|
5322
|
+
if (!res.ok)
|
|
5323
|
+
throw new Error('Diagram not found or expired');
|
|
5324
|
+
const blob = await res.arrayBuffer();
|
|
5325
|
+
const key = await base64ToKey(keyB64);
|
|
5326
|
+
return decryptBlob(blob, key);
|
|
5327
|
+
}
|
|
5328
|
+
|
|
4812
5329
|
// ============================================================
|
|
4813
5330
|
// sketchmark — Public API
|
|
4814
5331
|
// ============================================================
|
|
@@ -4900,6 +5417,8 @@ exports.layout = layout;
|
|
|
4900
5417
|
exports.lerp = lerp;
|
|
4901
5418
|
exports.listThemes = listThemes;
|
|
4902
5419
|
exports.loadFont = loadFont;
|
|
5420
|
+
exports.loadSharedDiagram = loadSharedDiagram;
|
|
5421
|
+
exports.markdownMap = markdownMap;
|
|
4903
5422
|
exports.nodeMap = nodeMap;
|
|
4904
5423
|
exports.parse = parse;
|
|
4905
5424
|
exports.parseHex = parseHex;
|
|
@@ -4909,6 +5428,7 @@ exports.renderToCanvas = renderToCanvas;
|
|
|
4909
5428
|
exports.renderToSVG = renderToSVG;
|
|
4910
5429
|
exports.resolveFont = resolveFont;
|
|
4911
5430
|
exports.resolvePalette = resolvePalette;
|
|
5431
|
+
exports.shareDiagram = shareDiagram;
|
|
4912
5432
|
exports.sleep = sleep;
|
|
4913
5433
|
exports.svgToPNGDataURL = svgToPNGDataURL;
|
|
4914
5434
|
exports.svgToString = svgToString;
|