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