figdown 0.1.0-rc.1
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/EXPRESSING.md +119 -0
- package/LAYOUT-GUIDE.md +367 -0
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/README.zh-tw.md +185 -0
- package/SHOWCASE.md +421 -0
- package/dist/figdown.js +2239 -0
- package/dist/figdown.mjs +2231 -0
- package/examples/evpn-fabric.svg +54 -0
- package/examples/showcase/arp-resolution.svg +51 -0
- package/examples/showcase/ethernet-frame.svg +37 -0
- package/examples/showcase/l2-forwarding-logic.svg +42 -0
- package/examples/showcase/tcp-handshake.svg +56 -0
- package/examples/showcase/tcp-header.svg +47 -0
- package/examples/showcase/tcp-state-machine.svg +140 -0
- package/package.json +51 -0
- package/skill/figdown/build-svg.js +73 -0
- package/skill/figdown/figdown.html +3290 -0
|
@@ -0,0 +1,3290 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>FigDown PoC — deterministic text→SVG renderer (draft v0.0)</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{
|
|
9
|
+
--bg:#fcfcfb; --panel:#ffffff; --ink:#1d1d1b; --muted:#6f6e69;
|
|
10
|
+
--line:#e3e1d9; --hair:#d6d4cc; --code:#f6f5ef; --err:#dc2626;
|
|
11
|
+
--sans:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
|
12
|
+
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
13
|
+
}
|
|
14
|
+
*{box-sizing:border-box}
|
|
15
|
+
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.5}
|
|
16
|
+
.wrap{max-width:1400px;margin:0 auto;padding:22px 20px 50px}
|
|
17
|
+
header h1{font-size:20px;font-weight:600;margin:0 0 3px;letter-spacing:-.01em}
|
|
18
|
+
header p{margin:0;color:var(--muted);font-size:13px}
|
|
19
|
+
.bar{display:flex;align-items:center;gap:9px;margin:14px 0 12px;flex-wrap:wrap}
|
|
20
|
+
.bar label{font-size:13px;color:var(--muted)}
|
|
21
|
+
select,button{font:inherit;font-size:13.5px;padding:6px 10px;border:1px solid var(--hair);border-radius:8px;background:var(--panel);color:var(--ink);cursor:pointer}
|
|
22
|
+
button:hover{border-color:var(--muted)}
|
|
23
|
+
.main{display:grid;grid-template-columns:minmax(340px,0.9fr) minmax(420px,1.1fr);gap:14px;align-items:start}
|
|
24
|
+
@media (max-width:980px){ .main{grid-template-columns:1fr} }
|
|
25
|
+
textarea{width:100%;min-height:520px;font-family:var(--mono);font-size:12.8px;line-height:1.55;
|
|
26
|
+
padding:12px 14px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--ink);
|
|
27
|
+
resize:vertical;white-space:pre;tab-size:4}
|
|
28
|
+
textarea:focus{outline:2px solid #bfdbfe;outline-offset:-1px}
|
|
29
|
+
.preview{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;min-height:520px;overflow:auto}
|
|
30
|
+
.errors{font-family:var(--mono);font-size:12.5px;color:var(--err);white-space:pre-wrap;margin:0}
|
|
31
|
+
.ok{color:var(--muted);font-size:12px;margin:0 0 8px}
|
|
32
|
+
svg{max-width:100%;height:auto;display:block}
|
|
33
|
+
footer{margin-top:18px;color:var(--muted);font-size:12px}
|
|
34
|
+
a{color:inherit}
|
|
35
|
+
details#svgbox,details#imgbox{margin-top:14px;border-top:1px solid var(--line);padding-top:8px}
|
|
36
|
+
details#svgbox summary,details#imgbox summary{font-size:12px;color:var(--muted);cursor:pointer;user-select:none}
|
|
37
|
+
#imgwrap{margin-top:8px;border:1px dashed var(--hair);border-radius:8px;padding:10px;background:#fff}
|
|
38
|
+
#svgimg{max-width:100%;display:block}
|
|
39
|
+
#svgsrc{font-family:var(--mono);font-size:11.5px;line-height:1.5;background:var(--code);
|
|
40
|
+
border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin:8px 0 0;
|
|
41
|
+
max-height:280px;overflow:auto;white-space:pre-wrap;word-break:break-all}
|
|
42
|
+
.fname{font-family:var(--mono);font-size:12px;color:var(--muted);padding:0 4px}
|
|
43
|
+
.sep{display:inline-block;width:1px;height:18px;background:var(--line);margin:0 4px;vertical-align:middle}
|
|
44
|
+
.errline{cursor:pointer}
|
|
45
|
+
.errline:hover{text-decoration:underline}
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<div class="wrap">
|
|
50
|
+
<header>
|
|
51
|
+
<h1>FigDown PoC <span style="color:var(--muted);font-weight:400">— deterministic text→SVG, draft v0.0</span></h1>
|
|
52
|
+
<p>Figures as text — one source, two readers. Edit left, render right. Same source → same SVG, bit-level.</p>
|
|
53
|
+
</header>
|
|
54
|
+
|
|
55
|
+
<div class="bar">
|
|
56
|
+
<button id="newdoc" title="new blank document">New</button>
|
|
57
|
+
<button id="open" title="open a .fd (or a generated .svg) — Ctrl+O">Open…</button>
|
|
58
|
+
<button id="save" title="save the .fd — Ctrl+S (saves back to the opened file)">Save</button>
|
|
59
|
+
<button id="saveas" title="save the .fd under a new name — Ctrl+Shift+S">As…</button>
|
|
60
|
+
<button id="dl" title="export the SVG artifact (source + SHA-256 embedded)">SVG ⬇</button>
|
|
61
|
+
<span id="fname" class="fname"></span>
|
|
62
|
+
<button id="undobtn" disabled title="undo the last edit — Ctrl+Z (outside the text pane)">↶</button>
|
|
63
|
+
<button id="redobtn" disabled title="redo — Ctrl+Shift+Z">↷</button>
|
|
64
|
+
<span class="sep"></span>
|
|
65
|
+
<label>Example</label>
|
|
66
|
+
<select id="examples"></select>
|
|
67
|
+
<select id="newkind" title="shape for new nodes"><option>box</option><option>rounded</option><option>circle</option><option>ellipse</option><option>cloud</option><option>diamond</option><option>cylinder</option></select>
|
|
68
|
+
<button id="addnode" title="insert a node line (or double-click the canvas)">+ Node</button>
|
|
69
|
+
<button id="linkmode" title="click two nodes to write an edge line">Link</button>
|
|
70
|
+
<button id="groupmode" title="drag a box around nodes to write a group line">⬚ Group</button>
|
|
71
|
+
<button id="pinall" title="materialize all auto positions into pin lines">Pin all</button>
|
|
72
|
+
<label>fill <input type="color" id="fillpick" value="#ffffff" disabled></label>
|
|
73
|
+
<button id="raise" disabled title="paint later = move its line down in the text">Raise ↑</button>
|
|
74
|
+
<button id="lower" disabled title="paint earlier = move its line up in the text">Lower ↓</button>
|
|
75
|
+
<button id="delnode" disabled title="delete the node and its pins/edges — Del">Delete</button>
|
|
76
|
+
<input type="file" id="loadfile" accept=".fd,.svg,.txt" style="display:none">
|
|
77
|
+
<span id="status" style="font-size:12px;color:var(--muted)"></span>
|
|
78
|
+
</div>
|
|
79
|
+
<p style="margin:0 0 10px;font-size:12px;color:var(--muted)">
|
|
80
|
+
Every GUI action rewrites the text: double-click empty canvas → new <code>node</code> + <code>pin</code> ·
|
|
81
|
+
drag a node → <code>pin</code> · arrow keys nudge (Shift = ×10) · drag the corner handle → <code>size</code> ·
|
|
82
|
+
double-click a node → rename · Del → remove node + its pins/edges · fill color → <code>color=</code> ·
|
|
83
|
+
Raise/Lower → line order = paint order · Link → <code>edge</code> · ⬚ Group → <code>group</code> + <code>in=</code> ·
|
|
84
|
+
click a table cell → <code>cell r,c color=</code> · drag a threshold line → <code>at=%</code> ·
|
|
85
|
+
click an error → jump to that line · Ctrl+S save · Ctrl+O open · unsaved work survives in autosave.</p>
|
|
86
|
+
|
|
87
|
+
<div class="main">
|
|
88
|
+
<textarea id="src" spellcheck="false"></textarea>
|
|
89
|
+
<div class="preview">
|
|
90
|
+
<p class="ok" id="okline"></p>
|
|
91
|
+
<pre class="errors" id="errs"></pre>
|
|
92
|
+
<div id="out"></div>
|
|
93
|
+
<details id="imgbox" open>
|
|
94
|
+
<summary>Artifact preview — the .svg file as an image (what a Markdown reader shows)</summary>
|
|
95
|
+
<div id="imgwrap"><img id="svgimg" alt="SVG artifact preview"></div>
|
|
96
|
+
</details>
|
|
97
|
+
<details id="svgbox">
|
|
98
|
+
<summary>Generated SVG source</summary>
|
|
99
|
+
<pre id="svgsrc"></pre>
|
|
100
|
+
</details>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<footer>FigDown draft v0.0 PoC — core scene (node/group/edge/layer, flow/rank/pin/size) + bitfield + table + wave.
|
|
105
|
+
Closed grammar: unknown lines are errors with line numbers. No LLM, no dependencies, no network.</footer>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<script>
|
|
109
|
+
'use strict';
|
|
110
|
+
// ============================================================
|
|
111
|
+
// 1. PARSER — closed, line-oriented grammar (draft v0.0)
|
|
112
|
+
// ============================================================
|
|
113
|
+
const SHAPES = ['box','rounded','circle','ellipse','cloud','diamond','cylinder'];
|
|
114
|
+
// Colors are CSS hex (#rgb / #rrggbb) or CSS named colors (spec §1) — the
|
|
115
|
+
// 147 CSS/SVG color keywords (lowercase) plus `transparent`. Anything else
|
|
116
|
+
// is a line error (closed grammar, 0.1-dev.11).
|
|
117
|
+
const CSS_COLORS=new Set(('aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond '+
|
|
118
|
+
'blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan '+
|
|
119
|
+
'darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange '+
|
|
120
|
+
'darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet '+
|
|
121
|
+
'deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite '+
|
|
122
|
+
'gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush '+
|
|
123
|
+
'lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey '+
|
|
124
|
+
'lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow '+
|
|
125
|
+
'lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen '+
|
|
126
|
+
'mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin '+
|
|
127
|
+
'navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise '+
|
|
128
|
+
'palevioletred papayawhip peachpuff peru pink plum powderblue purple red rosybrown royalblue saddlebrown '+
|
|
129
|
+
'salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen '+
|
|
130
|
+
'steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen transparent')
|
|
131
|
+
.split(' '));
|
|
132
|
+
const isColor=v=>/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)||CSS_COLORS.has(v);
|
|
133
|
+
|
|
134
|
+
function tokenize(line){
|
|
135
|
+
// split by whitespace, honoring double quotes
|
|
136
|
+
const toks=[]; let i=0;
|
|
137
|
+
while(i<line.length){
|
|
138
|
+
while(i<line.length && /\s/.test(line[i])) i++;
|
|
139
|
+
if(i>=line.length) break;
|
|
140
|
+
if(line[i]==='"'){
|
|
141
|
+
let j=i+1, s='';
|
|
142
|
+
while(j<line.length && line[j]!=='"'){
|
|
143
|
+
if(line[j]==='\\'){
|
|
144
|
+
const e=line[j+1];
|
|
145
|
+
if(e==='n'){ s+='\n'; j+=2; continue; }
|
|
146
|
+
if(e==='"'){ s+='"'; j+=2; continue; }
|
|
147
|
+
if(e==='\\'){ s+='\\'; j+=2; continue; }
|
|
148
|
+
return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'};
|
|
149
|
+
}
|
|
150
|
+
s+=line[j]; j++;
|
|
151
|
+
}
|
|
152
|
+
if(j>=line.length) return {error:'unterminated string'};
|
|
153
|
+
toks.push({v:s,q:true}); i=j+1;
|
|
154
|
+
} else {
|
|
155
|
+
let j=i, s='';
|
|
156
|
+
while(j<line.length && !/\s/.test(line[j])){
|
|
157
|
+
if(line[j]==='"'){ // key="value with spaces"
|
|
158
|
+
j++;
|
|
159
|
+
while(j<line.length && line[j]!=='"'){
|
|
160
|
+
if(line[j]==='\\'){
|
|
161
|
+
const e=line[j+1];
|
|
162
|
+
if(e==='n'){ s+='\n'; j+=2; continue; }
|
|
163
|
+
if(e==='"'){ s+='"'; j+=2; continue; }
|
|
164
|
+
if(e==='\\'){ s+='\\'; j+=2; continue; }
|
|
165
|
+
return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'};
|
|
166
|
+
}
|
|
167
|
+
s+=line[j]; j++;
|
|
168
|
+
}
|
|
169
|
+
if(j>=line.length) return {error:'unterminated string'};
|
|
170
|
+
j++; continue;
|
|
171
|
+
}
|
|
172
|
+
s+=line[j]; j++;
|
|
173
|
+
}
|
|
174
|
+
toks.push({v:s,q:false}); i=j;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return {toks};
|
|
178
|
+
}
|
|
179
|
+
// Option keys are a CLOSED set (two levels, 0.1-dev.11):
|
|
180
|
+
// - a key listed in OPT_KEYS is parsed as an option; whether it is
|
|
181
|
+
// *applicable* is checked per directive against DIRECTIVE_OPTS
|
|
182
|
+
// (a registered-but-inapplicable key is a line error, D3);
|
|
183
|
+
// - a key=value token with an unregistered key is an "unknown option"
|
|
184
|
+
// line error (D12) — except inside wave `signal` lanes, where bare
|
|
185
|
+
// tokens may contain '=' and stay positional (laneMode).
|
|
186
|
+
const OPT_KEYS=new Set(['kind','shape','color','stroke','text','in','layer','label',
|
|
187
|
+
'style','z','at','w','h','unit','note','labels','numbering','fill','from','to','gap','dir','level',
|
|
188
|
+
'taillabel','headlabel','class','via','routing']);
|
|
189
|
+
// Applicable option keys per directive. Keys with dedicated diagnostics
|
|
190
|
+
// (node kind/w/h, edge label/taillabel/headlabel, line fill, fill from/to)
|
|
191
|
+
// are listed so their specific error messages fire. `edge` is consumed by
|
|
192
|
+
// parseEdgeLine; `title` takes its rest-of-line verbatim (no options).
|
|
193
|
+
const DIRECTIVE_OPTS={
|
|
194
|
+
figdown:[],
|
|
195
|
+
node:['shape','color','stroke','text','style','class','in','layer','kind','w','h'],
|
|
196
|
+
group:['color','stroke','text','style','gap','class','layer'],
|
|
197
|
+
boundary:[],
|
|
198
|
+
edge:['style','class','color','layer','label','taillabel','headlabel'],
|
|
199
|
+
layer:['z'], flow:[], rank:[],
|
|
200
|
+
bundle:['color'],
|
|
201
|
+
line:['in','at','color','fill'],
|
|
202
|
+
fill:['in','dir','color','from','to'],
|
|
203
|
+
pin:['at'], size:['w','h'],
|
|
204
|
+
render:[],
|
|
205
|
+
routing:[], route:['via','routing'],
|
|
206
|
+
'class':['color','stroke','text','style'],
|
|
207
|
+
bitfield:['unit','numbering'], table:[], wave:[],
|
|
208
|
+
plot:['kind','level'],
|
|
209
|
+
field:['color','class','note'], wrap:[],
|
|
210
|
+
cell:['color','class'], colw:[],
|
|
211
|
+
signal:['labels'], gap:[]
|
|
212
|
+
};
|
|
213
|
+
function splitOpts(toks, laneMode){
|
|
214
|
+
const pos=[], opts={}, unk=[];
|
|
215
|
+
for(const t of toks){
|
|
216
|
+
const m = !t.q && /^([A-Za-z_][A-Za-z0-9_-]*)=([\s\S]*)$/.exec(t.v);
|
|
217
|
+
if(m && OPT_KEYS.has(m[1])) opts[m[1]]=m[2];
|
|
218
|
+
else if(m && !laneMode) unk.push(m[1]);
|
|
219
|
+
else pos.push(t.v);
|
|
220
|
+
}
|
|
221
|
+
return {pos,opts,unk};
|
|
222
|
+
}
|
|
223
|
+
const ID_RE=/^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
224
|
+
|
|
225
|
+
function parse(text){
|
|
226
|
+
const errs=[];
|
|
227
|
+
const doc={title:'',nodes:[],groups:[],edges:[],layers:[{id:'base',label:'',z:0}],
|
|
228
|
+
flow:'right',ranks:[],pins:{},sizes:{},blocks:[],trunks:[],glines:[],fills:[],
|
|
229
|
+
classes:[],routes:[],boundaries:[]};
|
|
230
|
+
const nodeIds=new Set(), groupIds=new Set(), layerIds=new Set(['base']), classIds=new Set(),
|
|
231
|
+
bundleIds=new Set(), boundaryIds=new Set();
|
|
232
|
+
let cur=null; // current typed block (bitfield/table/wave)
|
|
233
|
+
let sawHeader=false, firstContent=true, sawRender=false;
|
|
234
|
+
const RENDER_DIRECTIVES=new Set(['pin','size','route','routing']);
|
|
235
|
+
const lines=text.split('\n');
|
|
236
|
+
const err=(n,m)=>errs.push('Line '+n+': '+m);
|
|
237
|
+
|
|
238
|
+
// R34/R35: edge <id> [tail] <op> [head] <id> — a [mid] label splits the
|
|
239
|
+
// operator into halves: -[x]- -[x]-> <-[x]- <-[x]->. Bracket content:
|
|
240
|
+
// balanced brackets nest verbatim ([flags[3:0]] just works); ["..."] takes
|
|
241
|
+
// the standard quoted-string escapes for unbalanced brackets / \n.
|
|
242
|
+
function parseEdgeLine(s,n){
|
|
243
|
+
let i=4; // past 'edge'
|
|
244
|
+
const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
|
|
245
|
+
const readId=()=>{ const m=/^[A-Za-z_][A-Za-z0-9_-]*/.exec(s.slice(i));
|
|
246
|
+
if(!m) return null; i+=m[0].length; return m[0]; };
|
|
247
|
+
const readLbl=()=>{ // called at '['
|
|
248
|
+
i++;
|
|
249
|
+
if(s[i]==='"'){ // ["..."] — quoted content
|
|
250
|
+
i++; let v='';
|
|
251
|
+
while(i<s.length&&s[i]!=='"'){
|
|
252
|
+
if(s[i]==='\\'){ const e=s[i+1];
|
|
253
|
+
if(e==='n'){ v+='\n'; i+=2; continue; }
|
|
254
|
+
if(e==='"'){ v+='"'; i+=2; continue; }
|
|
255
|
+
if(e==='\\'){ v+='\\'; i+=2; continue; }
|
|
256
|
+
return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'}; }
|
|
257
|
+
v+=s[i]; i++;
|
|
258
|
+
}
|
|
259
|
+
if(i>=s.length) return {error:'unterminated string in [label]'};
|
|
260
|
+
i++;
|
|
261
|
+
if(s[i]!==']') return {error:'expected ] after quoted label'};
|
|
262
|
+
i++;
|
|
263
|
+
if(!v) return {error:'empty [label]'};
|
|
264
|
+
return {v};
|
|
265
|
+
}
|
|
266
|
+
let depth=1,v='';
|
|
267
|
+
while(i<s.length){
|
|
268
|
+
const c=s[i];
|
|
269
|
+
if(c==='[') depth++;
|
|
270
|
+
else if(c===']'){ depth--; if(!depth){ i++;
|
|
271
|
+
v=v.trim();
|
|
272
|
+
if(!v) return {error:'empty [label]'};
|
|
273
|
+
return {v}; } }
|
|
274
|
+
v+=c; i++;
|
|
275
|
+
}
|
|
276
|
+
return {error:'unterminated [label] — for unbalanced brackets use ["..."]'};
|
|
277
|
+
};
|
|
278
|
+
ws(); const a=readId();
|
|
279
|
+
if(!a){ err(n,'edge needs <id> ->|<-|--|<-> <id>'); return; }
|
|
280
|
+
ws(); let tail=null;
|
|
281
|
+
if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } tail=r.v; }
|
|
282
|
+
ws();
|
|
283
|
+
let lh=null;
|
|
284
|
+
if(s.startsWith('<-',i)){ lh='<-'; i+=2; }
|
|
285
|
+
else if(s[i]==='-'){ lh='-'; i++; }
|
|
286
|
+
else { err(n,'edge needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)'); return; }
|
|
287
|
+
let mid=null, op=null;
|
|
288
|
+
if(s[i]==='['){
|
|
289
|
+
const r=readLbl(); if(r.error){ err(n,r.error); return; } mid=r.v;
|
|
290
|
+
if(s.startsWith('->',i)){ op=lh==='<-'?'<->':'->'; i+=2; }
|
|
291
|
+
else if(s[i]==='-'){ op=lh==='<-'?'<-':'--'; i++; }
|
|
292
|
+
else { err(n,'expected - or -> to close the operator after [label]'); return; }
|
|
293
|
+
} else if(lh==='<-'){
|
|
294
|
+
if(s[i]==='>'){ op='<->'; i++; } else op='<-';
|
|
295
|
+
} else {
|
|
296
|
+
if(s[i]==='-'){ op='--'; i++; }
|
|
297
|
+
else if(s[i]==='>'){ op='->'; i++; }
|
|
298
|
+
else { err(n,'edge needs an operator: -> <- -- <->'); return; }
|
|
299
|
+
}
|
|
300
|
+
ws(); let head=null;
|
|
301
|
+
if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } head=r.v; }
|
|
302
|
+
ws(); const b=readId();
|
|
303
|
+
if(!b){ err(n,'edge needs a target id after the operator'); return; }
|
|
304
|
+
const tk2=tokenize(s.slice(i).trim());
|
|
305
|
+
if(tk2.error){ err(n,tk2.error); return; }
|
|
306
|
+
const {pos:p2,opts:o2,unk:u2}=splitOpts(tk2.toks);
|
|
307
|
+
if(u2.length){ err(n,'unknown option "'+u2[0]+'="'); return; }
|
|
308
|
+
if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
|
|
309
|
+
for(const k in o2)
|
|
310
|
+
if(!DIRECTIVE_OPTS.edge.includes(k)){ err(n,'edge does not take '+k+'='); return; }
|
|
311
|
+
for(const k of ['label','taillabel','headlabel'])
|
|
312
|
+
if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: edge A [tail] -[mid]-> [head] B (MIGRATIONS 0.1-dev.9)'); return; }
|
|
313
|
+
if(o2.color!==undefined && !isColor(o2.color)){ err(n,'unknown color "'+o2.color+'" (#hex or CSS color name)'); return; }
|
|
314
|
+
if(o2.style!==undefined && !['solid','dashed','dotted'].includes(o2.style)){ err(n,'style must be solid|dashed|dotted'); return; }
|
|
315
|
+
doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:o2['class'],
|
|
316
|
+
color:o2.color,layer:o2.layer||'base',line:n});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for(let li=0; li<lines.length; li++){
|
|
320
|
+
const n=li+1;
|
|
321
|
+
let raw=lines[li];
|
|
322
|
+
// pipe rows are raw GFM content: no comment stripping inside them
|
|
323
|
+
if(!raw.trimStart().startsWith('|')){
|
|
324
|
+
const hi=findComment(raw); if(hi>=0) raw=raw.slice(0,hi);
|
|
325
|
+
}
|
|
326
|
+
if(!raw.trim()) continue;
|
|
327
|
+
|
|
328
|
+
// GFM pipe row (table content)
|
|
329
|
+
if(raw.trim().startsWith('|')){
|
|
330
|
+
if(!cur||cur.type!=='table'){ err(n,'pipe row outside a table block'); continue; }
|
|
331
|
+
const s=raw.trim();
|
|
332
|
+
if(!s.endsWith('|')){ err(n,'pipe row must end with |'); continue; }
|
|
333
|
+
const inner=s.slice(1,-1);
|
|
334
|
+
const segs=[]; let acc='';
|
|
335
|
+
for(let i=0;i<inner.length;i++){
|
|
336
|
+
if(inner[i]==='\\'&&inner[i+1]==='|'){ acc+='|'; i++; }
|
|
337
|
+
else if(inner[i]==='|'){ segs.push(acc); acc=''; }
|
|
338
|
+
else acc+=inner[i];
|
|
339
|
+
}
|
|
340
|
+
segs.push(acc);
|
|
341
|
+
const isSep=segs.every(x=>/^\s*:?-+:?\s*$/.test(x)) && segs.length>0; // GFM: 1+ hyphens
|
|
342
|
+
if(isSep){
|
|
343
|
+
if(cur.sep){ err(n,'duplicate separator row'); continue; }
|
|
344
|
+
if(!cur.heads.length){ err(n,'separator row before any header row'); continue; }
|
|
345
|
+
if(segs.length!==cur.cols.length){ err(n,'separator has '+segs.length+' columns, expected '+cur.cols.length); continue; }
|
|
346
|
+
cur.sep=true;
|
|
347
|
+
cur.aligns=segs.map(x=>{ x=x.trim();
|
|
348
|
+
const l=x.startsWith(':'), r=x.endsWith(':');
|
|
349
|
+
return l&&r?'center':(r?'right':(l?'left':null)); });
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// content row: raw empty segment = colspan-left (multimd "||");
|
|
353
|
+
// cell exactly ^^ = rowspan-up (multimd). The only cell escapes are
|
|
354
|
+
// \| (handled during segmentation) and \^^ (literal caret pair) —
|
|
355
|
+
// any other leading backslash is literal cell text (D10).
|
|
356
|
+
const cells=segs.map(x=>{
|
|
357
|
+
if(x.length===0) return {v:'', m:'left'};
|
|
358
|
+
const t=x.trim();
|
|
359
|
+
if(t==='^^') return {v:'', m:'up'};
|
|
360
|
+
return {v:t.startsWith('\\^^')?t.slice(1):t, m:null};
|
|
361
|
+
});
|
|
362
|
+
if(cells[0].m==='left'){ err(n,'colspan cannot start in the first column'); continue; }
|
|
363
|
+
if(cur.heads.length===0 && cells.some(c=>c.m==='up')){ err(n,'"^^" cannot appear in the first row'); continue; }
|
|
364
|
+
if(!cur.sep){
|
|
365
|
+
if(cur.heads.length && cells.length!==cur.cols.length){ err(n,'header row has '+cells.length+' cells, expected '+cur.cols.length); continue; }
|
|
366
|
+
cur.heads.push(cells);
|
|
367
|
+
if(cur.heads.length===1) cur.cols=cells.map(c=>c.v);
|
|
368
|
+
} else {
|
|
369
|
+
if(cells.length!==cur.cols.length){ err(n,'row has '+cells.length+' cells, expected '+cur.cols.length); continue; }
|
|
370
|
+
// rowspan cannot cross the thead/tbody boundary (multimd prior
|
|
371
|
+
// art): "^^" in the first data row is a line error (D9).
|
|
372
|
+
if(!cur.rows.length && cells.some(c=>c.m==='up')){ err(n,'"^^" cannot appear in the first data row (rowspan does not cross the header separator)'); continue; }
|
|
373
|
+
cur.rows.push({cells,hl:false,line:n});
|
|
374
|
+
}
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
// edge lines carry inline [labels] with free text — dedicated scanner,
|
|
378
|
+
// not the generic tokenizer
|
|
379
|
+
if(/^edge(\s|$)/.test(raw.trim())){
|
|
380
|
+
if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 [template]"'); }
|
|
381
|
+
cur=null;
|
|
382
|
+
parseEdgeLine(raw.trim(),n);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const tk=tokenize(raw.trim());
|
|
386
|
+
if(tk.error){ err(n,tk.error); continue; }
|
|
387
|
+
const lead=(tk.toks.length&&!tk.toks[0].q)?tk.toks[0].v:'';
|
|
388
|
+
const {pos,opts,unk}=splitOpts(tk.toks, lead==='signal');
|
|
389
|
+
const kw=pos[0];
|
|
390
|
+
// D3/D12/D14 (0.1-dev.11): uniform per-directive option checks —
|
|
391
|
+
// unknown keys, registered-but-inapplicable keys, invalid colors.
|
|
392
|
+
// Directives not in DIRECTIVE_OPTS (title's rest-of-line, unknown
|
|
393
|
+
// keywords) are handled by their own paths.
|
|
394
|
+
const badOpts=(k)=>{
|
|
395
|
+
const allowed=DIRECTIVE_OPTS[k];
|
|
396
|
+
if(!allowed) return false;
|
|
397
|
+
let bad=false;
|
|
398
|
+
for(const u of unk){ err(n,'unknown option "'+u+'="'); bad=true; }
|
|
399
|
+
for(const o in opts){
|
|
400
|
+
if(allowed.includes(o)) continue;
|
|
401
|
+
if(k==='group'&&o==='in') err(n,'group does not take in= — nesting is one level (node in=group) in v0.1');
|
|
402
|
+
else err(n,k+' does not take '+o+'=');
|
|
403
|
+
bad=true;
|
|
404
|
+
}
|
|
405
|
+
for(const o of ['color','stroke','text'])
|
|
406
|
+
if(opts[o]!==undefined && allowed.includes(o) && !isColor(opts[o])){
|
|
407
|
+
err(n,'unknown color "'+opts[o]+'" (#hex or CSS color name)'); bad=true; }
|
|
408
|
+
return bad;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
if(firstContent){
|
|
412
|
+
firstContent=false;
|
|
413
|
+
if(kw==='figdown'){
|
|
414
|
+
sawHeader=true;
|
|
415
|
+
if(pos[1]!=='0.1') err(n,'unsupported version "'+(pos[1]||'')+'" (expected 0.1)');
|
|
416
|
+
if(pos[2]!==undefined){
|
|
417
|
+
const TEMPLATES=['block','topology','flowchart','bitfield','table','wave'];
|
|
418
|
+
if(!TEMPLATES.includes(pos[2])) err(n,'unknown template "'+pos[2]+'" (block|topology|flowchart|bitfield|table|wave)');
|
|
419
|
+
else{ doc.template=pos[2];
|
|
420
|
+
// template defaults (D8/R13): flowchart figures flow down —
|
|
421
|
+
// the census-dominant direction; an explicit flow line overrides
|
|
422
|
+
if(pos[2]==='flowchart') doc.flow='down'; }
|
|
423
|
+
}
|
|
424
|
+
badOpts('figdown');
|
|
425
|
+
continue;
|
|
426
|
+
} else { err(n,'first line must be "figdown 0.1 [template]"'); }
|
|
427
|
+
} else if(kw==='figdown'){ err(n,'duplicate version header'); continue; }
|
|
428
|
+
|
|
429
|
+
// typed-block children
|
|
430
|
+
if(cur && ['field','wrap','cell','colw','signal','gap'].includes(kw)){
|
|
431
|
+
if(badOpts(kw)) continue;
|
|
432
|
+
if(cur.type==='bitfield' && kw==='field'){
|
|
433
|
+
// Compact form (C bit-field convention): field F1:16,F2:8 SYN:1 ...
|
|
434
|
+
// — bare name:width items separated by commas and/or spaces, no
|
|
435
|
+
// per-field options. Classic form: field <name> <width> [options].
|
|
436
|
+
// Classic form: field <name> <width-in-bits|*> [optional] [color=] [note=]
|
|
437
|
+
// '*' = variable-length field: fills the remainder of the current row
|
|
438
|
+
const cname=pos[1], cw=pos[2];
|
|
439
|
+
if(cname!==undefined && (/^\d+$/.test(cw||'')&&+cw>=1 || cw==='*')){
|
|
440
|
+
const fextra=pos.slice(3).find(x=>x!=='optional');
|
|
441
|
+
if(fextra!==undefined){ err(n,'unexpected argument "'+fextra+'"'); continue; }
|
|
442
|
+
cur.fields.push({name:cname,w:cw==='*'?'*':+cw,optional:pos.includes('optional'),color:opts.color,cls:opts['class'],note:opts.note,line:n});
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
// Compact form (C bit-field convention): field a:1, b:2, Long Name:16
|
|
446
|
+
// Commas separate items; the name is everything before the last colon
|
|
447
|
+
// (spaces allowed, no quotes needed); no per-field options here.
|
|
448
|
+
const rest=raw.trim().replace(/^field\s+/,'');
|
|
449
|
+
if(!rest.includes(':')){ err(n,'field needs <name> <width-in-bits>, or a name:width list'); continue; }
|
|
450
|
+
const items=rest.split(',').map(s=>s.trim()).filter(Boolean);
|
|
451
|
+
let bad=null; const parsed=[];
|
|
452
|
+
for(const it of items){
|
|
453
|
+
const m=/^(.+):(\d+|\*)$/.exec(it);
|
|
454
|
+
if(!m||(m[2]!=='*'&&+m[2]<1)){ bad='bad item "'+it+'" (expected name:width)'; break; }
|
|
455
|
+
let nm=m[1].trim();
|
|
456
|
+
if(/^".*"$/.test(nm)) nm=nm.slice(1,-1); // quotes tolerated
|
|
457
|
+
if(/:(\d+|\*)(\s|$)/.test(nm)){ bad='"'+it+'" looks like two fields — missing comma?'; break; }
|
|
458
|
+
parsed.push({name:nm,w:m[2]==='*'?'*':+m[2]});
|
|
459
|
+
}
|
|
460
|
+
if(bad){ err(n,bad); continue; }
|
|
461
|
+
for(const it of parsed) cur.fields.push({name:it.name,w:it.w,optional:false,line:n});
|
|
462
|
+
} else if(cur.type==='bitfield' && kw==='wrap'){
|
|
463
|
+
cur.fields.push({wrap:true,line:n});
|
|
464
|
+
} else if(cur.type==='table' && kw==='colw'){
|
|
465
|
+
if(cur.colw){ err(n,'duplicate colw'); continue; }
|
|
466
|
+
const vals=pos.slice(1);
|
|
467
|
+
if(!vals.length){ err(n,'colw needs one width per column (auto | <px> | <n>%)'); continue; }
|
|
468
|
+
let badw=null;
|
|
469
|
+
const parsed=vals.map(v=>{
|
|
470
|
+
if(v==='auto') return {t:'auto'};
|
|
471
|
+
let m=/^(\d+(?:\.\d+)?)px$/.exec(v)||/^(\d+(?:\.\d+)?)$/.exec(v);
|
|
472
|
+
if(m) return {t:'px',v:+m[1]};
|
|
473
|
+
m=/^(\d+(?:\.\d+)?)%$/.exec(v);
|
|
474
|
+
if(m) return {t:'pct',v:+m[1]};
|
|
475
|
+
badw='bad width "'+v+'" (auto | <px> | <n>%)'; return null;
|
|
476
|
+
});
|
|
477
|
+
if(badw){ err(n,badw); continue; }
|
|
478
|
+
cur.colw={vals:parsed,line:n};
|
|
479
|
+
} else if(cur.type==='table' && kw==='cell'){
|
|
480
|
+
// cell h<k>,<c> | <r>,<c> color=… (h1..hN = header tiers top-down;
|
|
481
|
+
// data rows 1-based below the separator); cell <r> highlight
|
|
482
|
+
const rc=/^(h?)(\d+)(?:,(\d+))?$/.exec(pos[1]||'');
|
|
483
|
+
const hl=pos.includes('highlight');
|
|
484
|
+
const cextra=pos.slice(2).find(x=>x!=='highlight');
|
|
485
|
+
if(cextra!==undefined){ err(n,'unexpected argument "'+cextra+'"'); continue; }
|
|
486
|
+
if(!rc||(!opts.color&&!opts['class']&&!hl)){ err(n,'cell needs [h]<row>[,<col>] with color=…/class=… or highlight'); continue; }
|
|
487
|
+
if(!rc[1]&&+rc[2]===0){ err(n,'row 0 is retired — address header tiers as h1..hN (top-down)'); continue; }
|
|
488
|
+
if(hl&&rc[3]===undefined){
|
|
489
|
+
if(rc[1]){ err(n,'highlight applies to data rows only'); continue; }
|
|
490
|
+
cur.rowmarks=cur.rowmarks||[];
|
|
491
|
+
cur.rowmarks.push({r:+rc[2],line:n});
|
|
492
|
+
} else if(rc[3]!==undefined&&(opts.color||opts['class'])){
|
|
493
|
+
cur.marks=cur.marks||[];
|
|
494
|
+
cur.marks.push({hdr:!!rc[1],r:+rc[2],c:+rc[3],color:opts.color,cls:opts['class'],line:n});
|
|
495
|
+
} else { err(n,'cell needs [h]<row>,<col> color=…/class=… or <row> highlight'); }
|
|
496
|
+
} else if(cur.type==='wave' && kw==='signal'){
|
|
497
|
+
const name=pos[1], lane=pos[2];
|
|
498
|
+
if(!name||!lane){ err(n,'signal needs <name> <lane>'); continue; }
|
|
499
|
+
if(!/^[01pnx=.\d]+$/.test(lane)){ err(n,'lane may contain only 0 1 p n x = . digits'); continue; }
|
|
500
|
+
cur.signals.push({name,lane,labels:(opts.labels||'').split(',').filter(Boolean)});
|
|
501
|
+
} else if(cur.type==='wave' && kw==='gap'){
|
|
502
|
+
const t=parseInt(pos[1],10);
|
|
503
|
+
if(!isFinite(t)||t<0){ err(n,'gap needs a tick number'); continue; }
|
|
504
|
+
cur.gaps.push(t);
|
|
505
|
+
} else { err(n,'"'+kw+'" not valid inside '+cur.type); }
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
cur=null; // any other keyword closes the block
|
|
509
|
+
if(badOpts(kw)) continue;
|
|
510
|
+
|
|
511
|
+
// R43: render zone — after `render`, only rendering directives are legal
|
|
512
|
+
if(sawRender && !RENDER_DIRECTIVES.has(kw) && kw!=='render'){
|
|
513
|
+
err(n,'"'+kw+'" is a semantic directive — it must appear before the render zone (R43)');
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
switch(kw){
|
|
518
|
+
case 'title': {
|
|
519
|
+
// rest-of-line: `title TCP Header` == `title "TCP Header"` (A-1)
|
|
520
|
+
let t=raw.trim().replace(/^title\s*/,'');
|
|
521
|
+
const qm=/^"([\s\S]*)"$/.exec(t);
|
|
522
|
+
if(qm){
|
|
523
|
+
// decode escapes left-to-right in one pass, exactly like the
|
|
524
|
+
// generic tokenizer: \\ consumes first, so "a\\nb" is a\nb
|
|
525
|
+
// (backslash + letter n), never a line break (D2, 0.1-dev.11).
|
|
526
|
+
// Unknown escapes were already rejected by tokenize() above.
|
|
527
|
+
const s0=qm[1]; t='';
|
|
528
|
+
for(let ii=0;ii<s0.length;ii++){
|
|
529
|
+
if(s0[ii]==='\\'){
|
|
530
|
+
const e=s0[ii+1];
|
|
531
|
+
if(e==='n'){ t+='\n'; ii++; continue; }
|
|
532
|
+
if(e==='"'){ t+='"'; ii++; continue; }
|
|
533
|
+
if(e==='\\'){ t+='\\'; ii++; continue; }
|
|
534
|
+
}
|
|
535
|
+
t+=s0[ii];
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
doc.title=t; break;
|
|
539
|
+
}
|
|
540
|
+
case 'class': {
|
|
541
|
+
// semantic class (D9): meaning + presentation defaults declared
|
|
542
|
+
// once; elements join via class=<id>; the legend strip derives
|
|
543
|
+
const id=pos[1];
|
|
544
|
+
if(!id||!ID_RE.test(id)){ err(n,'class needs an id'); break; }
|
|
545
|
+
if(classIds.has(id)){ err(n,'duplicate class "'+id+'"'); break; }
|
|
546
|
+
if(!pos[2]){ err(n,'class needs a meaning: class '+id+' "<meaning>"'); break; }
|
|
547
|
+
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
548
|
+
const cstyle=opts.style;
|
|
549
|
+
if(cstyle!==undefined && !['solid','dashed','dotted'].includes(cstyle)){ err(n,'style must be solid|dashed|dotted'); break; }
|
|
550
|
+
classIds.add(id);
|
|
551
|
+
doc.classes.push({id,label:pos[2],color:opts.color,stroke:opts.stroke,
|
|
552
|
+
text:opts.text,style:cstyle,line:n});
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
case 'layer': {
|
|
556
|
+
const id=pos[1];
|
|
557
|
+
if(!id||!ID_RE.test(id)){ err(n,'layer needs an id'); break; }
|
|
558
|
+
if(layerIds.has(id)){ err(n,'duplicate layer id "'+id+'"'); break; }
|
|
559
|
+
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
560
|
+
let z=doc.layers.length;
|
|
561
|
+
if(opts.z!==undefined){
|
|
562
|
+
if(!/^-?\d+$/.test(opts.z)){ err(n,'z must be a number'); break; }
|
|
563
|
+
z=parseInt(opts.z,10);
|
|
564
|
+
}
|
|
565
|
+
layerIds.add(id);
|
|
566
|
+
doc.layers.push({id,label:pos[2]||'',z});
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
case 'node': {
|
|
570
|
+
const id=pos[1];
|
|
571
|
+
if(!id||!ID_RE.test(id)){ err(n,'node needs an id'); break; }
|
|
572
|
+
if(nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)){ err(n,'duplicate id "'+id+'"'); break; }
|
|
573
|
+
if(opts.kind!==undefined){ err(n,'kind= has been renamed: use shape= (geometric; the label text carries the device semantics)'); break; }
|
|
574
|
+
if(opts.w!==undefined||opts.h!==undefined){ err(n,'node does not take w=/h= — use a size line'); break; }
|
|
575
|
+
const shape=opts.shape||'box';
|
|
576
|
+
if(!SHAPES.includes(shape)){ err(n,'unknown shape "'+shape+'" ('+SHAPES.join('|')+')'); break; }
|
|
577
|
+
nodeIds.add(id);
|
|
578
|
+
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
579
|
+
const nstyle=opts.style;
|
|
580
|
+
if(nstyle!==undefined && !['solid','dashed','dotted'].includes(nstyle)){ err(n,'style must be solid|dashed|dotted'); break; }
|
|
581
|
+
doc.nodes.push({id,label:pos[2]||id,shape,color:opts.color,stroke:opts.stroke,
|
|
582
|
+
style:nstyle,text:opts.text,cls:opts['class'],
|
|
583
|
+
group:opts['in']||null,layer:opts.layer||'base',line:n});
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
case 'group': {
|
|
587
|
+
const id=pos[1];
|
|
588
|
+
if(!id||!ID_RE.test(id)){ err(n,'group needs an id'); break; }
|
|
589
|
+
if(nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)){ err(n,'duplicate id "'+id+'"'); break; }
|
|
590
|
+
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
591
|
+
groupIds.add(id);
|
|
592
|
+
let ggap;
|
|
593
|
+
if(opts.gap!==undefined){
|
|
594
|
+
ggap=parseFloat(opts.gap);
|
|
595
|
+
if(!isFinite(ggap)||ggap<0){ err(n,'gap must be a non-negative number'); break; }
|
|
596
|
+
}
|
|
597
|
+
const gstyle=opts.style;
|
|
598
|
+
if(gstyle!==undefined && !['solid','dashed','dotted'].includes(gstyle)){ err(n,'style must be solid|dashed|dotted'); break; }
|
|
599
|
+
doc.groups.push({id,label:pos[2]||id,color:opts.color,stroke:opts.stroke,
|
|
600
|
+
text:opts.text,style:gstyle,gap:ggap,cls:opts['class'],
|
|
601
|
+
layer:opts.layer||null,line:n});
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
case 'boundary': {
|
|
605
|
+
// external I/O endpoint (R44): "the outside world", not a
|
|
606
|
+
// participant node. Referenced by edges like a node, pinnable for
|
|
607
|
+
// layout, NEVER drawn as a shape — the edge simply ends open at an
|
|
608
|
+
// invisible anchor, optionally with the small label beyond the end.
|
|
609
|
+
// Shares the node/group id namespace; takes no options.
|
|
610
|
+
const id=pos[1];
|
|
611
|
+
if(!id||!ID_RE.test(id)){ err(n,'boundary needs an id'); break; }
|
|
612
|
+
if(nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)){ err(n,'duplicate id "'+id+'"'); break; }
|
|
613
|
+
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
614
|
+
boundaryIds.add(id);
|
|
615
|
+
doc.boundaries.push({id,label:pos[2]||undefined,line:n});
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
case 'flow': {
|
|
619
|
+
if(!['right','down','left','up'].includes(pos[1])){ err(n,'flow needs right|down|left|up'); break; }
|
|
620
|
+
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
|
|
621
|
+
doc.flow=pos[1]; break;
|
|
622
|
+
}
|
|
623
|
+
case 'rank': {
|
|
624
|
+
const ids=pos.slice(1);
|
|
625
|
+
if(ids.length<2){ err(n,'rank needs two or more node ids'); break; }
|
|
626
|
+
doc.ranks.push({ids,line:n}); break;
|
|
627
|
+
}
|
|
628
|
+
case 'bundle': {
|
|
629
|
+
// semantic link bundle (LAG / Ethernet Segment): the renderer draws
|
|
630
|
+
// the dashed ellipse around the member links automatically
|
|
631
|
+
const id=pos[1];
|
|
632
|
+
if(!id||!ID_RE.test(id)){ err(n,'bundle needs an id'); break; }
|
|
633
|
+
if(bundleIds.has(id)){ err(n,'duplicate bundle id "'+id+'"'); break; }
|
|
634
|
+
let rest=pos.slice(2), tlabel=id;
|
|
635
|
+
if(tk.toks[2]&&tk.toks[2].q){ tlabel=pos[2]; rest=pos.slice(3); }
|
|
636
|
+
const pairs=[]; let badp=null;
|
|
637
|
+
for(const t of rest){
|
|
638
|
+
const m=/^([A-Za-z_][A-Za-z0-9_-]*)--([A-Za-z_][A-Za-z0-9_-]*),?$/.exec(t);
|
|
639
|
+
if(!m){ badp='bad member "'+t+'" (expected A--B)'; break; }
|
|
640
|
+
pairs.push([m[1],m[2]]);
|
|
641
|
+
}
|
|
642
|
+
if(badp){ err(n,badp); break; }
|
|
643
|
+
if(!pairs.length){ err(n,'bundle needs at least one member link A--B'); break; }
|
|
644
|
+
bundleIds.add(id);
|
|
645
|
+
doc.trunks.push({id,label:tlabel,pairs,color:opts.color,line:n});
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
case 'line': {
|
|
649
|
+
// generic horizontal guide/threshold line across a group's box:
|
|
650
|
+
// line "<label>" in=<group> at=<0..100>% [color=] [fill=below|above]
|
|
651
|
+
const glabel=(tk.toks[1]&&tk.toks[1].q)?pos[1]:null;
|
|
652
|
+
if(glabel===null){ err(n,'line needs a quoted "<label>" first'); break; }
|
|
653
|
+
if(!opts['in']){ err(n,'line needs in=<group-id>'); break; }
|
|
654
|
+
const m=/^(\d+(?:\.\d+)?)%$/.exec(opts.at||''); // % is mandatory (D8)
|
|
655
|
+
if(!m||+m[1]<0||+m[1]>100){ err(n,'line needs at=<0..100>% (with the % sign)'); break; }
|
|
656
|
+
if(opts.fill!==undefined){ err(n,'line is a pure marker — use the fill directive for zones'); break; }
|
|
657
|
+
doc.glines.push({label:glabel,group:opts['in'],pct:+m[1],color:opts.color,line:n});
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
case 'plot': {
|
|
661
|
+
// chart family: plot <table-id> [kind=bars3d] [level=<value>]
|
|
662
|
+
// rows -> X, columns -> Y, numeric cells -> Z (the table IS the data)
|
|
663
|
+
const tid=pos[1];
|
|
664
|
+
if(!tid||!ID_RE.test(tid)){ err(n,'plot needs a table id'); break; }
|
|
665
|
+
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
|
|
666
|
+
const pkind=opts.kind||'bars3d';
|
|
667
|
+
if(pkind!=='bars3d'){ err(n,'unknown plot kind "'+pkind+'" (bars3d)'); break; }
|
|
668
|
+
let plevel=null;
|
|
669
|
+
if(opts.level!==undefined){
|
|
670
|
+
plevel=parseFloat(opts.level);
|
|
671
|
+
if(!isFinite(plevel)||plevel<0){ err(n,'level must be a non-negative number'); break; }
|
|
672
|
+
}
|
|
673
|
+
doc.blocks.push({type:'plot',id:'plot_'+doc.blocks.length,tid,kind:pkind,level:plevel,line:n});
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
case 'fill': {
|
|
677
|
+
// zone band: fill <pct>% | <a>-<b>% in=<node|group> [dir=] [color=]
|
|
678
|
+
// "fill 15%" = 0..15; "fill 15-35%" = the range in one token
|
|
679
|
+
if(opts.from!==undefined||opts.to!==undefined){ err(n,'from=/to= retired — write the range positionally: fill 15% or fill 15-35%'); break; }
|
|
680
|
+
if(!opts['in']){ err(n,'fill needs in=<node-or-group-id>'); break; }
|
|
681
|
+
const m=/^(\d+(?:\.\d+)?)%?(?:-(\d+(?:\.\d+)?)%?)?$/.exec(pos[1]||'');
|
|
682
|
+
if(!m){ err(n,'fill needs a range: <pct>% or <a>-<b>%'); break; }
|
|
683
|
+
const from=m[2]!==undefined?+m[1]:0;
|
|
684
|
+
const to=m[2]!==undefined?+m[2]:+m[1];
|
|
685
|
+
if(from<0||to>100||from>=to){ err(n,'fill range needs 0 <= from < to <= 100'); break; }
|
|
686
|
+
const fdir=opts.dir||'up';
|
|
687
|
+
if(!['up','down','left','right'].includes(fdir)){ err(n,'dir must be up|down|left|right'); break; }
|
|
688
|
+
doc.fills.push({target:opts['in'],from,to,dir:fdir,color:opts.color||'#e5e7eb',line:n});
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
case 'pin': {
|
|
692
|
+
const id=pos[1], at=opts.at;
|
|
693
|
+
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
|
|
694
|
+
const m=at&&/^(-?[\d.]+),(-?[\d.]+)$/.exec(at);
|
|
695
|
+
if(!id||!m){ err(n,'pin needs <id> at=<x>,<y>'); break; }
|
|
696
|
+
doc.pins[id]={fx:parseFloat(m[1]),fy:parseFloat(m[2]),line:n};
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
case 'size': {
|
|
700
|
+
const id=pos[1];
|
|
701
|
+
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
|
|
702
|
+
if(!id||(!opts.w&&!opts.h)){ err(n,'size needs <id> w=<px> and/or h=<px>'); break; }
|
|
703
|
+
let badsz=false;
|
|
704
|
+
const dim=(k)=>{ // px only (D7/D13)
|
|
705
|
+
const v=opts[k]; if(v===undefined||v==='') return null;
|
|
706
|
+
if(/%$/.test(v)){ err(n,'percentage sizes are not in v0.1 — use px'); badsz=true; return null; }
|
|
707
|
+
const f=parseFloat(v);
|
|
708
|
+
if(!isFinite(f)){ err(n,k+' must be a number'); badsz=true; return null; }
|
|
709
|
+
return f;
|
|
710
|
+
};
|
|
711
|
+
const w=dim('w'), h=dim('h');
|
|
712
|
+
if(badsz) break;
|
|
713
|
+
doc.sizes[id]={w,h,line:n};
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
case 'routing': {
|
|
717
|
+
// document-level presentation directive (0.1-dev.13): how straight
|
|
718
|
+
// scene edges are drawn. straight (default) = today's direct lines;
|
|
719
|
+
// orthogonal = deterministic manhattan elbows. Rendering parameter
|
|
720
|
+
// only — no semantics; belongs in the trailing layout section.
|
|
721
|
+
if(!['orthogonal','straight'].includes(pos[1]||'')){ err(n,'routing needs orthogonal|straight'); break; }
|
|
722
|
+
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
|
|
723
|
+
doc.routing=pos[1]; break;
|
|
724
|
+
}
|
|
725
|
+
case 'route': {
|
|
726
|
+
// per-edge declared waypoints (0.1-dev.13): route <a> <op> <b>
|
|
727
|
+
// via=x,y;x,y;… [routing=orthogonal|straight]. References ONE
|
|
728
|
+
// existing edge exactly as written (a, operator, b — endpoint
|
|
729
|
+
// order matters, unlike bundle members); waypoints are rigid
|
|
730
|
+
// canvas px, the same space as ungrouped pin. Layout section only.
|
|
731
|
+
const a=pos[1], op=pos[2], b=pos[3];
|
|
732
|
+
if(!a||!ID_RE.test(a)||!['->','<-','--','<->'].includes(op||'')||!b||!ID_RE.test(b)){
|
|
733
|
+
err(n,'route needs <a> ->|<-|--|<-> <b> via=x,y;x,y;…'); break; }
|
|
734
|
+
if(pos.length>4){ err(n,'unexpected argument "'+pos[4]+'"'); break; }
|
|
735
|
+
if(opts.via===undefined){ err(n,'route needs via=x,y;x,y;… (canvas px waypoints)'); break; }
|
|
736
|
+
const pairs=[]; let badv=null;
|
|
737
|
+
for(const t of String(opts.via).split(';')){
|
|
738
|
+
const m=/^(-?[\d.]+),(-?[\d.]+)$/.exec(t.trim());
|
|
739
|
+
const px=m&&parseFloat(m[1]), py=m&&parseFloat(m[2]);
|
|
740
|
+
if(!m||!isFinite(px)||!isFinite(py)){ badv='bad via point "'+t.trim()+'" (expected x,y)'; break; }
|
|
741
|
+
pairs.push([px,py]);
|
|
742
|
+
}
|
|
743
|
+
if(badv){ err(n,badv); break; }
|
|
744
|
+
if(opts.routing!==undefined && !['orthogonal','straight'].includes(opts.routing)){
|
|
745
|
+
err(n,'routing must be orthogonal|straight'); break; }
|
|
746
|
+
doc.routes.push({a,op,b,via:pairs,routing:opts.routing,line:n});
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
case 'render': {
|
|
750
|
+
if(sawRender){ err(n,'duplicate render line'); break; }
|
|
751
|
+
if(pos.length>1){ err(n,'render takes no arguments'); break; }
|
|
752
|
+
sawRender=true; break;
|
|
753
|
+
}
|
|
754
|
+
case 'bitfield': {
|
|
755
|
+
const id=pos[1];
|
|
756
|
+
if(!id||!ID_RE.test(id)){ err(n,'bitfield needs an id'); break; }
|
|
757
|
+
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
758
|
+
cur={type:'bitfield',id,label:pos[2]||id,unit:opts.unit?parseInt(opts.unit,10):32,
|
|
759
|
+
numbering:opts.numbering||'lsb0',fields:[],line:n};
|
|
760
|
+
if(!isFinite(cur.unit)||cur.unit<1){ err(n,'bad unit'); cur.unit=32; }
|
|
761
|
+
if(!['lsb0','msb0'].includes(cur.numbering)) err(n,'numbering must be lsb0 or msb0');
|
|
762
|
+
doc.blocks.push(cur); break;
|
|
763
|
+
}
|
|
764
|
+
case 'table': {
|
|
765
|
+
const id=pos[1];
|
|
766
|
+
if(!id||!ID_RE.test(id)){ err(n,'table needs an id'); break; }
|
|
767
|
+
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
768
|
+
cur={type:'table',id,label:pos[2]||id,cols:[],heads:[],rows:[],line:n};
|
|
769
|
+
doc.blocks.push(cur); break;
|
|
770
|
+
}
|
|
771
|
+
case 'wave': {
|
|
772
|
+
const id=pos[1];
|
|
773
|
+
if(!id||!ID_RE.test(id)){ err(n,'wave needs an id'); break; }
|
|
774
|
+
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
775
|
+
cur={type:'wave',id,label:pos[2]||id,signals:[],gaps:[],line:n};
|
|
776
|
+
doc.blocks.push(cur); break;
|
|
777
|
+
}
|
|
778
|
+
case 'page': case 'step': case 'set': case 'pulse':
|
|
779
|
+
err(n,'"'+kw+'" is reserved for the dynamic profile (not in v0.1)'); break;
|
|
780
|
+
case 'field': case 'wrap': case 'cell': case 'colw': case 'signal': case 'gap':
|
|
781
|
+
err(n,'"'+kw+'" is a typed-block child — it needs a bitfield/table/wave block above it'); break;
|
|
782
|
+
default:
|
|
783
|
+
err(n,'unrecognized line (unknown keyword "'+kw+'")');
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if(!sawHeader && text.trim()) { /* already reported on first content line */ }
|
|
787
|
+
|
|
788
|
+
// semantic checks
|
|
789
|
+
for(const nd of doc.nodes){
|
|
790
|
+
if(nd.group && !groupIds.has(nd.group)) errs.push('Line '+nd.line+': unknown group "'+nd.group+'"');
|
|
791
|
+
if(nd.layer && !layerIds.has(nd.layer)) errs.push('Line '+nd.line+': unknown layer "'+nd.layer+'"');
|
|
792
|
+
}
|
|
793
|
+
for(const g of doc.groups){
|
|
794
|
+
if(g.layer && !layerIds.has(g.layer)) errs.push('Line '+g.line+': unknown layer "'+g.layer+'"');
|
|
795
|
+
}
|
|
796
|
+
for(const e of doc.edges){
|
|
797
|
+
if(!nodeIds.has(e.a)&&!groupIds.has(e.a)&&!boundaryIds.has(e.a)) errs.push('Line '+e.line+': unknown endpoint "'+e.a+'"');
|
|
798
|
+
else if(groupIds.has(e.a)) errs.push('Line '+e.line+': edge endpoint "'+e.a+'" is a group — connect to a member node (group edges are not in v0.1)');
|
|
799
|
+
if(!nodeIds.has(e.b)&&!groupIds.has(e.b)&&!boundaryIds.has(e.b)) errs.push('Line '+e.line+': unknown endpoint "'+e.b+'"');
|
|
800
|
+
else if(groupIds.has(e.b)) errs.push('Line '+e.line+': edge endpoint "'+e.b+'" is a group — connect to a member node (group edges are not in v0.1)');
|
|
801
|
+
if(e.layer && !layerIds.has(e.layer)) errs.push('Line '+e.line+': unknown layer "'+e.layer+'"');
|
|
802
|
+
}
|
|
803
|
+
for(const r of doc.ranks) for(const id of r.ids)
|
|
804
|
+
if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
|
|
805
|
+
for(const gl of doc.glines)
|
|
806
|
+
if(!groupIds.has(gl.group)) errs.push('Line '+gl.line+': unknown group "'+gl.group+'" for line');
|
|
807
|
+
for(const f of doc.fills)
|
|
808
|
+
if(!groupIds.has(f.target)&&!nodeIds.has(f.target))
|
|
809
|
+
errs.push('Line '+f.line+': unknown target "'+f.target+'" for fill');
|
|
810
|
+
for(const t of doc.trunks) for(const [a,b] of t.pairs){
|
|
811
|
+
if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
|
|
812
|
+
const matches=doc.edges.filter(e=>(e.a===a&&e.b===b)||(e.a===b&&e.b===a)).length;
|
|
813
|
+
if(matches===0)
|
|
814
|
+
errs.push('Line '+t.line+': no edge between "'+a+'" and "'+b+'" for bundle member');
|
|
815
|
+
else if(matches>1)
|
|
816
|
+
errs.push('Line '+t.line+': "'+a+'--'+b+'" is ambiguous ('+matches+' parallel edges); parallel edges are out of scope for v0.1');
|
|
817
|
+
}
|
|
818
|
+
{ // route lines reference ONE existing edge exactly as written (a-op-b;
|
|
819
|
+
// endpoint order matters — unlike bundle members); parallel edges are
|
|
820
|
+
// ambiguous; one route per edge (closed grammar, 0.1-dev.13)
|
|
821
|
+
const seenRt=new Set();
|
|
822
|
+
for(const r of doc.routes){
|
|
823
|
+
const ref=r.a+' '+r.op+' '+r.b;
|
|
824
|
+
const matches=doc.edges.filter(e=>e.a===r.a&&e.op===r.op&&e.b===r.b);
|
|
825
|
+
if(!matches.length){ errs.push('Line '+r.line+': no edge "'+ref+'" for route (must match the edge as written)'); continue; }
|
|
826
|
+
if(matches.length>1){ errs.push('Line '+r.line+': "'+ref+'" is ambiguous ('+matches.length+' parallel edges); parallel edges are out of scope for v0.1'); continue; }
|
|
827
|
+
if(seenRt.has(ref)){ errs.push('Line '+r.line+': duplicate route for "'+ref+'"'); continue; }
|
|
828
|
+
seenRt.add(ref);
|
|
829
|
+
matches[0].route=r; // renderer convenience; the model keeps doc.routes
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
for(const id in doc.pins) if(!nodeIds.has(id)&&!groupIds.has(id)&&!boundaryIds.has(id))
|
|
833
|
+
errs.push('Line '+doc.pins[id].line+': pin of unknown id "'+id+'"');
|
|
834
|
+
{ // class references must resolve (closed grammar)
|
|
835
|
+
const chk=(cls,line)=>{ if(cls!==undefined && !classIds.has(cls))
|
|
836
|
+
errs.push('Line '+line+': unknown class "'+cls+'"'); };
|
|
837
|
+
for(const x of doc.nodes) chk(x.cls,x.line);
|
|
838
|
+
for(const x of doc.groups) chk(x.cls,x.line);
|
|
839
|
+
for(const x of doc.edges) chk(x.cls,x.line);
|
|
840
|
+
for(const b of doc.blocks){
|
|
841
|
+
if(b.fields) for(const f of b.fields) chk(f.cls,f.line);
|
|
842
|
+
if(b.marks) for(const mk of b.marks) chk(mk.cls,mk.line);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
for(const id in doc.sizes) if(!nodeIds.has(id)&&!groupIds.has(id))
|
|
846
|
+
errs.push('Line '+doc.sizes[id].line+': size of unknown id "'+id+'"');
|
|
847
|
+
for(const b of doc.blocks){
|
|
848
|
+
if(b.type==='table'&&!b.heads.length) errs.push('Line '+b.line+': table has no header row');
|
|
849
|
+
if(b.type==='table'&&b.heads.length&&!b.sep) errs.push('Line '+b.line+': table has no |---| separator row');
|
|
850
|
+
if(b.type==='bitfield'&&!b.fields.some(f=>!f.wrap)) errs.push('Line '+b.line+': bitfield has no fields');
|
|
851
|
+
if(b.type==='wave'&&!b.signals.length) errs.push('Line '+b.line+': wave has no signals');
|
|
852
|
+
if(b.type==='plot'){
|
|
853
|
+
const t=doc.blocks.find(x=>x.type==='table'&&x.id===b.tid);
|
|
854
|
+
if(!t) errs.push('Line '+b.line+': plot references unknown table "'+b.tid+'"');
|
|
855
|
+
else{
|
|
856
|
+
for(const r of t.rows) for(let c=1;c<r.cells.length;c++)
|
|
857
|
+
if(!r.cells[c].m && !/^-?\d+(?:\.\d+)?$/.test(r.cells[c].v))
|
|
858
|
+
{ errs.push('Line '+b.line+': plot data must be numeric (row value "'+r.cells[c].v+'")'); break; }
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
if(b.type==='table'&&b.colw&&b.colw.vals.length!==b.cols.length)
|
|
862
|
+
errs.push('Line '+b.colw.line+': colw has '+b.colw.vals.length+' widths, expected '+b.cols.length);
|
|
863
|
+
if(b.type==='table'&&b.marks) for(const mk of b.marks){
|
|
864
|
+
const H=b.heads.length;
|
|
865
|
+
const inRange = mk.hdr ? (mk.r>=1&&mk.r<=H) : (mk.r>=1&&mk.r<=b.rows.length);
|
|
866
|
+
if(!inRange||mk.c<1||mk.c>b.cols.length){
|
|
867
|
+
errs.push('Line '+mk.line+': cell '+(mk.hdr?'h':'')+mk.r+','+mk.c+' out of range'); continue;
|
|
868
|
+
}
|
|
869
|
+
// merged-away targets must be rejected: annotations target the anchor
|
|
870
|
+
const cells = mk.hdr ? b.heads[mk.r-1] : b.rows[mk.r-1].cells;
|
|
871
|
+
if(cells[mk.c-1].m)
|
|
872
|
+
errs.push('Line '+mk.line+': cell '+(mk.hdr?'h':'')+mk.r+','+mk.c+' is merged away — annotate the anchor cell');
|
|
873
|
+
}
|
|
874
|
+
if(b.type==='table'&&b.rowmarks) for(const mk of b.rowmarks){
|
|
875
|
+
if(mk.r<1||mk.r>b.rows.length)
|
|
876
|
+
errs.push('Line '+mk.line+': row '+mk.r+' out of range');
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return {doc,errs};
|
|
880
|
+
}
|
|
881
|
+
function findComment(s){
|
|
882
|
+
// '#' starts a comment only at line start or after whitespace,
|
|
883
|
+
// so hex colors like color=#0d9488 survive.
|
|
884
|
+
let inq=false;
|
|
885
|
+
for(let i=0;i<s.length;i++){
|
|
886
|
+
if(s[i]==='"') inq=!inq;
|
|
887
|
+
else if(s[i]==='#'&&!inq&&(i===0||/\s/.test(s[i-1]))) return i;
|
|
888
|
+
}
|
|
889
|
+
return -1;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ============================================================
|
|
893
|
+
// 2. LAYOUT + RENDER (deterministic; no randomness, no Date)
|
|
894
|
+
// ============================================================
|
|
895
|
+
const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
|
|
896
|
+
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
897
|
+
function tw(s){
|
|
898
|
+
const longest=Math.max(...String(s).split('\n').map(l=>l.length));
|
|
899
|
+
return Math.max(30,longest*CH+2*PADX);
|
|
900
|
+
}
|
|
901
|
+
// ---- shape geometry (one source of truth for size and for clipping) ----
|
|
902
|
+
// A node owns a bounding box (n.x,n.y,n.w,n.h), but only the rectangle
|
|
903
|
+
// shapes fill it. shapeAxes() reports the shape as it is actually DRAWN:
|
|
904
|
+
// half-extents (a,b) about the box centre and the exponent p of the curve
|
|
905
|
+
// through them,
|
|
906
|
+
// (|dx|/a)^p + (|dy|/b)^p = 1 p=1 rhombus, 2 ellipse, Inf rectangle
|
|
907
|
+
// with (ox,oy) = how far that outline is drawn OUTSIDE the box (cloud only),
|
|
908
|
+
// so the box can be recovered from the axes. Node sizing (how large must the
|
|
909
|
+
// box be for the label to sit inside the outline) and borderPoint() (where
|
|
910
|
+
// does a ray from the centre leave the outline) both read this, so the two
|
|
911
|
+
// can never disagree about where a shape ends. Keep it in step with the
|
|
912
|
+
// shape drawing in renderScene.
|
|
913
|
+
function shapeAxes(n){
|
|
914
|
+
const s=n.shape;
|
|
915
|
+
if(s==='diamond') return {a:n.w/2, b:n.h/2, p:1, ox:0, oy:0};
|
|
916
|
+
if(s==='cloud') return {a:n.w/2+10,b:n.h/2+8, p:2, ox:10,oy:8};
|
|
917
|
+
if(s==='circle') return {a:n.w/2, b:n.w/2, p:2, ox:0, oy:0}; // rx=ry=w/2
|
|
918
|
+
if(s==='ellipse') return {a:n.w/2, b:n.h/2, p:2, ox:0, oy:0};
|
|
919
|
+
return {a:n.w/2, b:n.h/2, p:Infinity, ox:0, oy:0}; // box, rounded, cylinder
|
|
920
|
+
}
|
|
921
|
+
// outlineNorm: <1 inside the outline, =1 on it, >1 outside. Homogeneous in
|
|
922
|
+
// (dx,dy) — scaling the offset scales the norm — which is what lets the same
|
|
923
|
+
// number serve as "how far out is this point" and "by how much must the shape
|
|
924
|
+
// grow to swallow it".
|
|
925
|
+
function outlineNorm(g,dx,dy){
|
|
926
|
+
const u=Math.abs(dx)/(g.a||1e-9), v=Math.abs(dy)/(g.b||1e-9);
|
|
927
|
+
return g.p===1?u+v:(g.p===2?Math.hypot(u,v):Math.max(u,v));
|
|
928
|
+
}
|
|
929
|
+
// outSide: the coordinate an orthogonal run has to reach to touch the DRAWN
|
|
930
|
+
// outline on one side ('l','r','t','b') — what a channel route needs when it
|
|
931
|
+
// arrives square-on instead of along a ray. Every shape but `cloud` is drawn
|
|
932
|
+
// inside its box, so this returns the box edge unchanged, bit for bit.
|
|
933
|
+
function outSide(n,k){
|
|
934
|
+
const g=shapeAxes(n);
|
|
935
|
+
return k==='l'?n.x-g.ox : k==='r'?n.x+n.w+g.ox : k==='t'?n.y-g.oy : n.y+n.h+g.oy;
|
|
936
|
+
}
|
|
937
|
+
// simplifyPts: Douglas–Peucker simplification in place. The waypoint chain
|
|
938
|
+
// emits an entry+exit port per crossed rank; home-anchoring keeps those ports
|
|
939
|
+
// in a narrow corridor, so what remains is a nearly-straight run carrying tens
|
|
940
|
+
// of cosmetic ±few-px jogs. DP collapses that corridor to the handful of
|
|
941
|
+
// genuine bends a dummy-vertex chain should have, keeping every retained point
|
|
942
|
+
// exactly where it was (endpoints are always kept). Presentation-only.
|
|
943
|
+
function simplifyPts(pts,eps){
|
|
944
|
+
eps=eps||3;
|
|
945
|
+
if(pts.length<3) return pts;
|
|
946
|
+
const keep=new Array(pts.length).fill(false);
|
|
947
|
+
keep[0]=keep[pts.length-1]=true;
|
|
948
|
+
const stack=[[0,pts.length-1]];
|
|
949
|
+
while(stack.length){
|
|
950
|
+
const [lo,hi]=stack.pop();
|
|
951
|
+
if(hi<=lo+1) continue;
|
|
952
|
+
const a=pts[lo], b=pts[hi];
|
|
953
|
+
const dx=b[0]-a[0], dy=b[1]-a[1], L=Math.hypot(dx,dy);
|
|
954
|
+
let far=-1, fd=eps;
|
|
955
|
+
for(let i=lo+1;i<hi;i++){
|
|
956
|
+
const p=pts[i];
|
|
957
|
+
const d=L<1e-9?Math.hypot(p[0]-a[0],p[1]-a[1])
|
|
958
|
+
:Math.abs((p[0]-a[0])*dy-(p[1]-a[1])*dx)/L;
|
|
959
|
+
if(d>fd){ fd=d; far=i; }
|
|
960
|
+
}
|
|
961
|
+
if(far>=0){ keep[far]=true; stack.push([lo,far],[far,hi]); }
|
|
962
|
+
}
|
|
963
|
+
let w=0;
|
|
964
|
+
for(let i=0;i<pts.length;i++) if(keep[i]) pts[w++]=pts[i];
|
|
965
|
+
pts.length=w;
|
|
966
|
+
return pts;
|
|
967
|
+
}
|
|
968
|
+
// roundPath: build an SVG path `d` for a polyline whose INTERIOR bends are
|
|
969
|
+
// softened into circular-arc fillets — the standard technical-diagram look.
|
|
970
|
+
// The point list is NOT modified: only the drawn path curves. At each interior
|
|
971
|
+
// vertex the two adjacent segments are shortened by r and rejoined with a
|
|
972
|
+
// quadratic (Q corner …), which for equal trims traces a circular arc tangent
|
|
973
|
+
// to both legs — one primitive used everywhere, so the output is deterministic.
|
|
974
|
+
// r = min(10, 40% of the shorter adjacent segment) keeps short jogs from
|
|
975
|
+
// over-rounding or inverting. Near-collinear bends (turn < ~10°, i.e. interior
|
|
976
|
+
// angle > ~170°) keep a hard corner. First/last points are never filleted, so
|
|
977
|
+
// arrowhead geometry and endpoint contact are untouched.
|
|
978
|
+
const FILLET_R=10;
|
|
979
|
+
function roundPath(pts){
|
|
980
|
+
if(pts.length<3) return 'M'+pts.map(p=>p.join(' ')).join(' L');
|
|
981
|
+
const f=n=>{ const s=(+n).toFixed(3); return s.replace(/\.?0+$/,''); };
|
|
982
|
+
let d='M'+f(pts[0][0])+' '+f(pts[0][1]);
|
|
983
|
+
for(let i=1;i+1<pts.length;i++){
|
|
984
|
+
const a=pts[i-1], c=pts[i], b=pts[i+1];
|
|
985
|
+
const v1x=a[0]-c[0], v1y=a[1]-c[1], L1=Math.hypot(v1x,v1y);
|
|
986
|
+
const v2x=b[0]-c[0], v2y=b[1]-c[1], L2=Math.hypot(v2x,v2y);
|
|
987
|
+
if(L1<1e-6||L2<1e-6){ d+=' L'+f(c[0])+' '+f(c[1]); continue; }
|
|
988
|
+
// turn angle: cos of the angle between the two legs at c. Near-collinear
|
|
989
|
+
// (legs nearly opposite → dot≈ -1 → angle between legs ≈180°, tiny turn)
|
|
990
|
+
// keeps a hard corner.
|
|
991
|
+
const dot=(v1x*v2x+v1y*v2y)/(L1*L2);
|
|
992
|
+
if(dot<-0.985){ d+=' L'+f(c[0])+' '+f(c[1]); continue; } // turn < ~10°
|
|
993
|
+
const r=Math.min(FILLET_R, 0.4*Math.min(L1,L2));
|
|
994
|
+
if(r<0.5){ d+=' L'+f(c[0])+' '+f(c[1]); continue; }
|
|
995
|
+
const p1x=c[0]+v1x/L1*r, p1y=c[1]+v1y/L1*r; // trim point toward a
|
|
996
|
+
const p2x=c[0]+v2x/L2*r, p2y=c[1]+v2y/L2*r; // trim point toward b
|
|
997
|
+
d+=' L'+f(p1x)+' '+f(p1y)+' Q'+f(c[0])+' '+f(c[1])+' '+f(p2x)+' '+f(p2y);
|
|
998
|
+
}
|
|
999
|
+
const e=pts[pts.length-1];
|
|
1000
|
+
d+=' L'+f(e[0])+' '+f(e[1]);
|
|
1001
|
+
return d;
|
|
1002
|
+
}
|
|
1003
|
+
// inscribedHalfW: the half-width the outline still offers at height |dy|=v —
|
|
1004
|
+
// the room a label line really has (a rectangle offers its full half-width).
|
|
1005
|
+
function inscribedHalfW(g,v){
|
|
1006
|
+
const t=Math.min(1,Math.abs(v)/(g.b||1e-9));
|
|
1007
|
+
return g.p===1?g.a*(1-t):(g.p===2?g.a*Math.sqrt(1-t*t):g.a);
|
|
1008
|
+
}
|
|
1009
|
+
// labelBox: the label's own text box (glyph extents) plus a small clearance
|
|
1010
|
+
// so glyphs never graze the stroke — this is what must end up INSIDE.
|
|
1011
|
+
// (line height 1.3*FONT as in textEl; 1.2*FONT covers one line's ink box)
|
|
1012
|
+
const LBLPADX=6, LBLPADY=4;
|
|
1013
|
+
function labelBox(label){
|
|
1014
|
+
const ls=String(label).split('\n');
|
|
1015
|
+
return [Math.max(...ls.map(l=>l.length))*CH+2*LBLPADX,
|
|
1016
|
+
((ls.length-1)*1.3+1.2)*FONT+2*LBLPADY];
|
|
1017
|
+
}
|
|
1018
|
+
// fitOutline: grow the box by the smallest factor that pulls an iw x ih box,
|
|
1019
|
+
// centred on the node, inside the outline. The norm at the text box's corner
|
|
1020
|
+
// IS that factor (homogeneity), so one evaluation answers both "does it fit"
|
|
1021
|
+
// and "by how much". Rectangles are their own outline and tw()/NH already pad
|
|
1022
|
+
// the text, so k<=1 there and the box never moves.
|
|
1023
|
+
function fitOutline(n,box){
|
|
1024
|
+
const g=shapeAxes(n), k=outlineNorm(g,box[0]/2,box[1]/2);
|
|
1025
|
+
if(k<=1) return;
|
|
1026
|
+
n.w=Math.max(n.w,2*(k*g.a-g.ox));
|
|
1027
|
+
n.h=Math.max(n.h,2*(k*g.b-g.oy));
|
|
1028
|
+
if(n.shape==='circle') n.w=n.h=Math.max(n.w,n.h); // rx=ry=w/2: box stays square
|
|
1029
|
+
}
|
|
1030
|
+
// multi-line <text>: "\n" in labels becomes centered tspans
|
|
1031
|
+
function textEl(x,y,fs,anchor,fill,content,extraAttrs){
|
|
1032
|
+
// halo text = two layers (white under-stroke + plain top copy) so the
|
|
1033
|
+
// halo survives SVG renderers without paint-order support
|
|
1034
|
+
if(extraAttrs && extraAttrs.indexOf('paint-order')>=0){
|
|
1035
|
+
const rest=extraAttrs.replace(' paint-order="stroke" stroke="#fff" stroke-width="3"','');
|
|
1036
|
+
return textEl(x,y,fs,anchor,'#fff',content,rest+' stroke="#fff" stroke-width="3" stroke-linejoin="round"')
|
|
1037
|
+
+ textEl(x,y,fs,anchor,fill,content,rest);
|
|
1038
|
+
}
|
|
1039
|
+
const lines=String(content).split('\n');
|
|
1040
|
+
const attrs='font-size="'+fs+'" text-anchor="'+anchor+'" fill="'+fill+'"'+(extraAttrs||'');
|
|
1041
|
+
if(lines.length===1)
|
|
1042
|
+
return '<text x="'+x+'" y="'+y+'" '+attrs+'>'+esc(content)+'</text>';
|
|
1043
|
+
const lh=fs*1.3, y0=y-(lines.length-1)*lh/2;
|
|
1044
|
+
return '<text x="'+x+'" y="'+y0+'" '+attrs+'>'+
|
|
1045
|
+
lines.map((l,i)=>'<tspan x="'+x+'" dy="'+(i?lh:0)+'">'+esc(l)+'</tspan>').join('')+'</text>';
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function render(doc,ropts){
|
|
1049
|
+
// presentation options (renderer tier, not language): {title:true}
|
|
1050
|
+
// draws the title. Default is NOT drawn (R13: embedded figures almost
|
|
1051
|
+
// always sit under a caption in the host document; the title text
|
|
1052
|
+
// stays semantic in the source either way).
|
|
1053
|
+
const RO=ropts||{};
|
|
1054
|
+
// resolve class defaults (explicit element attributes win — rigidity, R8)
|
|
1055
|
+
const C={}; for(const c of doc.classes||[]) C[c.id]=c;
|
|
1056
|
+
const rs=(x,k)=>{ if(x[k]===undefined && x.cls && C[x.cls] && C[x.cls][k]!==undefined) x[k]=C[x.cls][k]; };
|
|
1057
|
+
for(const n of doc.nodes){ rs(n,'color'); rs(n,'stroke'); rs(n,'text'); rs(n,'style'); if(n.style===undefined) n.style='solid'; }
|
|
1058
|
+
for(const g of doc.groups){ rs(g,'color'); rs(g,'stroke'); rs(g,'text'); rs(g,'style'); }
|
|
1059
|
+
for(const e of doc.edges){ rs(e,'color'); rs(e,'style'); if(e.style===undefined) e.style='solid'; }
|
|
1060
|
+
for(const b of doc.blocks){
|
|
1061
|
+
if(b.fields) for(const f of b.fields) rs(f,'color');
|
|
1062
|
+
if(b.marks) for(const mk of b.marks) rs(mk,'color');
|
|
1063
|
+
}
|
|
1064
|
+
const parts=[]; let y=0, maxW=0;
|
|
1065
|
+
if(doc.title && RO.title===true){ parts.push('<text x="0" y="16" font-size="15" font-weight="600">'+esc(doc.title)+'</text>'); y=30;
|
|
1066
|
+
maxW=Math.max(maxW, doc.title.length*8.6); } // canvas must fit the title
|
|
1067
|
+
let sceneMeta=null;
|
|
1068
|
+
if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
|
|
1069
|
+
const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
|
|
1070
|
+
sceneMeta=s.meta;
|
|
1071
|
+
}
|
|
1072
|
+
for(const b of doc.blocks){
|
|
1073
|
+
let s;
|
|
1074
|
+
if(b.type==='bitfield') s=renderBitfield(b,y);
|
|
1075
|
+
else if(b.type==='table') s=renderTable(b,y);
|
|
1076
|
+
else if(b.type==='plot') s=renderPlot(b,y,doc);
|
|
1077
|
+
else s=renderWave(b,y);
|
|
1078
|
+
parts.push(s.svg); y=s.y+24; maxW=Math.max(maxW,s.w);
|
|
1079
|
+
}
|
|
1080
|
+
if((doc.classes||[]).length){
|
|
1081
|
+
// derived legend strip (D9): declaration order, swatch + meaning
|
|
1082
|
+
const es=[]; let lx=0, ly=y+8; const rowH=20, wrapW=Math.max(maxW,420);
|
|
1083
|
+
for(const c of doc.classes){
|
|
1084
|
+
const tw=String(c.label).length*6.6+30;
|
|
1085
|
+
if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
|
|
1086
|
+
const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
1087
|
+
es.push('<rect x="'+lx+'" y="'+(ly+3)+'" width="16" height="11" fill="'+(c.color||'#fff')+'" stroke="'+(c.stroke||'#555')+'"'+dash+'/>');
|
|
1088
|
+
es.push('<text x="'+(lx+21)+'" y="'+(ly+12.5)+'" font-size="11" fill="#1d1d1b">'+esc(c.label)+'</text>');
|
|
1089
|
+
lx+=tw+14; maxW=Math.max(maxW,lx);
|
|
1090
|
+
}
|
|
1091
|
+
parts.push(es.join(''));
|
|
1092
|
+
y=ly+rowH;
|
|
1093
|
+
}
|
|
1094
|
+
const PADL=18, PADT=6;
|
|
1095
|
+
const W=Math.ceil(maxW)+PADL+8, H=Math.ceil(y)+PADT+4;
|
|
1096
|
+
return {svg:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" font-family="system-ui,sans-serif">'
|
|
1097
|
+
+'<defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">'
|
|
1098
|
+
+'<path d="M0,0 L10,5 L0,10 z" fill="#555"/></marker>'
|
|
1099
|
+
+'<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
|
|
1100
|
+
+'<line x1="0" y1="0" x2="0" y2="6" stroke="#bbb" stroke-width="2"/></pattern></defs>'
|
|
1101
|
+
+'<g transform="translate('+PADL+','+PADT+')">'+parts.join('')+'</g></svg>', w:W, h:H,
|
|
1102
|
+
sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// ---- scene ----
|
|
1106
|
+
function renderScene(doc,y0){
|
|
1107
|
+
const nodes=doc.nodes.map(n=>({...n}));
|
|
1108
|
+
// boundary anchors (R44): external I/O endpoints join the layout as
|
|
1109
|
+
// small invisible ~12x12 extents so externals land at natural margins;
|
|
1110
|
+
// they are never drawn as shapes (the edge ends open at the anchor).
|
|
1111
|
+
for(const b of doc.boundaries||[])
|
|
1112
|
+
nodes.push({id:b.id,label:b.label||'',boundary:true,layer:'base',group:null,line:b.line});
|
|
1113
|
+
const byId={}; nodes.forEach(n=>byId[n.id]=n);
|
|
1114
|
+
// sizes
|
|
1115
|
+
for(const n of nodes){
|
|
1116
|
+
if(n.boundary){ n.w=n.h=12; continue; }
|
|
1117
|
+
const nLines=String(n.label).split('\n').length;
|
|
1118
|
+
n.w=tw(n.label); n.h=NH+(nLines-1)*16;
|
|
1119
|
+
const s=doc.sizes[n.id]; if(s){ if(s.w)n.w=s.w; if(s.h)n.h=s.h; n.rigid=true; }
|
|
1120
|
+
if(n.shape==='diamond'){ n.w=Math.max(n.w+20,70); n.h=Math.max(n.h,48); }
|
|
1121
|
+
if(n.shape==='circle'){ n.w=n.h=Math.max(n.w,n.h); }
|
|
1122
|
+
// The box is what the layout reserves; the OUTLINE is what the reader
|
|
1123
|
+
// sees, and for every shape but the rectangles the two differ — a box
|
|
1124
|
+
// sized as if it were a rectangle lets the label cross the drawn outline
|
|
1125
|
+
// (a rhombus offers only its inscribed rectangle, ~half the box in each
|
|
1126
|
+
// direction). Grow the box until the label's text box is inside the
|
|
1127
|
+
// outline. An explicit `size` is the author's word: a rigid node keeps
|
|
1128
|
+
// its box and the label shrinks to the inscribed width instead (R10).
|
|
1129
|
+
if(!n.rigid) fitOutline(n,labelBox(n.label));
|
|
1130
|
+
}
|
|
1131
|
+
// ranks: DFS from document-order sources classifies back-edges; a
|
|
1132
|
+
// longest-path layering runs on the remaining DAG so sources sit at
|
|
1133
|
+
// layer 0. `rank a b c` merges its nodes into ONE vertex for the
|
|
1134
|
+
// layering (same layer) without dragging unrelated branches along.
|
|
1135
|
+
const pinned=id=>doc.pins[id]!==undefined;
|
|
1136
|
+
nodes.forEach((n,i)=>n.di=i); // document order (sort tiebreak)
|
|
1137
|
+
const rep={}; nodes.forEach(n=>rep[n.id]=n.id); // rank-group union-find
|
|
1138
|
+
const find=u=>{ while(rep[u]!==u) u=rep[u]=rep[rep[u]]; return u; };
|
|
1139
|
+
for(const r of doc.ranks){
|
|
1140
|
+
const ids=r.ids.filter(id=>byId[id]);
|
|
1141
|
+
for(let i=1;i<ids.length;i++){ const a=find(ids[0]),b=find(ids[i]); if(a!==b) rep[b]=a; }
|
|
1142
|
+
}
|
|
1143
|
+
const adj={}; nodes.forEach(n=>{ adj[find(n.id)]=adj[find(n.id)]||[]; });
|
|
1144
|
+
const isBack=new Set(); // edges that close a cycle
|
|
1145
|
+
for(const e of doc.edges){
|
|
1146
|
+
// undirected/bidirectional edges still order ranks by document direction
|
|
1147
|
+
if(!byId[e.a]||!byId[e.b]) continue;
|
|
1148
|
+
if(e.a===e.b){ isBack.add(e); continue; } // self-loop
|
|
1149
|
+
const u=find(e.a), v=find(e.b);
|
|
1150
|
+
if(u!==v) adj[u].push({v,e});
|
|
1151
|
+
}
|
|
1152
|
+
const state={};
|
|
1153
|
+
const classify=u=>{
|
|
1154
|
+
state[u]=1;
|
|
1155
|
+
for(const {v,e} of adj[u]){
|
|
1156
|
+
if(state[v]===1) isBack.add(e); // gray hit = back-edge
|
|
1157
|
+
else if(!state[v]) classify(v);
|
|
1158
|
+
}
|
|
1159
|
+
state[u]=2;
|
|
1160
|
+
};
|
|
1161
|
+
for(const n of nodes){ const u=find(n.id); if(!state[u]) classify(u); }
|
|
1162
|
+
const radj={}; nodes.forEach(n=>{ radj[find(n.id)]=radj[find(n.id)]||[]; });
|
|
1163
|
+
for(const e of doc.edges){
|
|
1164
|
+
if(!byId[e.a]||!byId[e.b]||isBack.has(e)) continue;
|
|
1165
|
+
const u=find(e.a), v=find(e.b); if(u!==v) radj[v].push(u);
|
|
1166
|
+
}
|
|
1167
|
+
const rk={};
|
|
1168
|
+
const rankOf=u=>{
|
|
1169
|
+
if(rk[u]!==undefined) return rk[u];
|
|
1170
|
+
rk[u]=0; // DAG: placeholder is never read
|
|
1171
|
+
let r=0; for(const p of radj[u]) r=Math.max(r, rankOf(p)+1);
|
|
1172
|
+
return rk[u]=r;
|
|
1173
|
+
};
|
|
1174
|
+
nodes.forEach(n=>n.rank=rankOf(find(n.id)));
|
|
1175
|
+
// positions: children spread around their parents' lane (barycenter
|
|
1176
|
+
// sweeps down/up/down). Edges that span multiple layers get invisible
|
|
1177
|
+
// waypoint slots so they no longer cut through intermediate nodes.
|
|
1178
|
+
// ALL nodes participate in auto layout (pinned ones hold their slot so
|
|
1179
|
+
// that pinning X never reflows Y); pins override coordinates afterwards.
|
|
1180
|
+
const horiz=(doc.flow==='right'||doc.flow==='left');
|
|
1181
|
+
const lblPx=s=>Math.max(...String(s).split('\n').map(l=>l.length))*6.5;
|
|
1182
|
+
const lay=[...nodes]; // layout participants
|
|
1183
|
+
const chains=new Map(); // edge -> [A, ...waypoints, B]
|
|
1184
|
+
for(const e of doc.edges){
|
|
1185
|
+
const A=byId[e.a], B=byId[e.b];
|
|
1186
|
+
if(!A||!B||isBack.has(e)||e.route) continue; // routed edges draw their own waypoints
|
|
1187
|
+
if(B.rank-A.rank<=1 || pinned(e.a) || pinned(e.b)) continue;
|
|
1188
|
+
const chain=[A];
|
|
1189
|
+
const span=B.rank-A.rank;
|
|
1190
|
+
for(let r=A.rank+1;r<B.rank;r++){
|
|
1191
|
+
// homeA/homeB/frac anchor this waypoint's cross position between its
|
|
1192
|
+
// OWN endpoints (dummy-vertex chain), so the barycenter sweep orders it
|
|
1193
|
+
// within the lane without letting the run drift to the figure margin.
|
|
1194
|
+
const v={virtual:true,rank:r,w:1,h:1,mn:e.mid?lblPx(e.mid):0,di:lay.length,
|
|
1195
|
+
homeA:A,homeB:B,frac:(r-A.rank)/span};
|
|
1196
|
+
lay.push(v); chain.push(v);
|
|
1197
|
+
}
|
|
1198
|
+
chain.push(B); chains.set(e,chain);
|
|
1199
|
+
}
|
|
1200
|
+
const preds=new Map(), succs=new Map(); // over layout segments
|
|
1201
|
+
const link=(p,c)=>{ if(!preds.has(c)) preds.set(c,[]); preds.get(c).push(p);
|
|
1202
|
+
if(!succs.has(p)) succs.set(p,[]); succs.get(p).push(c); };
|
|
1203
|
+
for(const e of doc.edges){
|
|
1204
|
+
const A=byId[e.a], B=byId[e.b];
|
|
1205
|
+
if(!A||!B||isBack.has(e)||A.rank===B.rank) continue;
|
|
1206
|
+
const ch=chains.get(e)||[A,B];
|
|
1207
|
+
for(let i=1;i<ch.length;i++) link(ch[i-1],ch[i]);
|
|
1208
|
+
}
|
|
1209
|
+
const midW={}; // widest mid label on a one-layer edge, per node
|
|
1210
|
+
for(const e of doc.edges){
|
|
1211
|
+
if(!e.mid) continue;
|
|
1212
|
+
const A=byId[e.a], B=byId[e.b];
|
|
1213
|
+
if(!A||!B||isBack.has(e)||Math.abs(A.rank-B.rank)!==1) continue;
|
|
1214
|
+
midW[e.a]=Math.max(midW[e.a]||0,lblPx(e.mid));
|
|
1215
|
+
midW[e.b]=Math.max(midW[e.b]||0,lblPx(e.mid));
|
|
1216
|
+
}
|
|
1217
|
+
const cs=n=>horiz?n.h:n.w; // cross-axis size
|
|
1218
|
+
const gapOf=(a,b)=>{ // spacing between adjacent lane members
|
|
1219
|
+
if(a.group&&a.group===b.group){
|
|
1220
|
+
const g=doc.groups.find(x=>x.id===a.group);
|
|
1221
|
+
if(g&&g.gap!==undefined) return g.gap; // explicit group gap is exact
|
|
1222
|
+
}
|
|
1223
|
+
let base=horiz?GAPY:GAPX;
|
|
1224
|
+
if(a.virtual||b.virtual) base=Math.min(base,30);
|
|
1225
|
+
if(!horiz){ // beside-labels on vertical edges need width
|
|
1226
|
+
const la=a.virtual?a.mn:(midW[a.id]||0);
|
|
1227
|
+
if(la) base=Math.max(base,la+23-cs(a)/2);
|
|
1228
|
+
}
|
|
1229
|
+
return base;
|
|
1230
|
+
};
|
|
1231
|
+
const ranksArr=[];
|
|
1232
|
+
for(const n of lay){ (ranksArr[n.rank]=ranksArr[n.rank]||[]).push(n); }
|
|
1233
|
+
const laneSize=[], mainGap=[];
|
|
1234
|
+
ranksArr.forEach((lane,i)=>{
|
|
1235
|
+
if(!lane) return;
|
|
1236
|
+
laneSize[i]=Math.max(...lane.map(n=>horiz?n.w:n.h));
|
|
1237
|
+
mainGap[i]=horiz?GAPX:GAPY;
|
|
1238
|
+
});
|
|
1239
|
+
if(horiz) for(const e of doc.edges){ // labels ride ON horizontal edges:
|
|
1240
|
+
if(!e.mid||isBack.has(e)) continue; // layer pitch grows to fit them
|
|
1241
|
+
const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
|
|
1242
|
+
const r0=Math.min(A.rank,B.rank), r1=Math.max(A.rank,B.rank);
|
|
1243
|
+
if(r0===r1) continue;
|
|
1244
|
+
const need=(lblPx(e.mid)+24)/(r1-r0);
|
|
1245
|
+
for(let i=r0;i<r1;i++) mainGap[i]=Math.max(mainGap[i],need);
|
|
1246
|
+
}
|
|
1247
|
+
const center=n=>n.cross+cs(n)/2;
|
|
1248
|
+
ranksArr.forEach(lane=>{ if(!lane) return; let c=0; // seed: doc order
|
|
1249
|
+
lane.forEach((n,k)=>{ n.cross=c; c+=cs(n)+(k<lane.length-1?gapOf(n,lane[k+1]):0); }); });
|
|
1250
|
+
const place=(lane,des)=>{ // order by desired center, resolve overlaps,
|
|
1251
|
+
const arr=lane.map(n=>({n,d:des.get(n)})); // recenter the lane
|
|
1252
|
+
arr.sort((p,q)=>p.d-q.d||p.n.di-q.n.di);
|
|
1253
|
+
let cEnd=-Infinity;
|
|
1254
|
+
arr.forEach((x,i)=>{
|
|
1255
|
+
x.n.cross=Math.max(x.d-cs(x.n)/2, cEnd);
|
|
1256
|
+
cEnd=x.n.cross+cs(x.n)+(i<arr.length-1?gapOf(x.n,arr[i+1].n):0);
|
|
1257
|
+
});
|
|
1258
|
+
const err=arr.reduce((s,x)=>s+center(x.n)-x.d,0)/arr.length;
|
|
1259
|
+
arr.forEach(x=>{ x.n.cross-=err; });
|
|
1260
|
+
lane.length=0; arr.forEach(x=>lane.push(x.n));
|
|
1261
|
+
};
|
|
1262
|
+
const SLACK=300; // how far a multi-rank waypoint may stray past its own
|
|
1263
|
+
// endpoints' cross-axis band before the sweep clamps it
|
|
1264
|
+
// (item 17); the knee of the excursion↔crossings trade.
|
|
1265
|
+
const sweep=dir=>{ // 1 = align to parents, -1 = align to children
|
|
1266
|
+
const idx=[]; ranksArr.forEach((l,i)=>l&&idx.push(i));
|
|
1267
|
+
const order=dir===1?idx.slice(1):idx.slice(0,-1).reverse();
|
|
1268
|
+
for(const i of order){
|
|
1269
|
+
const lane=ranksArr[i], des=new Map();
|
|
1270
|
+
for(const n of lane){
|
|
1271
|
+
const ref=(dir===1?preds:succs).get(n);
|
|
1272
|
+
let d=ref&&ref.length ? ref.reduce((s,m)=>s+center(m),0)/ref.length : center(n);
|
|
1273
|
+
// Waypoint excursion bound (item 17): a multi-rank forward edge's dummy
|
|
1274
|
+
// vertices may follow the barycenter freely WITHIN the cross-axis band
|
|
1275
|
+
// their own endpoints span — that is where the ordering that separates
|
|
1276
|
+
// parallel long edges from each other happens — but they may not stray
|
|
1277
|
+
// past that band by more than SLACK. Without this a long run in a
|
|
1278
|
+
// crowded region drifts, layer by layer, out to the figure margin (the
|
|
1279
|
+
// 900px staircase this item measured). Clamping the DESIRED position
|
|
1280
|
+
// (not the final one) leaves place()'s overlap resolution and node
|
|
1281
|
+
// spacing intact, so a clamped waypoint is not shoved onto a node; and
|
|
1282
|
+
// because the clamp is monotonic it does not reorder waypoints, so it
|
|
1283
|
+
// does not manufacture crossings among edges that did not cross before.
|
|
1284
|
+
// A centre-line PULL was tried instead and rejected: it makes edges
|
|
1285
|
+
// sharing a funnel target converge early and adds real crossings.
|
|
1286
|
+
if(n.virtual&&n.homeA&&n.homeB){
|
|
1287
|
+
const cA=center(n.homeA), cB=center(n.homeB);
|
|
1288
|
+
const lo=Math.min(cA,cB)-SLACK, hi=Math.max(cA,cB)+SLACK;
|
|
1289
|
+
d=Math.max(lo,Math.min(hi,d));
|
|
1290
|
+
}
|
|
1291
|
+
des.set(n,d);
|
|
1292
|
+
}
|
|
1293
|
+
place(lane,des);
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
sweep(1); sweep(-1); sweep(1);
|
|
1297
|
+
let minC=Infinity; lay.forEach(n=>{ minC=Math.min(minC,n.cross); });
|
|
1298
|
+
if(!isFinite(minC)) minC=0;
|
|
1299
|
+
let main=0;
|
|
1300
|
+
ranksArr.forEach((lane,i)=>{
|
|
1301
|
+
if(!lane) return;
|
|
1302
|
+
lane.forEach(n=>{
|
|
1303
|
+
// waypoints span the full layer so chain bends happen in the gaps
|
|
1304
|
+
if(n.virtual){ if(horiz) n.w=laneSize[i]; else n.h=laneSize[i]; }
|
|
1305
|
+
if(horiz){ n.x=main; n.y=y0+20+n.cross-minC; }
|
|
1306
|
+
else { n.y=y0+20+main; n.x=n.cross-minC; }
|
|
1307
|
+
});
|
|
1308
|
+
main+=laneSize[i]+mainGap[i];
|
|
1309
|
+
});
|
|
1310
|
+
if(doc.flow==='left'||doc.flow==='up'){
|
|
1311
|
+
const M=main; for(const n of lay){
|
|
1312
|
+
if(horiz) n.x=M-n.x-n.w; else n.y=y0+20+(M-(n.y-y0-20))-n.h; }
|
|
1313
|
+
}
|
|
1314
|
+
// Two-level coordinates (D6): a pinned GROUP anchors its local origin in
|
|
1315
|
+
// canvas px; a pinned MEMBER is group-local (relative to that origin);
|
|
1316
|
+
// ungrouped pins are canvas px. Moving a group = editing one pin line.
|
|
1317
|
+
const gOrigin={};
|
|
1318
|
+
for(const g of doc.groups){
|
|
1319
|
+
const p=doc.pins[g.id];
|
|
1320
|
+
if(p){ gOrigin[g.id]={x:p.fx, y:y0+20+p.fy}; }
|
|
1321
|
+
else{
|
|
1322
|
+
const mem=nodes.filter(n=>n.group===g.id);
|
|
1323
|
+
if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
|
|
1324
|
+
y:Math.min(...mem.map(n=>n.y))};
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
for(const n of nodes){ const p=doc.pins[n.id]; if(!p) continue;
|
|
1328
|
+
const o=n.group?gOrigin[n.group]:null;
|
|
1329
|
+
if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
|
|
1330
|
+
else { n.x=p.fx; n.y=y0+20+p.fy; }
|
|
1331
|
+
}
|
|
1332
|
+
// Boundary adjacency in pinned scenes (presentation-only): auto-layout ranks
|
|
1333
|
+
// a degree-1 boundary relative to the free lanes, so in a scene where the
|
|
1334
|
+
// real content is pinned to a compact box the boundary can drift to a far
|
|
1335
|
+
// rank and blow the canvas out. When a boundary's connected node is pinned
|
|
1336
|
+
// (or the scene is substantially pinned), place the boundary just outside
|
|
1337
|
+
// that node's border in the flow direction — upstream for `boundary -> node`,
|
|
1338
|
+
// downstream for `node -> boundary` — aligned on the node's cross-axis, at a
|
|
1339
|
+
// small fixed gap. Explicitly pinned or explicitly routed boundaries keep
|
|
1340
|
+
// their author-given placement. Strip pin/size/route and the AST is unchanged.
|
|
1341
|
+
{
|
|
1342
|
+
const real=nodes.filter(n=>!n.boundary);
|
|
1343
|
+
const pinnedReal=real.filter(n=>pinned(n.id)).length;
|
|
1344
|
+
const scenePinned=real.length>0 && pinnedReal>=Math.ceil(real.length/2);
|
|
1345
|
+
const BGAP=30; // border-to-anchor gap (spec range 24–40)
|
|
1346
|
+
for(const b of nodes){
|
|
1347
|
+
if(!b.boundary||pinned(b.id)) continue;
|
|
1348
|
+
// sole incident edge (degree-1); skip routed edges and self-refs
|
|
1349
|
+
let node=null, downstream=true;
|
|
1350
|
+
for(const e of doc.edges){
|
|
1351
|
+
if(e.route) continue;
|
|
1352
|
+
if(e.a===b.id&&byId[e.b]&&byId[e.b]!==b){ node=byId[e.b]; downstream=false; break; } // boundary -> node
|
|
1353
|
+
if(e.b===b.id&&byId[e.a]&&byId[e.a]!==b){ node=byId[e.a]; downstream=true; break; } // node -> boundary
|
|
1354
|
+
}
|
|
1355
|
+
if(!node) continue;
|
|
1356
|
+
if(!(pinned(node.id)||scenePinned)) continue; // unpinned scenes keep auto-layout
|
|
1357
|
+
// outward side in flow direction: downstream = end of flow, upstream = start
|
|
1358
|
+
const fwd=(doc.flow==='right'||doc.flow==='down');
|
|
1359
|
+
const outward=downstream===fwd ? 1 : -1; // +1 = max side, -1 = min side
|
|
1360
|
+
if(horiz){
|
|
1361
|
+
b.y=node.y+node.h/2-b.h/2; // align on node's horizontal axis
|
|
1362
|
+
b.x=outward>0 ? node.x+node.w+BGAP : node.x-BGAP-b.w;
|
|
1363
|
+
}else{
|
|
1364
|
+
b.x=node.x+node.w/2-b.w/2; // align on node's vertical axis
|
|
1365
|
+
b.y=outward>0 ? node.y+node.h+BGAP : node.y-BGAP-b.h;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
// Boundary labels sit beyond the open end, away from the figure (R44).
|
|
1370
|
+
// bDir = outward direction of an anchor: away from its first incident
|
|
1371
|
+
// edge's other endpoint (document order; [0,1] = below when unwired).
|
|
1372
|
+
const bDir=n=>{
|
|
1373
|
+
for(const e of doc.edges){
|
|
1374
|
+
const o=e.a===n.id?byId[e.b]:(e.b===n.id?byId[e.a]:null);
|
|
1375
|
+
if(!o||o===n) continue;
|
|
1376
|
+
return [n.x+n.w/2-(o.x+o.w/2), n.y+n.h/2-(o.y+o.h/2)];
|
|
1377
|
+
}
|
|
1378
|
+
return [0,1];
|
|
1379
|
+
};
|
|
1380
|
+
// A left-pointing label (an inbound external under flow right) would
|
|
1381
|
+
// stick out past the canvas' left edge: shift the whole scene right so
|
|
1382
|
+
// it stays on canvas. The shift is uniform (relative geometry, incl.
|
|
1383
|
+
// pins, is preserved — same pattern as the ring-channel y-shift below)
|
|
1384
|
+
// and meta.left reports it so drag→pin round-trips stay stable.
|
|
1385
|
+
let bShift=0;
|
|
1386
|
+
for(const n of nodes){
|
|
1387
|
+
if(!n.boundary||!n.label) continue;
|
|
1388
|
+
const [bdx,bdy]=bDir(n), cx=n.x+n.w/2;
|
|
1389
|
+
const ext=Math.abs(bdx)>=Math.abs(bdy) ? (bdx<0 ? cx-10-lblPx(n.label) : 0)
|
|
1390
|
+
: cx-lblPx(n.label)/2;
|
|
1391
|
+
bShift=Math.max(bShift,-ext);
|
|
1392
|
+
}
|
|
1393
|
+
if(bShift){ for(const n of lay) n.x+=bShift;
|
|
1394
|
+
for(const k in gOrigin) gOrigin[k].x+=bShift; }
|
|
1395
|
+
// back-edge channel plan: slots used to be handed out in paint order, so
|
|
1396
|
+
// a far source could take the innermost slot and its long run crossed
|
|
1397
|
+
// every other return. Sort channel-bound back-edges so the source NEAREST
|
|
1398
|
+
// the channel gets the INNERMOST slot (vertical flow, right channel:
|
|
1399
|
+
// larger cx = nearer; horizontal flow, bottom channel: larger cy = nearer;
|
|
1400
|
+
// ties: document order). Where the pattern allows it (vertical flow,
|
|
1401
|
+
// clear space below the source, clear sky above the target) the loop is
|
|
1402
|
+
// drawn as a full concentric ring — drop row, channel, return row and hub
|
|
1403
|
+
// entry nested in the same order, entering the hub's top edge on the
|
|
1404
|
+
// channel side — so fan-in hubs (N states -> IDLE reset) have no crossings.
|
|
1405
|
+
const chPlan=new Map(); let chTop=0, chShift=0;
|
|
1406
|
+
{
|
|
1407
|
+
const chList=doc.edges.filter(e=>byId[e.a]&&byId[e.b]&&isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)&&!e.route);
|
|
1408
|
+
const near=e=>{ const A=byId[e.a]; return horiz?A.y+A.h/2:A.x+A.w/2; };
|
|
1409
|
+
const order=chList.map((e,i)=>({e,i}));
|
|
1410
|
+
order.sort((p,q)=>near(q.e)-near(p.e)||p.i-q.i);
|
|
1411
|
+
let cum=0;
|
|
1412
|
+
order.forEach(({e},ring)=>{
|
|
1413
|
+
const A=byId[e.a], B=byId[e.b], sx=A.x+A.w/2;
|
|
1414
|
+
const ringOK=!horiz&&A!==B
|
|
1415
|
+
&&!nodes.some(n=>n!==A&&!n.boundary&&n.x<sx&&n.x+n.w>sx&&n.y+n.h>A.y+A.h)
|
|
1416
|
+
&&!nodes.some(n=>n!==B&&!n.boundary&&n.x<B.x+B.w&&n.x+n.w>B.x&&n.y<B.y);
|
|
1417
|
+
chPlan.set(e,{ring,slot:cum,ringOK});
|
|
1418
|
+
cum+=(!horiz&&e.mid)?Math.max(22,lblPx(e.mid)+14):22;
|
|
1419
|
+
});
|
|
1420
|
+
// rings are all-or-nothing per target: a hub whose loops are part ring,
|
|
1421
|
+
// part legacy would reintroduce crossings between the two styles
|
|
1422
|
+
const byT={};
|
|
1423
|
+
order.forEach(({e})=>{ (byT[e.b]=byT[e.b]||[]).push(e); });
|
|
1424
|
+
for(const t in byT)
|
|
1425
|
+
if(!byT[t].every(e=>chPlan.get(e).ringOK))
|
|
1426
|
+
byT[t].forEach(e=>{ chPlan.get(e).ringOK=false; });
|
|
1427
|
+
// hub entries fan across the target's top edge, innermost ring nearest
|
|
1428
|
+
// the channel, so concentric rings never cross on their way in
|
|
1429
|
+
for(const t in byT){
|
|
1430
|
+
const g=byT[t].filter(e=>chPlan.get(e).ringOK);
|
|
1431
|
+
const B=byId[t], m=g.length;
|
|
1432
|
+
g.forEach((e,k)=>{ chPlan.get(e).ex=B.x+B.w*(m-k)/(m+1); });
|
|
1433
|
+
}
|
|
1434
|
+
// ring return rows run above the top rank; shift the whole scene down
|
|
1435
|
+
// when they would spill into the title band. The shift is uniform
|
|
1436
|
+
// (relative geometry, incl. pins, is preserved) and meta.top reports
|
|
1437
|
+
// the shifted origin so the editor's drag->pin round-trip stays stable.
|
|
1438
|
+
const rings=order.filter(({e})=>chPlan.get(e).ringOK);
|
|
1439
|
+
if(rings.length){
|
|
1440
|
+
let occT=Infinity;
|
|
1441
|
+
for(const n of nodes) occT=Math.min(occT, n.y-(n.group?26:0));
|
|
1442
|
+
const maxRing=Math.max(...rings.map(({e})=>chPlan.get(e).ring));
|
|
1443
|
+
chShift=Math.max(0, y0+20+maxRing*12-occT);
|
|
1444
|
+
if(chShift){ for(const n of lay) n.y+=chShift;
|
|
1445
|
+
for(const k in gOrigin) gOrigin[k].y+=chShift; }
|
|
1446
|
+
chTop=occT+chShift;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
// anti-parallel straight edges (A->B and B->A) used to coincide: offset
|
|
1450
|
+
// each member of a same-pair straight group along the pair's CANONICAL
|
|
1451
|
+
// normal (endpoints sorted by id), so opposite directions land on
|
|
1452
|
+
// opposite sides; endpoints and labels shift together.
|
|
1453
|
+
const apOff=new Map();
|
|
1454
|
+
{
|
|
1455
|
+
const straight=e=>byId[e.a]&&byId[e.b]&&!(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b))&&!chains.get(e)&&!e.route;
|
|
1456
|
+
const pk=e=>e.a<e.b?e.a+'\t'+e.b:e.b+'\t'+e.a;
|
|
1457
|
+
const pairN={}, seen={};
|
|
1458
|
+
for(const e of doc.edges) if(straight(e)){ const k=pk(e); pairN[k]=(pairN[k]||0)+1; }
|
|
1459
|
+
for(const e of doc.edges){
|
|
1460
|
+
if(!straight(e)) continue;
|
|
1461
|
+
const k=pk(e), kk=pairN[k]; if(kk<2) continue;
|
|
1462
|
+
const idx=seen[k]||0; seen[k]=idx+1;
|
|
1463
|
+
const off=(idx-(kk-1)/2)*7;
|
|
1464
|
+
const lo=e.a<e.b?e.a:e.b, hi=e.a<e.b?e.b:e.a;
|
|
1465
|
+
const P=byId[lo], Q=byId[hi];
|
|
1466
|
+
const dx=(Q.x+Q.w/2)-(P.x+P.w/2), dy=(Q.y+Q.h/2)-(P.y+P.h/2), L=Math.hypot(dx,dy)||1;
|
|
1467
|
+
apOff.set(e,[-dy/L*off, dx/L*off]);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
let W=0,Hh=0;
|
|
1471
|
+
for(const n of lay){ W=Math.max(W,n.x+n.w); Hh=Math.max(Hh,n.y+n.h-y0-20); }
|
|
1472
|
+
if(W===0){W=480;} if(Hh===0){Hh=280;}
|
|
1473
|
+
// groups
|
|
1474
|
+
const gsvg=[]; const gBox={};
|
|
1475
|
+
for(const g of doc.groups){
|
|
1476
|
+
const mem=nodes.filter(n=>n.group===g.id);
|
|
1477
|
+
if(!mem.length) continue;
|
|
1478
|
+
const o=gOrigin[g.id];
|
|
1479
|
+
const x0=Math.min(...mem.map(n=>n.x))-14, x1=Math.max(...mem.map(n=>n.x+n.w))+14;
|
|
1480
|
+
const yA=Math.min(...mem.map(n=>n.y))-26, yB=Math.max(...mem.map(n=>n.y+n.h))+12;
|
|
1481
|
+
gBox[g.id]={x0,x1,yA,yB};
|
|
1482
|
+
const gdash=g.style==='dashed'?' stroke-dasharray="6 4"':(g.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
1483
|
+
gsvg.push('<g data-group="'+g.id+'" data-gx="'+o.x+'" data-gy="'+o.y+'" style="cursor:move">'
|
|
1484
|
+
+'<rect x="'+x0+'" y="'+yA+'" width="'+(x1-x0)+'" height="'+(yB-yA)+'" rx="10" fill="'+(g.color||'#f6f5ef')+'" stroke="'+(g.stroke||'#d6d4cc')+'"'+gdash+'/>'
|
|
1485
|
+
+'<text x="'+(x0+10)+'" y="'+(yA+16)+'" font-size="11.5" fill="'+(g.text||'#6f6e69')+'">'+esc(g.label)+'</text></g>');
|
|
1486
|
+
W=Math.max(W,x1); Hh=Math.max(Hh,yB-y0-20);
|
|
1487
|
+
}
|
|
1488
|
+
// zone bands on groups: above the group background, below the nodes.
|
|
1489
|
+
// dir picks the measuring axis and its 0% edge: up=bottom, down=top,
|
|
1490
|
+
// right=left edge, left=right edge.
|
|
1491
|
+
const bandRect=(f,x0,yA,x1,yB)=>{
|
|
1492
|
+
const w=x1-x0, h=yB-yA;
|
|
1493
|
+
if(f.dir==='up') return [x0, yB-h*f.to/100, w, h*(f.to-f.from)/100];
|
|
1494
|
+
if(f.dir==='down') return [x0, yA+h*f.from/100, w, h*(f.to-f.from)/100];
|
|
1495
|
+
if(f.dir==='right') return [x0+w*f.from/100, yA, w*(f.to-f.from)/100, h];
|
|
1496
|
+
return [x1-w*f.to/100, yA, w*(f.to-f.from)/100, h]; // left
|
|
1497
|
+
};
|
|
1498
|
+
for(const f of doc.fills){
|
|
1499
|
+
const B=gBox[f.target]; if(!B) continue;
|
|
1500
|
+
const [bx,by,bw,bh]=bandRect(f,B.x0,B.yA,B.x1,B.yB);
|
|
1501
|
+
gsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.color+'" opacity="0.9"/>');
|
|
1502
|
+
}
|
|
1503
|
+
// edges (sorted by layer z, then doc order)
|
|
1504
|
+
const zOf=l=>{const L=doc.layers.find(x=>x.id===l);return L?L.z:0;};
|
|
1505
|
+
const edges=[...doc.edges].sort((p,q)=>zOf(p.layer)-zOf(q.layer));
|
|
1506
|
+
const esvg=[], lblsvg=[]; // labels paint last = closest to the viewer
|
|
1507
|
+
// ── deferred edge-label placement ───────────────────────────────────────
|
|
1508
|
+
// An edge label is not written where it is emitted. Each emission reserves
|
|
1509
|
+
// its slot in lblsvg (so the paint order is unchanged) and registers the
|
|
1510
|
+
// segment it belongs to; one greedy pass after the edge loop scores several
|
|
1511
|
+
// candidate positions per label against the already-placed labels, the node
|
|
1512
|
+
// boxes, the arrowheads and the other edges, and writes the winner into the
|
|
1513
|
+
// reserved slot. Requests are consumed in registration order, which is
|
|
1514
|
+
// edge order, which is deterministic — same input, same output.
|
|
1515
|
+
const lblReq=[], edgeSegs=[], arrowBox=[];
|
|
1516
|
+
const reqLabel=o=>{ o.idx=lblsvg.length; lblsvg.push(''); lblReq.push(o); };
|
|
1517
|
+
const noteSegs=(e,pp)=>{ for(let i=0;i+1<pp.length;i++) edgeSegs.push({e,p:pp[i],q:pp[i+1]}); };
|
|
1518
|
+
// arrowTri: explicit triangle painted in lblsvg (above nodes) instead of
|
|
1519
|
+
// SVG marker-end/marker-start which are occluded by the node fill.
|
|
1520
|
+
// tip=[x,y], from=[x,y] is the adjacent shaft point toward the interior
|
|
1521
|
+
// (direction: from→tip). Geometry matches #arr marker (viewBox 0 0 10 10,
|
|
1522
|
+
// refX=9, refY=5, markerWidth=7, markerHeight=7, markerUnits=strokeWidth=1.6):
|
|
1523
|
+
// arm = 9*(7/10)*1.6 ≈ 10.08 px, half-width = 5*(7/10)*1.6 ≈ 5.6 px.
|
|
1524
|
+
const arrowTri=(tip,from,col)=>{
|
|
1525
|
+
const dx=tip[0]-from[0], dy=tip[1]-from[1], L=Math.hypot(dx,dy)||1;
|
|
1526
|
+
const ux=dx/L, uy=dy/L; // unit vector from→tip
|
|
1527
|
+
const arm=10.08, hw=5.6;
|
|
1528
|
+
const bx=tip[0]-ux*arm, by=tip[1]-uy*arm; // base centre
|
|
1529
|
+
const lx=bx-uy*hw, ly=by+ux*hw; // left corner
|
|
1530
|
+
const rx=bx+uy*hw, ry=by-ux*hw; // right corner
|
|
1531
|
+
lblsvg.push('<path d="M'+tip[0]+' '+tip[1]+' L'+lx+' '+ly+' L'+rx+' '+ry+' z" fill="'+col+'" stroke="none"/>');
|
|
1532
|
+
arrowBox.push({x:Math.min(tip[0],lx,rx), y:Math.min(tip[1],ly,ry),
|
|
1533
|
+
w:Math.max(tip[0],lx,rx)-Math.min(tip[0],lx,rx),
|
|
1534
|
+
h:Math.max(tip[1],ly,ry)-Math.min(tip[1],ly,ry)});
|
|
1535
|
+
};
|
|
1536
|
+
// back-edge side channel: beyond the occupied lanes (nodes AND group boxes)
|
|
1537
|
+
let occR=0, occB=0;
|
|
1538
|
+
for(const n of nodes){ occR=Math.max(occR,n.x+n.w); occB=Math.max(occB,n.y+n.h); }
|
|
1539
|
+
for(const k in gBox){ occR=Math.max(occR,gBox[k].x1); occB=Math.max(occB,gBox[k].yB); }
|
|
1540
|
+
if(!horiz) chains.forEach((chain,e)=>{ // chain labels stick out right
|
|
1541
|
+
if(!e.mid) return;
|
|
1542
|
+
const v=chain[1+Math.floor((chain.length-3)/2)];
|
|
1543
|
+
occR=Math.max(occR, v.x+v.w/2+9+lblPx(e.mid));
|
|
1544
|
+
});
|
|
1545
|
+
for(const e of edges){
|
|
1546
|
+
const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
|
|
1547
|
+
const col=e.color||'#555';
|
|
1548
|
+
const dash=e.style==='dashed'?' stroke-dasharray="6 4"':(e.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
1549
|
+
const wantsStart=e.op==='<->'||e.op==='<-', wantsEnd=e.op==='<->'||e.op==='->';
|
|
1550
|
+
const m1='', m2=''; // markers removed — arrowTri() paints triangles above nodes in lblsvg
|
|
1551
|
+
const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
|
|
1552
|
+
const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:'#555',halo,e,A,B,kind:'end'});
|
|
1553
|
+
if(e.route){
|
|
1554
|
+
// declared waypoints (route … via=, 0.1-dev.13) are RIGID: the edge
|
|
1555
|
+
// draws source → via1 → … → viaN → target and bypasses the automatic
|
|
1556
|
+
// machinery (chains, channels, obstacle detours). Waypoints are
|
|
1557
|
+
// canvas px in the same space as ungrouped pins (and follow the
|
|
1558
|
+
// ring-channel scene shift like pins do). Under orthogonal routing
|
|
1559
|
+
// (route-level routing= wins over the document directive) consecutive
|
|
1560
|
+
// points are joined by deterministic manhattan elbows — horizontal-
|
|
1561
|
+
// then-vertical for flow right|left, vertical-then-horizontal for
|
|
1562
|
+
// down|up — reusing the standard borderPoint anchoring at both ends.
|
|
1563
|
+
const orth=(e.route.routing||doc.routing||'straight')==='orthogonal';
|
|
1564
|
+
const via=e.route.via.map(p=>[p[0], y0+20+chShift+p[1]]);
|
|
1565
|
+
let pts=[borderPoint(A,via[0][0],via[0][1])]
|
|
1566
|
+
.concat(via,[borderPoint(B,via[via.length-1][0],via[via.length-1][1])]);
|
|
1567
|
+
if(orth){
|
|
1568
|
+
const out=[pts[0]];
|
|
1569
|
+
for(let i=1;i<pts.length;i++){
|
|
1570
|
+
const p=out[out.length-1], q=pts[i];
|
|
1571
|
+
if(p[0]!==q[0]&&p[1]!==q[1]) out.push(horiz?[q[0],p[1]]:[p[0],q[1]]);
|
|
1572
|
+
out.push(q);
|
|
1573
|
+
}
|
|
1574
|
+
pts=out;
|
|
1575
|
+
}
|
|
1576
|
+
for(let i=1;i+1<pts.length;i++) // drop zero-length interior steps
|
|
1577
|
+
if(Math.hypot(pts[i][0]-pts[i-1][0],pts[i][1]-pts[i-1][1])<0.5){ pts.splice(i,1); i--; }
|
|
1578
|
+
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
1579
|
+
noteSegs(e,pts);
|
|
1580
|
+
if(e.mid){ // the longest segment carries the mid label
|
|
1581
|
+
let bi=0,bl=-1;
|
|
1582
|
+
for(let i=0;i+1<pts.length;i++){
|
|
1583
|
+
const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
|
|
1584
|
+
if(l>bl){ bl=l; bi=i; }
|
|
1585
|
+
}
|
|
1586
|
+
reqLabel({p:pts[bi],q:pts[bi+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:bi===0});
|
|
1587
|
+
}
|
|
1588
|
+
if(e.tail) seg(pts[0],pts[1],0.25,e.tail,10);
|
|
1589
|
+
if(e.head) seg(pts[pts.length-1],pts[pts.length-2],0.25,e.head,10);
|
|
1590
|
+
if(wantsStart) arrowTri(pts[0],pts[1],col);
|
|
1591
|
+
if(wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
|
|
1592
|
+
for(const pP of pts){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+16-y0-20); }
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
|
|
1596
|
+
// back-edge (retry loop): polyline through a side channel beyond the
|
|
1597
|
+
// occupied lanes instead of a straight line hidden under the spine,
|
|
1598
|
+
// using the nested slot from the channel plan. Ring-eligible loops
|
|
1599
|
+
// wrap over the top and enter the hub's top edge; otherwise, when
|
|
1600
|
+
// the sideways run to the channel would cut through a sibling node,
|
|
1601
|
+
// the route drops into the inter-layer gap first.
|
|
1602
|
+
const lane=r=>(ranksArr[r]||[]).filter(n=>!n.virtual);
|
|
1603
|
+
const P=chPlan.get(e), ring=P.ring;
|
|
1604
|
+
const pts=[];
|
|
1605
|
+
if(horiz){ // channel runs below the lanes
|
|
1606
|
+
const chY=occB+28+P.slot; // labels ride ON the channel
|
|
1607
|
+
const colR=r=>Math.max(...lane(r).map(n=>n.x+n.w));
|
|
1608
|
+
const blockedV=(y1,y2,xx,skip)=>nodes.some(n=>n!==skip&&!n.boundary&&n.x<xx&&n.x+n.w>xx&&n.y+n.h>y1&&n.y<y2);
|
|
1609
|
+
const sx=A===B?A.x+A.w*0.3:A.x+A.w/2, tx=A===B?B.x+B.w*0.7:B.x+B.w/2;
|
|
1610
|
+
if(A!==B&&blockedV(A.y+A.h,chY,sx,A)){
|
|
1611
|
+
const gx=colR(A.rank)+10+ring*7;
|
|
1612
|
+
pts.push([outSide(A,'r'),A.y+A.h/2],[gx,A.y+A.h/2],[gx,chY]);
|
|
1613
|
+
} else pts.push([sx,outSide(A,'b')],[sx,chY]);
|
|
1614
|
+
if(A!==B&&blockedV(B.y+B.h,chY,tx,B)){
|
|
1615
|
+
const gx=colR(B.rank)+10+ring*7;
|
|
1616
|
+
pts.push([gx,chY],[gx,B.y+B.h/2],[outSide(B,'r'),B.y+B.h/2]);
|
|
1617
|
+
} else pts.push([tx,chY],[tx,outSide(B,'b')]);
|
|
1618
|
+
if(e.mid){
|
|
1619
|
+
const c1=pts.findIndex(p=>p[1]===chY);
|
|
1620
|
+
reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
|
|
1621
|
+
}
|
|
1622
|
+
} else if(P.ringOK){ // concentric ring: under, around, over, in
|
|
1623
|
+
const sx=A.x+A.w/2;
|
|
1624
|
+
const gy=occB+14+ring*12, chX=occR+28+P.slot, topY=chTop-14-ring*12;
|
|
1625
|
+
pts.push([sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]);
|
|
1626
|
+
if(e.mid){
|
|
1627
|
+
const c1=pts.findIndex(p=>p[0]===chX);
|
|
1628
|
+
reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
|
|
1629
|
+
}
|
|
1630
|
+
} else { // channel runs right of the lanes
|
|
1631
|
+
const chX=occR+28+P.slot;
|
|
1632
|
+
const laneB=r=>Math.max(...lane(r).map(n=>n.y+n.h));
|
|
1633
|
+
const blockedH=(x1,x2,yy,skip)=>nodes.some(n=>n!==skip&&!n.boundary&&n.y<yy&&n.y+n.h>yy&&n.x+n.w>x1&&n.x<x2);
|
|
1634
|
+
const sy=A===B?A.y+A.h*0.3:A.y+A.h/2, ty=A===B?B.y+B.h*0.7:B.y+B.h/2;
|
|
1635
|
+
if(A!==B&&blockedH(A.x+A.w,chX,sy,A)){
|
|
1636
|
+
const gy=laneB(A.rank)+10+ring*7;
|
|
1637
|
+
pts.push([A.x+A.w/2,outSide(A,'b')],[A.x+A.w/2,gy],[chX,gy]);
|
|
1638
|
+
} else pts.push([outSide(A,'r'),sy],[chX,sy]);
|
|
1639
|
+
if(A!==B&&blockedH(B.x+B.w,chX,ty,B)){
|
|
1640
|
+
const gy=laneB(B.rank)+10+ring*7;
|
|
1641
|
+
pts.push([chX,gy],[B.x+B.w/2,gy],[B.x+B.w/2,outSide(B,'b')]);
|
|
1642
|
+
} else pts.push([chX,ty],[outSide(B,'r'),ty]);
|
|
1643
|
+
if(e.mid){
|
|
1644
|
+
const c1=pts.findIndex(p=>p[0]===chX);
|
|
1645
|
+
reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
// non-incident nodes are obstacles for the channel runs too: a run
|
|
1649
|
+
// that would cut through a sibling (e.g. a pinned node parked on the
|
|
1650
|
+
// escape lane) detours around it instead of drawing across it. When
|
|
1651
|
+
// spanning the following corner point gives a shorter total run than
|
|
1652
|
+
// detour + remaining leg (a detour "spike"), the corner is dropped.
|
|
1653
|
+
const obsN=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
|
|
1654
|
+
const plen=pp=>{let s=0;for(let k=0;k+1<pp.length;k++)s+=Math.hypot(pp[k+1][0]-pp[k][0],pp[k+1][1]-pp[k][1]);return s;};
|
|
1655
|
+
for(let i=0;i+1<pts.length;i++){
|
|
1656
|
+
const d=routeAround(pts[i],pts[i+1],obsN);
|
|
1657
|
+
if(!d) continue;
|
|
1658
|
+
let ins=d.slice(1,-1), drop=0;
|
|
1659
|
+
if(i+2<pts.length){
|
|
1660
|
+
const span=segHitsObs(pts[i],pts[i+2],obsN)
|
|
1661
|
+
?routeAround(pts[i],pts[i+2],obsN):[pts[i],pts[i+2]];
|
|
1662
|
+
if(span&&plen(span)<plen(d)+Math.hypot(pts[i+2][0]-pts[i+1][0],pts[i+2][1]-pts[i+1][1])-1e-6){
|
|
1663
|
+
ins=span.slice(1,-1); drop=1;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
pts.splice(i+1,drop,...ins);
|
|
1667
|
+
i+=ins.length;
|
|
1668
|
+
}
|
|
1669
|
+
for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
|
|
1670
|
+
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
1671
|
+
noteSegs(e,pts);
|
|
1672
|
+
if(e.tail) seg(pts[0],pts[1],0.5,e.tail,10);
|
|
1673
|
+
if(e.head) seg(pts[pts.length-1],pts[pts.length-2],0.5,e.head,10);
|
|
1674
|
+
if(wantsStart) arrowTri(pts[0],pts[1],col);
|
|
1675
|
+
if(wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
|
|
1676
|
+
continue;
|
|
1677
|
+
}
|
|
1678
|
+
const chain=chains.get(e);
|
|
1679
|
+
if(chain){
|
|
1680
|
+
// multi-layer edge: polyline through its reserved waypoint lane;
|
|
1681
|
+
// each waypoint contributes an entry and an exit port so the run
|
|
1682
|
+
// through a layer is parallel to it and diagonals stay in the gaps
|
|
1683
|
+
const pts=[];
|
|
1684
|
+
let px=A.x+A.w/2, py=A.y+A.h/2;
|
|
1685
|
+
for(const v of chain.slice(1,-1)){
|
|
1686
|
+
const cx=v.x+v.w/2, cy=v.y+v.h/2;
|
|
1687
|
+
let p=horiz?[v.x,cy]:[cx,v.y], q=horiz?[v.x+v.w,cy]:[cx,v.y+v.h];
|
|
1688
|
+
if(horiz? px>cx : py>cy){ const t=p; p=q; q=t; }
|
|
1689
|
+
pts.push(p,q); px=q[0]; py=q[1];
|
|
1690
|
+
}
|
|
1691
|
+
const p0=borderPoint(A,pts[0][0],pts[0][1]);
|
|
1692
|
+
const p1=borderPoint(B,pts[pts.length-1][0],pts[pts.length-1][1]);
|
|
1693
|
+
pts.unshift(p0); pts.push(p1);
|
|
1694
|
+
// the middle waypoint's own port run carries the label; capture it now,
|
|
1695
|
+
// before collinear simplification renumbers the point list
|
|
1696
|
+
let midSeg=null;
|
|
1697
|
+
if(e.mid){ const j=Math.floor((chain.length-3)/2); midSeg=[pts[1+2*j],pts[2+2*j]]; }
|
|
1698
|
+
// Obstacle avoidance on the chain run: the excursion clamp pulls a long
|
|
1699
|
+
// forward run in toward its own endpoints, which can make a connector
|
|
1700
|
+
// segment graze a node the edge does not touch. routeAround detours only
|
|
1701
|
+
// segments that actually hit an obstacle (it returns null otherwise), so
|
|
1702
|
+
// clean chains — every chain before this change — are left byte-for-byte
|
|
1703
|
+
// unchanged; only a clamped segment that would pierce a node gets bent
|
|
1704
|
+
// around it. This keeps the edge-through-node count from regressing while
|
|
1705
|
+
// the clamp does its job. Same obstacle set the straight edges use.
|
|
1706
|
+
{
|
|
1707
|
+
const obs=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
|
|
1708
|
+
for(let i=0;i+1<pts.length;i++){
|
|
1709
|
+
const d=routeAround(pts[i],pts[i+1],obs);
|
|
1710
|
+
if(!d) continue;
|
|
1711
|
+
const ins=d.slice(1,-1);
|
|
1712
|
+
if(midSeg&&midSeg[0]===pts[i]&&midSeg[1]===pts[i+1]&&ins.length) midSeg=[pts[i],ins[0]];
|
|
1713
|
+
pts.splice(i+1,0,...ins); i+=ins.length;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
// Collapse collinear interior points: home-anchored waypoints line up, so
|
|
1717
|
+
// the per-rank entry/exit ports leave long straight runs punctuated by
|
|
1718
|
+
// redundant vertices. Dropping points that lie on the segment between
|
|
1719
|
+
// their neighbours turns a 40-point staircase into the 2–4 bends a
|
|
1720
|
+
// dummy-vertex chain should have, without moving the drawn line.
|
|
1721
|
+
simplifyPts(pts);
|
|
1722
|
+
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
1723
|
+
noteSegs(e,pts);
|
|
1724
|
+
if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
|
|
1725
|
+
if(e.tail) seg(p0,pts[1],0.4,e.tail,10);
|
|
1726
|
+
if(e.head) seg(p1,pts[pts.length-2],0.4,e.head,10);
|
|
1727
|
+
if(wantsStart) arrowTri(pts[0],pts[1],col);
|
|
1728
|
+
if(wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
const ax=A.x+A.w/2, ay=A.y+A.h/2, bx=B.x+B.w/2, by=B.y+B.h/2;
|
|
1732
|
+
let [x1,yy1]=borderPoint(A,bx,by), [x2,yy2]=borderPoint(B,ax,ay);
|
|
1733
|
+
const ap=apOff.get(e); // anti-parallel fan-out (labels ride along)
|
|
1734
|
+
if(ap){ x1+=ap[0]; yy1+=ap[1]; x2+=ap[0]; yy2+=ap[1]; }
|
|
1735
|
+
// group boxes and non-incident nodes are routing obstacles: a straight
|
|
1736
|
+
// run that pierces a node it does not touch, or a group box it neither
|
|
1737
|
+
// starts nor ends inside, detours around the obstacle boundary instead
|
|
1738
|
+
// (routeAround). Obstacle-free edges keep the plain line unchanged.
|
|
1739
|
+
let route=null;
|
|
1740
|
+
if(A!==B){
|
|
1741
|
+
const obs=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
|
|
1742
|
+
for(const k in gBox){
|
|
1743
|
+
const b=gBox[k];
|
|
1744
|
+
const inG=(px,py)=>px>b.x0&&px<b.x1&&py>b.yA&&py<b.yB;
|
|
1745
|
+
if(!inG(x1,yy1)&&!inG(x2,yy2))
|
|
1746
|
+
obs.push({x:b.x0,y:b.yA,w:b.x1-b.x0,h:b.yB-b.yA});
|
|
1747
|
+
}
|
|
1748
|
+
route=routeAround([x1,yy1],[x2,yy2],obs);
|
|
1749
|
+
if(route){ // leave the node facing the first/last bend
|
|
1750
|
+
route[0]=borderPoint(A,route[1][0],route[1][1]);
|
|
1751
|
+
route[route.length-1]=borderPoint(B,route[route.length-2][0],route[route.length-2][1]);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
if(!route && A!==B && doc.routing==='orthogonal' && ax!==bx && ay!==by){
|
|
1755
|
+
// document-level orthogonal routing (0.1-dev.13): plain straight
|
|
1756
|
+
// edges become deterministic manhattan elbows — horizontal-then-
|
|
1757
|
+
// vertical under flow right|left, vertical-then-horizontal under
|
|
1758
|
+
// down|up — anchored by the same borderPoint rule (the corner sits
|
|
1759
|
+
// on the far node's main-axis line, so exits are clean right-angle
|
|
1760
|
+
// stubs). Obstacle detours and channel/chain polylines keep their
|
|
1761
|
+
// existing shapes; axis-aligned pairs stay plain straight lines.
|
|
1762
|
+
const corner=horiz?[bx,ay]:[ax,by];
|
|
1763
|
+
let sP=borderPoint(A,corner[0],corner[1]), tP=borderPoint(B,corner[0],corner[1]);
|
|
1764
|
+
if(ap){ sP=[sP[0]+ap[0],sP[1]+ap[1]]; tP=[tP[0]+ap[0],tP[1]+ap[1]]; }
|
|
1765
|
+
const mid=horiz?[tP[0],sP[1]]:[sP[0],tP[1]];
|
|
1766
|
+
const deg=(pp,qq)=>Math.hypot(pp[0]-qq[0],pp[1]-qq[1])<0.5;
|
|
1767
|
+
if(!deg(mid,sP)&&!deg(mid,tP)) route=[sP,mid,tP];
|
|
1768
|
+
}
|
|
1769
|
+
if(route){
|
|
1770
|
+
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(route)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
1771
|
+
noteSegs(e,route);
|
|
1772
|
+
if(e.mid){ // the longest segment carries the mid label
|
|
1773
|
+
let bi=0,bl=-1;
|
|
1774
|
+
for(let i=0;i+1<route.length;i++){
|
|
1775
|
+
const l=Math.hypot(route[i+1][0]-route[i][0],route[i+1][1]-route[i][1]);
|
|
1776
|
+
if(l>bl){ bl=l; bi=i; }
|
|
1777
|
+
}
|
|
1778
|
+
reqLabel({p:route[bi],q:route[bi+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:bi===0});
|
|
1779
|
+
}
|
|
1780
|
+
if(e.tail) seg(route[0],route[1],0.25,e.tail,10);
|
|
1781
|
+
if(e.head) seg(route[route.length-1],route[route.length-2],0.25,e.head,10);
|
|
1782
|
+
if(wantsStart) arrowTri(route[0],route[1],col);
|
|
1783
|
+
if(wantsEnd) arrowTri(route[route.length-1],route[route.length-2],col);
|
|
1784
|
+
for(const pP of route){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+4-y0-20); }
|
|
1785
|
+
continue;
|
|
1786
|
+
}
|
|
1787
|
+
esvg.push('<line data-edge="'+e.line+'" x1="'+x1+'" y1="'+yy1+'" x2="'+x2+'" y2="'+yy2+'" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
1788
|
+
noteSegs(e,[[x1,yy1],[x2,yy2]]);
|
|
1789
|
+
if(e.mid)
|
|
1790
|
+
reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:true});
|
|
1791
|
+
// endpoint labels at the tail/head positions (three-position model, R34)
|
|
1792
|
+
if(e.tail) seg([x1,yy1],[x2,yy2],0.18,e.tail,10);
|
|
1793
|
+
if(e.head) seg([x1,yy1],[x2,yy2],0.82,e.head,10);
|
|
1794
|
+
if(wantsStart) arrowTri([x1,yy1],[x2,yy2],col);
|
|
1795
|
+
if(wantsEnd) arrowTri([x2,yy2],[x1,yy1],col);
|
|
1796
|
+
}
|
|
1797
|
+
// ── edge-label placement: candidates + greedy collision-aware choice ─────
|
|
1798
|
+
// For each registered label the carrying segment is sampled at several
|
|
1799
|
+
// parameters t and on both sides of the line, giving a small candidate set.
|
|
1800
|
+
// Candidates are scored by overlap area against everything already on the
|
|
1801
|
+
// canvas — the labels placed before it (document order), the node boxes,
|
|
1802
|
+
// the arrowheads, and the other edges — plus a pull back toward the
|
|
1803
|
+
// preferred point on the segment. The lowest score wins. No randomness,
|
|
1804
|
+
// no iteration to a fixed point: one deterministic pass.
|
|
1805
|
+
if(lblReq.length){
|
|
1806
|
+
const obst=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h,n}));
|
|
1807
|
+
const ovl=(a,b)=>{
|
|
1808
|
+
const ix=Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x);
|
|
1809
|
+
const iy=Math.min(a.y+a.h,b.y+b.h)-Math.max(a.y,b.y);
|
|
1810
|
+
return ix>0&&iy>0?ix*iy:0;
|
|
1811
|
+
};
|
|
1812
|
+
// Liang-Barsky: does segment p→q touch the interior of rect r?
|
|
1813
|
+
const segHit=(p,q,r)=>{
|
|
1814
|
+
let t0=0,t1=1;
|
|
1815
|
+
const d=[q[0]-p[0],q[1]-p[1]];
|
|
1816
|
+
const P=[-d[0],d[0],-d[1],d[1]];
|
|
1817
|
+
const Q=[p[0]-r.x, r.x+r.w-p[0], p[1]-r.y, r.y+r.h-p[1]];
|
|
1818
|
+
for(let i=0;i<4;i++){
|
|
1819
|
+
if(Math.abs(P[i])<1e-9){ if(Q[i]<0) return false; continue; }
|
|
1820
|
+
const t=Q[i]/P[i];
|
|
1821
|
+
if(P[i]<0){ if(t>t1) return false; if(t>t0) t0=t; }
|
|
1822
|
+
else { if(t<t0) return false; if(t<t1) t1=t; }
|
|
1823
|
+
}
|
|
1824
|
+
return t1>t0;
|
|
1825
|
+
};
|
|
1826
|
+
const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
|
|
1827
|
+
const cand=(r,t,side)=>{
|
|
1828
|
+
const lines=String(r.text).split('\n'), n=lines.length;
|
|
1829
|
+
const w=Math.max(...lines.map(l=>l.length))*6.5*r.fs/11;
|
|
1830
|
+
const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
|
|
1831
|
+
const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
|
|
1832
|
+
const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
|
|
1833
|
+
let bx,by,x,anchor=n>1?'middle':'start';
|
|
1834
|
+
if(side==='on') { bx=mx-w/2; by=my-4-up; anchor='middle'; }
|
|
1835
|
+
else if(side==='above') { bx=mx-w/2; by=my-3-h; anchor='middle'; }
|
|
1836
|
+
else if(side==='below') { bx=mx-w/2; by=my+3; anchor='middle'; }
|
|
1837
|
+
else if(side==='right') { bx=mx+6; by=my-h/2; }
|
|
1838
|
+
else { bx=mx-6-w; by=my-h/2; }
|
|
1839
|
+
x=anchor==='middle'?bx+w/2:bx;
|
|
1840
|
+
return {x,y:by+up,anchor,t,side,box:{x:bx,y:by,w,h}};
|
|
1841
|
+
};
|
|
1842
|
+
const placed=[];
|
|
1843
|
+
for(const r of lblReq){
|
|
1844
|
+
const dx=r.q[0]-r.p[0], dy=r.q[1]-r.p[1];
|
|
1845
|
+
const across=Math.abs(dx)>=Math.abs(dy);
|
|
1846
|
+
let sides, ts, tPref;
|
|
1847
|
+
if(r.kind==='end'){
|
|
1848
|
+
// endpoint labels keep their historical spot as first choice
|
|
1849
|
+
sides=['on'].concat(across?['above','below']:['right','left']);
|
|
1850
|
+
tPref=r.t0;
|
|
1851
|
+
ts=[r.t0,r.t0-0.06,r.t0+0.06,r.t0-0.12,r.t0+0.12].map(CLAMP);
|
|
1852
|
+
} else {
|
|
1853
|
+
sides=across?['above','below']:['right','left'];
|
|
1854
|
+
// flowchart convention: a short branch marker leaving a decision node
|
|
1855
|
+
// reads as that branch's name only if it sits next to the decision
|
|
1856
|
+
const branch=r.first && r.A && r.A.shape==='diamond' &&
|
|
1857
|
+
String(r.text).length<=3 && !String(r.text).includes('\n');
|
|
1858
|
+
tPref=branch?0.22:0.5;
|
|
1859
|
+
ts=branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72];
|
|
1860
|
+
}
|
|
1861
|
+
let best=null,bestS=Infinity;
|
|
1862
|
+
for(let si=0;si<sides.length;si++) for(const t of ts){
|
|
1863
|
+
const c=cand(r,t,sides[si]);
|
|
1864
|
+
let s=0;
|
|
1865
|
+
for(const b of placed) s+=3*ovl(c.box,b);
|
|
1866
|
+
for(const o of obst) s+=(o.n===r.A||o.n===r.B?6:2.4)*ovl(c.box,o);
|
|
1867
|
+
for(const a of arrowBox) s+=4*ovl(c.box,a);
|
|
1868
|
+
for(const g of edgeSegs) if(g.e!==r.e && segHit(g.p,g.q,c.box)) s+=26;
|
|
1869
|
+
s+=70*Math.abs(t-tPref)+si*10;
|
|
1870
|
+
if(c.box.x<2) s+=400; // would fall off the left margin
|
|
1871
|
+
if(s<bestS-1e-9){ bestS=s; best=c; }
|
|
1872
|
+
}
|
|
1873
|
+
lblsvg[r.idx]=textEl(best.x,best.y,r.fs,best.anchor,r.col,r.text,r.halo);
|
|
1874
|
+
placed.push(best.box);
|
|
1875
|
+
W=Math.max(W, best.box.x+best.box.w+4);
|
|
1876
|
+
Hh=Math.max(Hh, best.box.y+best.box.h+4-y0-20);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
// nodes on top (each wrapped in a draggable, identifiable group)
|
|
1880
|
+
const nsvg=[];
|
|
1881
|
+
for(const n of nodes){
|
|
1882
|
+
if(n.boundary){
|
|
1883
|
+
// never drawn as a shape (R44): the edge already ended open at the
|
|
1884
|
+
// anchor; a declared label sits just beyond the open end, on the
|
|
1885
|
+
// side away from the figure — small muted text with a white halo
|
|
1886
|
+
if(!n.label) continue;
|
|
1887
|
+
const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
|
|
1888
|
+
const bhalo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
|
|
1889
|
+
if(Math.abs(bdx)>=Math.abs(bdy)){
|
|
1890
|
+
if(bdx>=0){ lblsvg.push(textEl(cx+10,cy+3.5,10,'start','#555',n.label,bhalo));
|
|
1891
|
+
W=Math.max(W,cx+12+lblPx(n.label)); }
|
|
1892
|
+
else lblsvg.push(textEl(cx-10,cy+3.5,10,'end','#555',n.label,bhalo));
|
|
1893
|
+
} else if(bdy>=0){
|
|
1894
|
+
lblsvg.push(textEl(cx,cy+17,10,'middle','#555',n.label,bhalo));
|
|
1895
|
+
Hh=Math.max(Hh,cy+21-y0-20);
|
|
1896
|
+
} else lblsvg.push(textEl(cx,cy-10,10,'middle','#555',n.label,bhalo));
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'" style="cursor:move">');
|
|
1900
|
+
const fill=n.color||'#fff', stroke=n.stroke||'#8a8880', txt=n.text||'#1d1d1b';
|
|
1901
|
+
const ndash=n.style==='dashed'?' stroke-dasharray="6 4"':(n.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
1902
|
+
if(n.shape==='diamond'){
|
|
1903
|
+
const cx=n.x+n.w/2, cy=n.y+n.h/2;
|
|
1904
|
+
nsvg.push('<polygon points="'+cx+','+n.y+' '+(n.x+n.w)+','+cy+' '+cx+','+(n.y+n.h)+' '+n.x+','+cy+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
|
|
1905
|
+
} else if(n.shape==='rounded'){
|
|
1906
|
+
nsvg.push('<rect x="'+n.x+'" y="'+n.y+'" width="'+n.w+'" height="'+n.h+'" rx="'+Math.min(14,n.h/2)+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+' stroke-width="1.8"/>');
|
|
1907
|
+
} else if(n.shape==='cloud'){
|
|
1908
|
+
nsvg.push('<ellipse cx="'+(n.x+n.w/2)+'" cy="'+(n.y+n.h/2)+'" rx="'+(n.w/2+10)+'" ry="'+(n.h/2+8)+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
|
|
1909
|
+
} else if(n.shape==='ellipse'||n.shape==='circle'){
|
|
1910
|
+
nsvg.push('<ellipse cx="'+(n.x+n.w/2)+'" cy="'+(n.y+n.h/2)+'" rx="'+(n.w/2)+'" ry="'+(n.shape==='circle'?n.w/2:n.h/2)+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
|
|
1911
|
+
} else if(n.shape==='cylinder'){
|
|
1912
|
+
nsvg.push('<rect x="'+n.x+'" y="'+n.y+'" width="'+n.w+'" height="'+n.h+'" rx="3" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>'
|
|
1913
|
+
+'<line x1="'+n.x+'" y1="'+(n.y+7)+'" x2="'+(n.x+n.w)+'" y2="'+(n.y+7)+'" stroke="'+stroke+'"/>');
|
|
1914
|
+
} else {
|
|
1915
|
+
// box = right-angle rectangle (the mainstream default: Mermaid/
|
|
1916
|
+
// Graphviz rects, hardware block diagrams); use shape=rounded for corners
|
|
1917
|
+
nsvg.push('<rect x="'+n.x+'" y="'+n.y+'" width="'+n.w+'" height="'+n.h+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
|
|
1918
|
+
}
|
|
1919
|
+
// zone bands on this node (dir: up=bottom-based, down, left, right)
|
|
1920
|
+
for(const f of doc.fills){
|
|
1921
|
+
if(f.target!==n.id) continue;
|
|
1922
|
+
const [bx,by,bw,bh]=bandRect(f,n.x,n.y,n.x+n.w,n.y+n.h);
|
|
1923
|
+
nsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.color+'" opacity="0.9"/>');
|
|
1924
|
+
}
|
|
1925
|
+
// label with shrink-to-fit when size is rigid (R10); multi-line via "\n"
|
|
1926
|
+
let fs=FONT;
|
|
1927
|
+
const nl=String(n.label).split('\n');
|
|
1928
|
+
const need=Math.max(...nl.map(l=>l.length))*CH;
|
|
1929
|
+
// budget = the width the OUTLINE offers at the label's own height, minus
|
|
1930
|
+
// 8 px clearance each side. For a box that is n.w-16, exactly as before;
|
|
1931
|
+
// for a rhombus or an ellipse it is the inscribed width, so a rigid
|
|
1932
|
+
// shaped node shrinks its text to what the reader can actually see.
|
|
1933
|
+
const avail=2*inscribedHalfW(shapeAxes(n),nl.length*8)-16;
|
|
1934
|
+
if(n.rigid && need>avail) fs=Math.max(8, FONT*avail/need);
|
|
1935
|
+
nsvg.push(textEl(n.x+n.w/2, n.y+n.h/2+fs*0.35, fs, 'middle', txt, n.label));
|
|
1936
|
+
nsvg.push('</g>');
|
|
1937
|
+
}
|
|
1938
|
+
// trunk rings (semantic LAG/ES bundles): the ellipse is DERIVED from the
|
|
1939
|
+
// member links' midpoints — drag a node and the ring follows
|
|
1940
|
+
const tsvg=[];
|
|
1941
|
+
for(const t of doc.trunks){
|
|
1942
|
+
const mids=[];
|
|
1943
|
+
for(const [a,b] of t.pairs){
|
|
1944
|
+
const A=byId[a], B=byId[b]; if(!A||!B) continue;
|
|
1945
|
+
const [x1,yy1]=borderPoint(A,B.x+B.w/2,B.y+B.h/2);
|
|
1946
|
+
const [x2,yy2]=borderPoint(B,A.x+A.w/2,A.y+A.h/2);
|
|
1947
|
+
mids.push([(x1+x2)/2,(yy1+yy2)/2]);
|
|
1948
|
+
}
|
|
1949
|
+
if(!mids.length) continue;
|
|
1950
|
+
const cx=mids.reduce((s,m)=>s+m[0],0)/mids.length;
|
|
1951
|
+
const cy=mids.reduce((s,m)=>s+m[1],0)/mids.length;
|
|
1952
|
+
const rx=Math.max(46, Math.max(...mids.map(m=>Math.abs(m[0]-cx)))+38);
|
|
1953
|
+
const ry=Math.max(26, Math.max(...mids.map(m=>Math.abs(m[1]-cy)))+22);
|
|
1954
|
+
const col=t.color||'#64748b';
|
|
1955
|
+
tsvg.push('<ellipse cx="'+cx+'" cy="'+cy+'" rx="'+rx+'" ry="'+ry+'" fill="transparent" stroke="'+col+'" stroke-dasharray="6 4" stroke-width="1.6"/>');
|
|
1956
|
+
tsvg.push(textEl(cx, cy+4, 11.5, 'middle', col, t.label,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
|
|
1957
|
+
W=Math.max(W,cx+rx); Hh=Math.max(Hh,cy+ry-y0-20);
|
|
1958
|
+
}
|
|
1959
|
+
// guide lines + labels (top layer)
|
|
1960
|
+
for(const gl of doc.glines){
|
|
1961
|
+
const B=gBox[gl.group]; if(!B) continue;
|
|
1962
|
+
const ly=B.yB-(B.yB-B.yA)*gl.pct/100;
|
|
1963
|
+
const col=gl.color||'#ef4444';
|
|
1964
|
+
tsvg.push('<g data-gline="'+gl.line+'" data-gtop="'+B.yA+'" data-gbot="'+B.yB+'" style="cursor:ns-resize">'
|
|
1965
|
+
+'<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="'+col+'" stroke-width="'+(gl.pct>=100?4:2)+'" stroke-dasharray="7 4"/>'
|
|
1966
|
+
+'<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="transparent" stroke-width="12"/>'
|
|
1967
|
+
+textEl(B.x1+8, ly+4, 11, 'start', col, gl.label,' paint-order="stroke" stroke="#fff" stroke-width="3"')+'</g>');
|
|
1968
|
+
W=Math.max(W, B.x1+8+tw(gl.label)); Hh=Math.max(Hh, B.yB-y0-20);
|
|
1969
|
+
}
|
|
1970
|
+
const yEnd=y0+20+Hh+10;
|
|
1971
|
+
return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
|
|
1972
|
+
meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}};
|
|
1973
|
+
}
|
|
1974
|
+
// borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
|
|
1975
|
+
// It must leave the DRAWN outline: a rectangle clip on a diamond or an ellipse
|
|
1976
|
+
// stops on the bounding box, which for a rhombus can be a corner where the
|
|
1977
|
+
// shape is not — the endpoint then floats in empty space (or, on a cloud whose
|
|
1978
|
+
// ellipse is drawn outside the box, hides under the fill).
|
|
1979
|
+
function borderPoint(n,tx,ty){
|
|
1980
|
+
const cx=n.x+n.w/2, cy=n.y+n.h/2, dx=tx-cx, dy=ty-cy;
|
|
1981
|
+
if(dx===0&&dy===0) return [cx,cy];
|
|
1982
|
+
const g=shapeAxes(n);
|
|
1983
|
+
// rectangles keep the original min-of-ratios expression (bit-for-bit, so
|
|
1984
|
+
// no rectangle figure moves); curved shapes divide the direction by the
|
|
1985
|
+
// homogeneous outline norm, which lands exactly on the outline.
|
|
1986
|
+
const s=g.p===Infinity
|
|
1987
|
+
? Math.min(g.a/Math.abs(dx||1e-9), g.b/Math.abs(dy||1e-9))
|
|
1988
|
+
: 1/outlineNorm(g,dx,dy);
|
|
1989
|
+
return [cx+dx*s, cy+dy*s];
|
|
1990
|
+
}
|
|
1991
|
+
// straight-edge obstacle routing: group boxes and non-incident nodes are
|
|
1992
|
+
// obstacles a straight edge must not cut through.
|
|
1993
|
+
// clipSegRect (Liang-Barsky): the [t0,t1] parameter window where segment
|
|
1994
|
+
// p->q lies inside the box, or null when it misses entirely.
|
|
1995
|
+
function clipSegRect(p,q,x0,y0,x1,y1){
|
|
1996
|
+
let t0=0,t1=1; const dx=q[0]-p[0], dy=q[1]-p[1];
|
|
1997
|
+
for(const [den,num] of [[-dx,p[0]-x0],[dx,x1-p[0]],[-dy,p[1]-y0],[dy,y1-p[1]]]){
|
|
1998
|
+
if(den===0){ if(num<0) return null; continue; }
|
|
1999
|
+
const t=num/den;
|
|
2000
|
+
if(den<0){ if(t>t1) return null; if(t>t0) t0=t; }
|
|
2001
|
+
else { if(t<t0) return null; if(t<t1) t1=t; }
|
|
2002
|
+
}
|
|
2003
|
+
return t0<t1?[t0,t1]:null;
|
|
2004
|
+
}
|
|
2005
|
+
// routeAround: detour segment p->q around obstacle rects via the shortest
|
|
2006
|
+
// clear polyline. Candidate bend points are the corners of each obstacle
|
|
2007
|
+
// expanded by a 10px clearance margin; a sight-line is clear when it cuts
|
|
2008
|
+
// no obstacle interior. Dijkstra over that visibility graph keeps routes
|
|
2009
|
+
// short and calm, and is deterministic (fixed vertex order — endpoints,
|
|
2010
|
+
// then obstacles in caller order with corners clockwise from top-left —
|
|
2011
|
+
// breaks ties). Obstacles already containing an endpoint cannot be
|
|
2012
|
+
// avoided and are ignored. Returns the detour polyline, or null when the
|
|
2013
|
+
// straight segment is clear — callers keep their original rendering then.
|
|
2014
|
+
function segHitsObs(p,q,obs){
|
|
2015
|
+
for(const r of obs)
|
|
2016
|
+
if(clipSegRect(p,q,r.x+2,r.y+2,r.x+r.w-2,r.y+r.h-2)) return true;
|
|
2017
|
+
return false;
|
|
2018
|
+
}
|
|
2019
|
+
function routeAround(p,q,obs){
|
|
2020
|
+
const M=10;
|
|
2021
|
+
const inside=(pt,r)=>pt[0]>r.x+2&&pt[0]<r.x+r.w-2&&pt[1]>r.y+2&&pt[1]<r.y+r.h-2;
|
|
2022
|
+
obs=obs.filter(r=>!inside(p,r)&&!inside(q,r));
|
|
2023
|
+
const blocked=(a,b)=>segHitsObs(a,b,obs);
|
|
2024
|
+
if(!blocked(p,q)) return null;
|
|
2025
|
+
const V=[p.slice(),q.slice()];
|
|
2026
|
+
for(const r of obs){
|
|
2027
|
+
const L=r.x-M, T=r.y-M, R=r.x+r.w+M, B=r.y+r.h+M;
|
|
2028
|
+
for(const c of [[L,T],[R,T],[R,B],[L,B]])
|
|
2029
|
+
if(!obs.some(o=>inside(c,o))) V.push(c);
|
|
2030
|
+
}
|
|
2031
|
+
const n=V.length, dist=Array(n).fill(Infinity), from=Array(n).fill(-1), done=Array(n).fill(false);
|
|
2032
|
+
dist[0]=0;
|
|
2033
|
+
for(;;){
|
|
2034
|
+
let u=-1;
|
|
2035
|
+
for(let i=0;i<n;i++) if(!done[i]&&(u<0||dist[i]<dist[u])) u=i;
|
|
2036
|
+
if(u<0||u===1||dist[u]===Infinity) break;
|
|
2037
|
+
done[u]=true;
|
|
2038
|
+
for(let v=0;v<n;v++){
|
|
2039
|
+
if(done[v]||blocked(V[u],V[v])) continue;
|
|
2040
|
+
const d=dist[u]+Math.hypot(V[v][0]-V[u][0],V[v][1]-V[u][1]);
|
|
2041
|
+
if(d<dist[v]-1e-9){ dist[v]=d; from[v]=u; }
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
if(dist[1]===Infinity) return null; // boxed in: keep the straight line
|
|
2045
|
+
const pts=[]; for(let v=1;v!==-1;v=from[v]) pts.push(V[v]); pts.reverse();
|
|
2046
|
+
for(let i=1;i+1<pts.length;i++){ // drop duplicate / collinear midpoints
|
|
2047
|
+
const a=pts[i-1], b=pts[i], c=pts[i+1];
|
|
2048
|
+
const cr=(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]);
|
|
2049
|
+
if(Math.hypot(b[0]-a[0],b[1]-a[1])<0.5||Math.abs(cr)<1e-6){ pts.splice(i,1); i--; }
|
|
2050
|
+
}
|
|
2051
|
+
return pts.length>2?pts:null;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// ---- bitfield ----
|
|
2055
|
+
function renderBitfield(b,y0){
|
|
2056
|
+
const cell=Math.max(18,Math.min(28,Math.floor(760/b.unit))), rh=30, ruler=16;
|
|
2057
|
+
const svg=[]; let y=y0+18;
|
|
2058
|
+
svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(b.label)+'</text>');
|
|
2059
|
+
// ruler: lsb0 (register style, default) = N-1..0; msb0 (RFC style) = 0..N-1
|
|
2060
|
+
for(let i=0;i<b.unit;i++){
|
|
2061
|
+
const bit=(b.numbering==='msb0') ? i : b.unit-1-i;
|
|
2062
|
+
svg.push('<text x="'+(i*cell+cell/2)+'" y="'+(y+11)+'" font-size="8.5" text-anchor="middle" fill="#6f6e69">'+bit+'</text>');
|
|
2063
|
+
}
|
|
2064
|
+
y+=ruler;
|
|
2065
|
+
let pos=0; // bit cursor
|
|
2066
|
+
for(const f of b.fields){
|
|
2067
|
+
if(f.wrap){ pos=Math.ceil((pos||1)/b.unit)*b.unit; continue; }
|
|
2068
|
+
// '*' = variable-length: fill the remainder of the current row
|
|
2069
|
+
const w0 = f.w==='*' ? (b.unit - (pos % b.unit)) : f.w;
|
|
2070
|
+
let rem = w0;
|
|
2071
|
+
while(rem>0){
|
|
2072
|
+
const row=Math.floor(pos/b.unit), col=pos%b.unit;
|
|
2073
|
+
const span=Math.min(rem, b.unit-col);
|
|
2074
|
+
const x=col*cell, yy=y+row*rh;
|
|
2075
|
+
const dash=f.optional?' stroke-dasharray="5 3"':'';
|
|
2076
|
+
svg.push('<rect x="'+x+'" y="'+yy+'" width="'+(span*cell)+'" height="'+rh+'" fill="'+(f.color||'#fff')+'" stroke="#555"'+dash+'/>');
|
|
2077
|
+
if(span*cell>String(f.name).length*6 || rem===w0){
|
|
2078
|
+
let fs=11; const need=String(f.name).length*6.2;
|
|
2079
|
+
if(need>span*cell-6) fs=Math.max(7,11*(span*cell-6)/need);
|
|
2080
|
+
svg.push('<text x="'+(x+span*cell/2)+'" y="'+(yy+rh/2+fs*0.35)+'" font-size="'+fs+'" text-anchor="middle">'+esc(f.name)+'</text>');
|
|
2081
|
+
}
|
|
2082
|
+
if(f.note && rem===w0)
|
|
2083
|
+
svg.push('<title>'+esc(f.note)+'</title>');
|
|
2084
|
+
pos+=span; rem-=span;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
const rows=Math.max(1,Math.ceil(pos/b.unit));
|
|
2088
|
+
return {svg:svg.join(''), y:y+rows*rh+6, w:b.unit*cell+2};
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// ---- table (with ^ rowspan / < colspan merging and per-cell marks) ----
|
|
2092
|
+
function renderTable(t,y0){
|
|
2093
|
+
const rh=26; const svg=[]; let y=y0+18;
|
|
2094
|
+
svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(t.label)+'</text>');
|
|
2095
|
+
// grid: header tiers (from `head`/`cols` lines) then data rows
|
|
2096
|
+
const H=t.heads.length;
|
|
2097
|
+
const grid=t.heads.map(hr=>hr.map(c=>({v:c.v,m:c.m,hdr:true})))
|
|
2098
|
+
.concat(t.rows.map(r=>r.cells.map(c=>({v:c.v,m:c.m}))));
|
|
2099
|
+
const hlRow=r=>r>=H&&(t.rowmarks||[]).some(mk=>mk.r===r-H+1);
|
|
2100
|
+
const alignOf=c=>(t.aligns&&t.aligns[c])||null;
|
|
2101
|
+
const widths=t.cols.map((c,i)=>{
|
|
2102
|
+
let w=30;
|
|
2103
|
+
for(const hr of t.heads) if(!hr[i].m) w=Math.max(w,tw(hr[i].v));
|
|
2104
|
+
for(const r of t.rows) if(!r.cells[i].m) w=Math.max(w,tw(r.cells[i].v));
|
|
2105
|
+
return w;
|
|
2106
|
+
});
|
|
2107
|
+
if(t.colw){ // colw: auto = natural, px fixed, % of natural total
|
|
2108
|
+
const base=widths.reduce((a,b2)=>a+b2,0);
|
|
2109
|
+
t.colw.vals.forEach((v,i)=>{
|
|
2110
|
+
if(v.t==='px') widths[i]=v.v;
|
|
2111
|
+
else if(v.t==='pct') widths[i]=v.v/100*base;
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
const totalW=widths.reduce((a,b)=>a+b,0);
|
|
2115
|
+
// cell marks: h1..hN address header tiers top-down, r>=1 the data rows
|
|
2116
|
+
const markOf=(r,c)=>(t.marks||[]).find(mk=>(mk.hdr?mk.r-1:H+mk.r-1)===r&&mk.c===c+1);
|
|
2117
|
+
const yTop=y+4;
|
|
2118
|
+
for(let r=0;r<grid.length;r++){
|
|
2119
|
+
for(let c=0;c<grid[r].length;c++){
|
|
2120
|
+
const cell=grid[r][c];
|
|
2121
|
+
if(cell.m) continue; // merged into an anchor cell
|
|
2122
|
+
let cs=1; while(c+cs<grid[r].length && grid[r][c+cs].m==='left') cs++;
|
|
2123
|
+
let rs=1; while(r+rs<grid.length && grid[r+rs][c].m==='up') rs++;
|
|
2124
|
+
const x=widths.slice(0,c).reduce((a,b)=>a+b,0);
|
|
2125
|
+
const wsum=widths.slice(c,c+cs).reduce((a,b)=>a+b,0);
|
|
2126
|
+
const yy=yTop+r*rh, h=rs*rh;
|
|
2127
|
+
const mk=markOf(r,c);
|
|
2128
|
+
const fill=mk?mk.color:(cell.hdr?'#eeede6':(hlRow(r)?'#fef3c7':'#fff'));
|
|
2129
|
+
// addressable cells carry table-id:row:col (row 0 = bottom header tier)
|
|
2130
|
+
const addrR = r>=H ? (r-H+1) : (r===H-1 ? 0 : null);
|
|
2131
|
+
const addr = addrR===null ? '' : ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
|
|
2132
|
+
svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="#c9c7bf"'+addr+'/>');
|
|
2133
|
+
// alignment: headers centered; data follows GFM colon alignment (default left)
|
|
2134
|
+
const al=cell.hdr?'center':(alignOf(c)||'left');
|
|
2135
|
+
const tx=al==='center'?x+wsum/2:(al==='right'?x+wsum-7:x+7);
|
|
2136
|
+
const anchor=al==='center'?'middle':(al==='right'?'end':'start');
|
|
2137
|
+
svg.push('<text x="'+tx+'" y="'+(yy+h/2+4.3)+'" font-size="12" text-anchor="'+anchor+'"'+(cell.hdr?' font-weight="600"':'')+'>'+esc(cell.v)+'</text>');
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
const yEnd=yTop+grid.length*rh;
|
|
2141
|
+
return {svg:svg.join(''), y:yEnd+6, w:totalW+2};
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// ---- plot bars3d: deterministic isometric projection of a table ----
|
|
2145
|
+
function shade(hex,f){
|
|
2146
|
+
const v=parseInt(hex.slice(1),16);
|
|
2147
|
+
const c=x=>Math.round(Math.max(0,Math.min(255,x))).toString(16).padStart(2,'0');
|
|
2148
|
+
return '#'+c(((v>>16)&255)*f)+c(((v>>8)&255)*f)+c((v&255)*f);
|
|
2149
|
+
}
|
|
2150
|
+
const PLOT_PALETTE=['#3b82f6','#22c55e','#f59e0b','#ef4444','#a855f7','#14b8a6','#eab308','#64748b'];
|
|
2151
|
+
function renderPlot(b,y0,doc){
|
|
2152
|
+
const t=doc.blocks.find(x=>x.type==='table'&&x.id===b.tid);
|
|
2153
|
+
const rows=t.rows.map(r=>r.cells.slice(1).map(c=>parseFloat(c.v)||0));
|
|
2154
|
+
const rLab=t.rows.map(r=>r.cells[0].v), cLab=t.cols.slice(1);
|
|
2155
|
+
const R=rows.length, C=cLab.length;
|
|
2156
|
+
const zmax=Math.max(b.level||0, ...rows.flat(), 1);
|
|
2157
|
+
const W2=20,H2=10,ZS=130/zmax,BAR=0.72;
|
|
2158
|
+
const ox=R*W2+8, oy=y0+18+ZS*zmax+6;
|
|
2159
|
+
const P=(r,c,z)=>[ox+(c-r)*W2, oy+(c+r)*H2-z*ZS];
|
|
2160
|
+
const svg=[];
|
|
2161
|
+
svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' — bars3d</text>');
|
|
2162
|
+
// floor grid edges
|
|
2163
|
+
const F=[P(0,0,0),P(R,0,0),P(R,C,0),P(0,C,0)];
|
|
2164
|
+
svg.push('<polygon points="'+F.map(p=>p.join(',')).join(' ')+'" fill="#f6f5ef" stroke="#d6d4cc"/>');
|
|
2165
|
+
// bars, far to near
|
|
2166
|
+
const order=[];
|
|
2167
|
+
for(let r=0;r<R;r++)for(let c=0;c<C;c++)order.push([r,c]);
|
|
2168
|
+
order.sort((a,b2)=>(a[0]+a[1])-(b2[0]+b2[1]));
|
|
2169
|
+
for(const [r,c] of order){
|
|
2170
|
+
const h=rows[r][c]; if(h<=0) continue;
|
|
2171
|
+
const col=PLOT_PALETTE[c%PLOT_PALETTE.length];
|
|
2172
|
+
const i0=r+(1-BAR)/2, i1=r+(1+BAR)/2, j0=c+(1-BAR)/2, j1=c+(1+BAR)/2;
|
|
2173
|
+
const A=P(i0,j0,h),Bp=P(i1,j0,h),Cp=P(i1,j1,h),D=P(i0,j1,h);
|
|
2174
|
+
const B0=P(i1,j0,0),C0=P(i1,j1,0),D0=P(i0,j1,0);
|
|
2175
|
+
svg.push('<polygon points="'+[Bp,Cp,C0,B0].map(p=>p.join(',')).join(' ')+'" fill="'+shade(col,0.72)+'"/>');
|
|
2176
|
+
svg.push('<polygon points="'+[Cp,D,D0,C0].map(p=>p.join(',')).join(' ')+'" fill="'+shade(col,0.55)+'"/>');
|
|
2177
|
+
svg.push('<polygon points="'+[A,Bp,Cp,D].map(p=>p.join(',')).join(' ')+'" fill="'+col+'"/>');
|
|
2178
|
+
}
|
|
2179
|
+
// threshold plane (translucent, drawn over bars like the convention)
|
|
2180
|
+
if(b.level!==null&&b.level!==undefined){
|
|
2181
|
+
const L=[P(0,0,b.level),P(R,0,b.level),P(R,C,b.level),P(0,C,b.level)];
|
|
2182
|
+
svg.push('<polygon points="'+L.map(p=>p.join(',')).join(' ')+'" fill="#ef4444" opacity="0.18" stroke="#ef4444" stroke-dasharray="6 4"/>');
|
|
2183
|
+
svg.push(textEl(L[3][0]+8, L[3][1]+4, 11, 'start', '#ef4444', 'level '+b.level,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
|
|
2184
|
+
}
|
|
2185
|
+
// axis labels
|
|
2186
|
+
rLab.forEach((l,r)=>{ const p=P(r+0.5,-0.15,0); svg.push(textEl(p[0]-4,p[1]+10,10,'end','#6f6e69',l)); });
|
|
2187
|
+
cLab.forEach((l,c)=>{ const p=P(R+0.15,c+0.5,0); svg.push(textEl(p[0]+4,p[1]+10,10,'start','#6f6e69',l)); });
|
|
2188
|
+
// z ruler at the right-back corner
|
|
2189
|
+
const zr=P(0,C,0);
|
|
2190
|
+
svg.push('<line x1="'+(zr[0]+14)+'" y1="'+zr[1]+'" x2="'+(zr[0]+14)+'" y2="'+(zr[1]-zmax*ZS)+'" stroke="#8a8880"/>');
|
|
2191
|
+
for(const z of [0, Math.round(zmax/2), Math.round(zmax)]){
|
|
2192
|
+
svg.push('<line x1="'+(zr[0]+11)+'" y1="'+(zr[1]-z*ZS)+'" x2="'+(zr[0]+17)+'" y2="'+(zr[1]-z*ZS)+'" stroke="#8a8880"/>');
|
|
2193
|
+
svg.push(textEl(zr[0]+21, zr[1]-z*ZS+3.5, 9.5, 'start', '#6f6e69', String(z)));
|
|
2194
|
+
}
|
|
2195
|
+
const w=P(R,C,0)[0]+70, hgt=P(R,C,0)[1]+24-y0;
|
|
2196
|
+
return {svg:svg.join(''), y:y0+hgt, w:w};
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// ---- wave ----
|
|
2200
|
+
function renderWave(w,y0){
|
|
2201
|
+
const tickW=26, laneH=30, laneGap=12, nameW=Math.max(...w.signals.map(s=>tw(s.name)),60);
|
|
2202
|
+
const svg=[]; let y=y0+18;
|
|
2203
|
+
svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(w.label)+'</text>');
|
|
2204
|
+
const ticks=Math.max(...w.signals.map(s=>s.lane.length));
|
|
2205
|
+
w.signals.forEach((s,si)=>{
|
|
2206
|
+
const top=y+8+si*(laneH+laneGap), bot=top+laneH-8;
|
|
2207
|
+
svg.push('<text x="'+(nameW-10)+'" y="'+(top+(laneH-8)/2+4)+'" font-size="11.5" text-anchor="end" font-family="monospace">'+esc(s.name)+'</text>');
|
|
2208
|
+
let d='', prev=null, li=0, dataIdx=0;
|
|
2209
|
+
for(let i=0;i<s.lane.length;i++){
|
|
2210
|
+
let ch=s.lane[i];
|
|
2211
|
+
if(ch==='.') ch=prev||'0';
|
|
2212
|
+
const x=nameW+i*tickW;
|
|
2213
|
+
if(ch==='p'||ch==='n'){
|
|
2214
|
+
const hiFirst=(ch==='p');
|
|
2215
|
+
const a=hiFirst?top:bot, b=hiFirst?bot:top;
|
|
2216
|
+
d+='M'+x+','+a+' L'+(x+tickW/2)+','+a+' L'+(x+tickW/2)+','+b+' L'+(x+tickW)+','+b+' ';
|
|
2217
|
+
// draw transition edge at tick start
|
|
2218
|
+
if(prev) d+='M'+x+','+top+' L'+x+','+bot+' ';
|
|
2219
|
+
} else if(ch==='0'||ch==='1'){
|
|
2220
|
+
const yy=(ch==='1')?top:bot;
|
|
2221
|
+
const pv=(prev==='1')?top:(prev==='0'?bot:null);
|
|
2222
|
+
if(pv!==null&&pv!==yy) d+='M'+x+','+pv+' L'+x+','+yy+' ';
|
|
2223
|
+
d+='M'+x+','+yy+' L'+(x+tickW)+','+yy+' ';
|
|
2224
|
+
} else if(ch==='x'){
|
|
2225
|
+
svg.push('<rect x="'+x+'" y="'+top+'" width="'+tickW+'" height="'+(bot-top)+'" fill="url(#hatch)" stroke="#999"/>');
|
|
2226
|
+
} else if(/[=\d]/.test(ch)){
|
|
2227
|
+
// merge consecutive identical data chars
|
|
2228
|
+
let j=i; while(j+1<s.lane.length && s.lane[j+1]==='.') j++;
|
|
2229
|
+
const span=j-i+1;
|
|
2230
|
+
svg.push('<rect x="'+x+'" y="'+top+'" width="'+(tickW*span)+'" height="'+(bot-top)+'" fill="#eef6ff" stroke="#7aa7d9"/>');
|
|
2231
|
+
const lbl = ch==='='? (s.labels[dataIdx++]||'') : ch;
|
|
2232
|
+
if(lbl) svg.push('<text x="'+(x+tickW*span/2)+'" y="'+((top+bot)/2+4)+'" font-size="10.5" text-anchor="middle">'+esc(lbl)+'</text>');
|
|
2233
|
+
i=j;
|
|
2234
|
+
}
|
|
2235
|
+
prev=ch;
|
|
2236
|
+
}
|
|
2237
|
+
if(d) svg.push('<path d="'+d+'" fill="none" stroke="#1d4ed8" stroke-width="1.6"/>');
|
|
2238
|
+
});
|
|
2239
|
+
for(const g of w.gaps){
|
|
2240
|
+
const x=nameW+g*tickW;
|
|
2241
|
+
const hTotal=w.signals.length*(laneH+laneGap);
|
|
2242
|
+
svg.push('<path d="M'+x+','+(y+4)+' q4,'+(hTotal/4)+' 0,'+(hTotal/2)+' q-4,'+(hTotal/4)+' 0,'+(hTotal/2)+'" fill="none" stroke="#999" stroke-width="2"/>');
|
|
2243
|
+
}
|
|
2244
|
+
const H=y+8+w.signals.length*(laneH+laneGap);
|
|
2245
|
+
return {svg:svg.join(''), y:H, w:nameW+ticks*tickW+2};
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// ============================================================
|
|
2249
|
+
// 3. UI + SVG export with embedded source (R14)
|
|
2250
|
+
// ============================================================
|
|
2251
|
+
const EXAMPLES={
|
|
2252
|
+
'blank canvas (start drawing!)':
|
|
2253
|
+
`figdown 0.1 topology
|
|
2254
|
+
title "Untitled"
|
|
2255
|
+
# Pick a kind, press "+ Node", drag things around, Link two nodes -
|
|
2256
|
+
# every action writes a line below. This text IS your file.`,
|
|
2257
|
+
|
|
2258
|
+
'block-architecture (census #2)':
|
|
2259
|
+
`figdown 0.1 block
|
|
2260
|
+
title "Ingress datapath (excerpt)"
|
|
2261
|
+
group ing "Ingress Pipeline"
|
|
2262
|
+
node mac "MAC RX" in=ing
|
|
2263
|
+
node parser "Parser" in=ing
|
|
2264
|
+
node l2 "L2 Lookup" in=ing
|
|
2265
|
+
node l3 "L3 Lookup" in=ing color=#d1fae5
|
|
2266
|
+
node acl "ACL" in=ing
|
|
2267
|
+
node mmu "MMU / Buffer"
|
|
2268
|
+
flow right
|
|
2269
|
+
edge mac -> parser
|
|
2270
|
+
edge parser -> l2
|
|
2271
|
+
edge l2 -[L3 hit]-> l3
|
|
2272
|
+
edge l2 -[miss]-> acl style=dashed
|
|
2273
|
+
edge l3 -> mmu
|
|
2274
|
+
edge acl -> mmu`,
|
|
2275
|
+
|
|
2276
|
+
'bitfield (census #1)':
|
|
2277
|
+
`figdown 0.1 bitfield
|
|
2278
|
+
bitfield gre "GRE Header (RFC 2784/2890)" unit=32 numbering=msb0
|
|
2279
|
+
field C 1
|
|
2280
|
+
field R 1
|
|
2281
|
+
field K 1
|
|
2282
|
+
field S 1
|
|
2283
|
+
field s 1
|
|
2284
|
+
field Recur 3
|
|
2285
|
+
field Flags 5
|
|
2286
|
+
field Ver 3
|
|
2287
|
+
field "Protocol Type" 16 color=#dbeafe
|
|
2288
|
+
field "Checksum (optional)" 16 optional
|
|
2289
|
+
field "Offset (optional)" 16 optional
|
|
2290
|
+
field "Key (optional)" 32 optional color=#fef9c3
|
|
2291
|
+
field "Sequence Number (optional)" 32 optional`,
|
|
2292
|
+
|
|
2293
|
+
'table (census #3)':
|
|
2294
|
+
`figdown 0.1 table
|
|
2295
|
+
table fib "FIB (excerpt)"
|
|
2296
|
+
| Route || Forwarding ||
|
|
2297
|
+
| Prefix | Next Hop | Port | VRF |
|
|
2298
|
+
|--------|:--------:|------|-------|
|
|
2299
|
+
| 10.0.0.0/8 | R2 | p1 | default |
|
|
2300
|
+
| 10.1.0.0/16 | R4 | p2 | default |
|
|
2301
|
+
| 0.0.0.0/0 | R3 | p2 | default |
|
|
2302
|
+
cell 2 highlight
|
|
2303
|
+
cell 3,2 color=#dbeafe`,
|
|
2304
|
+
|
|
2305
|
+
'wave (census #4)':
|
|
2306
|
+
`figdown 0.1 wave
|
|
2307
|
+
wave por "Power-on reset sequencing"
|
|
2308
|
+
signal clk pppppppp
|
|
2309
|
+
signal rst_n 00011111
|
|
2310
|
+
signal cfg_ok 00000111
|
|
2311
|
+
signal data xxx=..=x labels="CFG,GO"`,
|
|
2312
|
+
|
|
2313
|
+
'topology + pin (stability demo)':
|
|
2314
|
+
`figdown 0.1 topology
|
|
2315
|
+
title "Leaf-spine, monitor pinned top-right"
|
|
2316
|
+
node sp1 "Spine-1" shape=rounded color=#e0e7ff
|
|
2317
|
+
node sp2 "Spine-2" shape=rounded color=#e0e7ff
|
|
2318
|
+
node lf1 "Leaf-1" shape=rounded
|
|
2319
|
+
node lf2 "Leaf-2" shape=rounded
|
|
2320
|
+
node h1 "Host-A"
|
|
2321
|
+
node h2 "Host-B"
|
|
2322
|
+
node mon "Monitor" color=#fef9c3
|
|
2323
|
+
flow down
|
|
2324
|
+
rank sp1 sp2
|
|
2325
|
+
edge sp1 -- lf1
|
|
2326
|
+
edge sp1 -- lf2
|
|
2327
|
+
edge sp2 -- lf1
|
|
2328
|
+
edge sp2 -- lf2
|
|
2329
|
+
edge lf1 -- h1
|
|
2330
|
+
edge lf2 -- h2
|
|
2331
|
+
edge mon -[telemetry]- sp2 style=dotted
|
|
2332
|
+
pin mon at=340,0`,
|
|
2333
|
+
|
|
2334
|
+
'EVPN fabric (topology + supplementary tables)':
|
|
2335
|
+
`figdown 0.1 topology
|
|
2336
|
+
title "VXLAN/EVPN Leaf-Spine Fabric — topology with plane notes"
|
|
2337
|
+
|
|
2338
|
+
# ── Topology ────────────────────────────────────────────────────────
|
|
2339
|
+
node sp1 "Spine-1" shape=rounded color=#e0e7ff
|
|
2340
|
+
node sp2 "Spine-2" shape=rounded color=#e0e7ff
|
|
2341
|
+
node lf1 "Leaf-1 (VTEP)" shape=rounded
|
|
2342
|
+
node lf2 "Leaf-2 (VTEP)" shape=rounded
|
|
2343
|
+
node lf3 "Leaf-3 (VTEP)" shape=rounded
|
|
2344
|
+
node h1 "VM-A (VLAN 10)"
|
|
2345
|
+
node h2 "VM-B (VLAN 10)"
|
|
2346
|
+
node h3 "VM-C (VLAN 20)"
|
|
2347
|
+
flow down
|
|
2348
|
+
rank sp1 sp2
|
|
2349
|
+
rank lf1 lf2 lf3
|
|
2350
|
+
|
|
2351
|
+
# underlay: every leaf peers with every spine
|
|
2352
|
+
edge sp1 -[eBGP]- lf1
|
|
2353
|
+
edge sp1 -- lf2
|
|
2354
|
+
edge sp1 -- lf3
|
|
2355
|
+
edge sp2 -- lf1
|
|
2356
|
+
edge sp2 -- lf2
|
|
2357
|
+
edge sp2 -- lf3
|
|
2358
|
+
edge lf1 -- h1
|
|
2359
|
+
edge lf2 -- h2
|
|
2360
|
+
edge lf3 -- h3
|
|
2361
|
+
|
|
2362
|
+
# overlay: VXLAN tunnel between the two VTEPs sharing VNI 10010
|
|
2363
|
+
layer overlay "VXLAN tunnels" z=2
|
|
2364
|
+
edge lf1 <-[VXLAN VNI 10010]-> lf2 style=dashed color=#dc2626 layer=overlay
|
|
2365
|
+
|
|
2366
|
+
# ── Supplementary knowledge ─────────────────────────────────────────
|
|
2367
|
+
table vni "VNI mapping"
|
|
2368
|
+
| Leaf | VLAN | VNI | Type |
|
|
2369
|
+
|--------|:----:|-------|------|
|
|
2370
|
+
| Leaf-1 | 10 | 10010 | L2 |
|
|
2371
|
+
| Leaf-2 | 10 | 10010 | L2 |
|
|
2372
|
+
| Leaf-3 | 20 | 10020 | L2 |
|
|
2373
|
+
cell 1,3 color=#fee2e2
|
|
2374
|
+
cell 2,3 color=#fee2e2
|
|
2375
|
+
|
|
2376
|
+
table planes "Fabric planes"
|
|
2377
|
+
| Plane | Protocol | Role |
|
|
2378
|
+
|-----------------|-------------------|---------------------------|
|
|
2379
|
+
| Underlay | eBGP | loopback reachability |
|
|
2380
|
+
| Overlay control | MP-BGP EVPN | MAC/IP route distribution |
|
|
2381
|
+
| Overlay data | VXLAN (UDP 4789) | L2-in-L3 encapsulation |`,
|
|
2382
|
+
|
|
2383
|
+
'EVPN-VXLAN IRB (vendor-style, hand-pinned)':
|
|
2384
|
+
`figdown 0.1 topology
|
|
2385
|
+
title "EVPN-VXLAN IRB — leaves with IP-VRF and bridge domains"
|
|
2386
|
+
|
|
2387
|
+
# Complex vendor-style figure, fully hand-laid-out (tier 3, D6 two-level
|
|
2388
|
+
# coordinates): each group is anchored once; members are group-local, so
|
|
2389
|
+
# moving a leaf = editing one pin line.
|
|
2390
|
+
|
|
2391
|
+
# ── Overlay cloud + spines ─────────────────────────────────────────
|
|
2392
|
+
node cloud "EVPN-VXLAN" shape=cloud color=#cfe8f7 stroke=#7fb8d8
|
|
2393
|
+
size cloud w=620 h=110
|
|
2394
|
+
pin cloud at=330,90
|
|
2395
|
+
node sp1 "Spine-1" shape=rounded color=#9aa2ad text=#ffffff
|
|
2396
|
+
pin sp1 at=480,0
|
|
2397
|
+
node sp2 "Spine-2" shape=rounded color=#9aa2ad text=#ffffff
|
|
2398
|
+
pin sp2 at=700,0
|
|
2399
|
+
|
|
2400
|
+
# ── Leaves: group = leaf box; members = IP-VRF badge + bridge domains
|
|
2401
|
+
group leaf2 "SRL LEAF-2"
|
|
2402
|
+
pin leaf2 at=90,235
|
|
2403
|
+
node vrf2 "IP-VRF-10\\nRD1/RT10\\nVNI10" in=leaf2 color=#1e3a8a text=#ffffff
|
|
2404
|
+
pin vrf2 at=80,0
|
|
2405
|
+
node bd2 "BD2\\nRD41/RT2\\nVNI2" in=leaf2 style=dashed stroke=#1d4ed8
|
|
2406
|
+
pin bd2 at=0,85
|
|
2407
|
+
node bd24a "BD24\\nRD22/RT24\\nVNI24" in=leaf2 style=dashed stroke=#1d4ed8
|
|
2408
|
+
pin bd24a at=160,85
|
|
2409
|
+
|
|
2410
|
+
group leaf4 "SRL LEAF-4"
|
|
2411
|
+
pin leaf4 at=540,235
|
|
2412
|
+
node vrf4 "IP-VRF-10\\nRD1/RT10\\nVNI10" in=leaf4 color=#1e3a8a text=#ffffff
|
|
2413
|
+
pin vrf4 at=80,0
|
|
2414
|
+
node bd24b "BD24\\nRD41/RT24\\nVNI24" in=leaf4 style=dashed stroke=#1d4ed8
|
|
2415
|
+
pin bd24b at=0,85
|
|
2416
|
+
node bd4 "BD4\\nRD42/RT4\\nVNI4" in=leaf4 style=dashed stroke=#1d4ed8
|
|
2417
|
+
pin bd4 at=160,85
|
|
2418
|
+
|
|
2419
|
+
group leaf3 "SRL LEAF-3"
|
|
2420
|
+
pin leaf3 at=1040,235
|
|
2421
|
+
node vrf3 "IP-VRF-10\\nRD1/RT10\\nVNI10" in=leaf3 color=#1e3a8a text=#ffffff
|
|
2422
|
+
pin vrf3 at=0,0
|
|
2423
|
+
node bd3 "BD3\\nRD31/RT3\\nVNI3" in=leaf3 style=dashed stroke=#1d4ed8
|
|
2424
|
+
pin bd3 at=0,85
|
|
2425
|
+
|
|
2426
|
+
# ── Edge devices and hosts ──────────────────────────────────────────
|
|
2427
|
+
node ce3 "CE-3\\nIP-20.1.1.3/24\\nMAC-00:01:03:FF:00:0B" shape=rounded
|
|
2428
|
+
pin ce3 at=0,430
|
|
2429
|
+
node h2 "HOST-2\\nIP-101.1.1.2/24\\nMAC-00:00:00:00:00:02" color=#bae6fd
|
|
2430
|
+
pin h2 at=180,500
|
|
2431
|
+
node srv "SERVER-1\\nHOST-12 IP-101.1.1.1/24\\nMAC-00:00:64:01:01:01"
|
|
2432
|
+
pin srv at=390,500
|
|
2433
|
+
node h4 "HOST-4\\nIP-104.1.1.1/24\\nMAC-00:00:00:00:00:04" color=#bae6fd
|
|
2434
|
+
pin h4 at=660,500
|
|
2435
|
+
node h3 "HOST-3\\nIP-103.1.1.1/24\\nMAC-00:00:00:00:00:03" shape=rounded
|
|
2436
|
+
pin h3 at=1020,500
|
|
2437
|
+
|
|
2438
|
+
# fabric attachment
|
|
2439
|
+
edge sp1 -- cloud
|
|
2440
|
+
edge sp2 -- cloud
|
|
2441
|
+
edge cloud ["irb0.24\\nAnycast-IP .254"] -- vrf2
|
|
2442
|
+
edge cloud -- vrf4
|
|
2443
|
+
edge cloud -- vrf3
|
|
2444
|
+
|
|
2445
|
+
# PE-CE and access links, port labels at the endpoints
|
|
2446
|
+
edge bd2 -["PE-CE\\nBGP IPv4"]- [20.1.1.2/24] ce3 style=dashed color=#1d4ed8
|
|
2447
|
+
edge bd2 [e1/12.24] -- h2
|
|
2448
|
+
edge bd24a [lag1.24] -- srv
|
|
2449
|
+
edge bd24b [lag1.24] -- srv
|
|
2450
|
+
|
|
2451
|
+
# multi-homing: declare the SEMANTICS (these links form one bundle) and
|
|
2452
|
+
# the renderer derives the dashed ring — it follows the nodes wherever
|
|
2453
|
+
# they move; no coordinates involved
|
|
2454
|
+
bundle es1 "ES-1 / LAG-1" bd24a--srv, bd24b--srv color=#0ea5e9
|
|
2455
|
+
edge bd4 [e1/13.4] -- h4
|
|
2456
|
+
edge bd3 [e1/2.3] -- h3`,
|
|
2457
|
+
|
|
2458
|
+
'VXLAN encapsulation (frames + tables)':
|
|
2459
|
+
`figdown 0.1 table
|
|
2460
|
+
title "VXLAN Overview — MAC-in-UDP encapsulation"
|
|
2461
|
+
|
|
2462
|
+
# Cell order IS byte order — no pin/size/rank/flow needed (R25-safe).
|
|
2463
|
+
# VLAN→VNI relation carried by the shared vni class (edges cannot
|
|
2464
|
+
# attach to table cells: OQ-S17).
|
|
2465
|
+
|
|
2466
|
+
# ── Semantic classes (R37: the meaning rides on the element) ────────
|
|
2467
|
+
class outer "Outer header added by the ingress VTEP (50B total)" color=#fcd34d
|
|
2468
|
+
class vni "VLAN ID 12-bit → VNI 24-bit: the identifier VXLAN widens" color=#fcd34d stroke=#ea580c
|
|
2469
|
+
class inner "Original L2 frame field — carried verbatim as VXLAN payload" color=#bae6fd
|
|
2470
|
+
class opaque "Upper-layer payload — opaque to the VTEP" color=#9aa2ad
|
|
2471
|
+
class oldfcs "Original FCS — dropped at encapsulation, not carried" color=#93c5fd
|
|
2472
|
+
class newfcs "New FCS — recomputed over the outer frame" color=#bbf7d0 stroke=#16a34a
|
|
2473
|
+
|
|
2474
|
+
# ── Classical Ethernet frame ────────────────────────────────────────
|
|
2475
|
+
table eth "Classical Ethernet frame — cells left to right are on-the-wire order"
|
|
2476
|
+
| Ethernet header |||| Data | Trailer |
|
|
2477
|
+
|---------|---------|-----------|----------|------------------|--------|
|
|
2478
|
+
| DMAC 6B | SMAC 6B | 802.1Q 4B | Etype 2B | Payload 46–1500B | FCS 4B |
|
|
2479
|
+
cell 1,1 class=inner
|
|
2480
|
+
cell 1,2 class=inner
|
|
2481
|
+
cell 1,3 class=vni
|
|
2482
|
+
cell 1,4 class=inner
|
|
2483
|
+
cell 1,5 class=opaque
|
|
2484
|
+
cell 1,6 class=oldfcs
|
|
2485
|
+
|
|
2486
|
+
# ── VXLAN-encapsulated frame ────────────────────────────────────────
|
|
2487
|
+
table vxlan "VXLAN-encapsulated frame — cells left to right are on-the-wire order"
|
|
2488
|
+
| Outer headers |||| Original L2 frame |||| New trailer |
|
|
2489
|
+
|---------------|--------------|--------------|----------|---------|---------|----------|------------------|--------------|
|
|
2490
|
+
| Outer MAC 14B | Outer IP 20B | Outer UDP 8B | VXLAN 8B | DMAC 6B | SMAC 6B | Etype 2B | Payload 46–1500B | FCS (new) 4B |
|
|
2491
|
+
cell 1,1 class=outer
|
|
2492
|
+
cell 1,2 class=outer
|
|
2493
|
+
cell 1,3 class=outer
|
|
2494
|
+
cell 1,4 class=vni
|
|
2495
|
+
cell 1,5 class=inner
|
|
2496
|
+
cell 1,6 class=inner
|
|
2497
|
+
cell 1,7 class=inner
|
|
2498
|
+
cell 1,8 class=opaque
|
|
2499
|
+
cell 1,9 class=newfcs
|
|
2500
|
+
|
|
2501
|
+
# ── Supplementary knowledge ─────────────────────────────────────────
|
|
2502
|
+
table overhead "Encapsulation overhead (MAC-in-UDP)"
|
|
2503
|
+
| Segment | Size | Notes |
|
|
2504
|
+
|------------|:----:|--------------------------------|
|
|
2505
|
+
| Outer MAC | 14B | new Ethernet header |
|
|
2506
|
+
| Outer IP | 20B | VTEP-to-VTEP |
|
|
2507
|
+
| UDP | 8B | dst port 4789 |
|
|
2508
|
+
| VXLAN | 8B | carries the 24-bit VNI |
|
|
2509
|
+
| Total | 50B | added to every original frame |
|
|
2510
|
+
cell 5 highlight
|
|
2511
|
+
|
|
2512
|
+
table facts "Why VXLAN"
|
|
2513
|
+
| Fact | Value |
|
|
2514
|
+
|-------------------------|--------------------------|
|
|
2515
|
+
| VLAN address space | 12 bits (4K segments) |
|
|
2516
|
+
| VNI address space | 24 bits (16M segments) |
|
|
2517
|
+
| Encapsulation | MAC-in-UDP |`,
|
|
2518
|
+
|
|
2519
|
+
'partition map (pool + global thresholds)':
|
|
2520
|
+
`figdown 0.1 block
|
|
2521
|
+
title "Partition map — a resource pool with global thresholds"
|
|
2522
|
+
|
|
2523
|
+
# The GENERIC partition-and-threshold map: one pool, N groups, global
|
|
2524
|
+
# threshold lines shared by all (group-level scope; per-node scope exists
|
|
2525
|
+
# for figures whose semantics are per-column). Instances of this category:
|
|
2526
|
+
# buffer quotas, memory maps, watermark/level charts.
|
|
2527
|
+
group pool "Resource pool (units)" gap=0
|
|
2528
|
+
node g0 "Group 0" in=pool color=transparent
|
|
2529
|
+
node g1 "Group 1" in=pool color=transparent
|
|
2530
|
+
node g2 "Group 2" in=pool color=transparent
|
|
2531
|
+
node g3 "Group 3" in=pool color=transparent
|
|
2532
|
+
flow down
|
|
2533
|
+
rank g0 g1 g2 g3
|
|
2534
|
+
size g0 w=120 h=190
|
|
2535
|
+
size g1 w=120 h=190
|
|
2536
|
+
size g2 w=120 h=190
|
|
2537
|
+
size g3 w=120 h=190
|
|
2538
|
+
|
|
2539
|
+
line "Max cap" in=pool at=80%
|
|
2540
|
+
line "Reserved" in=pool at=15%
|
|
2541
|
+
fill 15% in=pool color=#a3c93a`,
|
|
2542
|
+
|
|
2543
|
+
'table data -> heatmap + bars3d plot':
|
|
2544
|
+
`figdown 0.1 table
|
|
2545
|
+
title "Per-(port, queue) occupancy — one table, two projections"
|
|
2546
|
+
|
|
2547
|
+
table hm "Queue occupancy counters"
|
|
2548
|
+
| Port\\Q | Q0 | Q1 | Q2 | Q3 | Q4 | Q5 |
|
|
2549
|
+
|--------|:--:|:--:|:--:|:--:|:--:|:--:|
|
|
2550
|
+
| P0 | 12 | 55 | 31 | 8 | 72 | 19 |
|
|
2551
|
+
| P1 | 44 | 90 | 22 | 61 | 15 | 38 |
|
|
2552
|
+
| P2 | 5 | 27 | 83 | 47 | 33 | 96 |
|
|
2553
|
+
| P3 | 68 | 14 | 40 | 29 | 51 | 7 |
|
|
2554
|
+
# heatmap marks: light red = over 40, strong red = over 80
|
|
2555
|
+
cell 1,3 color=#fecaca
|
|
2556
|
+
cell 1,6 color=#fca5a5
|
|
2557
|
+
cell 2,2 color=#fecaca
|
|
2558
|
+
cell 2,3 color=#fca5a5
|
|
2559
|
+
cell 2,5 color=#fecaca
|
|
2560
|
+
cell 3,4 color=#fca5a5
|
|
2561
|
+
cell 3,5 color=#fecaca
|
|
2562
|
+
cell 3,7 color=#fca5a5
|
|
2563
|
+
cell 4,2 color=#fecaca
|
|
2564
|
+
cell 4,6 color=#fecaca
|
|
2565
|
+
|
|
2566
|
+
# the same table data, plotted on X-Y-Z axes (rows=X, columns=Y, values=Z)
|
|
2567
|
+
plot hm level=40`,
|
|
2568
|
+
|
|
2569
|
+
'mixed document':
|
|
2570
|
+
`figdown 0.1 block
|
|
2571
|
+
title "CPU tag insertion"
|
|
2572
|
+
node cpu "CPU"
|
|
2573
|
+
node tagger "Tag Insert" color=#fee2e2
|
|
2574
|
+
node port "Egress Port"
|
|
2575
|
+
flow right
|
|
2576
|
+
edge cpu -[DSA tag]-> tagger
|
|
2577
|
+
edge tagger -> port
|
|
2578
|
+
|
|
2579
|
+
bitfield tag "CPU Tag" unit=16
|
|
2580
|
+
field Op 2
|
|
2581
|
+
field Prio 3
|
|
2582
|
+
field RSVD 3
|
|
2583
|
+
field "Dest Port" 8
|
|
2584
|
+
|
|
2585
|
+
table map "Op encoding"
|
|
2586
|
+
| Op | Meaning | Notes |
|
|
2587
|
+
|----|----------|--------|
|
|
2588
|
+
| 0 | From CPU | DSA v1 |
|
|
2589
|
+
| 1 | To CPU | ^^ |
|
|
2590
|
+
| 2 | Mirror ||
|
|
2591
|
+
cell 2 highlight
|
|
2592
|
+
cell 3,1 color=#fee2e2`
|
|
2593
|
+
};
|
|
2594
|
+
|
|
2595
|
+
const $=id=>document.getElementById(id);
|
|
2596
|
+
const sel=$('examples');
|
|
2597
|
+
Object.keys(EXAMPLES).forEach(k=>{const o=document.createElement('option');o.textContent=k;sel.appendChild(o);});
|
|
2598
|
+
sel.onchange=()=>{
|
|
2599
|
+
if(isDirty() && !confirm('Discard unsaved changes in '+fileName+'?')){ return; }
|
|
2600
|
+
fdHandle=null; fileName='untitled.fd'; savedText=null; selectedId=null;
|
|
2601
|
+
$('src').value=EXAMPLES[sel.value]; refresh();
|
|
2602
|
+
};
|
|
2603
|
+
$('src').addEventListener('input',refresh);
|
|
2604
|
+
|
|
2605
|
+
let lastSVG='', lastMeta=null, lastPad={x:0,y:0}, lastDoc=null;
|
|
2606
|
+
|
|
2607
|
+
// ---- direct manipulation: every GUI action is a text edit ----
|
|
2608
|
+
const SCENE_KW=/^(figdown|title|node|group|boundary|edge|layer|flow|rank|pin|size|line|fill|bundle|class|routing|route|render)\b/;
|
|
2609
|
+
function applyPins(pins){ // pins: array of [id, px, py] — one text update, one refresh
|
|
2610
|
+
const lines=$('src').value.split('\n');
|
|
2611
|
+
for(const [id,px,py] of pins){
|
|
2612
|
+
const pinRe=new RegExp('^\\s*pin\\s+'+id+'\\b');
|
|
2613
|
+
const newLine='pin '+id+' at='+px+','+py;
|
|
2614
|
+
const idx=lines.findIndex(l=>pinRe.test(l));
|
|
2615
|
+
if(idx>=0){ lines[idx]=newLine; }
|
|
2616
|
+
else{
|
|
2617
|
+
// structure stays on top; materialized layout lives in a trailing
|
|
2618
|
+
// section, so an AI reader gets the full meaning before any pins (R25)
|
|
2619
|
+
let mark=lines.findIndex(l=>l.trim()==='render');
|
|
2620
|
+
if(mark<0){ lines.push('render'); mark=lines.length-1; }
|
|
2621
|
+
lines.push(newLine);
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
$('src').value=lines.join('\n'); refresh();
|
|
2625
|
+
}
|
|
2626
|
+
// D6 two-level pins: collect the full pin set for the current picture.
|
|
2627
|
+
// overrides = {id:{x,y}} absolute-canvas coords for items being moved
|
|
2628
|
+
// (for a group id, the new origin). Group members are written relative
|
|
2629
|
+
// to their group's origin, so later group moves edit ONE line.
|
|
2630
|
+
//
|
|
2631
|
+
// Snapshot the INTENDED absolute-canvas position of every node and every
|
|
2632
|
+
// group origin from the current render, applying any overrides. Kept
|
|
2633
|
+
// separate from pin emission so the same absolute targets can be re-emitted
|
|
2634
|
+
// against a DIFFERENT frame (see materializePins: the pinned re-render has
|
|
2635
|
+
// its own left/top because back-edge routing — hence chShift — changes once
|
|
2636
|
+
// the scene is pinned).
|
|
2637
|
+
function snapshotAbs(overrides){
|
|
2638
|
+
const svg=$('out').querySelector('svg'); if(!svg||!lastDoc) return null;
|
|
2639
|
+
const groups={}, nodes={};
|
|
2640
|
+
svg.querySelectorAll('[data-group]').forEach(g=>{
|
|
2641
|
+
const gid=g.dataset.group;
|
|
2642
|
+
groups[gid]=(overrides&&overrides[gid])||{x:+g.dataset.gx, y:+g.dataset.gy};
|
|
2643
|
+
});
|
|
2644
|
+
svg.querySelectorAll('[data-node]').forEach(g=>{
|
|
2645
|
+
const id=g.dataset.node;
|
|
2646
|
+
nodes[id]=(overrides&&overrides[id])||{x:+g.dataset.x, y:+g.dataset.y};
|
|
2647
|
+
});
|
|
2648
|
+
return {groups,nodes};
|
|
2649
|
+
}
|
|
2650
|
+
// Emit [id,px,py] pin rows for a snapshot, relative to a given frame
|
|
2651
|
+
// {left,top}. Members are written group-local (relative to their group's
|
|
2652
|
+
// snapshotted origin); ungrouped nodes and group origins are canvas px.
|
|
2653
|
+
function emitPins(snap, frame){
|
|
2654
|
+
const left=frame.left||0, top=frame.top||0, pins=[];
|
|
2655
|
+
for(const gid in snap.groups){
|
|
2656
|
+
const o=snap.groups[gid];
|
|
2657
|
+
pins.push([gid, Math.round(o.x-left), Math.round(o.y-top)]);
|
|
2658
|
+
}
|
|
2659
|
+
for(const id in snap.nodes){
|
|
2660
|
+
const abs=snap.nodes[id];
|
|
2661
|
+
const nd=lastDoc.nodes.find(n=>n.id===id);
|
|
2662
|
+
const o=nd&&nd.group?snap.groups[nd.group]:null;
|
|
2663
|
+
if(o) pins.push([id, Math.round(abs.x-o.x), Math.round(abs.y-o.y)]);
|
|
2664
|
+
else pins.push([id, Math.round(abs.x-left), Math.round(abs.y-top)]);
|
|
2665
|
+
}
|
|
2666
|
+
return pins;
|
|
2667
|
+
}
|
|
2668
|
+
// Build the source text that applyPins(pins) would produce, WITHOUT touching
|
|
2669
|
+
// the textarea or re-rendering — used to probe the pinned-regime frame headlessly.
|
|
2670
|
+
function textWithPins(baseText, pins){
|
|
2671
|
+
const lines=baseText.split('\n');
|
|
2672
|
+
for(const [id,px,py] of pins){
|
|
2673
|
+
const re=new RegExp('^\\s*pin\\s+'+id+'\\b');
|
|
2674
|
+
const nl='pin '+id+' at='+px+','+py;
|
|
2675
|
+
const idx=lines.findIndex(l=>re.test(l));
|
|
2676
|
+
if(idx>=0){ lines[idx]=nl; }
|
|
2677
|
+
else{ if(lines.findIndex(l=>l.trim()==='render')<0) lines.push('render'); lines.push(nl); }
|
|
2678
|
+
}
|
|
2679
|
+
return lines.join('\n');
|
|
2680
|
+
}
|
|
2681
|
+
// Back-compat shim: one-pass collect+emit against the current frame.
|
|
2682
|
+
function collectPins(overrides){
|
|
2683
|
+
const snap=snapshotAbs(overrides); if(!snap) return [];
|
|
2684
|
+
return emitPins(snap, {left:lastMeta.left, top:lastMeta.top});
|
|
2685
|
+
}
|
|
2686
|
+
// True when every scene node AND every group already carries a pin line, so
|
|
2687
|
+
// the scene is fully materialized and a drag is a single-line pin update
|
|
2688
|
+
// (the current, correct behaviour — no re-materialization needed).
|
|
2689
|
+
function scenePinnedComplete(){
|
|
2690
|
+
if(!lastDoc) return false;
|
|
2691
|
+
const P=lastDoc.pins||{};
|
|
2692
|
+
const scene=lastDoc.nodes.filter(n=>!n.boundary);
|
|
2693
|
+
if(!scene.length) return false;
|
|
2694
|
+
return scene.every(n=>P[n.id]!==undefined)
|
|
2695
|
+
&& (lastDoc.groups||[]).every(g=>P[g.id]!==undefined);
|
|
2696
|
+
}
|
|
2697
|
+
// Axiom-3 materialization. On the FIRST touch of an unpinned (or partially
|
|
2698
|
+
// pinned) scene, freeze EVERY node/group at its current rendered position so
|
|
2699
|
+
// the one dragged element moves and nothing else does. A single pass is not
|
|
2700
|
+
// enough: writing pins flips back-edges from return-rings to straight edges,
|
|
2701
|
+
// which collapses chShift and moves the whole figure's origin (meta.top). So
|
|
2702
|
+
// we snapshot the intended ABSOLUTE targets first, materialize once to reach
|
|
2703
|
+
// the pinned regime (which fixes the final frame), then re-emit the SAME
|
|
2704
|
+
// absolute targets against that final frame. The pinned regime is a fixed
|
|
2705
|
+
// point, so pass 2 round-trips to the pixel and a third pass would be a no-op.
|
|
2706
|
+
function materializePins(overrides){
|
|
2707
|
+
if(scenePinnedComplete()){ applyPins(collectPins(overrides)); return; }
|
|
2708
|
+
const snap=snapshotAbs(overrides); if(!snap){ return; }
|
|
2709
|
+
// Pass 1 is a HEADLESS probe (parse+render, no DOM/textarea write) purely to
|
|
2710
|
+
// learn the pinned-regime frame; only pass 2 actually edits the textarea, so
|
|
2711
|
+
// the whole materialization is a single undo step.
|
|
2712
|
+
let frame={left:lastMeta.left, top:lastMeta.top};
|
|
2713
|
+
try{
|
|
2714
|
+
const probeText=textWithPins($('src').value, emitPins(snap, frame));
|
|
2715
|
+
const pr=parse(probeText);
|
|
2716
|
+
if(!pr.errs.length){ const rr=render(pr.doc,{title:true}); if(rr.sceneMeta) frame=rr.sceneMeta; }
|
|
2717
|
+
}catch(e){ /* fall back to single-frame emit if the probe fails */ }
|
|
2718
|
+
applyPins(emitPins(snap, frame));
|
|
2719
|
+
}
|
|
2720
|
+
function setNodeLabel(id,label){
|
|
2721
|
+
const lines=$('src').value.split('\n');
|
|
2722
|
+
const re=new RegExp('^(\\s*node\\s+'+id+'\\s+)("[^"]*"|\\S+)?');
|
|
2723
|
+
const idx=lines.findIndex(l=>new RegExp('^\\s*node\\s+'+id+'\\b').test(l));
|
|
2724
|
+
if(idx<0) return;
|
|
2725
|
+
lines[idx]=lines[idx].replace(re,(m,p1)=>p1+'"'+label.replace(/"/g,'')+'"');
|
|
2726
|
+
$('src').value=lines.join('\n'); refresh();
|
|
2727
|
+
}
|
|
2728
|
+
function installDirectEdit(){
|
|
2729
|
+
const svg=$('out').querySelector('svg');
|
|
2730
|
+
if(!svg||!lastMeta) return;
|
|
2731
|
+
const toModel=ev=>{
|
|
2732
|
+
const pt=new DOMPoint(ev.clientX,ev.clientY).matrixTransform(svg.getScreenCTM().inverse());
|
|
2733
|
+
return {x:pt.x-lastPad.x, y:pt.y-lastPad.y};
|
|
2734
|
+
};
|
|
2735
|
+
// group drag: move the container AND all member nodes together
|
|
2736
|
+
svg.querySelectorAll('[data-group]').forEach(gg=>{
|
|
2737
|
+
gg.addEventListener('pointerdown',ev=>{
|
|
2738
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
2739
|
+
const gid=gg.dataset.group, start=toModel(ev);
|
|
2740
|
+
const memberIds=lastDoc.nodes.filter(n=>n.group===gid).map(n=>n.id);
|
|
2741
|
+
const memberGs=memberIds.map(id=>svg.querySelector('[data-node="'+id+'"]')).filter(Boolean);
|
|
2742
|
+
let dx=0,dy=0,moved=false;
|
|
2743
|
+
const mv=e=>{ const p=toModel(e); dx=p.x-start.x; dy=p.y-start.y;
|
|
2744
|
+
if(Math.abs(dx)+Math.abs(dy)>2) moved=true;
|
|
2745
|
+
const t='translate('+dx+','+dy+')';
|
|
2746
|
+
gg.setAttribute('transform',t); memberGs.forEach(m=>m.setAttribute('transform',t)); };
|
|
2747
|
+
const up=()=>{ window.removeEventListener('pointermove',mv); window.removeEventListener('pointerup',up);
|
|
2748
|
+
if(!moved){ gg.removeAttribute('transform'); memberGs.forEach(m=>m.removeAttribute('transform')); return; }
|
|
2749
|
+
// D6: move the group's ORIGIN; members are (or become) group-local,
|
|
2750
|
+
// so future drags of this group edit a single pin line
|
|
2751
|
+
const overrides={ [gid]: {x:+gg.dataset.gx+dx, y:+gg.dataset.gy+dy} };
|
|
2752
|
+
memberGs.forEach(m=>{ overrides[m.dataset.node]={x:+m.dataset.x+dx, y:+m.dataset.y+dy}; });
|
|
2753
|
+
materializePins(overrides);
|
|
2754
|
+
$('status').textContent='group drag → moved origin of "'+gid+'" (members ride along as group-local pins)'; };
|
|
2755
|
+
window.addEventListener('pointermove',mv); window.addEventListener('pointerup',up);
|
|
2756
|
+
});
|
|
2757
|
+
gg.addEventListener('dblclick',ev=>{
|
|
2758
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
2759
|
+
const gid=gg.dataset.group;
|
|
2760
|
+
const gr=lastDoc.groups.find(x=>x.id===gid);
|
|
2761
|
+
const nv=prompt('Label for group "'+gid+'":', gr?gr.label:'');
|
|
2762
|
+
if(nv!==null && nv!==''){
|
|
2763
|
+
const lines=$('src').value.split('\n');
|
|
2764
|
+
const idx=lines.findIndex(l=>new RegExp('^\\s*group\\s+'+gid+'\\b').test(l));
|
|
2765
|
+
if(idx>=0){
|
|
2766
|
+
lines[idx]=lines[idx].replace(new RegExp('^(\\s*group\\s+'+gid+'\\s+)("[^"]*"|\\S+)?'),(m,p1)=>p1+'"'+nv.replace(/"/g,'')+'"');
|
|
2767
|
+
$('src').value=lines.join('\n'); refresh();
|
|
2768
|
+
$('status').textContent='double-click → rewrote group '+gid+' label (GUI action = text edit)';
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
});
|
|
2772
|
+
});
|
|
2773
|
+
// table cells: click to color with the current picker value
|
|
2774
|
+
svg.querySelectorAll('[data-cell]').forEach(rc=>{
|
|
2775
|
+
rc.addEventListener('click',ev=>{
|
|
2776
|
+
ev.preventDefault();
|
|
2777
|
+
const [tid,r,c]=rc.dataset.cell.split(':');
|
|
2778
|
+
setCellColor(tid,+r,+c,$('fillpick').value);
|
|
2779
|
+
});
|
|
2780
|
+
});
|
|
2781
|
+
// double-click empty canvas = create a node right there (draw.io habit)
|
|
2782
|
+
svg.addEventListener('dblclick',ev=>{
|
|
2783
|
+
if(ev.target.closest('[data-node],[data-group],[data-cell],[data-gline],.rsz')) return;
|
|
2784
|
+
ev.preventDefault();
|
|
2785
|
+
const p=toModel(ev);
|
|
2786
|
+
addNodeAt(Math.round(p.x), Math.round(p.y));
|
|
2787
|
+
});
|
|
2788
|
+
// marquee group creation (armed by the ⬚ Group button)
|
|
2789
|
+
svg.addEventListener('pointerdown',ev=>{
|
|
2790
|
+
if(!groupArm) return;
|
|
2791
|
+
if(ev.target.closest('[data-node],[data-group]')) return;
|
|
2792
|
+
ev.preventDefault();
|
|
2793
|
+
const start=toModel(ev);
|
|
2794
|
+
const NS='http://www.w3.org/2000/svg';
|
|
2795
|
+
const band=document.createElementNS(NS,'rect');
|
|
2796
|
+
band.setAttribute('fill','rgba(37,99,235,.08)');
|
|
2797
|
+
band.setAttribute('stroke','#2563eb'); band.setAttribute('stroke-dasharray','4 3');
|
|
2798
|
+
svg.appendChild(band);
|
|
2799
|
+
let x0=start.x,y0=start.y,x1=x0,y1=y0;
|
|
2800
|
+
const draw=()=>{ band.setAttribute('x',Math.min(x0,x1)+lastPad.x); band.setAttribute('y',Math.min(y0,y1)+lastPad.y);
|
|
2801
|
+
band.setAttribute('width',Math.abs(x1-x0)); band.setAttribute('height',Math.abs(y1-y0)); };
|
|
2802
|
+
const mv=e=>{ const p=toModel(e); x1=p.x; y1=p.y; draw(); };
|
|
2803
|
+
const up=()=>{ window.removeEventListener('pointermove',mv); window.removeEventListener('pointerup',up);
|
|
2804
|
+
band.remove(); groupArm=false; $('groupmode').style.fontWeight='';
|
|
2805
|
+
const [mx0,mx1]=[Math.min(x0,x1),Math.max(x0,x1)], [my0,my1]=[Math.min(y0,y1),Math.max(y0,y1)];
|
|
2806
|
+
const ids=[];
|
|
2807
|
+
svg.querySelectorAll('[data-node]').forEach(g=>{
|
|
2808
|
+
const nd=lastDoc.nodes.find(n=>n.id===g.dataset.node);
|
|
2809
|
+
if(nd&&nd.group) return; // one nesting level only
|
|
2810
|
+
const cx=+g.dataset.x, cy=+g.dataset.y;
|
|
2811
|
+
if(cx>=mx0&&cx<=mx1&&cy>=my0&&cy<=my1) ids.push(g.dataset.node);
|
|
2812
|
+
});
|
|
2813
|
+
if(ids.length) createGroup(ids);
|
|
2814
|
+
else $('status').textContent='marquee selected no ungrouped nodes'; };
|
|
2815
|
+
window.addEventListener('pointermove',mv); window.addEventListener('pointerup',up);
|
|
2816
|
+
});
|
|
2817
|
+
// threshold lines: vertical drag rewrites the at=% on that source line
|
|
2818
|
+
svg.querySelectorAll('[data-gline]').forEach(gL=>{
|
|
2819
|
+
gL.addEventListener('pointerdown',ev=>{
|
|
2820
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
2821
|
+
const srcLine=+gL.dataset.gline, top=+gL.dataset.gtop, bot=+gL.dataset.gbot;
|
|
2822
|
+
const start=toModel(ev); let dy=0;
|
|
2823
|
+
const mv=e=>{ dy=toModel(e).y-start.y; gL.setAttribute('transform','translate(0,'+dy+')'); };
|
|
2824
|
+
const up=()=>{ window.removeEventListener('pointermove',mv); window.removeEventListener('pointerup',up);
|
|
2825
|
+
const lines=$('src').value.split('\n');
|
|
2826
|
+
const cur=/at=(\d+(?:\.\d+)?)%?/.exec(lines[srcLine-1]||'');
|
|
2827
|
+
if(!cur){ gL.removeAttribute('transform'); return; }
|
|
2828
|
+
const ly0=bot-(bot-top)*(+cur[1])/100;
|
|
2829
|
+
let pct=Math.round((bot-(ly0+dy))/(bot-top)*100);
|
|
2830
|
+
pct=Math.max(0,Math.min(100,pct));
|
|
2831
|
+
lines[srcLine-1]=lines[srcLine-1].replace(/at=\d+(?:\.\d+)?%?/,'at='+pct+'%');
|
|
2832
|
+
$('src').value=lines.join('\n'); refresh();
|
|
2833
|
+
$('status').textContent='line drag → rewrote at='+pct+'% (GUI action = text edit)'; };
|
|
2834
|
+
window.addEventListener('pointermove',mv); window.addEventListener('pointerup',up);
|
|
2835
|
+
});
|
|
2836
|
+
});
|
|
2837
|
+
svg.querySelectorAll('[data-node]').forEach(g=>{
|
|
2838
|
+
g.addEventListener('pointerdown',ev=>{
|
|
2839
|
+
ev.preventDefault();
|
|
2840
|
+
const id=g.dataset.node, x0=+g.dataset.x, y0=+g.dataset.y, start=toModel(ev);
|
|
2841
|
+
let dx=0,dy=0,moved=false;
|
|
2842
|
+
const mv=e=>{ const p=toModel(e); dx=p.x-start.x; dy=p.y-start.y;
|
|
2843
|
+
if(Math.abs(dx)+Math.abs(dy)>2) moved=true;
|
|
2844
|
+
g.setAttribute('transform','translate('+dx+','+dy+')'); };
|
|
2845
|
+
const up=()=>{ window.removeEventListener('pointermove',mv); window.removeEventListener('pointerup',up);
|
|
2846
|
+
if(!moved){ g.removeAttribute('transform');
|
|
2847
|
+
if(linkArm!==null){
|
|
2848
|
+
if(linkArm===undefined){ linkArm=id; $('status').textContent='Link mode: now click the target node'; return; }
|
|
2849
|
+
if(linkArm!==id){
|
|
2850
|
+
const lines=$('src').value.split('\n');
|
|
2851
|
+
let last=-1; lines.forEach((l,i)=>{ if(SCENE_KW.test(l.trim())) last=i; });
|
|
2852
|
+
lines.splice(last+1,0,'edge '+linkArm+' -> '+id);
|
|
2853
|
+
$('src').value=lines.join('\n'); refresh();
|
|
2854
|
+
$('status').textContent='link → wrote "edge '+linkArm+' -> '+id+'" (GUI action = text edit)';
|
|
2855
|
+
}
|
|
2856
|
+
linkArm=null; $('linkmode').style.fontWeight='';
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
select(id); return; }
|
|
2860
|
+
// pin-on-first-touch: the first drag freezes everything (groups get
|
|
2861
|
+
// an origin pin; members become group-local) — then applies the drag
|
|
2862
|
+
materializePins({ [id]: {x:x0+dx, y:y0+dy} });
|
|
2863
|
+
const nd=lastDoc.nodes.find(n=>n.id===id);
|
|
2864
|
+
$('status').textContent='drag → wrote pin for '+id+
|
|
2865
|
+
(nd&&nd.group?' (group-local, relative to "'+nd.group+'")':' (canvas px)')+
|
|
2866
|
+
' — GUI action = text edit'; };
|
|
2867
|
+
window.addEventListener('pointermove',mv); window.addEventListener('pointerup',up);
|
|
2868
|
+
});
|
|
2869
|
+
g.addEventListener('dblclick',ev=>{
|
|
2870
|
+
ev.preventDefault();
|
|
2871
|
+
const id=g.dataset.node;
|
|
2872
|
+
const nd=lastDoc.nodes.find(n=>n.id===id);
|
|
2873
|
+
const nv=prompt('Label for node "'+id+'":', nd?nd.label:'');
|
|
2874
|
+
if(nv!==null && nv!==''){ setNodeLabel(id,nv);
|
|
2875
|
+
$('status').textContent='double-click → rewrote node '+id+' label (GUI action = text edit)'; }
|
|
2876
|
+
});
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
let selectedId=null, linkArm=null;
|
|
2880
|
+
function nodeLineIdx(lines,id){ return lines.findIndex(l=>new RegExp('^\\s*node\\s+'+id+'\\b').test(l)); }
|
|
2881
|
+
function setNodeOption(id,key,val){
|
|
2882
|
+
const lines=$('src').value.split('\n');
|
|
2883
|
+
const idx=nodeLineIdx(lines,id); if(idx<0) return;
|
|
2884
|
+
const re=new RegExp('\\b'+key+'=\\S+');
|
|
2885
|
+
lines[idx]=re.test(lines[idx]) ? lines[idx].replace(re,key+'='+val) : lines[idx]+' '+key+'='+val;
|
|
2886
|
+
$('src').value=lines.join('\n'); refresh();
|
|
2887
|
+
}
|
|
2888
|
+
function deleteNode(id){
|
|
2889
|
+
// edges are found via the parsed doc (their line numbers), so inline
|
|
2890
|
+
// [labels] and every operator spelling are handled uniformly
|
|
2891
|
+
const edgeLines=new Set((lastDoc?lastDoc.edges:[]).filter(e=>e.a===id||e.b===id).map(e=>e.line));
|
|
2892
|
+
const lines=$('src').value.split('\n').filter((l,li)=>{
|
|
2893
|
+
if(edgeLines.has(li+1)) return false;
|
|
2894
|
+
const t=l.trim();
|
|
2895
|
+
if(new RegExp('^(node|pin|size)\\s+'+id+'\\b').test(t)) return false;
|
|
2896
|
+
const rm=/^rank\s+(.+)$/.exec(t);
|
|
2897
|
+
if(rm && rm[1].trim().split(/\s+/).includes(id)){
|
|
2898
|
+
const rest=rm[1].trim().split(/\s+/).filter(x=>x!==id);
|
|
2899
|
+
return rest.length>=2 ? true : false; // drop rank line if fewer than 2 remain
|
|
2900
|
+
}
|
|
2901
|
+
return true;
|
|
2902
|
+
});
|
|
2903
|
+
selectedId=null;
|
|
2904
|
+
$('src').value=lines.join('\n'); refresh();
|
|
2905
|
+
}
|
|
2906
|
+
function moveNodeLine(id,dir){ // dir +1 = later line = painted on top
|
|
2907
|
+
const lines=$('src').value.split('\n');
|
|
2908
|
+
const idx=nodeLineIdx(lines,id); if(idx<0) return;
|
|
2909
|
+
const isNode=l=>/^\s*node\s+/.test(l);
|
|
2910
|
+
let j=idx+dir;
|
|
2911
|
+
while(j>=0&&j<lines.length&&!isNode(lines[j])) j+=dir;
|
|
2912
|
+
if(j<0||j>=lines.length) { $('status').textContent='already at the '+(dir>0?'top':'bottom')+' of paint order'; return; }
|
|
2913
|
+
[lines[idx],lines[j]]=[lines[j],lines[idx]];
|
|
2914
|
+
$('src').value=lines.join('\n'); refresh();
|
|
2915
|
+
$('status').textContent=(dir>0?'raised':'lowered')+' '+id+' — its line moved '+(dir>0?'down':'up')+' in the text (line order = paint order)';
|
|
2916
|
+
}
|
|
2917
|
+
function select(id){
|
|
2918
|
+
selectedId=id;
|
|
2919
|
+
const has=id!==null && lastDoc && lastDoc.nodes.some(n=>n.id===id);
|
|
2920
|
+
['fillpick','delnode','raise','lower'].forEach(b=>$(b).disabled=!has);
|
|
2921
|
+
if(has){
|
|
2922
|
+
const nd=lastDoc.nodes.find(n=>n.id===id);
|
|
2923
|
+
if(nd.color&&/^#([0-9a-fA-F]{6})$/.test(nd.color)) $('fillpick').value=nd.color;
|
|
2924
|
+
highlightSelection();
|
|
2925
|
+
}
|
|
2926
|
+
drawResizeHandle();
|
|
2927
|
+
}
|
|
2928
|
+
// corner handle on the selected node: dragging it writes a `size` line
|
|
2929
|
+
function setSize(id,w,h){
|
|
2930
|
+
const lines=$('src').value.split('\n');
|
|
2931
|
+
const nl='size '+id+' w='+w+' h='+h;
|
|
2932
|
+
const idx=lines.findIndex(l=>new RegExp('^\\s*size\\s+'+id+'\\b').test(l));
|
|
2933
|
+
if(idx>=0) lines[idx]=nl;
|
|
2934
|
+
else{
|
|
2935
|
+
let mark=lines.findIndex(l=>l.trim()==='render');
|
|
2936
|
+
if(mark<0) lines.push('render');
|
|
2937
|
+
lines.push(nl);
|
|
2938
|
+
}
|
|
2939
|
+
$('src').value=lines.join('\n'); refresh(); select(id);
|
|
2940
|
+
$('status').textContent='resize → wrote "'+nl+'" (GUI action = text edit)';
|
|
2941
|
+
}
|
|
2942
|
+
function drawResizeHandle(){
|
|
2943
|
+
const svg=$('out').querySelector('svg'); if(!svg) return;
|
|
2944
|
+
svg.querySelectorAll('.rsz').forEach(x=>x.remove());
|
|
2945
|
+
if(!selectedId) return;
|
|
2946
|
+
const g=svg.querySelector('[data-node="'+selectedId+'"]'); if(!g) return;
|
|
2947
|
+
const sh=g.querySelector('rect,polygon,ellipse'); if(!sh) return;
|
|
2948
|
+
const b=sh.getBBox();
|
|
2949
|
+
const NS='http://www.w3.org/2000/svg';
|
|
2950
|
+
const hd=document.createElementNS(NS,'rect');
|
|
2951
|
+
hd.setAttribute('class','rsz');
|
|
2952
|
+
hd.setAttribute('x',b.x+b.width-5); hd.setAttribute('y',b.y+b.height-5);
|
|
2953
|
+
hd.setAttribute('width',10); hd.setAttribute('height',10);
|
|
2954
|
+
hd.setAttribute('fill','#2563eb'); hd.setAttribute('stroke','#fff');
|
|
2955
|
+
hd.style.cursor='nwse-resize';
|
|
2956
|
+
svg.appendChild(hd);
|
|
2957
|
+
hd.addEventListener('pointerdown',ev=>{
|
|
2958
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
2959
|
+
const id=selectedId, w0=b.width, h0=b.height;
|
|
2960
|
+
const pt0=new DOMPoint(ev.clientX,ev.clientY).matrixTransform(svg.getScreenCTM().inverse());
|
|
2961
|
+
const NS2='http://www.w3.org/2000/svg';
|
|
2962
|
+
const ghost=document.createElementNS(NS2,'rect');
|
|
2963
|
+
ghost.setAttribute('x',b.x); ghost.setAttribute('y',b.y);
|
|
2964
|
+
ghost.setAttribute('fill','none'); ghost.setAttribute('stroke','#2563eb');
|
|
2965
|
+
ghost.setAttribute('stroke-dasharray','4 3');
|
|
2966
|
+
svg.appendChild(ghost);
|
|
2967
|
+
let nw=w0, nh=h0;
|
|
2968
|
+
const mv=e=>{
|
|
2969
|
+
const p=new DOMPoint(e.clientX,e.clientY).matrixTransform(svg.getScreenCTM().inverse());
|
|
2970
|
+
nw=Math.max(24,Math.round(w0+p.x-pt0.x)); nh=Math.max(18,Math.round(h0+p.y-pt0.y));
|
|
2971
|
+
ghost.setAttribute('width',nw); ghost.setAttribute('height',nh);
|
|
2972
|
+
hd.setAttribute('x',b.x+nw-5); hd.setAttribute('y',b.y+nh-5);
|
|
2973
|
+
};
|
|
2974
|
+
const up=()=>{ window.removeEventListener('pointermove',mv); window.removeEventListener('pointerup',up);
|
|
2975
|
+
ghost.remove();
|
|
2976
|
+
if(nw!==Math.round(w0)||nh!==Math.round(h0)) setSize(id,nw,nh);
|
|
2977
|
+
};
|
|
2978
|
+
window.addEventListener('pointermove',mv); window.addEventListener('pointerup',up);
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
2981
|
+
function highlightSelection(){
|
|
2982
|
+
const svg=$('out').querySelector('svg'); if(!svg||!selectedId) return;
|
|
2983
|
+
const g=svg.querySelector('[data-node="'+selectedId+'"]'); if(!g) return;
|
|
2984
|
+
const sh=g.querySelector('rect,polygon'); if(!sh) return;
|
|
2985
|
+
sh.setAttribute('stroke','#2563eb'); sh.setAttribute('stroke-width','2.5');
|
|
2986
|
+
}
|
|
2987
|
+
// click a table cell -> write/update `cell r,c color=` inside that block
|
|
2988
|
+
function setCellColor(tid,r,c,color){
|
|
2989
|
+
const lines=$('src').value.split('\n');
|
|
2990
|
+
const start=lines.findIndex(l=>new RegExp('^\\s*table\\s+'+tid+'\\b').test(l));
|
|
2991
|
+
if(start<0) return;
|
|
2992
|
+
let end=start+1;
|
|
2993
|
+
while(end<lines.length){
|
|
2994
|
+
const t=lines[end].trim();
|
|
2995
|
+
if(t===''||t.startsWith('|')||t.startsWith('#')||/^cell\b/.test(t)){ end++; continue; }
|
|
2996
|
+
break;
|
|
2997
|
+
}
|
|
2998
|
+
const re=new RegExp('^\\s*cell\\s+'+r+','+c+'\\b');
|
|
2999
|
+
const newLine='cell '+r+','+c+' color='+color;
|
|
3000
|
+
let idx=-1;
|
|
3001
|
+
for(let i=start+1;i<end;i++) if(re.test(lines[i])){ idx=i; break; }
|
|
3002
|
+
if(idx>=0) lines[idx]=newLine; else lines.splice(end,0,newLine);
|
|
3003
|
+
$('src').value=lines.join('\n'); refresh();
|
|
3004
|
+
$('status').textContent='cell click → wrote "'+newLine+'" (GUI action = text edit)';
|
|
3005
|
+
}
|
|
3006
|
+
// marquee -> write a group line + in= on each enclosed node line
|
|
3007
|
+
let groupArm=false;
|
|
3008
|
+
function createGroup(ids){
|
|
3009
|
+
const lines=$('src').value.split('\n');
|
|
3010
|
+
let i=1; while(lines.some(l=>new RegExp('^\\s*group\\s+g'+i+'\\b').test(l))) i++;
|
|
3011
|
+
const gid='g'+i;
|
|
3012
|
+
let firstIdx=lines.length;
|
|
3013
|
+
for(const id of ids){
|
|
3014
|
+
const idx=nodeLineIdx(lines,id);
|
|
3015
|
+
if(idx<0) continue;
|
|
3016
|
+
if(!/\bin=/.test(lines[idx])) lines[idx]+=' in='+gid;
|
|
3017
|
+
firstIdx=Math.min(firstIdx,idx);
|
|
3018
|
+
}
|
|
3019
|
+
lines.splice(firstIdx,0,'group '+gid+' "Group '+i+'"');
|
|
3020
|
+
$('src').value=lines.join('\n'); refresh();
|
|
3021
|
+
$('status').textContent='marquee → wrote "group '+gid+'" and tagged '+ids.length+' node(s) with in='+gid;
|
|
3022
|
+
}
|
|
3023
|
+
function pinAll(){
|
|
3024
|
+
if(!lastDoc||!lastMeta) return;
|
|
3025
|
+
const snap=snapshotAbs({});
|
|
3026
|
+
if(!snap||(!Object.keys(snap.nodes).length&&!Object.keys(snap.groups).length)){ $('status').textContent='nothing to pin'; return; }
|
|
3027
|
+
const count=Object.keys(snap.nodes).length+Object.keys(snap.groups).length;
|
|
3028
|
+
materializePins({}); // two-pass so the frozen figure matches the live one to the pixel
|
|
3029
|
+
$('status').textContent='froze layout: '+count+' pin line(s) (groups anchored, members group-local) — future edits stay local';
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
function refresh(){
|
|
3033
|
+
const text=$('src').value;
|
|
3034
|
+
// undo history: every change funnels through here (GUI actions = one
|
|
3035
|
+
// snapshot each; typing coalesces within 800ms)
|
|
3036
|
+
if(!restoring && lastText!==null && text!==lastText){
|
|
3037
|
+
const now=Date.now();
|
|
3038
|
+
if(now-lastPush>800 || !undoStack.length){ undoStack.push(lastText); if(undoStack.length>200) undoStack.shift(); lastPush=now; }
|
|
3039
|
+
redoStack.length=0;
|
|
3040
|
+
}
|
|
3041
|
+
lastText=text;
|
|
3042
|
+
try{ localStorage.setItem('figdown.autosave',text); localStorage.setItem('figdown.autosave.name',fileName); }catch(e){}
|
|
3043
|
+
updateFileUI();
|
|
3044
|
+
const {doc,errs}=parse(text);
|
|
3045
|
+
const out=$('out'), eEl=$('errs'), ok=$('okline');
|
|
3046
|
+
if(errs.length){
|
|
3047
|
+
eEl.innerHTML=errs.map(x=>{
|
|
3048
|
+
const m=/^Line (\d+):/.exec(x);
|
|
3049
|
+
return '<div class="errline"'+(m?' data-line="'+m[1]+'"':'')+'>'+esc(x)+'</div>';
|
|
3050
|
+
}).join('');
|
|
3051
|
+
ok.textContent=''; out.innerHTML=''; lastSVG='';
|
|
3052
|
+
$('svgsrc').textContent=''; $('svgimg').removeAttribute('src');
|
|
3053
|
+
$('status').textContent=errs.length+' error(s) — nothing rendered (determinism over convenience)';
|
|
3054
|
+
return;
|
|
3055
|
+
}
|
|
3056
|
+
eEl.textContent='';
|
|
3057
|
+
const r=render(doc,{title:true});
|
|
3058
|
+
lastSVG=r.svg; lastMeta=r.sceneMeta; lastPad=r.pad; lastDoc=doc;
|
|
3059
|
+
out.innerHTML=r.svg;
|
|
3060
|
+
installDirectEdit();
|
|
3061
|
+
select(selectedId);
|
|
3062
|
+
// pretty-print for display only (the artifact itself stays single-line deterministic)
|
|
3063
|
+
$('svgsrc').textContent=r.svg.replace(/></g,'>\n<');
|
|
3064
|
+
updateArtifactPreview(text, r.svg);
|
|
3065
|
+
const n=doc.nodes.length, b=doc.blocks.length;
|
|
3066
|
+
ok.textContent='OK — '+n+' node(s), '+doc.edges.length+' edge(s), '+b+' typed block(s). Same source → same SVG.';
|
|
3067
|
+
$('status').textContent='';
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
async function svgArtifact(src, svg){
|
|
3071
|
+
// the exact bytes the downloaded .svg would contain: source + SHA-256 embedded
|
|
3072
|
+
const buf=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(src));
|
|
3073
|
+
const hash=[...new Uint8Array(buf)].map(x=>x.toString(16).padStart(2,'0')).join('');
|
|
3074
|
+
const meta='<metadata id="figdown-source" data-sha256="'+hash+'" data-render-options="with-title"><![CDATA[\n'+src.replace(/]]>/g,']]]]><![CDATA[>')+'\n]]></metadata>';
|
|
3075
|
+
return svg.replace(/<\/svg>$/, meta+'</svg>');
|
|
3076
|
+
}
|
|
3077
|
+
let previewSeq=0;
|
|
3078
|
+
async function updateArtifactPreview(src, svg){
|
|
3079
|
+
const seq=++previewSeq;
|
|
3080
|
+
const art=await svgArtifact(src, svg);
|
|
3081
|
+
if(seq!==previewSeq) return; // a newer edit superseded this one
|
|
3082
|
+
$('svgimg').src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(art);
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
$('dl').onclick=async ()=>{
|
|
3086
|
+
if(!lastSVG){ $('status').textContent='fix errors first'; return; }
|
|
3087
|
+
const svg=await svgArtifact($('src').value, lastSVG);
|
|
3088
|
+
const a=document.createElement('a');
|
|
3089
|
+
a.href=URL.createObjectURL(new Blob([svg],{type:'image/svg+xml'}));
|
|
3090
|
+
a.download=fileName.replace(/\.fd$/,'')+'.svg'; a.click(); URL.revokeObjectURL(a.href);
|
|
3091
|
+
$('status').textContent='SVG saved — source + SHA-256 embedded (self-carrying artifact, R14)';
|
|
3092
|
+
};
|
|
3093
|
+
|
|
3094
|
+
$('pinall').onclick=pinAll;
|
|
3095
|
+
$('fillpick').oninput=()=>{ if(selectedId) setNodeOption(selectedId,'color',$('fillpick').value); };
|
|
3096
|
+
$('delnode').onclick=()=>{ if(selectedId) deleteNode(selectedId); };
|
|
3097
|
+
$('raise').onclick=()=>{ if(selectedId) moveNodeLine(selectedId,+1); };
|
|
3098
|
+
$('lower').onclick=()=>{ if(selectedId) moveNodeLine(selectedId,-1); };
|
|
3099
|
+
function insertNodeLine(){
|
|
3100
|
+
const lines=$('src').value.split('\n');
|
|
3101
|
+
let i=1; while(lines.some(l=>new RegExp('^\\s*(node|group)\\s+n'+i+'\\b').test(l))) i++;
|
|
3102
|
+
let last=-1; lines.forEach((l,k)=>{ if(SCENE_KW.test(l.trim())) last=k; });
|
|
3103
|
+
const kk=$('newkind').value;
|
|
3104
|
+
lines.splice(last+1,0,'node n'+i+' "Node '+i+'"'+(kk!=='box'?' shape='+kk:''));
|
|
3105
|
+
$('src').value=lines.join('\n'); refresh();
|
|
3106
|
+
return 'n'+i;
|
|
3107
|
+
}
|
|
3108
|
+
$('addnode').onclick=()=>{
|
|
3109
|
+
const id=insertNodeLine(); select(id);
|
|
3110
|
+
$('status').textContent='+ Node → wrote "node '+id+'" line — drag it into place';
|
|
3111
|
+
};
|
|
3112
|
+
function addNodeAt(x,y){
|
|
3113
|
+
const id=insertNodeLine();
|
|
3114
|
+
// place its center at the double-click point (default box ≈ 100×40)
|
|
3115
|
+
materializePins({[id]:{x:x-50, y:y-20}});
|
|
3116
|
+
select(id);
|
|
3117
|
+
$('status').textContent='double-click → wrote node '+id+' + pin at the click point (GUI action = text edit)';
|
|
3118
|
+
}
|
|
3119
|
+
$('groupmode').onclick=()=>{
|
|
3120
|
+
groupArm=!groupArm;
|
|
3121
|
+
$('groupmode').style.fontWeight=groupArm?'700':'';
|
|
3122
|
+
$('status').textContent=groupArm?'Group mode: drag a box around the nodes to enclose':'Group mode off';
|
|
3123
|
+
};
|
|
3124
|
+
$('linkmode').onclick=()=>{
|
|
3125
|
+
if(linkArm===null && $('linkmode').style.fontWeight!=='700'){
|
|
3126
|
+
$('linkmode').style.fontWeight='700'; linkArm=undefined;
|
|
3127
|
+
$('status').textContent='Link mode: click the source node';
|
|
3128
|
+
} else { linkArm=null; $('linkmode').style.fontWeight=''; $('status').textContent='Link mode off'; }
|
|
3129
|
+
};
|
|
3130
|
+
|
|
3131
|
+
// ============================================================
|
|
3132
|
+
// 4. FILE OPS + UNDO + KEYBOARD (the daily-driver layer)
|
|
3133
|
+
// ============================================================
|
|
3134
|
+
// File handles use the File System Access API where available (Chrome/
|
|
3135
|
+
// Edge: open once, Ctrl+S writes back to the same file); elsewhere we
|
|
3136
|
+
// fall back to a file picker + download. Unsaved work survives in
|
|
3137
|
+
// localStorage autosave either way.
|
|
3138
|
+
let fdHandle=null, fileName='untitled.fd', savedText=null;
|
|
3139
|
+
let undoStack=[], redoStack=[], lastText=null, lastPush=0, restoring=false;
|
|
3140
|
+
const isDirty=()=>savedText!==null && $('src').value!==savedText;
|
|
3141
|
+
function updateFileUI(){
|
|
3142
|
+
$('fname').textContent=fileName+(isDirty()?' •':'');
|
|
3143
|
+
document.title=(isDirty()?'• ':'')+fileName+' — FigDown';
|
|
3144
|
+
$('undobtn').disabled=!undoStack.length;
|
|
3145
|
+
$('redobtn').disabled=!redoStack.length;
|
|
3146
|
+
}
|
|
3147
|
+
function undo(){
|
|
3148
|
+
if(!undoStack.length) return;
|
|
3149
|
+
redoStack.push($('src').value);
|
|
3150
|
+
restoring=true; $('src').value=undoStack.pop(); refresh(); restoring=false;
|
|
3151
|
+
$('status').textContent='undo';
|
|
3152
|
+
}
|
|
3153
|
+
function redo(){
|
|
3154
|
+
if(!redoStack.length) return;
|
|
3155
|
+
undoStack.push($('src').value);
|
|
3156
|
+
restoring=true; $('src').value=redoStack.pop(); refresh(); restoring=false;
|
|
3157
|
+
$('status').textContent='redo';
|
|
3158
|
+
}
|
|
3159
|
+
$('undobtn').onclick=undo; $('redobtn').onclick=redo;
|
|
3160
|
+
|
|
3161
|
+
// Load a .fd file, or a generated .svg — the artifact carries its own
|
|
3162
|
+
// source (metadata figdown-source), so the roundtrip needs no sidecar:
|
|
3163
|
+
// export SVG today, drop it back in tomorrow, tweak, export again.
|
|
3164
|
+
async function loadText(name, text){
|
|
3165
|
+
if(name.endsWith('.svg') || text.trimStart().startsWith('<')){
|
|
3166
|
+
const m=/<metadata id="figdown-source" data-sha256="([0-9a-f]{64})"[^>]*><!\[CDATA\[\n([\s\S]*?)\n\]\]><\/metadata>/.exec(text);
|
|
3167
|
+
if(!m){ $('status').textContent='no embedded FigDown source in this SVG'; return false; }
|
|
3168
|
+
const src=m[2].replace(/\]\]\]\]><!\[CDATA\[>/g,']]>');
|
|
3169
|
+
const buf=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(src));
|
|
3170
|
+
const hash=[...new Uint8Array(buf)].map(x=>x.toString(16).padStart(2,'0')).join('');
|
|
3171
|
+
fileName=name.replace(/\.svg$/,'.fd'); fdHandle=null; savedText=null; // recovered → not yet saved as .fd
|
|
3172
|
+
$('src').value=src; refresh();
|
|
3173
|
+
$('status').textContent = hash===m[1]
|
|
3174
|
+
? 'source recovered from SVG — integrity verified (SHA-256 matches)'
|
|
3175
|
+
: '⚠ source recovered, but SHA-256 does not match — the SVG was edited after generation';
|
|
3176
|
+
} else {
|
|
3177
|
+
fileName=name; fdHandle=null; savedText=text;
|
|
3178
|
+
$('src').value=text; refresh();
|
|
3179
|
+
$('status').textContent='loaded '+name;
|
|
3180
|
+
}
|
|
3181
|
+
return true;
|
|
3182
|
+
}
|
|
3183
|
+
async function openFile(){
|
|
3184
|
+
if(isDirty() && !confirm('Discard unsaved changes in '+fileName+'?')) return;
|
|
3185
|
+
if(window.showOpenFilePicker){
|
|
3186
|
+
let h;
|
|
3187
|
+
try{
|
|
3188
|
+
[h]=await window.showOpenFilePicker({types:[
|
|
3189
|
+
{description:'FigDown / SVG',accept:{'text/plain':['.fd','.txt'],'image/svg+xml':['.svg']}}]});
|
|
3190
|
+
}catch(e){ return; } // cancelled
|
|
3191
|
+
const f=await h.getFile();
|
|
3192
|
+
const ok=await loadText(f.name, await f.text());
|
|
3193
|
+
if(ok && !f.name.endsWith('.svg')) fdHandle=h; // Ctrl+S writes back here
|
|
3194
|
+
updateFileUI();
|
|
3195
|
+
} else $('loadfile').click();
|
|
3196
|
+
}
|
|
3197
|
+
async function saveFile(as){
|
|
3198
|
+
const text=$('src').value;
|
|
3199
|
+
if(window.showSaveFilePicker){
|
|
3200
|
+
try{
|
|
3201
|
+
if(as||!fdHandle){
|
|
3202
|
+
fdHandle=await window.showSaveFilePicker({suggestedName:fileName,
|
|
3203
|
+
types:[{description:'FigDown',accept:{'text/plain':['.fd']}}]});
|
|
3204
|
+
fileName=fdHandle.name;
|
|
3205
|
+
}
|
|
3206
|
+
const w=await fdHandle.createWritable(); await w.write(text); await w.close();
|
|
3207
|
+
savedText=text; updateFileUI();
|
|
3208
|
+
$('status').textContent='saved '+fileName;
|
|
3209
|
+
}catch(e){ if(!e||e.name!=='AbortError') $('status').textContent='save failed: '+(e&&e.message||e); }
|
|
3210
|
+
} else {
|
|
3211
|
+
const a=document.createElement('a');
|
|
3212
|
+
a.href=URL.createObjectURL(new Blob([text],{type:'text/plain'}));
|
|
3213
|
+
a.download=fileName; a.click(); URL.revokeObjectURL(a.href);
|
|
3214
|
+
savedText=text; updateFileUI();
|
|
3215
|
+
$('status').textContent='downloaded '+fileName+' (this browser has no File System Access API)';
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
$('open').onclick=openFile;
|
|
3219
|
+
$('save').onclick=()=>saveFile(false);
|
|
3220
|
+
$('saveas').onclick=()=>saveFile(true);
|
|
3221
|
+
$('newdoc').onclick=()=>{
|
|
3222
|
+
if(isDirty() && !confirm('Discard unsaved changes in '+fileName+'?')) return;
|
|
3223
|
+
const blankKey=Object.keys(EXAMPLES).find(k=>/blank/.test(k))||Object.keys(EXAMPLES)[0];
|
|
3224
|
+
fdHandle=null; fileName='untitled.fd'; savedText=null; selectedId=null;
|
|
3225
|
+
$('src').value=EXAMPLES[blankKey]; refresh();
|
|
3226
|
+
$('status').textContent='new document';
|
|
3227
|
+
};
|
|
3228
|
+
$('loadfile').onchange=async e=>{
|
|
3229
|
+
const f=e.target.files[0]; if(!f) return;
|
|
3230
|
+
loadText(f.name, await f.text()); e.target.value='';
|
|
3231
|
+
};
|
|
3232
|
+
document.addEventListener('dragover',e=>e.preventDefault());
|
|
3233
|
+
document.addEventListener('drop',async e=>{
|
|
3234
|
+
e.preventDefault();
|
|
3235
|
+
const f=e.dataTransfer.files[0]; if(f) loadText(f.name, await f.text());
|
|
3236
|
+
});
|
|
3237
|
+
window.addEventListener('beforeunload',e=>{ if(isDirty()){ e.preventDefault(); e.returnValue=''; } });
|
|
3238
|
+
|
|
3239
|
+
// clicking an error jumps to (and selects) the offending line
|
|
3240
|
+
$('errs').addEventListener('click',e=>{
|
|
3241
|
+
const d=e.target.closest('.errline'); if(!d||!d.dataset.line) return;
|
|
3242
|
+
const n=+d.dataset.line, lines=$('src').value.split('\n');
|
|
3243
|
+
const start=lines.slice(0,n-1).join('\n').length+(n>1?1:0);
|
|
3244
|
+
const ta=$('src'); ta.focus(); ta.setSelectionRange(start,start+(lines[n-1]||'').length);
|
|
3245
|
+
});
|
|
3246
|
+
|
|
3247
|
+
// arrow-key nudge writes pins, exactly like a drag would
|
|
3248
|
+
function nudge(id,dx,dy){
|
|
3249
|
+
const g=$('out').querySelector('[data-node="'+id+'"]'); if(!g) return;
|
|
3250
|
+
materializePins({[id]:{x:+g.dataset.x+dx, y:+g.dataset.y+dy}});
|
|
3251
|
+
select(id);
|
|
3252
|
+
$('status').textContent='nudge → rewrote pin for '+id+' (GUI action = text edit)';
|
|
3253
|
+
}
|
|
3254
|
+
document.addEventListener('keydown',e=>{
|
|
3255
|
+
const mod=e.ctrlKey||e.metaKey;
|
|
3256
|
+
const inText=/^(INPUT|TEXTAREA|SELECT)$/.test((e.target&&e.target.tagName)||'');
|
|
3257
|
+
if(mod && e.key.toLowerCase()==='s'){ e.preventDefault(); saveFile(e.shiftKey); return; }
|
|
3258
|
+
if(mod && e.key.toLowerCase()==='o'){ e.preventDefault(); openFile(); return; }
|
|
3259
|
+
if(mod && !inText && e.key.toLowerCase()==='z'){ e.preventDefault(); e.shiftKey?redo():undo(); return; }
|
|
3260
|
+
if(mod && !inText && e.key.toLowerCase()==='y'){ e.preventDefault(); redo(); return; }
|
|
3261
|
+
if(inText) return;
|
|
3262
|
+
if(e.key==='Escape'){
|
|
3263
|
+
select(null); linkArm=null; groupArm=false;
|
|
3264
|
+
$('linkmode').style.fontWeight=''; $('groupmode').style.fontWeight='';
|
|
3265
|
+
$('status').textContent=''; return;
|
|
3266
|
+
}
|
|
3267
|
+
if(!selectedId) return;
|
|
3268
|
+
if(e.key==='Delete'||e.key==='Backspace'){ e.preventDefault(); deleteNode(selectedId); return; }
|
|
3269
|
+
const step=e.shiftKey?10:1;
|
|
3270
|
+
const d={ArrowLeft:[-step,0],ArrowRight:[step,0],ArrowUp:[0,-step],ArrowDown:[0,step]}[e.key];
|
|
3271
|
+
if(d){ e.preventDefault(); nudge(selectedId,d[0],d[1]); }
|
|
3272
|
+
});
|
|
3273
|
+
|
|
3274
|
+
// startup: restore autosaved work from the last session, else first example
|
|
3275
|
+
(function(){
|
|
3276
|
+
let auto=null, autoName=null;
|
|
3277
|
+
try{ auto=localStorage.getItem('figdown.autosave'); autoName=localStorage.getItem('figdown.autosave.name'); }catch(e){}
|
|
3278
|
+
const first=EXAMPLES[Object.keys(EXAMPLES)[0]];
|
|
3279
|
+
if(auto && auto!==first){
|
|
3280
|
+
$('src').value=auto; if(autoName) fileName=autoName;
|
|
3281
|
+
refresh();
|
|
3282
|
+
$('status').textContent='restored unsaved work from the last session (autosave) — Ctrl+S to save it to a file';
|
|
3283
|
+
} else {
|
|
3284
|
+
$('src').value=first;
|
|
3285
|
+
refresh();
|
|
3286
|
+
}
|
|
3287
|
+
})();
|
|
3288
|
+
</script>
|
|
3289
|
+
</body>
|
|
3290
|
+
</html>
|