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,2239 @@
1
+ // figdown.js — FigDown embeddable library (0.1-dev.15)
2
+ // GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
3
+ // Regenerate with: node tools/make-lib.js
4
+ (function (root, factory) {
5
+ 'use strict';
6
+ if (typeof module === 'object' && typeof module.exports === 'object') {
7
+ module.exports = factory(); // CommonJS / Node
8
+ } else {
9
+ root.figdown = factory(); // script tag: globalThis.figdown
10
+ }
11
+ }(typeof globalThis !== 'undefined' ? globalThis : this, function () {
12
+ 'use strict';
13
+ var VERSION = "0.1-dev.15";
14
+
15
+ // ---- engine (extracted verbatim from editor/figdown.html) ----
16
+ var __engine = (function () {
17
+ const SHAPES = ['box','rounded','circle','ellipse','cloud','diamond','cylinder'];
18
+ // Colors are CSS hex (#rgb / #rrggbb) or CSS named colors (spec §1) — the
19
+ // 147 CSS/SVG color keywords (lowercase) plus `transparent`. Anything else
20
+ // is a line error (closed grammar, 0.1-dev.11).
21
+ const CSS_COLORS=new Set(('aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond '+
22
+ 'blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan '+
23
+ 'darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange '+
24
+ 'darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet '+
25
+ 'deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite '+
26
+ 'gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush '+
27
+ 'lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey '+
28
+ 'lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow '+
29
+ 'lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen '+
30
+ 'mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin '+
31
+ 'navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise '+
32
+ 'palevioletred papayawhip peachpuff peru pink plum powderblue purple red rosybrown royalblue saddlebrown '+
33
+ 'salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen '+
34
+ 'steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen transparent')
35
+ .split(' '));
36
+ const isColor=v=>/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)||CSS_COLORS.has(v);
37
+
38
+ function tokenize(line){
39
+ // split by whitespace, honoring double quotes
40
+ const toks=[]; let i=0;
41
+ while(i<line.length){
42
+ while(i<line.length && /\s/.test(line[i])) i++;
43
+ if(i>=line.length) break;
44
+ if(line[i]==='"'){
45
+ let j=i+1, s='';
46
+ while(j<line.length && line[j]!=='"'){
47
+ if(line[j]==='\\'){
48
+ const e=line[j+1];
49
+ if(e==='n'){ s+='\n'; j+=2; continue; }
50
+ if(e==='"'){ s+='"'; j+=2; continue; }
51
+ if(e==='\\'){ s+='\\'; j+=2; continue; }
52
+ return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'};
53
+ }
54
+ s+=line[j]; j++;
55
+ }
56
+ if(j>=line.length) return {error:'unterminated string'};
57
+ toks.push({v:s,q:true}); i=j+1;
58
+ } else {
59
+ let j=i, s='';
60
+ while(j<line.length && !/\s/.test(line[j])){
61
+ if(line[j]==='"'){ // key="value with spaces"
62
+ j++;
63
+ while(j<line.length && line[j]!=='"'){
64
+ if(line[j]==='\\'){
65
+ const e=line[j+1];
66
+ if(e==='n'){ s+='\n'; j+=2; continue; }
67
+ if(e==='"'){ s+='"'; j+=2; continue; }
68
+ if(e==='\\'){ s+='\\'; j+=2; continue; }
69
+ return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'};
70
+ }
71
+ s+=line[j]; j++;
72
+ }
73
+ if(j>=line.length) return {error:'unterminated string'};
74
+ j++; continue;
75
+ }
76
+ s+=line[j]; j++;
77
+ }
78
+ toks.push({v:s,q:false}); i=j;
79
+ }
80
+ }
81
+ return {toks};
82
+ }
83
+ // Option keys are a CLOSED set (two levels, 0.1-dev.11):
84
+ // - a key listed in OPT_KEYS is parsed as an option; whether it is
85
+ // *applicable* is checked per directive against DIRECTIVE_OPTS
86
+ // (a registered-but-inapplicable key is a line error, D3);
87
+ // - a key=value token with an unregistered key is an "unknown option"
88
+ // line error (D12) — except inside wave `signal` lanes, where bare
89
+ // tokens may contain '=' and stay positional (laneMode).
90
+ const OPT_KEYS=new Set(['kind','shape','color','stroke','text','in','layer','label',
91
+ 'style','z','at','w','h','unit','note','labels','numbering','fill','from','to','gap','dir','level',
92
+ 'taillabel','headlabel','class','via','routing']);
93
+ // Applicable option keys per directive. Keys with dedicated diagnostics
94
+ // (node kind/w/h, edge label/taillabel/headlabel, line fill, fill from/to)
95
+ // are listed so their specific error messages fire. `edge` is consumed by
96
+ // parseEdgeLine; `title` takes its rest-of-line verbatim (no options).
97
+ const DIRECTIVE_OPTS={
98
+ figdown:[],
99
+ node:['shape','color','stroke','text','style','class','in','layer','kind','w','h'],
100
+ group:['color','stroke','text','style','gap','class','layer'],
101
+ boundary:[],
102
+ edge:['style','class','color','layer','label','taillabel','headlabel'],
103
+ layer:['z'], flow:[], rank:[],
104
+ bundle:['color'],
105
+ line:['in','at','color','fill'],
106
+ fill:['in','dir','color','from','to'],
107
+ pin:['at'], size:['w','h'],
108
+ render:[],
109
+ routing:[], route:['via','routing'],
110
+ 'class':['color','stroke','text','style'],
111
+ bitfield:['unit','numbering'], table:[], wave:[],
112
+ plot:['kind','level'],
113
+ field:['color','class','note'], wrap:[],
114
+ cell:['color','class'], colw:[],
115
+ signal:['labels'], gap:[]
116
+ };
117
+ function splitOpts(toks, laneMode){
118
+ const pos=[], opts={}, unk=[];
119
+ for(const t of toks){
120
+ const m = !t.q && /^([A-Za-z_][A-Za-z0-9_-]*)=([\s\S]*)$/.exec(t.v);
121
+ if(m && OPT_KEYS.has(m[1])) opts[m[1]]=m[2];
122
+ else if(m && !laneMode) unk.push(m[1]);
123
+ else pos.push(t.v);
124
+ }
125
+ return {pos,opts,unk};
126
+ }
127
+ const ID_RE=/^[A-Za-z_][A-Za-z0-9_-]*$/;
128
+
129
+ function parse(text){
130
+ const errs=[];
131
+ const doc={title:'',nodes:[],groups:[],edges:[],layers:[{id:'base',label:'',z:0}],
132
+ flow:'right',ranks:[],pins:{},sizes:{},blocks:[],trunks:[],glines:[],fills:[],
133
+ classes:[],routes:[],boundaries:[]};
134
+ const nodeIds=new Set(), groupIds=new Set(), layerIds=new Set(['base']), classIds=new Set(),
135
+ bundleIds=new Set(), boundaryIds=new Set();
136
+ let cur=null; // current typed block (bitfield/table/wave)
137
+ let sawHeader=false, firstContent=true, sawRender=false;
138
+ const RENDER_DIRECTIVES=new Set(['pin','size','route','routing']);
139
+ const lines=text.split('\n');
140
+ const err=(n,m)=>errs.push('Line '+n+': '+m);
141
+
142
+ // R34/R35: edge <id> [tail] <op> [head] <id> — a [mid] label splits the
143
+ // operator into halves: -[x]- -[x]-> <-[x]- <-[x]->. Bracket content:
144
+ // balanced brackets nest verbatim ([flags[3:0]] just works); ["..."] takes
145
+ // the standard quoted-string escapes for unbalanced brackets / \n.
146
+ function parseEdgeLine(s,n){
147
+ let i=4; // past 'edge'
148
+ const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
149
+ const readId=()=>{ const m=/^[A-Za-z_][A-Za-z0-9_-]*/.exec(s.slice(i));
150
+ if(!m) return null; i+=m[0].length; return m[0]; };
151
+ const readLbl=()=>{ // called at '['
152
+ i++;
153
+ if(s[i]==='"'){ // ["..."] — quoted content
154
+ i++; let v='';
155
+ while(i<s.length&&s[i]!=='"'){
156
+ if(s[i]==='\\'){ const e=s[i+1];
157
+ if(e==='n'){ v+='\n'; i+=2; continue; }
158
+ if(e==='"'){ v+='"'; i+=2; continue; }
159
+ if(e==='\\'){ v+='\\'; i+=2; continue; }
160
+ return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'}; }
161
+ v+=s[i]; i++;
162
+ }
163
+ if(i>=s.length) return {error:'unterminated string in [label]'};
164
+ i++;
165
+ if(s[i]!==']') return {error:'expected ] after quoted label'};
166
+ i++;
167
+ if(!v) return {error:'empty [label]'};
168
+ return {v};
169
+ }
170
+ let depth=1,v='';
171
+ while(i<s.length){
172
+ const c=s[i];
173
+ if(c==='[') depth++;
174
+ else if(c===']'){ depth--; if(!depth){ i++;
175
+ v=v.trim();
176
+ if(!v) return {error:'empty [label]'};
177
+ return {v}; } }
178
+ v+=c; i++;
179
+ }
180
+ return {error:'unterminated [label] — for unbalanced brackets use ["..."]'};
181
+ };
182
+ ws(); const a=readId();
183
+ if(!a){ err(n,'edge needs <id> ->|<-|--|<-> <id>'); return; }
184
+ ws(); let tail=null;
185
+ if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } tail=r.v; }
186
+ ws();
187
+ let lh=null;
188
+ if(s.startsWith('<-',i)){ lh='<-'; i+=2; }
189
+ else if(s[i]==='-'){ lh='-'; i++; }
190
+ else { err(n,'edge needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)'); return; }
191
+ let mid=null, op=null;
192
+ if(s[i]==='['){
193
+ const r=readLbl(); if(r.error){ err(n,r.error); return; } mid=r.v;
194
+ if(s.startsWith('->',i)){ op=lh==='<-'?'<->':'->'; i+=2; }
195
+ else if(s[i]==='-'){ op=lh==='<-'?'<-':'--'; i++; }
196
+ else { err(n,'expected - or -> to close the operator after [label]'); return; }
197
+ } else if(lh==='<-'){
198
+ if(s[i]==='>'){ op='<->'; i++; } else op='<-';
199
+ } else {
200
+ if(s[i]==='-'){ op='--'; i++; }
201
+ else if(s[i]==='>'){ op='->'; i++; }
202
+ else { err(n,'edge needs an operator: -> <- -- <->'); return; }
203
+ }
204
+ ws(); let head=null;
205
+ if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } head=r.v; }
206
+ ws(); const b=readId();
207
+ if(!b){ err(n,'edge needs a target id after the operator'); return; }
208
+ const tk2=tokenize(s.slice(i).trim());
209
+ if(tk2.error){ err(n,tk2.error); return; }
210
+ const {pos:p2,opts:o2,unk:u2}=splitOpts(tk2.toks);
211
+ if(u2.length){ err(n,'unknown option "'+u2[0]+'="'); return; }
212
+ if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
213
+ for(const k in o2)
214
+ if(!DIRECTIVE_OPTS.edge.includes(k)){ err(n,'edge does not take '+k+'='); return; }
215
+ for(const k of ['label','taillabel','headlabel'])
216
+ 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; }
217
+ if(o2.color!==undefined && !isColor(o2.color)){ err(n,'unknown color "'+o2.color+'" (#hex or CSS color name)'); return; }
218
+ if(o2.style!==undefined && !['solid','dashed','dotted'].includes(o2.style)){ err(n,'style must be solid|dashed|dotted'); return; }
219
+ doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:o2['class'],
220
+ color:o2.color,layer:o2.layer||'base',line:n});
221
+ }
222
+
223
+ for(let li=0; li<lines.length; li++){
224
+ const n=li+1;
225
+ let raw=lines[li];
226
+ // pipe rows are raw GFM content: no comment stripping inside them
227
+ if(!raw.trimStart().startsWith('|')){
228
+ const hi=findComment(raw); if(hi>=0) raw=raw.slice(0,hi);
229
+ }
230
+ if(!raw.trim()) continue;
231
+
232
+ // GFM pipe row (table content)
233
+ if(raw.trim().startsWith('|')){
234
+ if(!cur||cur.type!=='table'){ err(n,'pipe row outside a table block'); continue; }
235
+ const s=raw.trim();
236
+ if(!s.endsWith('|')){ err(n,'pipe row must end with |'); continue; }
237
+ const inner=s.slice(1,-1);
238
+ const segs=[]; let acc='';
239
+ for(let i=0;i<inner.length;i++){
240
+ if(inner[i]==='\\'&&inner[i+1]==='|'){ acc+='|'; i++; }
241
+ else if(inner[i]==='|'){ segs.push(acc); acc=''; }
242
+ else acc+=inner[i];
243
+ }
244
+ segs.push(acc);
245
+ const isSep=segs.every(x=>/^\s*:?-+:?\s*$/.test(x)) && segs.length>0; // GFM: 1+ hyphens
246
+ if(isSep){
247
+ if(cur.sep){ err(n,'duplicate separator row'); continue; }
248
+ if(!cur.heads.length){ err(n,'separator row before any header row'); continue; }
249
+ if(segs.length!==cur.cols.length){ err(n,'separator has '+segs.length+' columns, expected '+cur.cols.length); continue; }
250
+ cur.sep=true;
251
+ cur.aligns=segs.map(x=>{ x=x.trim();
252
+ const l=x.startsWith(':'), r=x.endsWith(':');
253
+ return l&&r?'center':(r?'right':(l?'left':null)); });
254
+ continue;
255
+ }
256
+ // content row: raw empty segment = colspan-left (multimd "||");
257
+ // cell exactly ^^ = rowspan-up (multimd). The only cell escapes are
258
+ // \| (handled during segmentation) and \^^ (literal caret pair) —
259
+ // any other leading backslash is literal cell text (D10).
260
+ const cells=segs.map(x=>{
261
+ if(x.length===0) return {v:'', m:'left'};
262
+ const t=x.trim();
263
+ if(t==='^^') return {v:'', m:'up'};
264
+ return {v:t.startsWith('\\^^')?t.slice(1):t, m:null};
265
+ });
266
+ if(cells[0].m==='left'){ err(n,'colspan cannot start in the first column'); continue; }
267
+ if(cur.heads.length===0 && cells.some(c=>c.m==='up')){ err(n,'"^^" cannot appear in the first row'); continue; }
268
+ if(!cur.sep){
269
+ if(cur.heads.length && cells.length!==cur.cols.length){ err(n,'header row has '+cells.length+' cells, expected '+cur.cols.length); continue; }
270
+ cur.heads.push(cells);
271
+ if(cur.heads.length===1) cur.cols=cells.map(c=>c.v);
272
+ } else {
273
+ if(cells.length!==cur.cols.length){ err(n,'row has '+cells.length+' cells, expected '+cur.cols.length); continue; }
274
+ // rowspan cannot cross the thead/tbody boundary (multimd prior
275
+ // art): "^^" in the first data row is a line error (D9).
276
+ 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; }
277
+ cur.rows.push({cells,hl:false,line:n});
278
+ }
279
+ continue;
280
+ }
281
+ // edge lines carry inline [labels] with free text — dedicated scanner,
282
+ // not the generic tokenizer
283
+ if(/^edge(\s|$)/.test(raw.trim())){
284
+ if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 [template]"'); }
285
+ cur=null;
286
+ parseEdgeLine(raw.trim(),n);
287
+ continue;
288
+ }
289
+ const tk=tokenize(raw.trim());
290
+ if(tk.error){ err(n,tk.error); continue; }
291
+ const lead=(tk.toks.length&&!tk.toks[0].q)?tk.toks[0].v:'';
292
+ const {pos,opts,unk}=splitOpts(tk.toks, lead==='signal');
293
+ const kw=pos[0];
294
+ // D3/D12/D14 (0.1-dev.11): uniform per-directive option checks —
295
+ // unknown keys, registered-but-inapplicable keys, invalid colors.
296
+ // Directives not in DIRECTIVE_OPTS (title's rest-of-line, unknown
297
+ // keywords) are handled by their own paths.
298
+ const badOpts=(k)=>{
299
+ const allowed=DIRECTIVE_OPTS[k];
300
+ if(!allowed) return false;
301
+ let bad=false;
302
+ for(const u of unk){ err(n,'unknown option "'+u+'="'); bad=true; }
303
+ for(const o in opts){
304
+ if(allowed.includes(o)) continue;
305
+ if(k==='group'&&o==='in') err(n,'group does not take in= — nesting is one level (node in=group) in v0.1');
306
+ else err(n,k+' does not take '+o+'=');
307
+ bad=true;
308
+ }
309
+ for(const o of ['color','stroke','text'])
310
+ if(opts[o]!==undefined && allowed.includes(o) && !isColor(opts[o])){
311
+ err(n,'unknown color "'+opts[o]+'" (#hex or CSS color name)'); bad=true; }
312
+ return bad;
313
+ };
314
+
315
+ if(firstContent){
316
+ firstContent=false;
317
+ if(kw==='figdown'){
318
+ sawHeader=true;
319
+ if(pos[1]!=='0.1') err(n,'unsupported version "'+(pos[1]||'')+'" (expected 0.1)');
320
+ if(pos[2]!==undefined){
321
+ const TEMPLATES=['block','topology','flowchart','bitfield','table','wave'];
322
+ if(!TEMPLATES.includes(pos[2])) err(n,'unknown template "'+pos[2]+'" (block|topology|flowchart|bitfield|table|wave)');
323
+ else{ doc.template=pos[2];
324
+ // template defaults (D8/R13): flowchart figures flow down —
325
+ // the census-dominant direction; an explicit flow line overrides
326
+ if(pos[2]==='flowchart') doc.flow='down'; }
327
+ }
328
+ badOpts('figdown');
329
+ continue;
330
+ } else { err(n,'first line must be "figdown 0.1 [template]"'); }
331
+ } else if(kw==='figdown'){ err(n,'duplicate version header'); continue; }
332
+
333
+ // typed-block children
334
+ if(cur && ['field','wrap','cell','colw','signal','gap'].includes(kw)){
335
+ if(badOpts(kw)) continue;
336
+ if(cur.type==='bitfield' && kw==='field'){
337
+ // Compact form (C bit-field convention): field F1:16,F2:8 SYN:1 ...
338
+ // — bare name:width items separated by commas and/or spaces, no
339
+ // per-field options. Classic form: field <name> <width> [options].
340
+ // Classic form: field <name> <width-in-bits|*> [optional] [color=] [note=]
341
+ // '*' = variable-length field: fills the remainder of the current row
342
+ const cname=pos[1], cw=pos[2];
343
+ if(cname!==undefined && (/^\d+$/.test(cw||'')&&+cw>=1 || cw==='*')){
344
+ const fextra=pos.slice(3).find(x=>x!=='optional');
345
+ if(fextra!==undefined){ err(n,'unexpected argument "'+fextra+'"'); continue; }
346
+ cur.fields.push({name:cname,w:cw==='*'?'*':+cw,optional:pos.includes('optional'),color:opts.color,cls:opts['class'],note:opts.note,line:n});
347
+ continue;
348
+ }
349
+ // Compact form (C bit-field convention): field a:1, b:2, Long Name:16
350
+ // Commas separate items; the name is everything before the last colon
351
+ // (spaces allowed, no quotes needed); no per-field options here.
352
+ const rest=raw.trim().replace(/^field\s+/,'');
353
+ if(!rest.includes(':')){ err(n,'field needs <name> <width-in-bits>, or a name:width list'); continue; }
354
+ const items=rest.split(',').map(s=>s.trim()).filter(Boolean);
355
+ let bad=null; const parsed=[];
356
+ for(const it of items){
357
+ const m=/^(.+):(\d+|\*)$/.exec(it);
358
+ if(!m||(m[2]!=='*'&&+m[2]<1)){ bad='bad item "'+it+'" (expected name:width)'; break; }
359
+ let nm=m[1].trim();
360
+ if(/^".*"$/.test(nm)) nm=nm.slice(1,-1); // quotes tolerated
361
+ if(/:(\d+|\*)(\s|$)/.test(nm)){ bad='"'+it+'" looks like two fields — missing comma?'; break; }
362
+ parsed.push({name:nm,w:m[2]==='*'?'*':+m[2]});
363
+ }
364
+ if(bad){ err(n,bad); continue; }
365
+ for(const it of parsed) cur.fields.push({name:it.name,w:it.w,optional:false,line:n});
366
+ } else if(cur.type==='bitfield' && kw==='wrap'){
367
+ cur.fields.push({wrap:true,line:n});
368
+ } else if(cur.type==='table' && kw==='colw'){
369
+ if(cur.colw){ err(n,'duplicate colw'); continue; }
370
+ const vals=pos.slice(1);
371
+ if(!vals.length){ err(n,'colw needs one width per column (auto | <px> | <n>%)'); continue; }
372
+ let badw=null;
373
+ const parsed=vals.map(v=>{
374
+ if(v==='auto') return {t:'auto'};
375
+ let m=/^(\d+(?:\.\d+)?)px$/.exec(v)||/^(\d+(?:\.\d+)?)$/.exec(v);
376
+ if(m) return {t:'px',v:+m[1]};
377
+ m=/^(\d+(?:\.\d+)?)%$/.exec(v);
378
+ if(m) return {t:'pct',v:+m[1]};
379
+ badw='bad width "'+v+'" (auto | <px> | <n>%)'; return null;
380
+ });
381
+ if(badw){ err(n,badw); continue; }
382
+ cur.colw={vals:parsed,line:n};
383
+ } else if(cur.type==='table' && kw==='cell'){
384
+ // cell h<k>,<c> | <r>,<c> color=… (h1..hN = header tiers top-down;
385
+ // data rows 1-based below the separator); cell <r> highlight
386
+ const rc=/^(h?)(\d+)(?:,(\d+))?$/.exec(pos[1]||'');
387
+ const hl=pos.includes('highlight');
388
+ const cextra=pos.slice(2).find(x=>x!=='highlight');
389
+ if(cextra!==undefined){ err(n,'unexpected argument "'+cextra+'"'); continue; }
390
+ if(!rc||(!opts.color&&!opts['class']&&!hl)){ err(n,'cell needs [h]<row>[,<col>] with color=…/class=… or highlight'); continue; }
391
+ if(!rc[1]&&+rc[2]===0){ err(n,'row 0 is retired — address header tiers as h1..hN (top-down)'); continue; }
392
+ if(hl&&rc[3]===undefined){
393
+ if(rc[1]){ err(n,'highlight applies to data rows only'); continue; }
394
+ cur.rowmarks=cur.rowmarks||[];
395
+ cur.rowmarks.push({r:+rc[2],line:n});
396
+ } else if(rc[3]!==undefined&&(opts.color||opts['class'])){
397
+ cur.marks=cur.marks||[];
398
+ cur.marks.push({hdr:!!rc[1],r:+rc[2],c:+rc[3],color:opts.color,cls:opts['class'],line:n});
399
+ } else { err(n,'cell needs [h]<row>,<col> color=…/class=… or <row> highlight'); }
400
+ } else if(cur.type==='wave' && kw==='signal'){
401
+ const name=pos[1], lane=pos[2];
402
+ if(!name||!lane){ err(n,'signal needs <name> <lane>'); continue; }
403
+ if(!/^[01pnx=.\d]+$/.test(lane)){ err(n,'lane may contain only 0 1 p n x = . digits'); continue; }
404
+ cur.signals.push({name,lane,labels:(opts.labels||'').split(',').filter(Boolean)});
405
+ } else if(cur.type==='wave' && kw==='gap'){
406
+ const t=parseInt(pos[1],10);
407
+ if(!isFinite(t)||t<0){ err(n,'gap needs a tick number'); continue; }
408
+ cur.gaps.push(t);
409
+ } else { err(n,'"'+kw+'" not valid inside '+cur.type); }
410
+ continue;
411
+ }
412
+ cur=null; // any other keyword closes the block
413
+ if(badOpts(kw)) continue;
414
+
415
+ // R43: render zone — after `render`, only rendering directives are legal
416
+ if(sawRender && !RENDER_DIRECTIVES.has(kw) && kw!=='render'){
417
+ err(n,'"'+kw+'" is a semantic directive — it must appear before the render zone (R43)');
418
+ continue;
419
+ }
420
+
421
+ switch(kw){
422
+ case 'title': {
423
+ // rest-of-line: `title TCP Header` == `title "TCP Header"` (A-1)
424
+ let t=raw.trim().replace(/^title\s*/,'');
425
+ const qm=/^"([\s\S]*)"$/.exec(t);
426
+ if(qm){
427
+ // decode escapes left-to-right in one pass, exactly like the
428
+ // generic tokenizer: \\ consumes first, so "a\\nb" is a\nb
429
+ // (backslash + letter n), never a line break (D2, 0.1-dev.11).
430
+ // Unknown escapes were already rejected by tokenize() above.
431
+ const s0=qm[1]; t='';
432
+ for(let ii=0;ii<s0.length;ii++){
433
+ if(s0[ii]==='\\'){
434
+ const e=s0[ii+1];
435
+ if(e==='n'){ t+='\n'; ii++; continue; }
436
+ if(e==='"'){ t+='"'; ii++; continue; }
437
+ if(e==='\\'){ t+='\\'; ii++; continue; }
438
+ }
439
+ t+=s0[ii];
440
+ }
441
+ }
442
+ doc.title=t; break;
443
+ }
444
+ case 'class': {
445
+ // semantic class (D9): meaning + presentation defaults declared
446
+ // once; elements join via class=<id>; the legend strip derives
447
+ const id=pos[1];
448
+ if(!id||!ID_RE.test(id)){ err(n,'class needs an id'); break; }
449
+ if(classIds.has(id)){ err(n,'duplicate class "'+id+'"'); break; }
450
+ if(!pos[2]){ err(n,'class needs a meaning: class '+id+' "<meaning>"'); break; }
451
+ if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
452
+ const cstyle=opts.style;
453
+ if(cstyle!==undefined && !['solid','dashed','dotted'].includes(cstyle)){ err(n,'style must be solid|dashed|dotted'); break; }
454
+ classIds.add(id);
455
+ doc.classes.push({id,label:pos[2],color:opts.color,stroke:opts.stroke,
456
+ text:opts.text,style:cstyle,line:n});
457
+ break;
458
+ }
459
+ case 'layer': {
460
+ const id=pos[1];
461
+ if(!id||!ID_RE.test(id)){ err(n,'layer needs an id'); break; }
462
+ if(layerIds.has(id)){ err(n,'duplicate layer id "'+id+'"'); break; }
463
+ if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
464
+ let z=doc.layers.length;
465
+ if(opts.z!==undefined){
466
+ if(!/^-?\d+$/.test(opts.z)){ err(n,'z must be a number'); break; }
467
+ z=parseInt(opts.z,10);
468
+ }
469
+ layerIds.add(id);
470
+ doc.layers.push({id,label:pos[2]||'',z});
471
+ break;
472
+ }
473
+ case 'node': {
474
+ const id=pos[1];
475
+ if(!id||!ID_RE.test(id)){ err(n,'node needs an id'); break; }
476
+ if(nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)){ err(n,'duplicate id "'+id+'"'); break; }
477
+ if(opts.kind!==undefined){ err(n,'kind= has been renamed: use shape= (geometric; the label text carries the device semantics)'); break; }
478
+ if(opts.w!==undefined||opts.h!==undefined){ err(n,'node does not take w=/h= — use a size line'); break; }
479
+ const shape=opts.shape||'box';
480
+ if(!SHAPES.includes(shape)){ err(n,'unknown shape "'+shape+'" ('+SHAPES.join('|')+')'); break; }
481
+ nodeIds.add(id);
482
+ if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
483
+ const nstyle=opts.style;
484
+ if(nstyle!==undefined && !['solid','dashed','dotted'].includes(nstyle)){ err(n,'style must be solid|dashed|dotted'); break; }
485
+ doc.nodes.push({id,label:pos[2]||id,shape,color:opts.color,stroke:opts.stroke,
486
+ style:nstyle,text:opts.text,cls:opts['class'],
487
+ group:opts['in']||null,layer:opts.layer||'base',line:n});
488
+ break;
489
+ }
490
+ case 'group': {
491
+ const id=pos[1];
492
+ if(!id||!ID_RE.test(id)){ err(n,'group needs an id'); break; }
493
+ if(nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)){ err(n,'duplicate id "'+id+'"'); break; }
494
+ if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
495
+ groupIds.add(id);
496
+ let ggap;
497
+ if(opts.gap!==undefined){
498
+ ggap=parseFloat(opts.gap);
499
+ if(!isFinite(ggap)||ggap<0){ err(n,'gap must be a non-negative number'); break; }
500
+ }
501
+ const gstyle=opts.style;
502
+ if(gstyle!==undefined && !['solid','dashed','dotted'].includes(gstyle)){ err(n,'style must be solid|dashed|dotted'); break; }
503
+ doc.groups.push({id,label:pos[2]||id,color:opts.color,stroke:opts.stroke,
504
+ text:opts.text,style:gstyle,gap:ggap,cls:opts['class'],
505
+ layer:opts.layer||null,line:n});
506
+ break;
507
+ }
508
+ case 'boundary': {
509
+ // external I/O endpoint (R44): "the outside world", not a
510
+ // participant node. Referenced by edges like a node, pinnable for
511
+ // layout, NEVER drawn as a shape — the edge simply ends open at an
512
+ // invisible anchor, optionally with the small label beyond the end.
513
+ // Shares the node/group id namespace; takes no options.
514
+ const id=pos[1];
515
+ if(!id||!ID_RE.test(id)){ err(n,'boundary needs an id'); break; }
516
+ if(nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)){ err(n,'duplicate id "'+id+'"'); break; }
517
+ if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
518
+ boundaryIds.add(id);
519
+ doc.boundaries.push({id,label:pos[2]||undefined,line:n});
520
+ break;
521
+ }
522
+ case 'flow': {
523
+ if(!['right','down','left','up'].includes(pos[1])){ err(n,'flow needs right|down|left|up'); break; }
524
+ if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
525
+ doc.flow=pos[1]; break;
526
+ }
527
+ case 'rank': {
528
+ const ids=pos.slice(1);
529
+ if(ids.length<2){ err(n,'rank needs two or more node ids'); break; }
530
+ doc.ranks.push({ids,line:n}); break;
531
+ }
532
+ case 'bundle': {
533
+ // semantic link bundle (LAG / Ethernet Segment): the renderer draws
534
+ // the dashed ellipse around the member links automatically
535
+ const id=pos[1];
536
+ if(!id||!ID_RE.test(id)){ err(n,'bundle needs an id'); break; }
537
+ if(bundleIds.has(id)){ err(n,'duplicate bundle id "'+id+'"'); break; }
538
+ let rest=pos.slice(2), tlabel=id;
539
+ if(tk.toks[2]&&tk.toks[2].q){ tlabel=pos[2]; rest=pos.slice(3); }
540
+ const pairs=[]; let badp=null;
541
+ for(const t of rest){
542
+ const m=/^([A-Za-z_][A-Za-z0-9_-]*)--([A-Za-z_][A-Za-z0-9_-]*),?$/.exec(t);
543
+ if(!m){ badp='bad member "'+t+'" (expected A--B)'; break; }
544
+ pairs.push([m[1],m[2]]);
545
+ }
546
+ if(badp){ err(n,badp); break; }
547
+ if(!pairs.length){ err(n,'bundle needs at least one member link A--B'); break; }
548
+ bundleIds.add(id);
549
+ doc.trunks.push({id,label:tlabel,pairs,color:opts.color,line:n});
550
+ break;
551
+ }
552
+ case 'line': {
553
+ // generic horizontal guide/threshold line across a group's box:
554
+ // line "<label>" in=<group> at=<0..100>% [color=] [fill=below|above]
555
+ const glabel=(tk.toks[1]&&tk.toks[1].q)?pos[1]:null;
556
+ if(glabel===null){ err(n,'line needs a quoted "<label>" first'); break; }
557
+ if(!opts['in']){ err(n,'line needs in=<group-id>'); break; }
558
+ const m=/^(\d+(?:\.\d+)?)%$/.exec(opts.at||''); // % is mandatory (D8)
559
+ if(!m||+m[1]<0||+m[1]>100){ err(n,'line needs at=<0..100>% (with the % sign)'); break; }
560
+ if(opts.fill!==undefined){ err(n,'line is a pure marker — use the fill directive for zones'); break; }
561
+ doc.glines.push({label:glabel,group:opts['in'],pct:+m[1],color:opts.color,line:n});
562
+ break;
563
+ }
564
+ case 'plot': {
565
+ // chart family: plot <table-id> [kind=bars3d] [level=<value>]
566
+ // rows -> X, columns -> Y, numeric cells -> Z (the table IS the data)
567
+ const tid=pos[1];
568
+ if(!tid||!ID_RE.test(tid)){ err(n,'plot needs a table id'); break; }
569
+ if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
570
+ const pkind=opts.kind||'bars3d';
571
+ if(pkind!=='bars3d'){ err(n,'unknown plot kind "'+pkind+'" (bars3d)'); break; }
572
+ let plevel=null;
573
+ if(opts.level!==undefined){
574
+ plevel=parseFloat(opts.level);
575
+ if(!isFinite(plevel)||plevel<0){ err(n,'level must be a non-negative number'); break; }
576
+ }
577
+ doc.blocks.push({type:'plot',id:'plot_'+doc.blocks.length,tid,kind:pkind,level:plevel,line:n});
578
+ break;
579
+ }
580
+ case 'fill': {
581
+ // zone band: fill <pct>% | <a>-<b>% in=<node|group> [dir=] [color=]
582
+ // "fill 15%" = 0..15; "fill 15-35%" = the range in one token
583
+ if(opts.from!==undefined||opts.to!==undefined){ err(n,'from=/to= retired — write the range positionally: fill 15% or fill 15-35%'); break; }
584
+ if(!opts['in']){ err(n,'fill needs in=<node-or-group-id>'); break; }
585
+ const m=/^(\d+(?:\.\d+)?)%?(?:-(\d+(?:\.\d+)?)%?)?$/.exec(pos[1]||'');
586
+ if(!m){ err(n,'fill needs a range: <pct>% or <a>-<b>%'); break; }
587
+ const from=m[2]!==undefined?+m[1]:0;
588
+ const to=m[2]!==undefined?+m[2]:+m[1];
589
+ if(from<0||to>100||from>=to){ err(n,'fill range needs 0 <= from < to <= 100'); break; }
590
+ const fdir=opts.dir||'up';
591
+ if(!['up','down','left','right'].includes(fdir)){ err(n,'dir must be up|down|left|right'); break; }
592
+ doc.fills.push({target:opts['in'],from,to,dir:fdir,color:opts.color||'#e5e7eb',line:n});
593
+ break;
594
+ }
595
+ case 'pin': {
596
+ const id=pos[1], at=opts.at;
597
+ if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
598
+ const m=at&&/^(-?[\d.]+),(-?[\d.]+)$/.exec(at);
599
+ if(!id||!m){ err(n,'pin needs <id> at=<x>,<y>'); break; }
600
+ doc.pins[id]={fx:parseFloat(m[1]),fy:parseFloat(m[2]),line:n};
601
+ break;
602
+ }
603
+ case 'size': {
604
+ const id=pos[1];
605
+ if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
606
+ if(!id||(!opts.w&&!opts.h)){ err(n,'size needs <id> w=<px> and/or h=<px>'); break; }
607
+ let badsz=false;
608
+ const dim=(k)=>{ // px only (D7/D13)
609
+ const v=opts[k]; if(v===undefined||v==='') return null;
610
+ if(/%$/.test(v)){ err(n,'percentage sizes are not in v0.1 — use px'); badsz=true; return null; }
611
+ const f=parseFloat(v);
612
+ if(!isFinite(f)){ err(n,k+' must be a number'); badsz=true; return null; }
613
+ return f;
614
+ };
615
+ const w=dim('w'), h=dim('h');
616
+ if(badsz) break;
617
+ doc.sizes[id]={w,h,line:n};
618
+ break;
619
+ }
620
+ case 'routing': {
621
+ // document-level presentation directive (0.1-dev.13): how straight
622
+ // scene edges are drawn. straight (default) = today's direct lines;
623
+ // orthogonal = deterministic manhattan elbows. Rendering parameter
624
+ // only — no semantics; belongs in the trailing layout section.
625
+ if(!['orthogonal','straight'].includes(pos[1]||'')){ err(n,'routing needs orthogonal|straight'); break; }
626
+ if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
627
+ doc.routing=pos[1]; break;
628
+ }
629
+ case 'route': {
630
+ // per-edge declared waypoints (0.1-dev.13): route <a> <op> <b>
631
+ // via=x,y;x,y;… [routing=orthogonal|straight]. References ONE
632
+ // existing edge exactly as written (a, operator, b — endpoint
633
+ // order matters, unlike bundle members); waypoints are rigid
634
+ // canvas px, the same space as ungrouped pin. Layout section only.
635
+ const a=pos[1], op=pos[2], b=pos[3];
636
+ if(!a||!ID_RE.test(a)||!['->','<-','--','<->'].includes(op||'')||!b||!ID_RE.test(b)){
637
+ err(n,'route needs <a> ->|<-|--|<-> <b> via=x,y;x,y;…'); break; }
638
+ if(pos.length>4){ err(n,'unexpected argument "'+pos[4]+'"'); break; }
639
+ if(opts.via===undefined){ err(n,'route needs via=x,y;x,y;… (canvas px waypoints)'); break; }
640
+ const pairs=[]; let badv=null;
641
+ for(const t of String(opts.via).split(';')){
642
+ const m=/^(-?[\d.]+),(-?[\d.]+)$/.exec(t.trim());
643
+ const px=m&&parseFloat(m[1]), py=m&&parseFloat(m[2]);
644
+ if(!m||!isFinite(px)||!isFinite(py)){ badv='bad via point "'+t.trim()+'" (expected x,y)'; break; }
645
+ pairs.push([px,py]);
646
+ }
647
+ if(badv){ err(n,badv); break; }
648
+ if(opts.routing!==undefined && !['orthogonal','straight'].includes(opts.routing)){
649
+ err(n,'routing must be orthogonal|straight'); break; }
650
+ doc.routes.push({a,op,b,via:pairs,routing:opts.routing,line:n});
651
+ break;
652
+ }
653
+ case 'render': {
654
+ if(sawRender){ err(n,'duplicate render line'); break; }
655
+ if(pos.length>1){ err(n,'render takes no arguments'); break; }
656
+ sawRender=true; break;
657
+ }
658
+ case 'bitfield': {
659
+ const id=pos[1];
660
+ if(!id||!ID_RE.test(id)){ err(n,'bitfield needs an id'); break; }
661
+ if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
662
+ cur={type:'bitfield',id,label:pos[2]||id,unit:opts.unit?parseInt(opts.unit,10):32,
663
+ numbering:opts.numbering||'lsb0',fields:[],line:n};
664
+ if(!isFinite(cur.unit)||cur.unit<1){ err(n,'bad unit'); cur.unit=32; }
665
+ if(!['lsb0','msb0'].includes(cur.numbering)) err(n,'numbering must be lsb0 or msb0');
666
+ doc.blocks.push(cur); break;
667
+ }
668
+ case 'table': {
669
+ const id=pos[1];
670
+ if(!id||!ID_RE.test(id)){ err(n,'table needs an id'); break; }
671
+ if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
672
+ cur={type:'table',id,label:pos[2]||id,cols:[],heads:[],rows:[],line:n};
673
+ doc.blocks.push(cur); break;
674
+ }
675
+ case 'wave': {
676
+ const id=pos[1];
677
+ if(!id||!ID_RE.test(id)){ err(n,'wave needs an id'); break; }
678
+ if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
679
+ cur={type:'wave',id,label:pos[2]||id,signals:[],gaps:[],line:n};
680
+ doc.blocks.push(cur); break;
681
+ }
682
+ case 'page': case 'step': case 'set': case 'pulse':
683
+ err(n,'"'+kw+'" is reserved for the dynamic profile (not in v0.1)'); break;
684
+ case 'field': case 'wrap': case 'cell': case 'colw': case 'signal': case 'gap':
685
+ err(n,'"'+kw+'" is a typed-block child — it needs a bitfield/table/wave block above it'); break;
686
+ default:
687
+ err(n,'unrecognized line (unknown keyword "'+kw+'")');
688
+ }
689
+ }
690
+ if(!sawHeader && text.trim()) { /* already reported on first content line */ }
691
+
692
+ // semantic checks
693
+ for(const nd of doc.nodes){
694
+ if(nd.group && !groupIds.has(nd.group)) errs.push('Line '+nd.line+': unknown group "'+nd.group+'"');
695
+ if(nd.layer && !layerIds.has(nd.layer)) errs.push('Line '+nd.line+': unknown layer "'+nd.layer+'"');
696
+ }
697
+ for(const g of doc.groups){
698
+ if(g.layer && !layerIds.has(g.layer)) errs.push('Line '+g.line+': unknown layer "'+g.layer+'"');
699
+ }
700
+ for(const e of doc.edges){
701
+ if(!nodeIds.has(e.a)&&!groupIds.has(e.a)&&!boundaryIds.has(e.a)) errs.push('Line '+e.line+': unknown endpoint "'+e.a+'"');
702
+ 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)');
703
+ if(!nodeIds.has(e.b)&&!groupIds.has(e.b)&&!boundaryIds.has(e.b)) errs.push('Line '+e.line+': unknown endpoint "'+e.b+'"');
704
+ 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)');
705
+ if(e.layer && !layerIds.has(e.layer)) errs.push('Line '+e.line+': unknown layer "'+e.layer+'"');
706
+ }
707
+ for(const r of doc.ranks) for(const id of r.ids)
708
+ if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
709
+ for(const gl of doc.glines)
710
+ if(!groupIds.has(gl.group)) errs.push('Line '+gl.line+': unknown group "'+gl.group+'" for line');
711
+ for(const f of doc.fills)
712
+ if(!groupIds.has(f.target)&&!nodeIds.has(f.target))
713
+ errs.push('Line '+f.line+': unknown target "'+f.target+'" for fill');
714
+ for(const t of doc.trunks) for(const [a,b] of t.pairs){
715
+ if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
716
+ const matches=doc.edges.filter(e=>(e.a===a&&e.b===b)||(e.a===b&&e.b===a)).length;
717
+ if(matches===0)
718
+ errs.push('Line '+t.line+': no edge between "'+a+'" and "'+b+'" for bundle member');
719
+ else if(matches>1)
720
+ errs.push('Line '+t.line+': "'+a+'--'+b+'" is ambiguous ('+matches+' parallel edges); parallel edges are out of scope for v0.1');
721
+ }
722
+ { // route lines reference ONE existing edge exactly as written (a-op-b;
723
+ // endpoint order matters — unlike bundle members); parallel edges are
724
+ // ambiguous; one route per edge (closed grammar, 0.1-dev.13)
725
+ const seenRt=new Set();
726
+ for(const r of doc.routes){
727
+ const ref=r.a+' '+r.op+' '+r.b;
728
+ const matches=doc.edges.filter(e=>e.a===r.a&&e.op===r.op&&e.b===r.b);
729
+ if(!matches.length){ errs.push('Line '+r.line+': no edge "'+ref+'" for route (must match the edge as written)'); continue; }
730
+ 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; }
731
+ if(seenRt.has(ref)){ errs.push('Line '+r.line+': duplicate route for "'+ref+'"'); continue; }
732
+ seenRt.add(ref);
733
+ matches[0].route=r; // renderer convenience; the model keeps doc.routes
734
+ }
735
+ }
736
+ for(const id in doc.pins) if(!nodeIds.has(id)&&!groupIds.has(id)&&!boundaryIds.has(id))
737
+ errs.push('Line '+doc.pins[id].line+': pin of unknown id "'+id+'"');
738
+ { // class references must resolve (closed grammar)
739
+ const chk=(cls,line)=>{ if(cls!==undefined && !classIds.has(cls))
740
+ errs.push('Line '+line+': unknown class "'+cls+'"'); };
741
+ for(const x of doc.nodes) chk(x.cls,x.line);
742
+ for(const x of doc.groups) chk(x.cls,x.line);
743
+ for(const x of doc.edges) chk(x.cls,x.line);
744
+ for(const b of doc.blocks){
745
+ if(b.fields) for(const f of b.fields) chk(f.cls,f.line);
746
+ if(b.marks) for(const mk of b.marks) chk(mk.cls,mk.line);
747
+ }
748
+ }
749
+ for(const id in doc.sizes) if(!nodeIds.has(id)&&!groupIds.has(id))
750
+ errs.push('Line '+doc.sizes[id].line+': size of unknown id "'+id+'"');
751
+ for(const b of doc.blocks){
752
+ if(b.type==='table'&&!b.heads.length) errs.push('Line '+b.line+': table has no header row');
753
+ if(b.type==='table'&&b.heads.length&&!b.sep) errs.push('Line '+b.line+': table has no |---| separator row');
754
+ if(b.type==='bitfield'&&!b.fields.some(f=>!f.wrap)) errs.push('Line '+b.line+': bitfield has no fields');
755
+ if(b.type==='wave'&&!b.signals.length) errs.push('Line '+b.line+': wave has no signals');
756
+ if(b.type==='plot'){
757
+ const t=doc.blocks.find(x=>x.type==='table'&&x.id===b.tid);
758
+ if(!t) errs.push('Line '+b.line+': plot references unknown table "'+b.tid+'"');
759
+ else{
760
+ for(const r of t.rows) for(let c=1;c<r.cells.length;c++)
761
+ if(!r.cells[c].m && !/^-?\d+(?:\.\d+)?$/.test(r.cells[c].v))
762
+ { errs.push('Line '+b.line+': plot data must be numeric (row value "'+r.cells[c].v+'")'); break; }
763
+ }
764
+ }
765
+ if(b.type==='table'&&b.colw&&b.colw.vals.length!==b.cols.length)
766
+ errs.push('Line '+b.colw.line+': colw has '+b.colw.vals.length+' widths, expected '+b.cols.length);
767
+ if(b.type==='table'&&b.marks) for(const mk of b.marks){
768
+ const H=b.heads.length;
769
+ const inRange = mk.hdr ? (mk.r>=1&&mk.r<=H) : (mk.r>=1&&mk.r<=b.rows.length);
770
+ if(!inRange||mk.c<1||mk.c>b.cols.length){
771
+ errs.push('Line '+mk.line+': cell '+(mk.hdr?'h':'')+mk.r+','+mk.c+' out of range'); continue;
772
+ }
773
+ // merged-away targets must be rejected: annotations target the anchor
774
+ const cells = mk.hdr ? b.heads[mk.r-1] : b.rows[mk.r-1].cells;
775
+ if(cells[mk.c-1].m)
776
+ errs.push('Line '+mk.line+': cell '+(mk.hdr?'h':'')+mk.r+','+mk.c+' is merged away — annotate the anchor cell');
777
+ }
778
+ if(b.type==='table'&&b.rowmarks) for(const mk of b.rowmarks){
779
+ if(mk.r<1||mk.r>b.rows.length)
780
+ errs.push('Line '+mk.line+': row '+mk.r+' out of range');
781
+ }
782
+ }
783
+ return {doc,errs};
784
+ }
785
+ function findComment(s){
786
+ // '#' starts a comment only at line start or after whitespace,
787
+ // so hex colors like color=#0d9488 survive.
788
+ let inq=false;
789
+ for(let i=0;i<s.length;i++){
790
+ if(s[i]==='"') inq=!inq;
791
+ else if(s[i]==='#'&&!inq&&(i===0||/\s/.test(s[i-1]))) return i;
792
+ }
793
+ return -1;
794
+ }
795
+
796
+ // ============================================================
797
+ // 2. LAYOUT + RENDER (deterministic; no randomness, no Date)
798
+ // ============================================================
799
+ const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
800
+ function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
801
+ function tw(s){
802
+ const longest=Math.max(...String(s).split('\n').map(l=>l.length));
803
+ return Math.max(30,longest*CH+2*PADX);
804
+ }
805
+ // ---- shape geometry (one source of truth for size and for clipping) ----
806
+ // A node owns a bounding box (n.x,n.y,n.w,n.h), but only the rectangle
807
+ // shapes fill it. shapeAxes() reports the shape as it is actually DRAWN:
808
+ // half-extents (a,b) about the box centre and the exponent p of the curve
809
+ // through them,
810
+ // (|dx|/a)^p + (|dy|/b)^p = 1 p=1 rhombus, 2 ellipse, Inf rectangle
811
+ // with (ox,oy) = how far that outline is drawn OUTSIDE the box (cloud only),
812
+ // so the box can be recovered from the axes. Node sizing (how large must the
813
+ // box be for the label to sit inside the outline) and borderPoint() (where
814
+ // does a ray from the centre leave the outline) both read this, so the two
815
+ // can never disagree about where a shape ends. Keep it in step with the
816
+ // shape drawing in renderScene.
817
+ function shapeAxes(n){
818
+ const s=n.shape;
819
+ if(s==='diamond') return {a:n.w/2, b:n.h/2, p:1, ox:0, oy:0};
820
+ if(s==='cloud') return {a:n.w/2+10,b:n.h/2+8, p:2, ox:10,oy:8};
821
+ if(s==='circle') return {a:n.w/2, b:n.w/2, p:2, ox:0, oy:0}; // rx=ry=w/2
822
+ if(s==='ellipse') return {a:n.w/2, b:n.h/2, p:2, ox:0, oy:0};
823
+ return {a:n.w/2, b:n.h/2, p:Infinity, ox:0, oy:0}; // box, rounded, cylinder
824
+ }
825
+ // outlineNorm: <1 inside the outline, =1 on it, >1 outside. Homogeneous in
826
+ // (dx,dy) — scaling the offset scales the norm — which is what lets the same
827
+ // number serve as "how far out is this point" and "by how much must the shape
828
+ // grow to swallow it".
829
+ function outlineNorm(g,dx,dy){
830
+ const u=Math.abs(dx)/(g.a||1e-9), v=Math.abs(dy)/(g.b||1e-9);
831
+ return g.p===1?u+v:(g.p===2?Math.hypot(u,v):Math.max(u,v));
832
+ }
833
+ // outSide: the coordinate an orthogonal run has to reach to touch the DRAWN
834
+ // outline on one side ('l','r','t','b') — what a channel route needs when it
835
+ // arrives square-on instead of along a ray. Every shape but `cloud` is drawn
836
+ // inside its box, so this returns the box edge unchanged, bit for bit.
837
+ function outSide(n,k){
838
+ const g=shapeAxes(n);
839
+ 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;
840
+ }
841
+ // simplifyPts: Douglas–Peucker simplification in place. The waypoint chain
842
+ // emits an entry+exit port per crossed rank; home-anchoring keeps those ports
843
+ // in a narrow corridor, so what remains is a nearly-straight run carrying tens
844
+ // of cosmetic ±few-px jogs. DP collapses that corridor to the handful of
845
+ // genuine bends a dummy-vertex chain should have, keeping every retained point
846
+ // exactly where it was (endpoints are always kept). Presentation-only.
847
+ function simplifyPts(pts,eps){
848
+ eps=eps||3;
849
+ if(pts.length<3) return pts;
850
+ const keep=new Array(pts.length).fill(false);
851
+ keep[0]=keep[pts.length-1]=true;
852
+ const stack=[[0,pts.length-1]];
853
+ while(stack.length){
854
+ const [lo,hi]=stack.pop();
855
+ if(hi<=lo+1) continue;
856
+ const a=pts[lo], b=pts[hi];
857
+ const dx=b[0]-a[0], dy=b[1]-a[1], L=Math.hypot(dx,dy);
858
+ let far=-1, fd=eps;
859
+ for(let i=lo+1;i<hi;i++){
860
+ const p=pts[i];
861
+ const d=L<1e-9?Math.hypot(p[0]-a[0],p[1]-a[1])
862
+ :Math.abs((p[0]-a[0])*dy-(p[1]-a[1])*dx)/L;
863
+ if(d>fd){ fd=d; far=i; }
864
+ }
865
+ if(far>=0){ keep[far]=true; stack.push([lo,far],[far,hi]); }
866
+ }
867
+ let w=0;
868
+ for(let i=0;i<pts.length;i++) if(keep[i]) pts[w++]=pts[i];
869
+ pts.length=w;
870
+ return pts;
871
+ }
872
+ // roundPath: build an SVG path `d` for a polyline whose INTERIOR bends are
873
+ // softened into circular-arc fillets — the standard technical-diagram look.
874
+ // The point list is NOT modified: only the drawn path curves. At each interior
875
+ // vertex the two adjacent segments are shortened by r and rejoined with a
876
+ // quadratic (Q corner …), which for equal trims traces a circular arc tangent
877
+ // to both legs — one primitive used everywhere, so the output is deterministic.
878
+ // r = min(10, 40% of the shorter adjacent segment) keeps short jogs from
879
+ // over-rounding or inverting. Near-collinear bends (turn < ~10°, i.e. interior
880
+ // angle > ~170°) keep a hard corner. First/last points are never filleted, so
881
+ // arrowhead geometry and endpoint contact are untouched.
882
+ const FILLET_R=10;
883
+ function roundPath(pts){
884
+ if(pts.length<3) return 'M'+pts.map(p=>p.join(' ')).join(' L');
885
+ const f=n=>{ const s=(+n).toFixed(3); return s.replace(/\.?0+$/,''); };
886
+ let d='M'+f(pts[0][0])+' '+f(pts[0][1]);
887
+ for(let i=1;i+1<pts.length;i++){
888
+ const a=pts[i-1], c=pts[i], b=pts[i+1];
889
+ const v1x=a[0]-c[0], v1y=a[1]-c[1], L1=Math.hypot(v1x,v1y);
890
+ const v2x=b[0]-c[0], v2y=b[1]-c[1], L2=Math.hypot(v2x,v2y);
891
+ if(L1<1e-6||L2<1e-6){ d+=' L'+f(c[0])+' '+f(c[1]); continue; }
892
+ // turn angle: cos of the angle between the two legs at c. Near-collinear
893
+ // (legs nearly opposite → dot≈ -1 → angle between legs ≈180°, tiny turn)
894
+ // keeps a hard corner.
895
+ const dot=(v1x*v2x+v1y*v2y)/(L1*L2);
896
+ if(dot<-0.985){ d+=' L'+f(c[0])+' '+f(c[1]); continue; } // turn < ~10°
897
+ const r=Math.min(FILLET_R, 0.4*Math.min(L1,L2));
898
+ if(r<0.5){ d+=' L'+f(c[0])+' '+f(c[1]); continue; }
899
+ const p1x=c[0]+v1x/L1*r, p1y=c[1]+v1y/L1*r; // trim point toward a
900
+ const p2x=c[0]+v2x/L2*r, p2y=c[1]+v2y/L2*r; // trim point toward b
901
+ d+=' L'+f(p1x)+' '+f(p1y)+' Q'+f(c[0])+' '+f(c[1])+' '+f(p2x)+' '+f(p2y);
902
+ }
903
+ const e=pts[pts.length-1];
904
+ d+=' L'+f(e[0])+' '+f(e[1]);
905
+ return d;
906
+ }
907
+ // inscribedHalfW: the half-width the outline still offers at height |dy|=v —
908
+ // the room a label line really has (a rectangle offers its full half-width).
909
+ function inscribedHalfW(g,v){
910
+ const t=Math.min(1,Math.abs(v)/(g.b||1e-9));
911
+ return g.p===1?g.a*(1-t):(g.p===2?g.a*Math.sqrt(1-t*t):g.a);
912
+ }
913
+ // labelBox: the label's own text box (glyph extents) plus a small clearance
914
+ // so glyphs never graze the stroke — this is what must end up INSIDE.
915
+ // (line height 1.3*FONT as in textEl; 1.2*FONT covers one line's ink box)
916
+ const LBLPADX=6, LBLPADY=4;
917
+ function labelBox(label){
918
+ const ls=String(label).split('\n');
919
+ return [Math.max(...ls.map(l=>l.length))*CH+2*LBLPADX,
920
+ ((ls.length-1)*1.3+1.2)*FONT+2*LBLPADY];
921
+ }
922
+ // fitOutline: grow the box by the smallest factor that pulls an iw x ih box,
923
+ // centred on the node, inside the outline. The norm at the text box's corner
924
+ // IS that factor (homogeneity), so one evaluation answers both "does it fit"
925
+ // and "by how much". Rectangles are their own outline and tw()/NH already pad
926
+ // the text, so k<=1 there and the box never moves.
927
+ function fitOutline(n,box){
928
+ const g=shapeAxes(n), k=outlineNorm(g,box[0]/2,box[1]/2);
929
+ if(k<=1) return;
930
+ n.w=Math.max(n.w,2*(k*g.a-g.ox));
931
+ n.h=Math.max(n.h,2*(k*g.b-g.oy));
932
+ if(n.shape==='circle') n.w=n.h=Math.max(n.w,n.h); // rx=ry=w/2: box stays square
933
+ }
934
+ // multi-line <text>: "\n" in labels becomes centered tspans
935
+ function textEl(x,y,fs,anchor,fill,content,extraAttrs){
936
+ // halo text = two layers (white under-stroke + plain top copy) so the
937
+ // halo survives SVG renderers without paint-order support
938
+ if(extraAttrs && extraAttrs.indexOf('paint-order')>=0){
939
+ const rest=extraAttrs.replace(' paint-order="stroke" stroke="#fff" stroke-width="3"','');
940
+ return textEl(x,y,fs,anchor,'#fff',content,rest+' stroke="#fff" stroke-width="3" stroke-linejoin="round"')
941
+ + textEl(x,y,fs,anchor,fill,content,rest);
942
+ }
943
+ const lines=String(content).split('\n');
944
+ const attrs='font-size="'+fs+'" text-anchor="'+anchor+'" fill="'+fill+'"'+(extraAttrs||'');
945
+ if(lines.length===1)
946
+ return '<text x="'+x+'" y="'+y+'" '+attrs+'>'+esc(content)+'</text>';
947
+ const lh=fs*1.3, y0=y-(lines.length-1)*lh/2;
948
+ return '<text x="'+x+'" y="'+y0+'" '+attrs+'>'+
949
+ lines.map((l,i)=>'<tspan x="'+x+'" dy="'+(i?lh:0)+'">'+esc(l)+'</tspan>').join('')+'</text>';
950
+ }
951
+
952
+ function render(doc,ropts){
953
+ // presentation options (renderer tier, not language): {title:true}
954
+ // draws the title. Default is NOT drawn (R13: embedded figures almost
955
+ // always sit under a caption in the host document; the title text
956
+ // stays semantic in the source either way).
957
+ const RO=ropts||{};
958
+ // resolve class defaults (explicit element attributes win — rigidity, R8)
959
+ const C={}; for(const c of doc.classes||[]) C[c.id]=c;
960
+ const rs=(x,k)=>{ if(x[k]===undefined && x.cls && C[x.cls] && C[x.cls][k]!==undefined) x[k]=C[x.cls][k]; };
961
+ 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'; }
962
+ for(const g of doc.groups){ rs(g,'color'); rs(g,'stroke'); rs(g,'text'); rs(g,'style'); }
963
+ for(const e of doc.edges){ rs(e,'color'); rs(e,'style'); if(e.style===undefined) e.style='solid'; }
964
+ for(const b of doc.blocks){
965
+ if(b.fields) for(const f of b.fields) rs(f,'color');
966
+ if(b.marks) for(const mk of b.marks) rs(mk,'color');
967
+ }
968
+ const parts=[]; let y=0, maxW=0;
969
+ 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;
970
+ maxW=Math.max(maxW, doc.title.length*8.6); } // canvas must fit the title
971
+ let sceneMeta=null;
972
+ if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
973
+ const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
974
+ sceneMeta=s.meta;
975
+ }
976
+ for(const b of doc.blocks){
977
+ let s;
978
+ if(b.type==='bitfield') s=renderBitfield(b,y);
979
+ else if(b.type==='table') s=renderTable(b,y);
980
+ else if(b.type==='plot') s=renderPlot(b,y,doc);
981
+ else s=renderWave(b,y);
982
+ parts.push(s.svg); y=s.y+24; maxW=Math.max(maxW,s.w);
983
+ }
984
+ if((doc.classes||[]).length){
985
+ // derived legend strip (D9): declaration order, swatch + meaning
986
+ const es=[]; let lx=0, ly=y+8; const rowH=20, wrapW=Math.max(maxW,420);
987
+ for(const c of doc.classes){
988
+ const tw=String(c.label).length*6.6+30;
989
+ if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
990
+ const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
991
+ es.push('<rect x="'+lx+'" y="'+(ly+3)+'" width="16" height="11" fill="'+(c.color||'#fff')+'" stroke="'+(c.stroke||'#555')+'"'+dash+'/>');
992
+ es.push('<text x="'+(lx+21)+'" y="'+(ly+12.5)+'" font-size="11" fill="#1d1d1b">'+esc(c.label)+'</text>');
993
+ lx+=tw+14; maxW=Math.max(maxW,lx);
994
+ }
995
+ parts.push(es.join(''));
996
+ y=ly+rowH;
997
+ }
998
+ const PADL=18, PADT=6;
999
+ const W=Math.ceil(maxW)+PADL+8, H=Math.ceil(y)+PADT+4;
1000
+ 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">'
1001
+ +'<defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">'
1002
+ +'<path d="M0,0 L10,5 L0,10 z" fill="#555"/></marker>'
1003
+ +'<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
1004
+ +'<line x1="0" y1="0" x2="0" y2="6" stroke="#bbb" stroke-width="2"/></pattern></defs>'
1005
+ +'<g transform="translate('+PADL+','+PADT+')">'+parts.join('')+'</g></svg>', w:W, h:H,
1006
+ sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}};
1007
+ }
1008
+
1009
+ // ---- scene ----
1010
+ function renderScene(doc,y0){
1011
+ const nodes=doc.nodes.map(n=>({...n}));
1012
+ // boundary anchors (R44): external I/O endpoints join the layout as
1013
+ // small invisible ~12x12 extents so externals land at natural margins;
1014
+ // they are never drawn as shapes (the edge ends open at the anchor).
1015
+ for(const b of doc.boundaries||[])
1016
+ nodes.push({id:b.id,label:b.label||'',boundary:true,layer:'base',group:null,line:b.line});
1017
+ const byId={}; nodes.forEach(n=>byId[n.id]=n);
1018
+ // sizes
1019
+ for(const n of nodes){
1020
+ if(n.boundary){ n.w=n.h=12; continue; }
1021
+ const nLines=String(n.label).split('\n').length;
1022
+ n.w=tw(n.label); n.h=NH+(nLines-1)*16;
1023
+ 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; }
1024
+ if(n.shape==='diamond'){ n.w=Math.max(n.w+20,70); n.h=Math.max(n.h,48); }
1025
+ if(n.shape==='circle'){ n.w=n.h=Math.max(n.w,n.h); }
1026
+ // The box is what the layout reserves; the OUTLINE is what the reader
1027
+ // sees, and for every shape but the rectangles the two differ — a box
1028
+ // sized as if it were a rectangle lets the label cross the drawn outline
1029
+ // (a rhombus offers only its inscribed rectangle, ~half the box in each
1030
+ // direction). Grow the box until the label's text box is inside the
1031
+ // outline. An explicit `size` is the author's word: a rigid node keeps
1032
+ // its box and the label shrinks to the inscribed width instead (R10).
1033
+ if(!n.rigid) fitOutline(n,labelBox(n.label));
1034
+ }
1035
+ // ranks: DFS from document-order sources classifies back-edges; a
1036
+ // longest-path layering runs on the remaining DAG so sources sit at
1037
+ // layer 0. `rank a b c` merges its nodes into ONE vertex for the
1038
+ // layering (same layer) without dragging unrelated branches along.
1039
+ const pinned=id=>doc.pins[id]!==undefined;
1040
+ nodes.forEach((n,i)=>n.di=i); // document order (sort tiebreak)
1041
+ const rep={}; nodes.forEach(n=>rep[n.id]=n.id); // rank-group union-find
1042
+ const find=u=>{ while(rep[u]!==u) u=rep[u]=rep[rep[u]]; return u; };
1043
+ for(const r of doc.ranks){
1044
+ const ids=r.ids.filter(id=>byId[id]);
1045
+ for(let i=1;i<ids.length;i++){ const a=find(ids[0]),b=find(ids[i]); if(a!==b) rep[b]=a; }
1046
+ }
1047
+ const adj={}; nodes.forEach(n=>{ adj[find(n.id)]=adj[find(n.id)]||[]; });
1048
+ const isBack=new Set(); // edges that close a cycle
1049
+ for(const e of doc.edges){
1050
+ // undirected/bidirectional edges still order ranks by document direction
1051
+ if(!byId[e.a]||!byId[e.b]) continue;
1052
+ if(e.a===e.b){ isBack.add(e); continue; } // self-loop
1053
+ const u=find(e.a), v=find(e.b);
1054
+ if(u!==v) adj[u].push({v,e});
1055
+ }
1056
+ const state={};
1057
+ const classify=u=>{
1058
+ state[u]=1;
1059
+ for(const {v,e} of adj[u]){
1060
+ if(state[v]===1) isBack.add(e); // gray hit = back-edge
1061
+ else if(!state[v]) classify(v);
1062
+ }
1063
+ state[u]=2;
1064
+ };
1065
+ for(const n of nodes){ const u=find(n.id); if(!state[u]) classify(u); }
1066
+ const radj={}; nodes.forEach(n=>{ radj[find(n.id)]=radj[find(n.id)]||[]; });
1067
+ for(const e of doc.edges){
1068
+ if(!byId[e.a]||!byId[e.b]||isBack.has(e)) continue;
1069
+ const u=find(e.a), v=find(e.b); if(u!==v) radj[v].push(u);
1070
+ }
1071
+ const rk={};
1072
+ const rankOf=u=>{
1073
+ if(rk[u]!==undefined) return rk[u];
1074
+ rk[u]=0; // DAG: placeholder is never read
1075
+ let r=0; for(const p of radj[u]) r=Math.max(r, rankOf(p)+1);
1076
+ return rk[u]=r;
1077
+ };
1078
+ nodes.forEach(n=>n.rank=rankOf(find(n.id)));
1079
+ // positions: children spread around their parents' lane (barycenter
1080
+ // sweeps down/up/down). Edges that span multiple layers get invisible
1081
+ // waypoint slots so they no longer cut through intermediate nodes.
1082
+ // ALL nodes participate in auto layout (pinned ones hold their slot so
1083
+ // that pinning X never reflows Y); pins override coordinates afterwards.
1084
+ const horiz=(doc.flow==='right'||doc.flow==='left');
1085
+ const lblPx=s=>Math.max(...String(s).split('\n').map(l=>l.length))*6.5;
1086
+ const lay=[...nodes]; // layout participants
1087
+ const chains=new Map(); // edge -> [A, ...waypoints, B]
1088
+ for(const e of doc.edges){
1089
+ const A=byId[e.a], B=byId[e.b];
1090
+ if(!A||!B||isBack.has(e)||e.route) continue; // routed edges draw their own waypoints
1091
+ if(B.rank-A.rank<=1 || pinned(e.a) || pinned(e.b)) continue;
1092
+ const chain=[A];
1093
+ const span=B.rank-A.rank;
1094
+ for(let r=A.rank+1;r<B.rank;r++){
1095
+ // homeA/homeB/frac anchor this waypoint's cross position between its
1096
+ // OWN endpoints (dummy-vertex chain), so the barycenter sweep orders it
1097
+ // within the lane without letting the run drift to the figure margin.
1098
+ const v={virtual:true,rank:r,w:1,h:1,mn:e.mid?lblPx(e.mid):0,di:lay.length,
1099
+ homeA:A,homeB:B,frac:(r-A.rank)/span};
1100
+ lay.push(v); chain.push(v);
1101
+ }
1102
+ chain.push(B); chains.set(e,chain);
1103
+ }
1104
+ const preds=new Map(), succs=new Map(); // over layout segments
1105
+ const link=(p,c)=>{ if(!preds.has(c)) preds.set(c,[]); preds.get(c).push(p);
1106
+ if(!succs.has(p)) succs.set(p,[]); succs.get(p).push(c); };
1107
+ for(const e of doc.edges){
1108
+ const A=byId[e.a], B=byId[e.b];
1109
+ if(!A||!B||isBack.has(e)||A.rank===B.rank) continue;
1110
+ const ch=chains.get(e)||[A,B];
1111
+ for(let i=1;i<ch.length;i++) link(ch[i-1],ch[i]);
1112
+ }
1113
+ const midW={}; // widest mid label on a one-layer edge, per node
1114
+ for(const e of doc.edges){
1115
+ if(!e.mid) continue;
1116
+ const A=byId[e.a], B=byId[e.b];
1117
+ if(!A||!B||isBack.has(e)||Math.abs(A.rank-B.rank)!==1) continue;
1118
+ midW[e.a]=Math.max(midW[e.a]||0,lblPx(e.mid));
1119
+ midW[e.b]=Math.max(midW[e.b]||0,lblPx(e.mid));
1120
+ }
1121
+ const cs=n=>horiz?n.h:n.w; // cross-axis size
1122
+ const gapOf=(a,b)=>{ // spacing between adjacent lane members
1123
+ if(a.group&&a.group===b.group){
1124
+ const g=doc.groups.find(x=>x.id===a.group);
1125
+ if(g&&g.gap!==undefined) return g.gap; // explicit group gap is exact
1126
+ }
1127
+ let base=horiz?GAPY:GAPX;
1128
+ if(a.virtual||b.virtual) base=Math.min(base,30);
1129
+ if(!horiz){ // beside-labels on vertical edges need width
1130
+ const la=a.virtual?a.mn:(midW[a.id]||0);
1131
+ if(la) base=Math.max(base,la+23-cs(a)/2);
1132
+ }
1133
+ return base;
1134
+ };
1135
+ const ranksArr=[];
1136
+ for(const n of lay){ (ranksArr[n.rank]=ranksArr[n.rank]||[]).push(n); }
1137
+ const laneSize=[], mainGap=[];
1138
+ ranksArr.forEach((lane,i)=>{
1139
+ if(!lane) return;
1140
+ laneSize[i]=Math.max(...lane.map(n=>horiz?n.w:n.h));
1141
+ mainGap[i]=horiz?GAPX:GAPY;
1142
+ });
1143
+ if(horiz) for(const e of doc.edges){ // labels ride ON horizontal edges:
1144
+ if(!e.mid||isBack.has(e)) continue; // layer pitch grows to fit them
1145
+ const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
1146
+ const r0=Math.min(A.rank,B.rank), r1=Math.max(A.rank,B.rank);
1147
+ if(r0===r1) continue;
1148
+ const need=(lblPx(e.mid)+24)/(r1-r0);
1149
+ for(let i=r0;i<r1;i++) mainGap[i]=Math.max(mainGap[i],need);
1150
+ }
1151
+ const center=n=>n.cross+cs(n)/2;
1152
+ ranksArr.forEach(lane=>{ if(!lane) return; let c=0; // seed: doc order
1153
+ lane.forEach((n,k)=>{ n.cross=c; c+=cs(n)+(k<lane.length-1?gapOf(n,lane[k+1]):0); }); });
1154
+ const place=(lane,des)=>{ // order by desired center, resolve overlaps,
1155
+ const arr=lane.map(n=>({n,d:des.get(n)})); // recenter the lane
1156
+ arr.sort((p,q)=>p.d-q.d||p.n.di-q.n.di);
1157
+ let cEnd=-Infinity;
1158
+ arr.forEach((x,i)=>{
1159
+ x.n.cross=Math.max(x.d-cs(x.n)/2, cEnd);
1160
+ cEnd=x.n.cross+cs(x.n)+(i<arr.length-1?gapOf(x.n,arr[i+1].n):0);
1161
+ });
1162
+ const err=arr.reduce((s,x)=>s+center(x.n)-x.d,0)/arr.length;
1163
+ arr.forEach(x=>{ x.n.cross-=err; });
1164
+ lane.length=0; arr.forEach(x=>lane.push(x.n));
1165
+ };
1166
+ const SLACK=300; // how far a multi-rank waypoint may stray past its own
1167
+ // endpoints' cross-axis band before the sweep clamps it
1168
+ // (item 17); the knee of the excursion↔crossings trade.
1169
+ const sweep=dir=>{ // 1 = align to parents, -1 = align to children
1170
+ const idx=[]; ranksArr.forEach((l,i)=>l&&idx.push(i));
1171
+ const order=dir===1?idx.slice(1):idx.slice(0,-1).reverse();
1172
+ for(const i of order){
1173
+ const lane=ranksArr[i], des=new Map();
1174
+ for(const n of lane){
1175
+ const ref=(dir===1?preds:succs).get(n);
1176
+ let d=ref&&ref.length ? ref.reduce((s,m)=>s+center(m),0)/ref.length : center(n);
1177
+ // Waypoint excursion bound (item 17): a multi-rank forward edge's dummy
1178
+ // vertices may follow the barycenter freely WITHIN the cross-axis band
1179
+ // their own endpoints span — that is where the ordering that separates
1180
+ // parallel long edges from each other happens — but they may not stray
1181
+ // past that band by more than SLACK. Without this a long run in a
1182
+ // crowded region drifts, layer by layer, out to the figure margin (the
1183
+ // 900px staircase this item measured). Clamping the DESIRED position
1184
+ // (not the final one) leaves place()'s overlap resolution and node
1185
+ // spacing intact, so a clamped waypoint is not shoved onto a node; and
1186
+ // because the clamp is monotonic it does not reorder waypoints, so it
1187
+ // does not manufacture crossings among edges that did not cross before.
1188
+ // A centre-line PULL was tried instead and rejected: it makes edges
1189
+ // sharing a funnel target converge early and adds real crossings.
1190
+ if(n.virtual&&n.homeA&&n.homeB){
1191
+ const cA=center(n.homeA), cB=center(n.homeB);
1192
+ const lo=Math.min(cA,cB)-SLACK, hi=Math.max(cA,cB)+SLACK;
1193
+ d=Math.max(lo,Math.min(hi,d));
1194
+ }
1195
+ des.set(n,d);
1196
+ }
1197
+ place(lane,des);
1198
+ }
1199
+ };
1200
+ sweep(1); sweep(-1); sweep(1);
1201
+ let minC=Infinity; lay.forEach(n=>{ minC=Math.min(minC,n.cross); });
1202
+ if(!isFinite(minC)) minC=0;
1203
+ let main=0;
1204
+ ranksArr.forEach((lane,i)=>{
1205
+ if(!lane) return;
1206
+ lane.forEach(n=>{
1207
+ // waypoints span the full layer so chain bends happen in the gaps
1208
+ if(n.virtual){ if(horiz) n.w=laneSize[i]; else n.h=laneSize[i]; }
1209
+ if(horiz){ n.x=main; n.y=y0+20+n.cross-minC; }
1210
+ else { n.y=y0+20+main; n.x=n.cross-minC; }
1211
+ });
1212
+ main+=laneSize[i]+mainGap[i];
1213
+ });
1214
+ if(doc.flow==='left'||doc.flow==='up'){
1215
+ const M=main; for(const n of lay){
1216
+ if(horiz) n.x=M-n.x-n.w; else n.y=y0+20+(M-(n.y-y0-20))-n.h; }
1217
+ }
1218
+ // Two-level coordinates (D6): a pinned GROUP anchors its local origin in
1219
+ // canvas px; a pinned MEMBER is group-local (relative to that origin);
1220
+ // ungrouped pins are canvas px. Moving a group = editing one pin line.
1221
+ const gOrigin={};
1222
+ for(const g of doc.groups){
1223
+ const p=doc.pins[g.id];
1224
+ if(p){ gOrigin[g.id]={x:p.fx, y:y0+20+p.fy}; }
1225
+ else{
1226
+ const mem=nodes.filter(n=>n.group===g.id);
1227
+ if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
1228
+ y:Math.min(...mem.map(n=>n.y))};
1229
+ }
1230
+ }
1231
+ for(const n of nodes){ const p=doc.pins[n.id]; if(!p) continue;
1232
+ const o=n.group?gOrigin[n.group]:null;
1233
+ if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
1234
+ else { n.x=p.fx; n.y=y0+20+p.fy; }
1235
+ }
1236
+ // Boundary adjacency in pinned scenes (presentation-only): auto-layout ranks
1237
+ // a degree-1 boundary relative to the free lanes, so in a scene where the
1238
+ // real content is pinned to a compact box the boundary can drift to a far
1239
+ // rank and blow the canvas out. When a boundary's connected node is pinned
1240
+ // (or the scene is substantially pinned), place the boundary just outside
1241
+ // that node's border in the flow direction — upstream for `boundary -> node`,
1242
+ // downstream for `node -> boundary` — aligned on the node's cross-axis, at a
1243
+ // small fixed gap. Explicitly pinned or explicitly routed boundaries keep
1244
+ // their author-given placement. Strip pin/size/route and the AST is unchanged.
1245
+ {
1246
+ const real=nodes.filter(n=>!n.boundary);
1247
+ const pinnedReal=real.filter(n=>pinned(n.id)).length;
1248
+ const scenePinned=real.length>0 && pinnedReal>=Math.ceil(real.length/2);
1249
+ const BGAP=30; // border-to-anchor gap (spec range 24–40)
1250
+ for(const b of nodes){
1251
+ if(!b.boundary||pinned(b.id)) continue;
1252
+ // sole incident edge (degree-1); skip routed edges and self-refs
1253
+ let node=null, downstream=true;
1254
+ for(const e of doc.edges){
1255
+ if(e.route) continue;
1256
+ if(e.a===b.id&&byId[e.b]&&byId[e.b]!==b){ node=byId[e.b]; downstream=false; break; } // boundary -> node
1257
+ if(e.b===b.id&&byId[e.a]&&byId[e.a]!==b){ node=byId[e.a]; downstream=true; break; } // node -> boundary
1258
+ }
1259
+ if(!node) continue;
1260
+ if(!(pinned(node.id)||scenePinned)) continue; // unpinned scenes keep auto-layout
1261
+ // outward side in flow direction: downstream = end of flow, upstream = start
1262
+ const fwd=(doc.flow==='right'||doc.flow==='down');
1263
+ const outward=downstream===fwd ? 1 : -1; // +1 = max side, -1 = min side
1264
+ if(horiz){
1265
+ b.y=node.y+node.h/2-b.h/2; // align on node's horizontal axis
1266
+ b.x=outward>0 ? node.x+node.w+BGAP : node.x-BGAP-b.w;
1267
+ }else{
1268
+ b.x=node.x+node.w/2-b.w/2; // align on node's vertical axis
1269
+ b.y=outward>0 ? node.y+node.h+BGAP : node.y-BGAP-b.h;
1270
+ }
1271
+ }
1272
+ }
1273
+ // Boundary labels sit beyond the open end, away from the figure (R44).
1274
+ // bDir = outward direction of an anchor: away from its first incident
1275
+ // edge's other endpoint (document order; [0,1] = below when unwired).
1276
+ const bDir=n=>{
1277
+ for(const e of doc.edges){
1278
+ const o=e.a===n.id?byId[e.b]:(e.b===n.id?byId[e.a]:null);
1279
+ if(!o||o===n) continue;
1280
+ return [n.x+n.w/2-(o.x+o.w/2), n.y+n.h/2-(o.y+o.h/2)];
1281
+ }
1282
+ return [0,1];
1283
+ };
1284
+ // A left-pointing label (an inbound external under flow right) would
1285
+ // stick out past the canvas' left edge: shift the whole scene right so
1286
+ // it stays on canvas. The shift is uniform (relative geometry, incl.
1287
+ // pins, is preserved — same pattern as the ring-channel y-shift below)
1288
+ // and meta.left reports it so drag→pin round-trips stay stable.
1289
+ let bShift=0;
1290
+ for(const n of nodes){
1291
+ if(!n.boundary||!n.label) continue;
1292
+ const [bdx,bdy]=bDir(n), cx=n.x+n.w/2;
1293
+ const ext=Math.abs(bdx)>=Math.abs(bdy) ? (bdx<0 ? cx-10-lblPx(n.label) : 0)
1294
+ : cx-lblPx(n.label)/2;
1295
+ bShift=Math.max(bShift,-ext);
1296
+ }
1297
+ if(bShift){ for(const n of lay) n.x+=bShift;
1298
+ for(const k in gOrigin) gOrigin[k].x+=bShift; }
1299
+ // back-edge channel plan: slots used to be handed out in paint order, so
1300
+ // a far source could take the innermost slot and its long run crossed
1301
+ // every other return. Sort channel-bound back-edges so the source NEAREST
1302
+ // the channel gets the INNERMOST slot (vertical flow, right channel:
1303
+ // larger cx = nearer; horizontal flow, bottom channel: larger cy = nearer;
1304
+ // ties: document order). Where the pattern allows it (vertical flow,
1305
+ // clear space below the source, clear sky above the target) the loop is
1306
+ // drawn as a full concentric ring — drop row, channel, return row and hub
1307
+ // entry nested in the same order, entering the hub's top edge on the
1308
+ // channel side — so fan-in hubs (N states -> IDLE reset) have no crossings.
1309
+ const chPlan=new Map(); let chTop=0, chShift=0;
1310
+ {
1311
+ const chList=doc.edges.filter(e=>byId[e.a]&&byId[e.b]&&isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)&&!e.route);
1312
+ const near=e=>{ const A=byId[e.a]; return horiz?A.y+A.h/2:A.x+A.w/2; };
1313
+ const order=chList.map((e,i)=>({e,i}));
1314
+ order.sort((p,q)=>near(q.e)-near(p.e)||p.i-q.i);
1315
+ let cum=0;
1316
+ order.forEach(({e},ring)=>{
1317
+ const A=byId[e.a], B=byId[e.b], sx=A.x+A.w/2;
1318
+ const ringOK=!horiz&&A!==B
1319
+ &&!nodes.some(n=>n!==A&&!n.boundary&&n.x<sx&&n.x+n.w>sx&&n.y+n.h>A.y+A.h)
1320
+ &&!nodes.some(n=>n!==B&&!n.boundary&&n.x<B.x+B.w&&n.x+n.w>B.x&&n.y<B.y);
1321
+ chPlan.set(e,{ring,slot:cum,ringOK});
1322
+ cum+=(!horiz&&e.mid)?Math.max(22,lblPx(e.mid)+14):22;
1323
+ });
1324
+ // rings are all-or-nothing per target: a hub whose loops are part ring,
1325
+ // part legacy would reintroduce crossings between the two styles
1326
+ const byT={};
1327
+ order.forEach(({e})=>{ (byT[e.b]=byT[e.b]||[]).push(e); });
1328
+ for(const t in byT)
1329
+ if(!byT[t].every(e=>chPlan.get(e).ringOK))
1330
+ byT[t].forEach(e=>{ chPlan.get(e).ringOK=false; });
1331
+ // hub entries fan across the target's top edge, innermost ring nearest
1332
+ // the channel, so concentric rings never cross on their way in
1333
+ for(const t in byT){
1334
+ const g=byT[t].filter(e=>chPlan.get(e).ringOK);
1335
+ const B=byId[t], m=g.length;
1336
+ g.forEach((e,k)=>{ chPlan.get(e).ex=B.x+B.w*(m-k)/(m+1); });
1337
+ }
1338
+ // ring return rows run above the top rank; shift the whole scene down
1339
+ // when they would spill into the title band. The shift is uniform
1340
+ // (relative geometry, incl. pins, is preserved) and meta.top reports
1341
+ // the shifted origin so the editor's drag->pin round-trip stays stable.
1342
+ const rings=order.filter(({e})=>chPlan.get(e).ringOK);
1343
+ if(rings.length){
1344
+ let occT=Infinity;
1345
+ for(const n of nodes) occT=Math.min(occT, n.y-(n.group?26:0));
1346
+ const maxRing=Math.max(...rings.map(({e})=>chPlan.get(e).ring));
1347
+ chShift=Math.max(0, y0+20+maxRing*12-occT);
1348
+ if(chShift){ for(const n of lay) n.y+=chShift;
1349
+ for(const k in gOrigin) gOrigin[k].y+=chShift; }
1350
+ chTop=occT+chShift;
1351
+ }
1352
+ }
1353
+ // anti-parallel straight edges (A->B and B->A) used to coincide: offset
1354
+ // each member of a same-pair straight group along the pair's CANONICAL
1355
+ // normal (endpoints sorted by id), so opposite directions land on
1356
+ // opposite sides; endpoints and labels shift together.
1357
+ const apOff=new Map();
1358
+ {
1359
+ const straight=e=>byId[e.a]&&byId[e.b]&&!(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b))&&!chains.get(e)&&!e.route;
1360
+ const pk=e=>e.a<e.b?e.a+'\t'+e.b:e.b+'\t'+e.a;
1361
+ const pairN={}, seen={};
1362
+ for(const e of doc.edges) if(straight(e)){ const k=pk(e); pairN[k]=(pairN[k]||0)+1; }
1363
+ for(const e of doc.edges){
1364
+ if(!straight(e)) continue;
1365
+ const k=pk(e), kk=pairN[k]; if(kk<2) continue;
1366
+ const idx=seen[k]||0; seen[k]=idx+1;
1367
+ const off=(idx-(kk-1)/2)*7;
1368
+ const lo=e.a<e.b?e.a:e.b, hi=e.a<e.b?e.b:e.a;
1369
+ const P=byId[lo], Q=byId[hi];
1370
+ 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;
1371
+ apOff.set(e,[-dy/L*off, dx/L*off]);
1372
+ }
1373
+ }
1374
+ let W=0,Hh=0;
1375
+ for(const n of lay){ W=Math.max(W,n.x+n.w); Hh=Math.max(Hh,n.y+n.h-y0-20); }
1376
+ if(W===0){W=480;} if(Hh===0){Hh=280;}
1377
+ // groups
1378
+ const gsvg=[]; const gBox={};
1379
+ for(const g of doc.groups){
1380
+ const mem=nodes.filter(n=>n.group===g.id);
1381
+ if(!mem.length) continue;
1382
+ const o=gOrigin[g.id];
1383
+ const x0=Math.min(...mem.map(n=>n.x))-14, x1=Math.max(...mem.map(n=>n.x+n.w))+14;
1384
+ const yA=Math.min(...mem.map(n=>n.y))-26, yB=Math.max(...mem.map(n=>n.y+n.h))+12;
1385
+ gBox[g.id]={x0,x1,yA,yB};
1386
+ const gdash=g.style==='dashed'?' stroke-dasharray="6 4"':(g.style==='dotted'?' stroke-dasharray="2 4"':'');
1387
+ gsvg.push('<g data-group="'+g.id+'" data-gx="'+o.x+'" data-gy="'+o.y+'" style="cursor:move">'
1388
+ +'<rect x="'+x0+'" y="'+yA+'" width="'+(x1-x0)+'" height="'+(yB-yA)+'" rx="10" fill="'+(g.color||'#f6f5ef')+'" stroke="'+(g.stroke||'#d6d4cc')+'"'+gdash+'/>'
1389
+ +'<text x="'+(x0+10)+'" y="'+(yA+16)+'" font-size="11.5" fill="'+(g.text||'#6f6e69')+'">'+esc(g.label)+'</text></g>');
1390
+ W=Math.max(W,x1); Hh=Math.max(Hh,yB-y0-20);
1391
+ }
1392
+ // zone bands on groups: above the group background, below the nodes.
1393
+ // dir picks the measuring axis and its 0% edge: up=bottom, down=top,
1394
+ // right=left edge, left=right edge.
1395
+ const bandRect=(f,x0,yA,x1,yB)=>{
1396
+ const w=x1-x0, h=yB-yA;
1397
+ if(f.dir==='up') return [x0, yB-h*f.to/100, w, h*(f.to-f.from)/100];
1398
+ if(f.dir==='down') return [x0, yA+h*f.from/100, w, h*(f.to-f.from)/100];
1399
+ if(f.dir==='right') return [x0+w*f.from/100, yA, w*(f.to-f.from)/100, h];
1400
+ return [x1-w*f.to/100, yA, w*(f.to-f.from)/100, h]; // left
1401
+ };
1402
+ for(const f of doc.fills){
1403
+ const B=gBox[f.target]; if(!B) continue;
1404
+ const [bx,by,bw,bh]=bandRect(f,B.x0,B.yA,B.x1,B.yB);
1405
+ gsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.color+'" opacity="0.9"/>');
1406
+ }
1407
+ // edges (sorted by layer z, then doc order)
1408
+ const zOf=l=>{const L=doc.layers.find(x=>x.id===l);return L?L.z:0;};
1409
+ const edges=[...doc.edges].sort((p,q)=>zOf(p.layer)-zOf(q.layer));
1410
+ const esvg=[], lblsvg=[]; // labels paint last = closest to the viewer
1411
+ // ── deferred edge-label placement ───────────────────────────────────────
1412
+ // An edge label is not written where it is emitted. Each emission reserves
1413
+ // its slot in lblsvg (so the paint order is unchanged) and registers the
1414
+ // segment it belongs to; one greedy pass after the edge loop scores several
1415
+ // candidate positions per label against the already-placed labels, the node
1416
+ // boxes, the arrowheads and the other edges, and writes the winner into the
1417
+ // reserved slot. Requests are consumed in registration order, which is
1418
+ // edge order, which is deterministic — same input, same output.
1419
+ const lblReq=[], edgeSegs=[], arrowBox=[];
1420
+ const reqLabel=o=>{ o.idx=lblsvg.length; lblsvg.push(''); lblReq.push(o); };
1421
+ const noteSegs=(e,pp)=>{ for(let i=0;i+1<pp.length;i++) edgeSegs.push({e,p:pp[i],q:pp[i+1]}); };
1422
+ // arrowTri: explicit triangle painted in lblsvg (above nodes) instead of
1423
+ // SVG marker-end/marker-start which are occluded by the node fill.
1424
+ // tip=[x,y], from=[x,y] is the adjacent shaft point toward the interior
1425
+ // (direction: from→tip). Geometry matches #arr marker (viewBox 0 0 10 10,
1426
+ // refX=9, refY=5, markerWidth=7, markerHeight=7, markerUnits=strokeWidth=1.6):
1427
+ // arm = 9*(7/10)*1.6 ≈ 10.08 px, half-width = 5*(7/10)*1.6 ≈ 5.6 px.
1428
+ const arrowTri=(tip,from,col)=>{
1429
+ const dx=tip[0]-from[0], dy=tip[1]-from[1], L=Math.hypot(dx,dy)||1;
1430
+ const ux=dx/L, uy=dy/L; // unit vector from→tip
1431
+ const arm=10.08, hw=5.6;
1432
+ const bx=tip[0]-ux*arm, by=tip[1]-uy*arm; // base centre
1433
+ const lx=bx-uy*hw, ly=by+ux*hw; // left corner
1434
+ const rx=bx+uy*hw, ry=by-ux*hw; // right corner
1435
+ lblsvg.push('<path d="M'+tip[0]+' '+tip[1]+' L'+lx+' '+ly+' L'+rx+' '+ry+' z" fill="'+col+'" stroke="none"/>');
1436
+ arrowBox.push({x:Math.min(tip[0],lx,rx), y:Math.min(tip[1],ly,ry),
1437
+ w:Math.max(tip[0],lx,rx)-Math.min(tip[0],lx,rx),
1438
+ h:Math.max(tip[1],ly,ry)-Math.min(tip[1],ly,ry)});
1439
+ };
1440
+ // back-edge side channel: beyond the occupied lanes (nodes AND group boxes)
1441
+ let occR=0, occB=0;
1442
+ for(const n of nodes){ occR=Math.max(occR,n.x+n.w); occB=Math.max(occB,n.y+n.h); }
1443
+ for(const k in gBox){ occR=Math.max(occR,gBox[k].x1); occB=Math.max(occB,gBox[k].yB); }
1444
+ if(!horiz) chains.forEach((chain,e)=>{ // chain labels stick out right
1445
+ if(!e.mid) return;
1446
+ const v=chain[1+Math.floor((chain.length-3)/2)];
1447
+ occR=Math.max(occR, v.x+v.w/2+9+lblPx(e.mid));
1448
+ });
1449
+ for(const e of edges){
1450
+ const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
1451
+ const col=e.color||'#555';
1452
+ const dash=e.style==='dashed'?' stroke-dasharray="6 4"':(e.style==='dotted'?' stroke-dasharray="2 4"':'');
1453
+ const wantsStart=e.op==='<->'||e.op==='<-', wantsEnd=e.op==='<->'||e.op==='->';
1454
+ const m1='', m2=''; // markers removed — arrowTri() paints triangles above nodes in lblsvg
1455
+ const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
1456
+ const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:'#555',halo,e,A,B,kind:'end'});
1457
+ if(e.route){
1458
+ // declared waypoints (route … via=, 0.1-dev.13) are RIGID: the edge
1459
+ // draws source → via1 → … → viaN → target and bypasses the automatic
1460
+ // machinery (chains, channels, obstacle detours). Waypoints are
1461
+ // canvas px in the same space as ungrouped pins (and follow the
1462
+ // ring-channel scene shift like pins do). Under orthogonal routing
1463
+ // (route-level routing= wins over the document directive) consecutive
1464
+ // points are joined by deterministic manhattan elbows — horizontal-
1465
+ // then-vertical for flow right|left, vertical-then-horizontal for
1466
+ // down|up — reusing the standard borderPoint anchoring at both ends.
1467
+ const orth=(e.route.routing||doc.routing||'straight')==='orthogonal';
1468
+ const via=e.route.via.map(p=>[p[0], y0+20+chShift+p[1]]);
1469
+ let pts=[borderPoint(A,via[0][0],via[0][1])]
1470
+ .concat(via,[borderPoint(B,via[via.length-1][0],via[via.length-1][1])]);
1471
+ if(orth){
1472
+ const out=[pts[0]];
1473
+ for(let i=1;i<pts.length;i++){
1474
+ const p=out[out.length-1], q=pts[i];
1475
+ if(p[0]!==q[0]&&p[1]!==q[1]) out.push(horiz?[q[0],p[1]]:[p[0],q[1]]);
1476
+ out.push(q);
1477
+ }
1478
+ pts=out;
1479
+ }
1480
+ for(let i=1;i+1<pts.length;i++) // drop zero-length interior steps
1481
+ if(Math.hypot(pts[i][0]-pts[i-1][0],pts[i][1]-pts[i-1][1])<0.5){ pts.splice(i,1); i--; }
1482
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
1483
+ noteSegs(e,pts);
1484
+ if(e.mid){ // the longest segment carries the mid label
1485
+ let bi=0,bl=-1;
1486
+ for(let i=0;i+1<pts.length;i++){
1487
+ const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
1488
+ if(l>bl){ bl=l; bi=i; }
1489
+ }
1490
+ reqLabel({p:pts[bi],q:pts[bi+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:bi===0});
1491
+ }
1492
+ if(e.tail) seg(pts[0],pts[1],0.25,e.tail,10);
1493
+ if(e.head) seg(pts[pts.length-1],pts[pts.length-2],0.25,e.head,10);
1494
+ if(wantsStart) arrowTri(pts[0],pts[1],col);
1495
+ if(wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
1496
+ for(const pP of pts){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+16-y0-20); }
1497
+ continue;
1498
+ }
1499
+ if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
1500
+ // back-edge (retry loop): polyline through a side channel beyond the
1501
+ // occupied lanes instead of a straight line hidden under the spine,
1502
+ // using the nested slot from the channel plan. Ring-eligible loops
1503
+ // wrap over the top and enter the hub's top edge; otherwise, when
1504
+ // the sideways run to the channel would cut through a sibling node,
1505
+ // the route drops into the inter-layer gap first.
1506
+ const lane=r=>(ranksArr[r]||[]).filter(n=>!n.virtual);
1507
+ const P=chPlan.get(e), ring=P.ring;
1508
+ const pts=[];
1509
+ if(horiz){ // channel runs below the lanes
1510
+ const chY=occB+28+P.slot; // labels ride ON the channel
1511
+ const colR=r=>Math.max(...lane(r).map(n=>n.x+n.w));
1512
+ 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);
1513
+ 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;
1514
+ if(A!==B&&blockedV(A.y+A.h,chY,sx,A)){
1515
+ const gx=colR(A.rank)+10+ring*7;
1516
+ pts.push([outSide(A,'r'),A.y+A.h/2],[gx,A.y+A.h/2],[gx,chY]);
1517
+ } else pts.push([sx,outSide(A,'b')],[sx,chY]);
1518
+ if(A!==B&&blockedV(B.y+B.h,chY,tx,B)){
1519
+ const gx=colR(B.rank)+10+ring*7;
1520
+ pts.push([gx,chY],[gx,B.y+B.h/2],[outSide(B,'r'),B.y+B.h/2]);
1521
+ } else pts.push([tx,chY],[tx,outSide(B,'b')]);
1522
+ if(e.mid){
1523
+ const c1=pts.findIndex(p=>p[1]===chY);
1524
+ reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
1525
+ }
1526
+ } else if(P.ringOK){ // concentric ring: under, around, over, in
1527
+ const sx=A.x+A.w/2;
1528
+ const gy=occB+14+ring*12, chX=occR+28+P.slot, topY=chTop-14-ring*12;
1529
+ pts.push([sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]);
1530
+ if(e.mid){
1531
+ const c1=pts.findIndex(p=>p[0]===chX);
1532
+ reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
1533
+ }
1534
+ } else { // channel runs right of the lanes
1535
+ const chX=occR+28+P.slot;
1536
+ const laneB=r=>Math.max(...lane(r).map(n=>n.y+n.h));
1537
+ 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);
1538
+ 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;
1539
+ if(A!==B&&blockedH(A.x+A.w,chX,sy,A)){
1540
+ const gy=laneB(A.rank)+10+ring*7;
1541
+ pts.push([A.x+A.w/2,outSide(A,'b')],[A.x+A.w/2,gy],[chX,gy]);
1542
+ } else pts.push([outSide(A,'r'),sy],[chX,sy]);
1543
+ if(A!==B&&blockedH(B.x+B.w,chX,ty,B)){
1544
+ const gy=laneB(B.rank)+10+ring*7;
1545
+ pts.push([chX,gy],[B.x+B.w/2,gy],[B.x+B.w/2,outSide(B,'b')]);
1546
+ } else pts.push([chX,ty],[outSide(B,'r'),ty]);
1547
+ if(e.mid){
1548
+ const c1=pts.findIndex(p=>p[0]===chX);
1549
+ reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
1550
+ }
1551
+ }
1552
+ // non-incident nodes are obstacles for the channel runs too: a run
1553
+ // that would cut through a sibling (e.g. a pinned node parked on the
1554
+ // escape lane) detours around it instead of drawing across it. When
1555
+ // spanning the following corner point gives a shorter total run than
1556
+ // detour + remaining leg (a detour "spike"), the corner is dropped.
1557
+ 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}));
1558
+ 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;};
1559
+ for(let i=0;i+1<pts.length;i++){
1560
+ const d=routeAround(pts[i],pts[i+1],obsN);
1561
+ if(!d) continue;
1562
+ let ins=d.slice(1,-1), drop=0;
1563
+ if(i+2<pts.length){
1564
+ const span=segHitsObs(pts[i],pts[i+2],obsN)
1565
+ ?routeAround(pts[i],pts[i+2],obsN):[pts[i],pts[i+2]];
1566
+ 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){
1567
+ ins=span.slice(1,-1); drop=1;
1568
+ }
1569
+ }
1570
+ pts.splice(i+1,drop,...ins);
1571
+ i+=ins.length;
1572
+ }
1573
+ for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
1574
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
1575
+ noteSegs(e,pts);
1576
+ if(e.tail) seg(pts[0],pts[1],0.5,e.tail,10);
1577
+ if(e.head) seg(pts[pts.length-1],pts[pts.length-2],0.5,e.head,10);
1578
+ if(wantsStart) arrowTri(pts[0],pts[1],col);
1579
+ if(wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
1580
+ continue;
1581
+ }
1582
+ const chain=chains.get(e);
1583
+ if(chain){
1584
+ // multi-layer edge: polyline through its reserved waypoint lane;
1585
+ // each waypoint contributes an entry and an exit port so the run
1586
+ // through a layer is parallel to it and diagonals stay in the gaps
1587
+ const pts=[];
1588
+ let px=A.x+A.w/2, py=A.y+A.h/2;
1589
+ for(const v of chain.slice(1,-1)){
1590
+ const cx=v.x+v.w/2, cy=v.y+v.h/2;
1591
+ let p=horiz?[v.x,cy]:[cx,v.y], q=horiz?[v.x+v.w,cy]:[cx,v.y+v.h];
1592
+ if(horiz? px>cx : py>cy){ const t=p; p=q; q=t; }
1593
+ pts.push(p,q); px=q[0]; py=q[1];
1594
+ }
1595
+ const p0=borderPoint(A,pts[0][0],pts[0][1]);
1596
+ const p1=borderPoint(B,pts[pts.length-1][0],pts[pts.length-1][1]);
1597
+ pts.unshift(p0); pts.push(p1);
1598
+ // the middle waypoint's own port run carries the label; capture it now,
1599
+ // before collinear simplification renumbers the point list
1600
+ let midSeg=null;
1601
+ if(e.mid){ const j=Math.floor((chain.length-3)/2); midSeg=[pts[1+2*j],pts[2+2*j]]; }
1602
+ // Obstacle avoidance on the chain run: the excursion clamp pulls a long
1603
+ // forward run in toward its own endpoints, which can make a connector
1604
+ // segment graze a node the edge does not touch. routeAround detours only
1605
+ // segments that actually hit an obstacle (it returns null otherwise), so
1606
+ // clean chains — every chain before this change — are left byte-for-byte
1607
+ // unchanged; only a clamped segment that would pierce a node gets bent
1608
+ // around it. This keeps the edge-through-node count from regressing while
1609
+ // the clamp does its job. Same obstacle set the straight edges use.
1610
+ {
1611
+ 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}));
1612
+ for(let i=0;i+1<pts.length;i++){
1613
+ const d=routeAround(pts[i],pts[i+1],obs);
1614
+ if(!d) continue;
1615
+ const ins=d.slice(1,-1);
1616
+ if(midSeg&&midSeg[0]===pts[i]&&midSeg[1]===pts[i+1]&&ins.length) midSeg=[pts[i],ins[0]];
1617
+ pts.splice(i+1,0,...ins); i+=ins.length;
1618
+ }
1619
+ }
1620
+ // Collapse collinear interior points: home-anchored waypoints line up, so
1621
+ // the per-rank entry/exit ports leave long straight runs punctuated by
1622
+ // redundant vertices. Dropping points that lie on the segment between
1623
+ // their neighbours turns a 40-point staircase into the 2–4 bends a
1624
+ // dummy-vertex chain should have, without moving the drawn line.
1625
+ simplifyPts(pts);
1626
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
1627
+ noteSegs(e,pts);
1628
+ if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
1629
+ if(e.tail) seg(p0,pts[1],0.4,e.tail,10);
1630
+ if(e.head) seg(p1,pts[pts.length-2],0.4,e.head,10);
1631
+ if(wantsStart) arrowTri(pts[0],pts[1],col);
1632
+ if(wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
1633
+ continue;
1634
+ }
1635
+ 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;
1636
+ let [x1,yy1]=borderPoint(A,bx,by), [x2,yy2]=borderPoint(B,ax,ay);
1637
+ const ap=apOff.get(e); // anti-parallel fan-out (labels ride along)
1638
+ if(ap){ x1+=ap[0]; yy1+=ap[1]; x2+=ap[0]; yy2+=ap[1]; }
1639
+ // group boxes and non-incident nodes are routing obstacles: a straight
1640
+ // run that pierces a node it does not touch, or a group box it neither
1641
+ // starts nor ends inside, detours around the obstacle boundary instead
1642
+ // (routeAround). Obstacle-free edges keep the plain line unchanged.
1643
+ let route=null;
1644
+ if(A!==B){
1645
+ 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}));
1646
+ for(const k in gBox){
1647
+ const b=gBox[k];
1648
+ const inG=(px,py)=>px>b.x0&&px<b.x1&&py>b.yA&&py<b.yB;
1649
+ if(!inG(x1,yy1)&&!inG(x2,yy2))
1650
+ obs.push({x:b.x0,y:b.yA,w:b.x1-b.x0,h:b.yB-b.yA});
1651
+ }
1652
+ route=routeAround([x1,yy1],[x2,yy2],obs);
1653
+ if(route){ // leave the node facing the first/last bend
1654
+ route[0]=borderPoint(A,route[1][0],route[1][1]);
1655
+ route[route.length-1]=borderPoint(B,route[route.length-2][0],route[route.length-2][1]);
1656
+ }
1657
+ }
1658
+ if(!route && A!==B && doc.routing==='orthogonal' && ax!==bx && ay!==by){
1659
+ // document-level orthogonal routing (0.1-dev.13): plain straight
1660
+ // edges become deterministic manhattan elbows — horizontal-then-
1661
+ // vertical under flow right|left, vertical-then-horizontal under
1662
+ // down|up — anchored by the same borderPoint rule (the corner sits
1663
+ // on the far node's main-axis line, so exits are clean right-angle
1664
+ // stubs). Obstacle detours and channel/chain polylines keep their
1665
+ // existing shapes; axis-aligned pairs stay plain straight lines.
1666
+ const corner=horiz?[bx,ay]:[ax,by];
1667
+ let sP=borderPoint(A,corner[0],corner[1]), tP=borderPoint(B,corner[0],corner[1]);
1668
+ if(ap){ sP=[sP[0]+ap[0],sP[1]+ap[1]]; tP=[tP[0]+ap[0],tP[1]+ap[1]]; }
1669
+ const mid=horiz?[tP[0],sP[1]]:[sP[0],tP[1]];
1670
+ const deg=(pp,qq)=>Math.hypot(pp[0]-qq[0],pp[1]-qq[1])<0.5;
1671
+ if(!deg(mid,sP)&&!deg(mid,tP)) route=[sP,mid,tP];
1672
+ }
1673
+ if(route){
1674
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(route)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
1675
+ noteSegs(e,route);
1676
+ if(e.mid){ // the longest segment carries the mid label
1677
+ let bi=0,bl=-1;
1678
+ for(let i=0;i+1<route.length;i++){
1679
+ const l=Math.hypot(route[i+1][0]-route[i][0],route[i+1][1]-route[i][1]);
1680
+ if(l>bl){ bl=l; bi=i; }
1681
+ }
1682
+ reqLabel({p:route[bi],q:route[bi+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:bi===0});
1683
+ }
1684
+ if(e.tail) seg(route[0],route[1],0.25,e.tail,10);
1685
+ if(e.head) seg(route[route.length-1],route[route.length-2],0.25,e.head,10);
1686
+ if(wantsStart) arrowTri(route[0],route[1],col);
1687
+ if(wantsEnd) arrowTri(route[route.length-1],route[route.length-2],col);
1688
+ for(const pP of route){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+4-y0-20); }
1689
+ continue;
1690
+ }
1691
+ esvg.push('<line data-edge="'+e.line+'" x1="'+x1+'" y1="'+yy1+'" x2="'+x2+'" y2="'+yy2+'" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
1692
+ noteSegs(e,[[x1,yy1],[x2,yy2]]);
1693
+ if(e.mid)
1694
+ reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:true});
1695
+ // endpoint labels at the tail/head positions (three-position model, R34)
1696
+ if(e.tail) seg([x1,yy1],[x2,yy2],0.18,e.tail,10);
1697
+ if(e.head) seg([x1,yy1],[x2,yy2],0.82,e.head,10);
1698
+ if(wantsStart) arrowTri([x1,yy1],[x2,yy2],col);
1699
+ if(wantsEnd) arrowTri([x2,yy2],[x1,yy1],col);
1700
+ }
1701
+ // ── edge-label placement: candidates + greedy collision-aware choice ─────
1702
+ // For each registered label the carrying segment is sampled at several
1703
+ // parameters t and on both sides of the line, giving a small candidate set.
1704
+ // Candidates are scored by overlap area against everything already on the
1705
+ // canvas — the labels placed before it (document order), the node boxes,
1706
+ // the arrowheads, and the other edges — plus a pull back toward the
1707
+ // preferred point on the segment. The lowest score wins. No randomness,
1708
+ // no iteration to a fixed point: one deterministic pass.
1709
+ if(lblReq.length){
1710
+ const obst=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h,n}));
1711
+ const ovl=(a,b)=>{
1712
+ const ix=Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x);
1713
+ const iy=Math.min(a.y+a.h,b.y+b.h)-Math.max(a.y,b.y);
1714
+ return ix>0&&iy>0?ix*iy:0;
1715
+ };
1716
+ // Liang-Barsky: does segment p→q touch the interior of rect r?
1717
+ const segHit=(p,q,r)=>{
1718
+ let t0=0,t1=1;
1719
+ const d=[q[0]-p[0],q[1]-p[1]];
1720
+ const P=[-d[0],d[0],-d[1],d[1]];
1721
+ const Q=[p[0]-r.x, r.x+r.w-p[0], p[1]-r.y, r.y+r.h-p[1]];
1722
+ for(let i=0;i<4;i++){
1723
+ if(Math.abs(P[i])<1e-9){ if(Q[i]<0) return false; continue; }
1724
+ const t=Q[i]/P[i];
1725
+ if(P[i]<0){ if(t>t1) return false; if(t>t0) t0=t; }
1726
+ else { if(t<t0) return false; if(t<t1) t1=t; }
1727
+ }
1728
+ return t1>t0;
1729
+ };
1730
+ const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
1731
+ const cand=(r,t,side)=>{
1732
+ const lines=String(r.text).split('\n'), n=lines.length;
1733
+ const w=Math.max(...lines.map(l=>l.length))*6.5*r.fs/11;
1734
+ const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
1735
+ const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
1736
+ const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
1737
+ let bx,by,x,anchor=n>1?'middle':'start';
1738
+ if(side==='on') { bx=mx-w/2; by=my-4-up; anchor='middle'; }
1739
+ else if(side==='above') { bx=mx-w/2; by=my-3-h; anchor='middle'; }
1740
+ else if(side==='below') { bx=mx-w/2; by=my+3; anchor='middle'; }
1741
+ else if(side==='right') { bx=mx+6; by=my-h/2; }
1742
+ else { bx=mx-6-w; by=my-h/2; }
1743
+ x=anchor==='middle'?bx+w/2:bx;
1744
+ return {x,y:by+up,anchor,t,side,box:{x:bx,y:by,w,h}};
1745
+ };
1746
+ const placed=[];
1747
+ for(const r of lblReq){
1748
+ const dx=r.q[0]-r.p[0], dy=r.q[1]-r.p[1];
1749
+ const across=Math.abs(dx)>=Math.abs(dy);
1750
+ let sides, ts, tPref;
1751
+ if(r.kind==='end'){
1752
+ // endpoint labels keep their historical spot as first choice
1753
+ sides=['on'].concat(across?['above','below']:['right','left']);
1754
+ tPref=r.t0;
1755
+ ts=[r.t0,r.t0-0.06,r.t0+0.06,r.t0-0.12,r.t0+0.12].map(CLAMP);
1756
+ } else {
1757
+ sides=across?['above','below']:['right','left'];
1758
+ // flowchart convention: a short branch marker leaving a decision node
1759
+ // reads as that branch's name only if it sits next to the decision
1760
+ const branch=r.first && r.A && r.A.shape==='diamond' &&
1761
+ String(r.text).length<=3 && !String(r.text).includes('\n');
1762
+ tPref=branch?0.22:0.5;
1763
+ ts=branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72];
1764
+ }
1765
+ let best=null,bestS=Infinity;
1766
+ for(let si=0;si<sides.length;si++) for(const t of ts){
1767
+ const c=cand(r,t,sides[si]);
1768
+ let s=0;
1769
+ for(const b of placed) s+=3*ovl(c.box,b);
1770
+ for(const o of obst) s+=(o.n===r.A||o.n===r.B?6:2.4)*ovl(c.box,o);
1771
+ for(const a of arrowBox) s+=4*ovl(c.box,a);
1772
+ for(const g of edgeSegs) if(g.e!==r.e && segHit(g.p,g.q,c.box)) s+=26;
1773
+ s+=70*Math.abs(t-tPref)+si*10;
1774
+ if(c.box.x<2) s+=400; // would fall off the left margin
1775
+ if(s<bestS-1e-9){ bestS=s; best=c; }
1776
+ }
1777
+ lblsvg[r.idx]=textEl(best.x,best.y,r.fs,best.anchor,r.col,r.text,r.halo);
1778
+ placed.push(best.box);
1779
+ W=Math.max(W, best.box.x+best.box.w+4);
1780
+ Hh=Math.max(Hh, best.box.y+best.box.h+4-y0-20);
1781
+ }
1782
+ }
1783
+ // nodes on top (each wrapped in a draggable, identifiable group)
1784
+ const nsvg=[];
1785
+ for(const n of nodes){
1786
+ if(n.boundary){
1787
+ // never drawn as a shape (R44): the edge already ended open at the
1788
+ // anchor; a declared label sits just beyond the open end, on the
1789
+ // side away from the figure — small muted text with a white halo
1790
+ if(!n.label) continue;
1791
+ const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
1792
+ const bhalo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
1793
+ if(Math.abs(bdx)>=Math.abs(bdy)){
1794
+ if(bdx>=0){ lblsvg.push(textEl(cx+10,cy+3.5,10,'start','#555',n.label,bhalo));
1795
+ W=Math.max(W,cx+12+lblPx(n.label)); }
1796
+ else lblsvg.push(textEl(cx-10,cy+3.5,10,'end','#555',n.label,bhalo));
1797
+ } else if(bdy>=0){
1798
+ lblsvg.push(textEl(cx,cy+17,10,'middle','#555',n.label,bhalo));
1799
+ Hh=Math.max(Hh,cy+21-y0-20);
1800
+ } else lblsvg.push(textEl(cx,cy-10,10,'middle','#555',n.label,bhalo));
1801
+ continue;
1802
+ }
1803
+ nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'" style="cursor:move">');
1804
+ const fill=n.color||'#fff', stroke=n.stroke||'#8a8880', txt=n.text||'#1d1d1b';
1805
+ const ndash=n.style==='dashed'?' stroke-dasharray="6 4"':(n.style==='dotted'?' stroke-dasharray="2 4"':'');
1806
+ if(n.shape==='diamond'){
1807
+ const cx=n.x+n.w/2, cy=n.y+n.h/2;
1808
+ 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+'/>');
1809
+ } else if(n.shape==='rounded'){
1810
+ 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"/>');
1811
+ } else if(n.shape==='cloud'){
1812
+ 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+'/>');
1813
+ } else if(n.shape==='ellipse'||n.shape==='circle'){
1814
+ 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+'/>');
1815
+ } else if(n.shape==='cylinder'){
1816
+ nsvg.push('<rect x="'+n.x+'" y="'+n.y+'" width="'+n.w+'" height="'+n.h+'" rx="3" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>'
1817
+ +'<line x1="'+n.x+'" y1="'+(n.y+7)+'" x2="'+(n.x+n.w)+'" y2="'+(n.y+7)+'" stroke="'+stroke+'"/>');
1818
+ } else {
1819
+ // box = right-angle rectangle (the mainstream default: Mermaid/
1820
+ // Graphviz rects, hardware block diagrams); use shape=rounded for corners
1821
+ nsvg.push('<rect x="'+n.x+'" y="'+n.y+'" width="'+n.w+'" height="'+n.h+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
1822
+ }
1823
+ // zone bands on this node (dir: up=bottom-based, down, left, right)
1824
+ for(const f of doc.fills){
1825
+ if(f.target!==n.id) continue;
1826
+ const [bx,by,bw,bh]=bandRect(f,n.x,n.y,n.x+n.w,n.y+n.h);
1827
+ nsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.color+'" opacity="0.9"/>');
1828
+ }
1829
+ // label with shrink-to-fit when size is rigid (R10); multi-line via "\n"
1830
+ let fs=FONT;
1831
+ const nl=String(n.label).split('\n');
1832
+ const need=Math.max(...nl.map(l=>l.length))*CH;
1833
+ // budget = the width the OUTLINE offers at the label's own height, minus
1834
+ // 8 px clearance each side. For a box that is n.w-16, exactly as before;
1835
+ // for a rhombus or an ellipse it is the inscribed width, so a rigid
1836
+ // shaped node shrinks its text to what the reader can actually see.
1837
+ const avail=2*inscribedHalfW(shapeAxes(n),nl.length*8)-16;
1838
+ if(n.rigid && need>avail) fs=Math.max(8, FONT*avail/need);
1839
+ nsvg.push(textEl(n.x+n.w/2, n.y+n.h/2+fs*0.35, fs, 'middle', txt, n.label));
1840
+ nsvg.push('</g>');
1841
+ }
1842
+ // trunk rings (semantic LAG/ES bundles): the ellipse is DERIVED from the
1843
+ // member links' midpoints — drag a node and the ring follows
1844
+ const tsvg=[];
1845
+ for(const t of doc.trunks){
1846
+ const mids=[];
1847
+ for(const [a,b] of t.pairs){
1848
+ const A=byId[a], B=byId[b]; if(!A||!B) continue;
1849
+ const [x1,yy1]=borderPoint(A,B.x+B.w/2,B.y+B.h/2);
1850
+ const [x2,yy2]=borderPoint(B,A.x+A.w/2,A.y+A.h/2);
1851
+ mids.push([(x1+x2)/2,(yy1+yy2)/2]);
1852
+ }
1853
+ if(!mids.length) continue;
1854
+ const cx=mids.reduce((s,m)=>s+m[0],0)/mids.length;
1855
+ const cy=mids.reduce((s,m)=>s+m[1],0)/mids.length;
1856
+ const rx=Math.max(46, Math.max(...mids.map(m=>Math.abs(m[0]-cx)))+38);
1857
+ const ry=Math.max(26, Math.max(...mids.map(m=>Math.abs(m[1]-cy)))+22);
1858
+ const col=t.color||'#64748b';
1859
+ tsvg.push('<ellipse cx="'+cx+'" cy="'+cy+'" rx="'+rx+'" ry="'+ry+'" fill="transparent" stroke="'+col+'" stroke-dasharray="6 4" stroke-width="1.6"/>');
1860
+ tsvg.push(textEl(cx, cy+4, 11.5, 'middle', col, t.label,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
1861
+ W=Math.max(W,cx+rx); Hh=Math.max(Hh,cy+ry-y0-20);
1862
+ }
1863
+ // guide lines + labels (top layer)
1864
+ for(const gl of doc.glines){
1865
+ const B=gBox[gl.group]; if(!B) continue;
1866
+ const ly=B.yB-(B.yB-B.yA)*gl.pct/100;
1867
+ const col=gl.color||'#ef4444';
1868
+ tsvg.push('<g data-gline="'+gl.line+'" data-gtop="'+B.yA+'" data-gbot="'+B.yB+'" style="cursor:ns-resize">'
1869
+ +'<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="'+col+'" stroke-width="'+(gl.pct>=100?4:2)+'" stroke-dasharray="7 4"/>'
1870
+ +'<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="transparent" stroke-width="12"/>'
1871
+ +textEl(B.x1+8, ly+4, 11, 'start', col, gl.label,' paint-order="stroke" stroke="#fff" stroke-width="3"')+'</g>');
1872
+ W=Math.max(W, B.x1+8+tw(gl.label)); Hh=Math.max(Hh, B.yB-y0-20);
1873
+ }
1874
+ const yEnd=y0+20+Hh+10;
1875
+ return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
1876
+ meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}};
1877
+ }
1878
+ // borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
1879
+ // It must leave the DRAWN outline: a rectangle clip on a diamond or an ellipse
1880
+ // stops on the bounding box, which for a rhombus can be a corner where the
1881
+ // shape is not — the endpoint then floats in empty space (or, on a cloud whose
1882
+ // ellipse is drawn outside the box, hides under the fill).
1883
+ function borderPoint(n,tx,ty){
1884
+ const cx=n.x+n.w/2, cy=n.y+n.h/2, dx=tx-cx, dy=ty-cy;
1885
+ if(dx===0&&dy===0) return [cx,cy];
1886
+ const g=shapeAxes(n);
1887
+ // rectangles keep the original min-of-ratios expression (bit-for-bit, so
1888
+ // no rectangle figure moves); curved shapes divide the direction by the
1889
+ // homogeneous outline norm, which lands exactly on the outline.
1890
+ const s=g.p===Infinity
1891
+ ? Math.min(g.a/Math.abs(dx||1e-9), g.b/Math.abs(dy||1e-9))
1892
+ : 1/outlineNorm(g,dx,dy);
1893
+ return [cx+dx*s, cy+dy*s];
1894
+ }
1895
+ // straight-edge obstacle routing: group boxes and non-incident nodes are
1896
+ // obstacles a straight edge must not cut through.
1897
+ // clipSegRect (Liang-Barsky): the [t0,t1] parameter window where segment
1898
+ // p->q lies inside the box, or null when it misses entirely.
1899
+ function clipSegRect(p,q,x0,y0,x1,y1){
1900
+ let t0=0,t1=1; const dx=q[0]-p[0], dy=q[1]-p[1];
1901
+ for(const [den,num] of [[-dx,p[0]-x0],[dx,x1-p[0]],[-dy,p[1]-y0],[dy,y1-p[1]]]){
1902
+ if(den===0){ if(num<0) return null; continue; }
1903
+ const t=num/den;
1904
+ if(den<0){ if(t>t1) return null; if(t>t0) t0=t; }
1905
+ else { if(t<t0) return null; if(t<t1) t1=t; }
1906
+ }
1907
+ return t0<t1?[t0,t1]:null;
1908
+ }
1909
+ // routeAround: detour segment p->q around obstacle rects via the shortest
1910
+ // clear polyline. Candidate bend points are the corners of each obstacle
1911
+ // expanded by a 10px clearance margin; a sight-line is clear when it cuts
1912
+ // no obstacle interior. Dijkstra over that visibility graph keeps routes
1913
+ // short and calm, and is deterministic (fixed vertex order — endpoints,
1914
+ // then obstacles in caller order with corners clockwise from top-left —
1915
+ // breaks ties). Obstacles already containing an endpoint cannot be
1916
+ // avoided and are ignored. Returns the detour polyline, or null when the
1917
+ // straight segment is clear — callers keep their original rendering then.
1918
+ function segHitsObs(p,q,obs){
1919
+ for(const r of obs)
1920
+ if(clipSegRect(p,q,r.x+2,r.y+2,r.x+r.w-2,r.y+r.h-2)) return true;
1921
+ return false;
1922
+ }
1923
+ function routeAround(p,q,obs){
1924
+ const M=10;
1925
+ 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;
1926
+ obs=obs.filter(r=>!inside(p,r)&&!inside(q,r));
1927
+ const blocked=(a,b)=>segHitsObs(a,b,obs);
1928
+ if(!blocked(p,q)) return null;
1929
+ const V=[p.slice(),q.slice()];
1930
+ for(const r of obs){
1931
+ const L=r.x-M, T=r.y-M, R=r.x+r.w+M, B=r.y+r.h+M;
1932
+ for(const c of [[L,T],[R,T],[R,B],[L,B]])
1933
+ if(!obs.some(o=>inside(c,o))) V.push(c);
1934
+ }
1935
+ const n=V.length, dist=Array(n).fill(Infinity), from=Array(n).fill(-1), done=Array(n).fill(false);
1936
+ dist[0]=0;
1937
+ for(;;){
1938
+ let u=-1;
1939
+ for(let i=0;i<n;i++) if(!done[i]&&(u<0||dist[i]<dist[u])) u=i;
1940
+ if(u<0||u===1||dist[u]===Infinity) break;
1941
+ done[u]=true;
1942
+ for(let v=0;v<n;v++){
1943
+ if(done[v]||blocked(V[u],V[v])) continue;
1944
+ const d=dist[u]+Math.hypot(V[v][0]-V[u][0],V[v][1]-V[u][1]);
1945
+ if(d<dist[v]-1e-9){ dist[v]=d; from[v]=u; }
1946
+ }
1947
+ }
1948
+ if(dist[1]===Infinity) return null; // boxed in: keep the straight line
1949
+ const pts=[]; for(let v=1;v!==-1;v=from[v]) pts.push(V[v]); pts.reverse();
1950
+ for(let i=1;i+1<pts.length;i++){ // drop duplicate / collinear midpoints
1951
+ const a=pts[i-1], b=pts[i], c=pts[i+1];
1952
+ const cr=(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]);
1953
+ if(Math.hypot(b[0]-a[0],b[1]-a[1])<0.5||Math.abs(cr)<1e-6){ pts.splice(i,1); i--; }
1954
+ }
1955
+ return pts.length>2?pts:null;
1956
+ }
1957
+
1958
+ // ---- bitfield ----
1959
+ function renderBitfield(b,y0){
1960
+ const cell=Math.max(18,Math.min(28,Math.floor(760/b.unit))), rh=30, ruler=16;
1961
+ const svg=[]; let y=y0+18;
1962
+ svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(b.label)+'</text>');
1963
+ // ruler: lsb0 (register style, default) = N-1..0; msb0 (RFC style) = 0..N-1
1964
+ for(let i=0;i<b.unit;i++){
1965
+ const bit=(b.numbering==='msb0') ? i : b.unit-1-i;
1966
+ svg.push('<text x="'+(i*cell+cell/2)+'" y="'+(y+11)+'" font-size="8.5" text-anchor="middle" fill="#6f6e69">'+bit+'</text>');
1967
+ }
1968
+ y+=ruler;
1969
+ let pos=0; // bit cursor
1970
+ for(const f of b.fields){
1971
+ if(f.wrap){ pos=Math.ceil((pos||1)/b.unit)*b.unit; continue; }
1972
+ // '*' = variable-length: fill the remainder of the current row
1973
+ const w0 = f.w==='*' ? (b.unit - (pos % b.unit)) : f.w;
1974
+ let rem = w0;
1975
+ while(rem>0){
1976
+ const row=Math.floor(pos/b.unit), col=pos%b.unit;
1977
+ const span=Math.min(rem, b.unit-col);
1978
+ const x=col*cell, yy=y+row*rh;
1979
+ const dash=f.optional?' stroke-dasharray="5 3"':'';
1980
+ svg.push('<rect x="'+x+'" y="'+yy+'" width="'+(span*cell)+'" height="'+rh+'" fill="'+(f.color||'#fff')+'" stroke="#555"'+dash+'/>');
1981
+ if(span*cell>String(f.name).length*6 || rem===w0){
1982
+ let fs=11; const need=String(f.name).length*6.2;
1983
+ if(need>span*cell-6) fs=Math.max(7,11*(span*cell-6)/need);
1984
+ 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>');
1985
+ }
1986
+ if(f.note && rem===w0)
1987
+ svg.push('<title>'+esc(f.note)+'</title>');
1988
+ pos+=span; rem-=span;
1989
+ }
1990
+ }
1991
+ const rows=Math.max(1,Math.ceil(pos/b.unit));
1992
+ return {svg:svg.join(''), y:y+rows*rh+6, w:b.unit*cell+2};
1993
+ }
1994
+
1995
+ // ---- table (with ^ rowspan / < colspan merging and per-cell marks) ----
1996
+ function renderTable(t,y0){
1997
+ const rh=26; const svg=[]; let y=y0+18;
1998
+ svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(t.label)+'</text>');
1999
+ // grid: header tiers (from `head`/`cols` lines) then data rows
2000
+ const H=t.heads.length;
2001
+ const grid=t.heads.map(hr=>hr.map(c=>({v:c.v,m:c.m,hdr:true})))
2002
+ .concat(t.rows.map(r=>r.cells.map(c=>({v:c.v,m:c.m}))));
2003
+ const hlRow=r=>r>=H&&(t.rowmarks||[]).some(mk=>mk.r===r-H+1);
2004
+ const alignOf=c=>(t.aligns&&t.aligns[c])||null;
2005
+ const widths=t.cols.map((c,i)=>{
2006
+ let w=30;
2007
+ for(const hr of t.heads) if(!hr[i].m) w=Math.max(w,tw(hr[i].v));
2008
+ for(const r of t.rows) if(!r.cells[i].m) w=Math.max(w,tw(r.cells[i].v));
2009
+ return w;
2010
+ });
2011
+ if(t.colw){ // colw: auto = natural, px fixed, % of natural total
2012
+ const base=widths.reduce((a,b2)=>a+b2,0);
2013
+ t.colw.vals.forEach((v,i)=>{
2014
+ if(v.t==='px') widths[i]=v.v;
2015
+ else if(v.t==='pct') widths[i]=v.v/100*base;
2016
+ });
2017
+ }
2018
+ const totalW=widths.reduce((a,b)=>a+b,0);
2019
+ // cell marks: h1..hN address header tiers top-down, r>=1 the data rows
2020
+ const markOf=(r,c)=>(t.marks||[]).find(mk=>(mk.hdr?mk.r-1:H+mk.r-1)===r&&mk.c===c+1);
2021
+ const yTop=y+4;
2022
+ for(let r=0;r<grid.length;r++){
2023
+ for(let c=0;c<grid[r].length;c++){
2024
+ const cell=grid[r][c];
2025
+ if(cell.m) continue; // merged into an anchor cell
2026
+ let cs=1; while(c+cs<grid[r].length && grid[r][c+cs].m==='left') cs++;
2027
+ let rs=1; while(r+rs<grid.length && grid[r+rs][c].m==='up') rs++;
2028
+ const x=widths.slice(0,c).reduce((a,b)=>a+b,0);
2029
+ const wsum=widths.slice(c,c+cs).reduce((a,b)=>a+b,0);
2030
+ const yy=yTop+r*rh, h=rs*rh;
2031
+ const mk=markOf(r,c);
2032
+ const fill=mk?mk.color:(cell.hdr?'#eeede6':(hlRow(r)?'#fef3c7':'#fff'));
2033
+ // addressable cells carry table-id:row:col (row 0 = bottom header tier)
2034
+ const addrR = r>=H ? (r-H+1) : (r===H-1 ? 0 : null);
2035
+ const addr = addrR===null ? '' : ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
2036
+ svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="#c9c7bf"'+addr+'/>');
2037
+ // alignment: headers centered; data follows GFM colon alignment (default left)
2038
+ const al=cell.hdr?'center':(alignOf(c)||'left');
2039
+ const tx=al==='center'?x+wsum/2:(al==='right'?x+wsum-7:x+7);
2040
+ const anchor=al==='center'?'middle':(al==='right'?'end':'start');
2041
+ 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>');
2042
+ }
2043
+ }
2044
+ const yEnd=yTop+grid.length*rh;
2045
+ return {svg:svg.join(''), y:yEnd+6, w:totalW+2};
2046
+ }
2047
+
2048
+ // ---- plot bars3d: deterministic isometric projection of a table ----
2049
+ function shade(hex,f){
2050
+ const v=parseInt(hex.slice(1),16);
2051
+ const c=x=>Math.round(Math.max(0,Math.min(255,x))).toString(16).padStart(2,'0');
2052
+ return '#'+c(((v>>16)&255)*f)+c(((v>>8)&255)*f)+c((v&255)*f);
2053
+ }
2054
+ const PLOT_PALETTE=['#3b82f6','#22c55e','#f59e0b','#ef4444','#a855f7','#14b8a6','#eab308','#64748b'];
2055
+ function renderPlot(b,y0,doc){
2056
+ const t=doc.blocks.find(x=>x.type==='table'&&x.id===b.tid);
2057
+ const rows=t.rows.map(r=>r.cells.slice(1).map(c=>parseFloat(c.v)||0));
2058
+ const rLab=t.rows.map(r=>r.cells[0].v), cLab=t.cols.slice(1);
2059
+ const R=rows.length, C=cLab.length;
2060
+ const zmax=Math.max(b.level||0, ...rows.flat(), 1);
2061
+ const W2=20,H2=10,ZS=130/zmax,BAR=0.72;
2062
+ const ox=R*W2+8, oy=y0+18+ZS*zmax+6;
2063
+ const P=(r,c,z)=>[ox+(c-r)*W2, oy+(c+r)*H2-z*ZS];
2064
+ const svg=[];
2065
+ svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' — bars3d</text>');
2066
+ // floor grid edges
2067
+ const F=[P(0,0,0),P(R,0,0),P(R,C,0),P(0,C,0)];
2068
+ svg.push('<polygon points="'+F.map(p=>p.join(',')).join(' ')+'" fill="#f6f5ef" stroke="#d6d4cc"/>');
2069
+ // bars, far to near
2070
+ const order=[];
2071
+ for(let r=0;r<R;r++)for(let c=0;c<C;c++)order.push([r,c]);
2072
+ order.sort((a,b2)=>(a[0]+a[1])-(b2[0]+b2[1]));
2073
+ for(const [r,c] of order){
2074
+ const h=rows[r][c]; if(h<=0) continue;
2075
+ const col=PLOT_PALETTE[c%PLOT_PALETTE.length];
2076
+ const i0=r+(1-BAR)/2, i1=r+(1+BAR)/2, j0=c+(1-BAR)/2, j1=c+(1+BAR)/2;
2077
+ const A=P(i0,j0,h),Bp=P(i1,j0,h),Cp=P(i1,j1,h),D=P(i0,j1,h);
2078
+ const B0=P(i1,j0,0),C0=P(i1,j1,0),D0=P(i0,j1,0);
2079
+ svg.push('<polygon points="'+[Bp,Cp,C0,B0].map(p=>p.join(',')).join(' ')+'" fill="'+shade(col,0.72)+'"/>');
2080
+ svg.push('<polygon points="'+[Cp,D,D0,C0].map(p=>p.join(',')).join(' ')+'" fill="'+shade(col,0.55)+'"/>');
2081
+ svg.push('<polygon points="'+[A,Bp,Cp,D].map(p=>p.join(',')).join(' ')+'" fill="'+col+'"/>');
2082
+ }
2083
+ // threshold plane (translucent, drawn over bars like the convention)
2084
+ if(b.level!==null&&b.level!==undefined){
2085
+ const L=[P(0,0,b.level),P(R,0,b.level),P(R,C,b.level),P(0,C,b.level)];
2086
+ svg.push('<polygon points="'+L.map(p=>p.join(',')).join(' ')+'" fill="#ef4444" opacity="0.18" stroke="#ef4444" stroke-dasharray="6 4"/>');
2087
+ 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"'));
2088
+ }
2089
+ // axis labels
2090
+ 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)); });
2091
+ 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)); });
2092
+ // z ruler at the right-back corner
2093
+ const zr=P(0,C,0);
2094
+ svg.push('<line x1="'+(zr[0]+14)+'" y1="'+zr[1]+'" x2="'+(zr[0]+14)+'" y2="'+(zr[1]-zmax*ZS)+'" stroke="#8a8880"/>');
2095
+ for(const z of [0, Math.round(zmax/2), Math.round(zmax)]){
2096
+ svg.push('<line x1="'+(zr[0]+11)+'" y1="'+(zr[1]-z*ZS)+'" x2="'+(zr[0]+17)+'" y2="'+(zr[1]-z*ZS)+'" stroke="#8a8880"/>');
2097
+ svg.push(textEl(zr[0]+21, zr[1]-z*ZS+3.5, 9.5, 'start', '#6f6e69', String(z)));
2098
+ }
2099
+ const w=P(R,C,0)[0]+70, hgt=P(R,C,0)[1]+24-y0;
2100
+ return {svg:svg.join(''), y:y0+hgt, w:w};
2101
+ }
2102
+
2103
+ // ---- wave ----
2104
+ function renderWave(w,y0){
2105
+ const tickW=26, laneH=30, laneGap=12, nameW=Math.max(...w.signals.map(s=>tw(s.name)),60);
2106
+ const svg=[]; let y=y0+18;
2107
+ svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(w.label)+'</text>');
2108
+ const ticks=Math.max(...w.signals.map(s=>s.lane.length));
2109
+ w.signals.forEach((s,si)=>{
2110
+ const top=y+8+si*(laneH+laneGap), bot=top+laneH-8;
2111
+ 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>');
2112
+ let d='', prev=null, li=0, dataIdx=0;
2113
+ for(let i=0;i<s.lane.length;i++){
2114
+ let ch=s.lane[i];
2115
+ if(ch==='.') ch=prev||'0';
2116
+ const x=nameW+i*tickW;
2117
+ if(ch==='p'||ch==='n'){
2118
+ const hiFirst=(ch==='p');
2119
+ const a=hiFirst?top:bot, b=hiFirst?bot:top;
2120
+ d+='M'+x+','+a+' L'+(x+tickW/2)+','+a+' L'+(x+tickW/2)+','+b+' L'+(x+tickW)+','+b+' ';
2121
+ // draw transition edge at tick start
2122
+ if(prev) d+='M'+x+','+top+' L'+x+','+bot+' ';
2123
+ } else if(ch==='0'||ch==='1'){
2124
+ const yy=(ch==='1')?top:bot;
2125
+ const pv=(prev==='1')?top:(prev==='0'?bot:null);
2126
+ if(pv!==null&&pv!==yy) d+='M'+x+','+pv+' L'+x+','+yy+' ';
2127
+ d+='M'+x+','+yy+' L'+(x+tickW)+','+yy+' ';
2128
+ } else if(ch==='x'){
2129
+ svg.push('<rect x="'+x+'" y="'+top+'" width="'+tickW+'" height="'+(bot-top)+'" fill="url(#hatch)" stroke="#999"/>');
2130
+ } else if(/[=\d]/.test(ch)){
2131
+ // merge consecutive identical data chars
2132
+ let j=i; while(j+1<s.lane.length && s.lane[j+1]==='.') j++;
2133
+ const span=j-i+1;
2134
+ svg.push('<rect x="'+x+'" y="'+top+'" width="'+(tickW*span)+'" height="'+(bot-top)+'" fill="#eef6ff" stroke="#7aa7d9"/>');
2135
+ const lbl = ch==='='? (s.labels[dataIdx++]||'') : ch;
2136
+ 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>');
2137
+ i=j;
2138
+ }
2139
+ prev=ch;
2140
+ }
2141
+ if(d) svg.push('<path d="'+d+'" fill="none" stroke="#1d4ed8" stroke-width="1.6"/>');
2142
+ });
2143
+ for(const g of w.gaps){
2144
+ const x=nameW+g*tickW;
2145
+ const hTotal=w.signals.length*(laneH+laneGap);
2146
+ 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"/>');
2147
+ }
2148
+ const H=y+8+w.signals.length*(laneH+laneGap);
2149
+ return {svg:svg.join(''), y:H, w:nameW+ticks*tickW+2};
2150
+ }
2151
+
2152
+ // ============================================================
2153
+ return { parse: parse, render: render };
2154
+ })();
2155
+
2156
+ // ---- minimal synchronous SHA-256 (FIPS 180-4), hex output ----
2157
+ // Dependency-free so artifact() works in browsers and Node alike.
2158
+ var __SHA_K = [
2159
+ 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
2160
+ 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
2161
+ 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
2162
+ 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
2163
+ 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
2164
+ 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
2165
+ 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
2166
+ 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
2167
+ function __sha256hex(text) {
2168
+ var b = [], i, c;
2169
+ for (i = 0; i < text.length; i++) { // UTF-8 encode
2170
+ c = text.codePointAt(i); if (c > 0xffff) i++;
2171
+ if (c < 0x80) b.push(c);
2172
+ else if (c < 0x800) b.push(0xc0 | (c >> 6), 0x80 | (c & 63));
2173
+ else if (c < 0x10000) b.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
2174
+ else b.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
2175
+ }
2176
+ var len = b.length, hi = Math.floor(len / 0x20000000), lo = (len << 3) >>> 0;
2177
+ b.push(0x80);
2178
+ while (b.length % 64 !== 56) b.push(0);
2179
+ b.push(hi >>> 24 & 255, hi >>> 16 & 255, hi >>> 8 & 255, hi & 255,
2180
+ lo >>> 24 & 255, lo >>> 16 & 255, lo >>> 8 & 255, lo & 255);
2181
+ var H = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
2182
+ var w = new Array(64), r = function (x, n) { return (x >>> n) | (x << (32 - n)); };
2183
+ for (var off = 0; off < b.length; off += 64) {
2184
+ for (i = 0; i < 16; i++)
2185
+ w[i] = (b[off+4*i] << 24) | (b[off+4*i+1] << 16) | (b[off+4*i+2] << 8) | b[off+4*i+3];
2186
+ for (i = 16; i < 64; i++)
2187
+ w[i] = (w[i-16] + (r(w[i-15],7) ^ r(w[i-15],18) ^ (w[i-15] >>> 3))
2188
+ + w[i-7] + (r(w[i-2],17) ^ r(w[i-2],19) ^ (w[i-2] >>> 10))) | 0;
2189
+ var a=H[0],bb=H[1],cc=H[2],d=H[3],e=H[4],f=H[5],g=H[6],hh=H[7];
2190
+ for (i = 0; i < 64; i++) {
2191
+ var t1 = (hh + (r(e,6)^r(e,11)^r(e,25)) + ((e & f) ^ (~e & g)) + __SHA_K[i] + w[i]) | 0;
2192
+ var t2 = ((r(a,2)^r(a,13)^r(a,22)) + ((a & bb) ^ (a & cc) ^ (bb & cc))) | 0;
2193
+ hh=g; g=f; f=e; e=(d+t1)|0; d=cc; cc=bb; bb=a; a=(t1+t2)|0;
2194
+ }
2195
+ H[0]=(H[0]+a)|0; H[1]=(H[1]+bb)|0; H[2]=(H[2]+cc)|0; H[3]=(H[3]+d)|0;
2196
+ H[4]=(H[4]+e)|0; H[5]=(H[5]+f)|0; H[6]=(H[6]+g)|0; H[7]=(H[7]+hh)|0;
2197
+ }
2198
+ var out = '';
2199
+ for (i = 0; i < 8; i++) out += ('00000000' + (H[i] >>> 0).toString(16)).slice(-8);
2200
+ return out;
2201
+ }
2202
+
2203
+ // ---- public API ----
2204
+ // parse(text) -> { doc, errors } errors: array of "Line N: message"
2205
+ function parse(text) {
2206
+ var p = __engine.parse(String(text));
2207
+ return { doc: p.doc, errors: p.errs };
2208
+ }
2209
+ // render(text, opts) -> { svg, errors } svg is null when there are errors
2210
+ // (determinism over convenience: no partial renders of invalid input).
2211
+ // opts (presentation, renderer tier): { title: true } draws the title;
2212
+ // the default does NOT (embedded figures almost always sit under the
2213
+ // host document's caption — the majority case).
2214
+ function render(text, opts) {
2215
+ var p = parse(text);
2216
+ if (p.errors.length) return { svg: null, errors: p.errors };
2217
+ return { svg: __engine.render(p.doc, opts).svg, errors: [] };
2218
+ }
2219
+ // renderDoc(doc, opts) -> svg string, for an already-validated doc from parse().
2220
+ function renderDoc(doc, opts) {
2221
+ return __engine.render(doc, opts).svg;
2222
+ }
2223
+ // artifact(text) -> { svg, errors } svg is the full self-carrying SVG:
2224
+ // the render plus a <metadata id="figdown-source"> block embedding the
2225
+ // source text and its SHA-256 (same convention as tools/build-svg.js).
2226
+ // svg is null when there are errors.
2227
+ function artifact(text, opts) {
2228
+ var src = String(text);
2229
+ var p = render(src, opts);
2230
+ if (p.errors.length) return { svg: null, errors: p.errors };
2231
+ // recorded render options keep third-party rebuilds bit-identical
2232
+ var optAttr = (opts && opts.title === true) ? ' data-render-options="with-title"' : '';
2233
+ var meta = '<metadata id="figdown-source" data-sha256="' + __sha256hex(src) + '"' + optAttr + '><![CDATA[\n'
2234
+ + src.replace(/]]>/g, ']]]]><![CDATA[>') + '\n]]></metadata>';
2235
+ return { svg: p.svg.replace(/<\/svg>$/, meta + '</svg>'), errors: [] };
2236
+ }
2237
+
2238
+ return { parse: parse, render: render, renderDoc: renderDoc, artifact: artifact, version: VERSION };
2239
+ }));