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.
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
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>