figdown 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/figdown.js CHANGED
@@ -1,4 +1,4 @@
1
- // figdown.js — FigDown embeddable library (0.3.0)
1
+ // figdown.js — FigDown embeddable library (0.3.2)
2
2
  // GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
3
3
  // Regenerate with: node tools/make-lib.js
4
4
  (function (root, factory) {
@@ -10,7 +10,7 @@
10
10
  }
11
11
  }(typeof globalThis !== 'undefined' ? globalThis : this, function () {
12
12
  'use strict';
13
- var VERSION = "0.3.0";
13
+ var VERSION = "0.3.2";
14
14
 
15
15
  // ---- engine (extracted verbatim from editor/figdown.html) ----
16
16
  var __engine = (function () {
@@ -24,7 +24,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
24
24
  // input to that promise, and under core §13 a 0.x renderer may differ from
25
25
  // the next — which makes the recorded version the only thing that can
26
26
  // explain a diff between two renderings of one source.
27
- const FIGDOWN_VERSION = '0.3.0';
27
+ const FIGDOWN_VERSION = '0.3.2';
28
28
  // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
29
29
  // counter does NOT reset (core §13.0.4 — `N` counts source states of the
30
30
  // engine and only ever increases), so 0.1 is followed by
@@ -3438,6 +3438,44 @@ function renderScene(doc,y0){
3438
3438
  return rk[u]=r;
3439
3439
  };
3440
3440
  nodes.forEach(n=>n.rank=rankOf(find(n.id)));
3441
+ // SPINE CHAIN (item 27, ordering phase). A figure whose reading order is a
3442
+ // chain must draw that chain in ONE lane; the incumbent barycenter sweep
3443
+ // cannot, because a chain node and its branch sibling share one desired
3444
+ // position and the sibling — declared first — takes the slot, so the chain
3445
+ // steps aside once per rank and a logical column renders as a staircase
3446
+ // (spine.fd: +165 px per rank, 2208 px wide for a 200 px column).
3447
+ //
3448
+ // The chain is found HERE, before any coordinate exists, and it is the
3449
+ // longest rank-consecutive path of real nodes. Deterministic throughout:
3450
+ // longest-path DP taken in decreasing rank, every tie broken by document
3451
+ // order, so one document has exactly one chain.
3452
+ const chainNext=new Map(), chainPrev=new Map();
3453
+ const CHAIN_MIN=4; // nodes; below this a figure has no spine to
3454
+ { // hold and the incumbent sweep is left alone
3455
+ const sc=new Map();
3456
+ for(const e of doc.edges){
3457
+ const A=byId[e.a],B=byId[e.b];
3458
+ if(!A||!B||isBack.has(e)||e.a===e.b) continue;
3459
+ if(e.op!=='->') continue; // a chain is a READING order, and
3460
+ // only a directed edge states one
3461
+ if(B.rank!==A.rank+1) continue; // a chain is rank-consecutive
3462
+ if(pinned(e.a)||pinned(e.b)) continue; // a pin is the author's word
3463
+ if(!sc.has(A)) sc.set(A,[]); sc.get(A).push(B);
3464
+ }
3465
+ const len=new Map(), nxt=new Map();
3466
+ for(const n of [...nodes].sort((p,q)=>q.rank-p.rank||p.di-q.di)){
3467
+ let best=null,bl=0;
3468
+ for(const s of sc.get(n)||[]){
3469
+ const l=len.get(s)||1;
3470
+ if(l>bl||(l===bl&&best&&s.di<best.di)){ bl=l; best=s; }
3471
+ }
3472
+ len.set(n,bl+1); if(best) nxt.set(n,best);
3473
+ }
3474
+ let head=null;
3475
+ for(const n of nodes) if(!head||len.get(n)>len.get(head)) head=n; // ties: doc order
3476
+ if(head&&len.get(head)>=CHAIN_MIN)
3477
+ for(let n=head,m=nxt.get(n);m;n=m,m=nxt.get(n)){ chainNext.set(n,m); chainPrev.set(m,n); }
3478
+ }
3441
3479
  // positions: children spread around their parents' lane (barycenter
3442
3480
  // sweeps down/up/down). Edges that span multiple layers get invisible
3443
3481
  // waypoint slots so they no longer cut through intermediate nodes.
@@ -3447,6 +3485,33 @@ function renderScene(doc,y0){
3447
3485
  const lblPx=s=>cwMax(s)*6.5;
3448
3486
  const lay=[...nodes]; // layout participants
3449
3487
  const chains=new Map(); // edge -> [A, ...waypoints, B]
3488
+ // Bus eligibility, TOPOLOGICAL half (item 26 stage 1). Three or more forward
3489
+ // `->` edges arriving at one target with one label, one stroke and one dash,
3490
+ // no endpoint labels and no pinned endpoint. It is computed here, before the
3491
+ // geometry, because the render pass needs the group SET whole: the figure
3492
+ // decides all-or-none, so it has to know how many groups it is deciding for.
3493
+ //
3494
+ // Placement is deliberately NOT touched. A member still reserves its
3495
+ // waypoint column, which is the cost item 27 records; suppressing those
3496
+ // columns was implemented and measured (spine.fd 2208 -> 1318 px and a
3497
+ // visibly better drawing) and is NOT landed here, because the suppression
3498
+ // has to be decided before coordinates exist while adoption can only be
3499
+ // decided after, and a figure that suppresses and then declines draws
3500
+ // straight through its own boxes (bfd-session: score 30 -> 35 with a new
3501
+ // `thru`). That belongs with item 27's ordering change, priced.
3502
+ const busGroups=[];
3503
+ {
3504
+ const g=new Map();
3505
+ for(const e of doc.edges){
3506
+ const A=byId[e.a], B=byId[e.b];
3507
+ if(!A||!B||e.a===e.b||isBack.has(e)) continue;
3508
+ if(e.op!=='->'||e.tail||e.head) continue;
3509
+ if(pinned(e.a)||pinned(e.b)||B.rank<=A.rank) continue;
3510
+ const k=e.b+' '+(e.mid||'')+' '+(e.stroke||'')+' '+(e.style||'');
3511
+ if(!g.has(k)) g.set(k,[]); g.get(k).push(e);
3512
+ }
3513
+ for(const [,m] of g) if(m.length>=3) busGroups.push(m);
3514
+ }
3450
3515
  for(const e of doc.edges){
3451
3516
  const A=byId[e.a], B=byId[e.b];
3452
3517
  if(!A||!B||isBack.has(e)) continue;
@@ -3513,7 +3578,92 @@ function renderScene(doc,y0){
3513
3578
  const center=n=>n.cross+cs(n)/2;
3514
3579
  ranksArr.forEach(lane=>{ if(!lane) return; let c=0; // seed: doc order
3515
3580
  lane.forEach((n,k)=>{ n.cross=c; c+=cs(n)+(k<lane.length-1?gapOf(n,lane[k+1]):0); }); });
3516
- const place=(lane,des)=>{ // order by desired center, resolve overlaps,
3581
+ // WHERE THE HOLD YIELDS, WHICH IS MOST OF THE RULE. Holding a chain node on
3582
+ // its chain neighbour puts every OTHER neighbour of that node on one side of
3583
+ // it, and where the figure diverges or converges that is the wrong drawing:
3584
+ // item 27's Brandes-Köpf rejection measured this exact mechanism from the
3585
+ // other end — aligning on ONE neighbour where the barycentre uses the AVERAGE
3586
+ // made 11 of 19 figures worse, and "the average is what a human draws". So
3587
+ // the hold is dropped wherever it would displace a spread the reader reads.
3588
+ //
3589
+ // `realDeg` is degree as the READER sees it at one rank boundary: real
3590
+ // neighbours, plus the waypoints of long edges whose far end is OFF the
3591
+ // chain. A long edge that leaves the chain and rejoins it later is not a
3592
+ // spread — counting it would drop the hold on exactly the columns this pass
3593
+ // exists to create (bfd-session's ADMINDOWN is entered by UP and by two
3594
+ // waypoints of edges that left DOWN and INIT) — while a long edge arriving
3595
+ // from elsewhere is one, and its target belongs at the average (that is
3596
+ // packet-ingress's `Forward`, entered by `IPv4 checksum OK?` beside it and by
3597
+ // two waypoints from the IPv6 and ARP branches).
3598
+ const onChain=n=>chainNext.has(n)||chainPrev.has(n);
3599
+ const realDeg=(m,side)=>{
3600
+ let k=0;
3601
+ for(const s of (side===1?succs:preds).get(m)||[]){
3602
+ if(!s.virtual){ k++; continue; }
3603
+ const o=side===1?s.homeB:s.homeA;
3604
+ if(o&&!onChain(o)) k++;
3605
+ }
3606
+ return k;
3607
+ };
3608
+ const realFan=(n,dir)=>{
3609
+ const m=(dir===1?chainPrev:chainNext).get(n); return m?realDeg(m,dir):0;
3610
+ };
3611
+ // PROSPECTIVE BUSES, AND WHY THE HOLD YIELDS TO THEM RATHER THAN SERVING
3612
+ // THEM. Item 26 records the trap this pass had to answer: a bus member's
3613
+ // waypoint column can be suppressed only BEFORE coordinates exist, while the
3614
+ // bus is adopted only AFTER, so a figure that suppresses and then declines
3615
+ // routes through its own boxes (bfd-session 30 -> 35 with a new `thru`).
3616
+ // Nothing here suppresses anything. It takes the one direction of that
3617
+ // decision which is safe under a decline: it WITHHOLDS the hold from the
3618
+ // source of a bus group that is topologically eligible, and withholding is
3619
+ // the incumbent behaviour — a figure that declines is drawn exactly as it is
3620
+ // drawn today, with nothing to undo. Holding them is the unsafe direction:
3621
+ // it stacks the sources of one convergence into a single column, and a bus
3622
+ // leg dropping from the earliest then pierces the latest — patterns/
3623
+ // flowchart-a loses the trunk it gained that way, measured.
3624
+ //
3625
+ // ...and only for a group that could ever BE a rail. A bus drops every source
3626
+ // onto one cross-axis rail, so a group whose sources sit on top of each other
3627
+ // along the FLOW axis — one source an ancestor of another — is unadoptable
3628
+ // whatever ordering does, and withholding there would cost the column and buy
3629
+ // nothing (bfd-session's three `admin disable` edges leave DOWN, INIT and UP,
3630
+ // and DOWN reaches both of the others).
3631
+ const busSrc=new Set();
3632
+ {
3633
+ const fwd=new Map();
3634
+ for(const e of doc.edges){
3635
+ if(!byId[e.a]||!byId[e.b]||isBack.has(e)||e.a===e.b) continue;
3636
+ if(!fwd.has(e.a)) fwd.set(e.a,[]); fwd.get(e.a).push(e.b);
3637
+ }
3638
+ const reaches=(u,v)=>{ // forward-DAG reachability
3639
+ const seen=new Set([u]), st=[u];
3640
+ while(st.length){ const x=st.pop();
3641
+ for(const y of fwd.get(x)||[]){ if(y===v) return true;
3642
+ if(!seen.has(y)){ seen.add(y); st.push(y); } } }
3643
+ return false;
3644
+ };
3645
+ for(const m of busGroups){
3646
+ const s=m.map(e=>e.a);
3647
+ let stacked=false;
3648
+ for(const a of s) for(const b of s) if(a!==b&&reaches(a,b)) stacked=true;
3649
+ if(!stacked) for(const a of s) busSrc.add(a);
3650
+ }
3651
+ }
3652
+ // A chain node is HELD — it follows its chain neighbour rather than the
3653
+ // average of all of them — unless it is a bus source (above), unless the
3654
+ // neighbour it would follow spreads three or more ways into this rank, or
3655
+ // unless the node itself is where three or more come together (block-a's
3656
+ // Collector, lifted off the middle lane by BK, is the recorded instance of
3657
+ // the latter).
3658
+ const held=(n,dir)=>onChain(n)
3659
+ &&!(n.id&&busSrc.has(n.id))
3660
+ &&realFan(n,dir)<3&&realDeg(n,-dir)<3;
3661
+ // A whole LANE keeps its barycentre recentring if anything in it converges,
3662
+ // even where the chain node itself does not: recentring on the chain node
3663
+ // moves every other member of that lane, and a convergence is read from the
3664
+ // spread of its inputs.
3665
+ const laneConverges=(lane,dir)=>lane.some(n=>!n.virtual&&realDeg(n,-dir)>=3);
3666
+ const place=(lane,des,dir)=>{ // order by desired center, resolve overlaps,
3517
3667
  const arr=lane.map(n=>({n,d:des.get(n)})); // recenter the lane
3518
3668
  arr.sort((p,q)=>p.d-q.d||p.n.di-q.n.di);
3519
3669
  let cEnd=-Infinity;
@@ -3521,7 +3671,25 @@ function renderScene(doc,y0){
3521
3671
  x.n.cross=Math.max(x.d-cs(x.n)/2, cEnd);
3522
3672
  cEnd=x.n.cross+cs(x.n)+(i<arr.length-1?gapOf(x.n,arr[i+1].n):0);
3523
3673
  });
3524
- const err=arr.reduce((s,x)=>s+center(x.n)-x.d,0)/arr.length;
3674
+ // Recentre. Normally on the lane's MEAN error, which shares the packing
3675
+ // displacement out over every member — and that is exactly what walks a
3676
+ // chain sideways, since the chain node is one member among many. When the
3677
+ // lane carries the chain (at most one node per rank, by construction) the
3678
+ // lane is recentred on THAT node instead: it lands on its desired position
3679
+ // exactly, its siblings keep the order and spacing the sort gave them, and
3680
+ // the chain is straight by construction rather than by iteration.
3681
+ // Two more lanes keep the mean. A lane holding a PINNED node, because the
3682
+ // pin's coordinate is the author's word and does not move with the lane, so
3683
+ // sliding the lane against it can only put free nodes on a fixed one
3684
+ // (reference/block's `Drop?` diamond landed on the pinned `Rule set` that
3685
+ // way, `novlp 1`). And the chain's LAST lane in the sweep direction, where
3686
+ // there is no next step to keep aligned, so the hold buys no straightness
3687
+ // and only redistributes that lane's other members (annotated-datapath
3688
+ // redrew for no gain until this clause was added).
3689
+ const anc=(lane.some(n=>!n.virtual&&pinned(n.id))||laneConverges(lane,dir))
3690
+ ?null:arr.find(x=>held(x.n,dir)&&(dir===1?chainNext:chainPrev).has(x.n));
3691
+ const err=anc?center(anc.n)-anc.d
3692
+ :arr.reduce((s,x)=>s+center(x.n)-x.d,0)/arr.length;
3525
3693
  arr.forEach(x=>{ x.n.cross-=err; });
3526
3694
  lane.length=0; arr.forEach(x=>lane.push(x.n));
3527
3695
  };
@@ -3536,6 +3704,13 @@ function renderScene(doc,y0){
3536
3704
  for(const n of lane){
3537
3705
  const ref=(dir===1?preds:succs).get(n);
3538
3706
  let d=ref&&ref.length ? ref.reduce((s,m)=>s+center(m),0)/ref.length : center(n);
3707
+ // A chain node follows its CHAIN neighbour alone, not the average of
3708
+ // its neighbours: a branch that leaves the chain and rejoins it later
3709
+ // otherwise drags the chain off its own lane, which is the drift this
3710
+ // pass exists to remove. Its other neighbours still order themselves
3711
+ // around it in the sweep below.
3712
+ const cn=(dir===1?chainPrev:chainNext).get(n);
3713
+ if(cn&&held(n,dir)) d=center(cn);
3539
3714
  // Waypoint excursion bound (item 17): a multi-rank forward edge's dummy
3540
3715
  // vertices may follow the barycenter freely WITHIN the cross-axis band
3541
3716
  // their own endpoints span — that is where the ordering that separates
@@ -3556,7 +3731,7 @@ function renderScene(doc,y0){
3556
3731
  }
3557
3732
  des.set(n,d);
3558
3733
  }
3559
- place(lane,des);
3734
+ place(lane,des,dir);
3560
3735
  }
3561
3736
  };
3562
3737
  sweep(1); sweep(-1); sweep(1);
@@ -3578,24 +3753,44 @@ function renderScene(doc,y0){
3578
3753
  if(horiz) n.x=M-n.x-n.w; else n.y=y0+20+(M-(n.y-y0-20))-n.h; }
3579
3754
  }
3580
3755
  // Two-level coordinates (`PIN-COORDINATE-SCOPE`): a pinned GROUP anchors its local origin in
3581
- // canvas px; a pinned MEMBER is group-local (relative to that origin);
3756
+ // canvas px; a pinned MEMBER of it is group-local (relative to that origin);
3582
3757
  // ungrouped pins are canvas px. Moving a group = editing one pin line.
3758
+ //
3759
+ // A member of an UNPINNED group has NO anchored origin to be relative to, so
3760
+ // its pin is canvas px exactly like an ungrouped node's. This is `LAYOUT-STABILITY` rigidity:
3761
+ // the pin is the author's word and MUST land where written, whether or not
3762
+ // the node is a group member. The prior code derived an unpinned group's
3763
+ // origin from its members' AUTO-LAYOUT extent and then added the member pin
3764
+ // to it, so the pin was neither honoured (it read canvas 400 as origin+400)
3765
+ // nor stable (the origin moved whenever an unrelated edit reshaped the auto
3766
+ // layout — a pinned member drifted 160.9px under a synthetic added edge,
3767
+ // violating `RENDERING-DETERMINISM` stability; task #47). The pin now wins and the group BOX grows
3768
+ // to CONTAIN the member wherever it lands (box is measured from final member
3769
+ // positions below), rather than the member being repositioned to fit the box.
3583
3770
  const gOrigin={};
3771
+ // Pass 1: a pinned group anchors its origin in canvas px (`ELEMENT-GEOMETRY-DIRECTIVE`: only a pin
3772
+ // carrying `at=` anchors one). An unpinned group gets no origin here, so its
3773
+ // members fall to the canvas-px branch below.
3584
3774
  for(const g of doc.groups){
3585
3775
  const p=doc.pins[g.id];
3586
- // `ELEMENT-GEOMETRY-DIRECTIVE`: only a pin that carries `at=` anchors an origin.
3587
- if(p&&p.fx!==null){ gOrigin[g.id]={x:p.fx, y:y0+20+p.fy}; }
3588
- else{
3589
- const mem=nodes.filter(n=>n.group===g.id);
3590
- if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
3591
- y:Math.min(...mem.map(n=>n.y))};
3592
- }
3776
+ if(p&&p.fx!==null) gOrigin[g.id]={x:p.fx, y:y0+20+p.fy};
3593
3777
  }
3778
+ // Pass 2: place pinned nodes. A member of a PINNED group is group-local; an
3779
+ // ungrouped node OR a member of an UNPINNED group is canvas px.
3594
3780
  for(const n of nodes){ const p=doc.pins[n.id]; if(!p||p.fx===null) continue;
3595
3781
  const o=n.group?gOrigin[n.group]:null;
3596
3782
  if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
3597
3783
  else { n.x=p.fx; n.y=y0+20+p.fy; }
3598
3784
  }
3785
+ // Pass 3: an unpinned group has no anchor of its own; its display origin
3786
+ // (drag anchor / data-gx,gy) is the top-left of its members' FINAL positions,
3787
+ // so it reflects any pinned members and matches the group box drawn below.
3788
+ for(const g of doc.groups){
3789
+ if(gOrigin[g.id]) continue;
3790
+ const mem=nodes.filter(n=>n.group===g.id);
3791
+ if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
3792
+ y:Math.min(...mem.map(n=>n.y))};
3793
+ }
3599
3794
  // Boundary adjacency in pinned scenes (presentation-only): auto-layout ranks
3600
3795
  // a degree-1 boundary relative to the free lanes, so in a scene where the
3601
3796
  // real content is pinned to a compact box the boundary can drift to a far
@@ -3697,6 +3892,27 @@ function renderScene(doc,y0){
3697
3892
  const B=byId[t], m=g.length;
3698
3893
  g.forEach((e,k)=>{ chPlan.get(e).ex=B.x+B.w*(m-k)/(m+1); });
3699
3894
  }
3895
+ // RETURN LANES — the other axis. A back edge got a lane in ONE axis and
3896
+ // not the other: each route was handed its own COLUMN out in the channel
3897
+ // and then every route into one target came home along that target's
3898
+ // CENTRE line, so N returns drew as one line. bfd-session put three of
3899
+ // them (452 px, 263 px, 263 px of shared ink) on y=46, and the figure
3900
+ // showed one horizontal stroke with three arrowheads stacked on it.
3901
+ // The entry now fans across the target's border exactly as a ring hub
3902
+ // entry fans across its top, and the ORDER is what keeps the returns from
3903
+ // crossing one another: an outer return has to pass every inner column on
3904
+ // its way in, so it must arrive BEYOND where those columns stop —
3905
+ // innermost ring takes the lane furthest from the channel's turn-in side,
3906
+ // outermost the nearest. The fraction is stored, not the coordinate,
3907
+ // because the three entry forms need it on different edges of the box
3908
+ // (right border, bottom border, detour into the bottom). One back edge
3909
+ // into a target still lands on the centre line (m=1 -> 1/2), so every
3910
+ // figure without a fan-in is byte-unchanged.
3911
+ for(const t in byT){
3912
+ const g=byT[t].filter(e=>!chPlan.get(e).ringOK&&e.a!==e.b);
3913
+ const m=g.length;
3914
+ g.forEach((e,k)=>{ chPlan.get(e).ef=(m-k)/(m+1); });
3915
+ }
3700
3916
  // ring return rows run above the top rank; shift the whole scene down
3701
3917
  // when they would spill into the title band. The shift is uniform
3702
3918
  // (relative geometry, incl. pins, is preserved) and meta.top reports
@@ -3807,6 +4023,167 @@ function renderScene(doc,y0){
3807
4023
  const v=chain[1+Math.floor((chain.length-3)/2)];
3808
4024
  occR=Math.max(occR, v.x+v.w/2+9+lblPx(e.mid));
3809
4025
  });
4026
+ // ── merge bus (item 26 stage 1) ──────────────────────────────────────────
4027
+ // Three or more edges that arrive at the SAME target carrying the SAME
4028
+ // (or no) label are one statement — "all of these go there" — and a drawing
4029
+ // tool draws it once: each source drops to a shared rail, the rail runs to
4030
+ // one trunk, the trunk enters the target with ONE arrowhead and ONE label,
4031
+ // and the joins are marked with junction dots. Drawing three lines to one
4032
+ // box and repeating one label three times is what this removes.
4033
+ //
4034
+ // Each member still emits its OWN full path from its source outline to the
4035
+ // target outline — shape-check asserts exactly that, and `data-edge` carries
4036
+ // one source line — so the shared trunk is stroked once per member. That
4037
+ // coincidence is the convention and not a defect, and the members say so:
4038
+ // every bus path carries `data-bus="<target>"`, which is what lets a reader
4039
+ // (and layout-lint) tell a deliberate trunk from two edges hidden under each
4040
+ // other.
4041
+ //
4042
+ // ── THE FIGURE-LEVEL STYLE DECISION (item 26's unresolved tension) ────────
4043
+ // A bus is axis-aligned by construction, so a figure that takes one has
4044
+ // taken an orthogonal convention. Item 26 records the failure mode: keeping
4045
+ // the incumbent PER EDGE leaves a figure with diagonal and orthogonal routes
4046
+ // mixed, and the mixture itself reads unprofessional (`dhcp-client` was
4047
+ // rejected on exactly that). So the decision is taken ONCE PER FIGURE and it
4048
+ // is ALL-OR-NONE:
4049
+ //
4050
+ // 1. enumerate every eligible group (the topological test above: three or
4051
+ // more forward `->` edges, one target, one label, one stroke and dash,
4052
+ // no endpoint labels, no pinned endpoint, no source an ancestor of
4053
+ // another source);
4054
+ // 2. build and test each one — every leg must clear every node it does not
4055
+ // touch and every group box it does not belong to, the sources must all
4056
+ // lie on one side of the target along the flow axis with room for a
4057
+ // rail, and the bus must not cross more of the figure than the routes
4058
+ // it replaces (item 26's "kept unless strictly beaten", moved from the
4059
+ // edge to the group);
4060
+ // 3. IF ANY ELIGIBLE GROUP FAILS, THE FIGURE ADOPTS NO BUS AT ALL.
4061
+ //
4062
+ // Clause 3 is the whole of the style rule. A figure with one convergence
4063
+ // merged into a trunk and another left as a fan is the mixed drawing; a
4064
+ // figure where every convergence is a trunk, or none is, is one drawing
4065
+ // either way. There is deliberately no per-edge escape.
4066
+ const busRoute=new Map();
4067
+ {
4068
+ const RAIL_GAP=22, RAIL_CLEAR=12, RAIL_ROOM=30;
4069
+ const fLo=n=>horiz?n.x:n.y, fHi=n=>horiz?n.x+n.w:n.y+n.h;
4070
+ const cC =n=>horiz?n.y+n.h/2:n.x+n.w/2;
4071
+ const P=(f,c)=>horiz?[f,c]:[c,f]; // (flow,cross) -> [x,y]
4072
+ const gObs=[];
4073
+ for(const k in gBox){ const b=gBox[k]; gObs.push({x:b.x0,y:b.yA,w:b.x1-b.x0,h:b.yB-b.yA}); }
4074
+ const obsFor=(s,t)=>{
4075
+ const o=nodes.filter(n=>n!==s&&n!==t&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
4076
+ const inG=(b,q)=>q[0]>b.x&&q[0]<b.x+b.w&&q[1]>b.y&&q[1]<b.y+b.h;
4077
+ const ps=[s.x+s.w/2,s.y+s.h/2], pt=[t.x+t.w/2,t.y+t.h/2];
4078
+ for(const b of gObs) if(!inG(b,ps)&&!inG(b,pt)) o.push(b);
4079
+ return o;
4080
+ };
4081
+ // The incumbent a bus is measured against, reconstructed exactly as the
4082
+ // edge loop would draw it in THIS layout: a multi-rank edge follows its
4083
+ // waypoint chain, everything else is the straight border-to-border line.
4084
+ // Placement is untouched by the bus, so this is a like-for-like comparison
4085
+ // inside one drawing — not a comparison across two layouts, which is the
4086
+ // mistake item 27 was rejected for.
4087
+ const incumbent=e=>{
4088
+ const A=byId[e.a], B=byId[e.b], ch=chains.get(e);
4089
+ const pp=[];
4090
+ if(ch) for(const v of ch.slice(1,-1)) pp.push([v.x+v.w/2,v.y+v.h/2]);
4091
+ const first=pp.length?pp[0]:[B.x+B.w/2,B.y+B.h/2];
4092
+ const last =pp.length?pp[pp.length-1]:[A.x+A.w/2,A.y+A.h/2];
4093
+ return [borderPoint(A,first[0],first[1]),...pp,borderPoint(B,last[0],last[1])];
4094
+ };
4095
+ // crossing count of a polyline against the rest of the figure's incumbent
4096
+ // geometry — the term item 26's score weights highest, and the only one on
4097
+ // which "never worse" is worth promising for a construct whose whole point
4098
+ // is to share ink.
4099
+ const busMem=new Set(); for(const m of busGroups) for(const e of m) busMem.add(e);
4100
+ const others=[];
4101
+ for(const e of edges){
4102
+ if(!byId[e.a]||!byId[e.b]||e.a===e.b) continue;
4103
+ if(busMem.has(e)) continue;
4104
+ if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)) continue; // channel routes: not reconstructible here
4105
+ others.push(incumbent(e));
4106
+ }
4107
+ const xseg=(a,b,c,d)=>{
4108
+ const rx=b[0]-a[0], ry=b[1]-a[1], sx=d[0]-c[0], sy=d[1]-c[1];
4109
+ const den=rx*sy-ry*sx; if(Math.abs(den)<1e-9) return false;
4110
+ const t=((c[0]-a[0])*sy-(c[1]-a[1])*sx)/den, u=((c[0]-a[0])*ry-(c[1]-a[1])*rx)/den;
4111
+ return t>1e-6&&t<1-1e-6&&u>1e-6&&u<1-1e-6;
4112
+ };
4113
+ // ...and a route that pierces a box counts the same as a crossing, because
4114
+ // a short crossing-free line that goes straight through a node is not a
4115
+ // better drawing than a long one that goes round it.
4116
+ const pierceCount=(rts,mem)=>{
4117
+ let n=0;
4118
+ rts.forEach((r,i)=>{
4119
+ const A=byId[mem[i].a], B=byId[mem[i].b];
4120
+ const obs=obsFor(A,B);
4121
+ for(let k=0;k+1<r.length;k++) if(segHitsObs(r[k],r[k+1],obs)){ n++; break; }
4122
+ });
4123
+ return n;
4124
+ };
4125
+ const crossCount=rts=>{
4126
+ let n=0;
4127
+ const pairs=rts.map(r=>r).concat(others);
4128
+ for(let i=0;i<rts.length;i++) for(let j=0;j<pairs.length;j++){
4129
+ if(pairs[j]===rts[i]) continue;
4130
+ if(j<rts.length&&j<i) continue; // count each member pair once
4131
+ for(let a=0;a+1<rts[i].length;a++) for(let b=0;b+1<pairs[j].length;b++)
4132
+ if(xseg(rts[i][a],rts[i][a+1],pairs[j][b],pairs[j][b+1])) n++;
4133
+ }
4134
+ return n;
4135
+ };
4136
+ const built=[];
4137
+ let figureOK=busGroups.length>0;
4138
+ for(const mem of busGroups){
4139
+ if(!figureOK) break;
4140
+ const T=byId[mem[0].b], src=mem.map(e=>byId[e.a]);
4141
+ let dir=0;
4142
+ if(src.every(s=>fLo(T)-fHi(s)>=RAIL_ROOM)) dir=1;
4143
+ else if(src.every(s=>fLo(s)-fHi(T)>=RAIL_ROOM)) dir=-1;
4144
+ else { figureOK=false; break; } // no room for a rail
4145
+ const railF=dir>0
4146
+ ? Math.min(fLo(T)-RAIL_CLEAR, Math.max(fLo(T)-RAIL_GAP, Math.max(...src.map(fHi))+RAIL_CLEAR))
4147
+ : Math.max(fHi(T)+RAIL_CLEAR, Math.min(fHi(T)+RAIL_GAP, Math.min(...src.map(fLo))-RAIL_CLEAR));
4148
+ const tc=cC(T);
4149
+ const cand=[]; let ok=true;
4150
+ for(const e of mem){
4151
+ const s=byId[e.a], cs=cC(s);
4152
+ const j=P(railF,cs), h=P(railF,tc);
4153
+ const pts=Math.abs(cs-tc)<0.5
4154
+ ? [borderPoint(s,h[0],h[1]), h, borderPoint(T,h[0],h[1])]
4155
+ : [borderPoint(s,j[0],j[1]), j, h, borderPoint(T,h[0],h[1])];
4156
+ const obs=obsFor(s,T);
4157
+ for(let i=0;i+1<pts.length;i++) if(segHitsObs(pts[i],pts[i+1],obs)) ok=false;
4158
+ if(!ok) break;
4159
+ cand.push({e,cs,pts});
4160
+ }
4161
+ if(!ok){ figureOK=false; break; } // a leg pierces something
4162
+ const bpts=cand.map(c=>c.pts), ipts=mem.map(incumbent);
4163
+ const bc=crossCount(bpts)+pierceCount(bpts,mem);
4164
+ const ic=crossCount(ipts)+pierceCount(ipts,mem);
4165
+ if(bc>ic){
4166
+ figureOK=false; break; // not beaten: keep the incumbents
4167
+ }
4168
+ // junction dots mark the interior joins only: the two ends of the rail
4169
+ // are corners, not junctions, and a dot on a corner is wrong.
4170
+ const xs=cand.map(c=>c.cs).concat([tc]);
4171
+ const cLo=Math.min(...xs), cHi=Math.max(...xs);
4172
+ const dots=[];
4173
+ for(const c of cand) if(c.cs>cLo+0.5&&c.cs<cHi-0.5) dots.push(P(railF,c.cs));
4174
+ if(tc>cLo+0.5&&tc<cHi-0.5&&!dots.some(d=>Math.abs(d[horiz?1:0]-tc)<0.5)) dots.push(P(railF,tc));
4175
+ // one label and one arrowhead for the whole bus: the member whose rail
4176
+ // run is longest carries the label, document order breaks the tie.
4177
+ let lead=cand[0], best=-1;
4178
+ for(const c of cand){ const d=Math.abs(c.cs-tc); if(d>best+0.5){ best=d; lead=c; } }
4179
+ built.push({T,cand,dots,lead});
4180
+ }
4181
+ // Nothing to undo when the figure declines: the bus is a routing pass and
4182
+ // the layout it declines is the layout it already had.
4183
+ if(figureOK) for(const g of built)
4184
+ g.cand.forEach((c,i)=>busRoute.set(c.e,{pts:c.pts,bus:g.T.id,lead:c===g.lead,
4185
+ dots:i===g.cand.length-1?g.dots:null, arrow:i===g.cand.length-1}));
4186
+ }
3810
4187
  for(const e of edges){
3811
4188
  const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
3812
4189
  // an edge is pure stroke: `stroke=` and `fill=` name the same channel
@@ -3826,6 +4203,29 @@ function renderScene(doc,y0){
3826
4203
  const m1='', m2=''; // markers removed — arrowTri() paints triangles above nodes in lblsvg
3827
4204
  const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
3828
4205
  const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
4206
+ const bus=busRoute.get(e);
4207
+ if(bus){
4208
+ const pts=bus.pts;
4209
+ // data-bus is written LAST so every reader that keys on the
4210
+ // `d=… fill=none stroke=… stroke-width=1.6` prefix is unaffected.
4211
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+' data-bus="'+esc(bus.bus)+'"/>');
4212
+ noteSegs(e,pts);
4213
+ for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+4-y0-20); }
4214
+ if(bus.dots) for(const d of bus.dots)
4215
+ lblsvg.push('<circle cx="'+d[0]+'" cy="'+d[1]+'" r="3" fill="'+col+'" stroke="none"/>');
4216
+ // the trunk is drawn once by every member; the label and the arrowhead
4217
+ // are drawn ONCE for the bus, which is the whole point of merging it.
4218
+ if(bus.lead&&e.mid){ // longest rail run carries the one label
4219
+ let bi=0,bl=-1;
4220
+ for(let i=0;i+1<pts.length;i++){
4221
+ const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
4222
+ if(l>bl){ bl=l; bi=i; }
4223
+ }
4224
+ reqLabel({p:pts[bi],q:pts[bi+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:bi===0});
4225
+ }
4226
+ if(bus.arrow&&wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
4227
+ continue;
4228
+ }
3829
4229
  if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
3830
4230
  // ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
3831
4231
  // Edge labels are DEFERRED: every label is registered against its
@@ -3843,6 +4243,32 @@ function renderScene(doc,y0){
3843
4243
  // convention of every drawing tool — never a lap of the figure
3844
4244
  // through the back-edge channel. Side order r,l,b,t; first side
3845
4245
  // whose loop box overlaps no other node wins (deterministic).
4246
+ //
4247
+ // THE LOOP AND THE CHANNEL SHARE THIS SIDE, AND THAT IS A KNOWN,
4248
+ // MEASURED, UNFIXED DEFECT. A loop hangs off one side of the box on
4249
+ // the box's MID line; a channel back edge leaves and enters on the
4250
+ // SAME side (right under vertical flow, bottom under horizontal) at
4251
+ // rows near that same mid line — so a state that both loops and takes
4252
+ // a channel route has a line drawn across a 20 px ornament. It is
4253
+ // CROSSING, not shared ink: measured over the whole corpus, no
4254
+ // self-loop shares more than 0 px of collinear ink with anything.
4255
+ // bfd-session is the only figure where it bites (turnstile's two loops
4256
+ // are clean), and there it is 12 crossings over four loops.
4257
+ //
4258
+ // THE OBVIOUS FIX WAS BUILT AND REJECTED, so it is not re-attempted
4259
+ // blind: treat the channel side as occupied and take the next free
4260
+ // side. bfd-session's crossings fall 15 -> 4 and every loop comes
4261
+ // clean — but DOWN, INIT and UP have only 'l' free (their 'b' and 't'
4262
+ // boxes sit on the spine, which loopHit does not test), and the left
4263
+ // of a scene is only PADL=18 px wide. Their three trigger labels were
4264
+ // placed at x = -102.6, -73.4 and -57.1 and CLIPPED OFF THE CANVAS —
4265
+ // three labels lost to buy eleven crossings, which is the wrong trade
4266
+ // in the direction label placement has been moving all week.
4267
+ // WHAT WOULD REOPEN IT: a left-margin mechanism for the scene (the
4268
+ // uniform-shift pattern bShift/chShift already use, applied before the
4269
+ // label pass), so a loop and its label can hang off the left at all.
4270
+ // Until then the loop stays on the channel side and the crossing is
4271
+ // recorded rather than papered over.
3846
4272
  const scy=A.y+A.h/2, scx=A.x+A.w/2;
3847
4273
  const mkLoop=sd=>sd==='r'?[[A.x+A.w,scy-8],[A.x+A.w+20,scy-8],[A.x+A.w+20,scy+8],[A.x+A.w,scy+8]]
3848
4274
  :sd==='l'?[[A.x,scy-8],[A.x-20,scy-8],[A.x-20,scy+8],[A.x,scy+8]]
@@ -3859,7 +4285,15 @@ function renderScene(doc,y0){
3859
4285
  for(const p of sp){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
3860
4286
  esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
3861
4287
  noteSegs(e,sp);
3862
- if(e.mid) reqLabel({p:sp[1],q:sp[2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
4288
+ // A self-loop's outer run is 16 px long, so sliding the label ALONG it
4289
+ // buys ~15 px and no escape at all from a line crossing it — and a
4290
+ // back edge leaves the same node on the same side at the same mid-y,
4291
+ // which is how bfd-session drew three self-loop labels with a line
4292
+ // through them. Parameters outside [0,1] are offered too: they park the
4293
+ // box just above or just below the loop, still hard against it, which
4294
+ // is a placement a reader still reads as belonging to the loop.
4295
+ if(e.mid) reqLabel({p:sp[1],q:sp[2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false,
4296
+ ts:[0.5,0.2,0.8,-0.7,1.7,-1.4,2.4],tw:10});
3863
4297
  if(e.tail) seg(sp[0],sp[1],0.5,e.tail,10);
3864
4298
  if(e.head) seg(sp[3],sp[2],0.5,e.head,10);
3865
4299
  if(wantsStart) arrowTri(sp[0],sp[1],col);
@@ -3875,47 +4309,68 @@ function renderScene(doc,y0){
3875
4309
  const lane=r=>(ranksArr[r]||[]).filter(n=>!n.virtual);
3876
4310
  const P=chPlan.get(e), ring=P.ring;
3877
4311
  const pts=[];
4312
+ // WHERE A BACK-EDGE LABEL GOES. It used to be registered on
4313
+ // the CHANNEL run — the long leg out in the side channel, past every node
4314
+ // in the figure. That is the furthest point on the route from either
4315
+ // endpoint, and every back edge's channel run is in the same channel, so
4316
+ // the labels landed in one column with nothing but proximity to say which
4317
+ // line each named (bfd-session parked three of them around x=1100 while
4318
+ // its four states occupied x 57-200). The label now rides the first
4319
+ // stretch of the route AS IT LEAVES THE SOURCE, where the reader can see
4320
+ // which box the line comes out of. The stub is capped so the candidate
4321
+ // parameters land the box beside the source rather than halfway to the
4322
+ // channel; a shorter first leg just uses all of itself. The cap has to
4323
+ // scale with the LABEL, not be a constant: at the middle of a stub the
4324
+ // box spans the midpoint plus and minus half its width, so a stub
4325
+ // shorter than the label puts the box back on top of the source box
4326
+ // whatever parameter is chosen (bfd-session's "Detect expired, Echo
4327
+ // failed" is 169 px wide and a fixed 64 px stub buried it in INIT).
4328
+ const srcStub=(pp,wpx)=>{
4329
+ const a=pp[0], b=pp[1], L=Math.hypot(b[0]-a[0],b[1]-a[1])||1;
4330
+ const k=Math.min(1,Math.max(64,wpx+24)/L);
4331
+ return [a,[a[0]+(b[0]-a[0])*k, a[1]+(b[1]-a[1])*k]];
4332
+ };
3878
4333
  if(horiz){ // channel runs below the lanes
3879
4334
  const chY=occB+28+P.slot; // labels ride ON the channel
3880
4335
  const colR=r=>Math.max(...lane(r).map(n=>n.x+n.w));
3881
4336
  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);
3882
- 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;
4337
+ 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*P.ef;
3883
4338
  if(A!==B&&blockedV(A.y+A.h,chY,sx,A)){
3884
4339
  const gx=colR(A.rank)+10+ring*7;
3885
4340
  pts.push([outSide(A,'r'),A.y+A.h/2],[gx,A.y+A.h/2],[gx,chY]);
3886
4341
  } else pts.push([sx,outSide(A,'b')],[sx,chY]);
3887
4342
  if(A!==B&&blockedV(B.y+B.h,chY,tx,B)){
3888
4343
  const gx=colR(B.rank)+10+ring*7;
3889
- pts.push([gx,chY],[gx,B.y+B.h/2],[outSide(B,'r'),B.y+B.h/2]);
4344
+ pts.push([gx,chY],[gx,B.y+B.h*P.ef],[outSide(B,'r'),B.y+B.h*P.ef]);
3890
4345
  } else pts.push([tx,chY],[tx,outSide(B,'b')]);
3891
4346
  if(e.mid){
3892
- const c1=pts.findIndex(p=>p[1]===chY);
3893
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
4347
+ const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[1]===chY);
4348
+ reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
3894
4349
  }
3895
4350
  } else if(P.ringOK){ // concentric ring: under, around, over, in
3896
4351
  const sx=A.x+A.w/2;
3897
4352
  const gy=occB+14+ring*12, chX=occR+28+P.slot, topY=chTop-14-ring*12;
3898
4353
  pts.push([sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]);
3899
4354
  if(e.mid){
3900
- const c1=pts.findIndex(p=>p[0]===chX);
3901
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
4355
+ const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
4356
+ reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
3902
4357
  }
3903
4358
  } else { // channel runs right of the lanes
3904
4359
  const chX=occR+28+P.slot;
3905
4360
  const laneB=r=>Math.max(...lane(r).map(n=>n.y+n.h));
3906
4361
  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);
3907
- 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;
4362
+ 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*P.ef;
3908
4363
  if(A!==B&&blockedH(A.x+A.w,chX,sy,A)){
3909
4364
  const gy=laneB(A.rank)+10+ring*7;
3910
4365
  pts.push([A.x+A.w/2,outSide(A,'b')],[A.x+A.w/2,gy],[chX,gy]);
3911
4366
  } else pts.push([outSide(A,'r'),sy],[chX,sy]);
3912
4367
  if(A!==B&&blockedH(B.x+B.w,chX,ty,B)){
3913
4368
  const gy=laneB(B.rank)+10+ring*7;
3914
- pts.push([chX,gy],[B.x+B.w/2,gy],[B.x+B.w/2,outSide(B,'b')]);
4369
+ pts.push([chX,gy],[B.x+B.w*P.ef,gy],[B.x+B.w*P.ef,outSide(B,'b')]);
3915
4370
  } else pts.push([chX,ty],[outSide(B,'r'),ty]);
3916
4371
  if(e.mid){
3917
- const c1=pts.findIndex(p=>p[0]===chX);
3918
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
4372
+ const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
4373
+ reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
3919
4374
  }
3920
4375
  }
3921
4376
  // non-incident nodes are obstacles for the channel runs too: a run
@@ -4112,6 +4567,21 @@ function renderScene(doc,y0){
4112
4567
  // So the scaffolding is hoisted and only the LOOP keeps the guard.
4113
4568
  {
4114
4569
  const obst=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h,n}));
4570
+ // An `external` is never DRAWN as a shape, so its 12x12 anchor is not ink
4571
+ // and is rightly excluded above — but its LABEL is ink, and this pass could
4572
+ // not see it. arp-resolution put a 234 px edge label straight through
4573
+ // "rest of the LAN / (hosts C, D, ...)". The label box is added here with
4574
+ // the same geometry the node pass below emits it at, so the obstacle and
4575
+ // the drawing cannot disagree.
4576
+ for(const n of nodes){
4577
+ if(!n.boundary||!n.label) continue;
4578
+ const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
4579
+ const bw=lblPx(n.label), bl=String(n.label).split('\n').length, bh=13*bl;
4580
+ let ox,oy;
4581
+ if(Math.abs(bdx)>=Math.abs(bdy)){ ox=bdx>=0?cx+10:cx-10-bw; oy=cy+3.5-13*bl/2-1.5; }
4582
+ else { ox=cx-bw/2; oy=(bdy>=0?cy+17:cy-10)-13*bl/2-1.5; }
4583
+ obst.push({x:ox,y:oy,w:bw,h:bh,n:null});
4584
+ }
4115
4585
  const ovl=(a,b)=>{
4116
4586
  const ix=Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x);
4117
4587
  const iy=Math.min(a.y+a.h,b.y+b.h)-Math.max(a.y,b.y);
@@ -4132,26 +4602,54 @@ function renderScene(doc,y0){
4132
4602
  return t1>t0;
4133
4603
  };
4134
4604
  const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
4135
- const cand=(r,t,side)=>{
4605
+ // SLOPE CLEARANCE (`cl`): "3 px above the line" clears the line only where
4606
+ // the box touches it. The offsets are axis-aligned while the segment is
4607
+ // not, so on a diagonal the line keeps climbing across the box's WIDTH and
4608
+ // re-enters it — which is why a label could sit squarely across its own
4609
+ // edge and the drawing showed a strikethrough. Over half a box the line
4610
+ // rises |dy/dx|*w/2, so that much extra offset is exactly what puts the
4611
+ // whole box on one side of the line. It is offered as a SECOND candidate
4612
+ // per side (cl=1) rather than imposed, priced per pixel of displacement
4613
+ // below: a label 7 px further out to stop being struck is worth it, a
4614
+ // 90 px shove for a long label on a 45 degree line is not, and the scorer
4615
+ // decides which case it is holding.
4616
+ const cand=(r,t,side,cl)=>{
4136
4617
  const lines=String(r.text).split('\n'), n=lines.length;
4137
4618
  const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
4138
4619
  const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
4139
4620
  const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
4140
4621
  const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
4622
+ const sdx=Math.abs(r.q[0]-r.p[0]), sdy=Math.abs(r.q[1]-r.p[1]);
4623
+ let ex=0;
4624
+ if(cl){
4625
+ if(side==='above'||side==='below') ex=sdx>1e-9?Math.min(1,sdy/sdx)*w/2:0;
4626
+ else if(side==='right'||side==='left') ex=sdy>1e-9?Math.min(1,sdx/sdy)*h/2:0;
4627
+ }
4141
4628
  let bx,by,x,anchor=n>1?'middle':'start';
4142
4629
  if(side==='on') { bx=mx-w/2; by=my-4-up; anchor='middle'; }
4143
- else if(side==='above') { bx=mx-w/2; by=my-3-h; anchor='middle'; }
4144
- else if(side==='below') { bx=mx-w/2; by=my+3; anchor='middle'; }
4145
- else if(side==='right') { bx=mx+6; by=my-h/2; }
4146
- else { bx=mx-6-w; by=my-h/2; }
4630
+ else if(side==='above') { bx=mx-w/2; by=my-3-h-ex; anchor='middle'; }
4631
+ else if(side==='below') { bx=mx-w/2; by=my+3+ex; anchor='middle'; }
4632
+ else if(side==='right') { bx=mx+6+ex; by=my-h/2; }
4633
+ else { bx=mx-6-w-ex; by=my-h/2; }
4147
4634
  x=anchor==='middle'?bx+w/2:bx;
4148
- return {x,y:by+up,anchor,t,side,box:{x:bx,y:by,w,h}};
4635
+ return {x,y:by+up,anchor,t,side,ex,box:{x:bx,y:by,w,h}};
4149
4636
  };
4150
4637
  const placed=[];
4151
- if(lblReq.length) for(const r of lblReq){
4638
+ // A request may name a SECOND carrying segment (`alt`). Back edges do: the
4639
+ // stub leaving the source is the preferred carrier because it says which
4640
+ // box the line comes out of, but on a figure where two edges leave the same
4641
+ // node the stub can only put the label where an earlier one already sits
4642
+ // (flowchart-b drew "no" twice, one under the other, and neither said which
4643
+ // line it named). The alternate carrier — the channel run — is offered at a
4644
+ // flat surcharge so it is taken only when the stub really has nowhere.
4645
+ if(lblReq.length) for(const r0 of lblReq){
4646
+ const carriers=[[r0.p,r0.q]].concat(r0.alt?[r0.alt]:[]);
4647
+ let best=null,bestS=Infinity;
4648
+ for(let ci=0;ci<carriers.length;ci++){
4649
+ const r=ci?Object.assign({},r0,{p:carriers[ci][0],q:carriers[ci][1]}):r0;
4152
4650
  const dx=r.q[0]-r.p[0], dy=r.q[1]-r.p[1];
4153
4651
  const across=Math.abs(dx)>=Math.abs(dy);
4154
- let sides, ts, tPref;
4652
+ let sides, ts, tPref, apWant=null;
4155
4653
  if(r.kind==='end'){
4156
4654
  // endpoint labels keep their historical spot as first choice
4157
4655
  sides=['on'].concat(across?['above','below']:['right','left']);
@@ -4159,6 +4657,30 @@ function renderScene(doc,y0){
4159
4657
  ts=[r.t0,r.t0-0.06,r.t0+0.06,r.t0-0.12,r.t0+0.12].map(CLAMP);
4160
4658
  } else {
4161
4659
  sides=across?['above','below']:['right','left'];
4660
+ // ANTI-PARALLEL PAIRS: the label belongs on the OUTSIDE of its own
4661
+ // stroke. `apOff` moved the two strokes of an A->B / B->A pair to
4662
+ // opposite sides of the pair's centre line so they stop coinciding —
4663
+ // "so opposite directions land on opposite sides" — and the label
4664
+ // rides its own offset segment. But which SIDE of that segment the
4665
+ // text lands on was decided here, independently, by score, and the two
4666
+ // strokes are only 7 px apart, so the two candidate sets are nearly
4667
+ // identical. Both labels took the same side and the pair drew as two
4668
+ // lines of text stacked 1.6 px apart (tcp-state-machine: "passive OPEN
4669
+ // / create TCB" directly over "CLOSE / delete TCB", 117 px of shared
4670
+ // width, one of them lying across the partner's stroke).
4671
+ // The offset vector IS the index that decided which side the stroke
4672
+ // took, so `apWant` is read straight off it. It is not merely ORDERED
4673
+ // first: measured on that pair, the outside candidate cost 52 and the
4674
+ // stacked one 36, because a stack that does not actually OVERLAP costs
4675
+ // the scorer NOTHING while the outside position crossed one edge (26).
4676
+ // Ordering is worth 10 and could not move it. The wrong side is
4677
+ // therefore PRICED, in the band the identical-text term already uses
4678
+ // (34): an anti-parallel pair is exactly two lines a reader must tell
4679
+ // apart, and a label on the inside of its own stroke — between the two,
4680
+ // or beyond the partner — has stopped saying which one it names, which
4681
+ // is the same defect that term exists to charge for.
4682
+ const apv=apOff.get(r.e);
4683
+ apWant=apv?(across?(apv[1]<0?'above':'below'):(apv[0]<0?'left':'right')):null;
4162
4684
  // flowchart convention: a short branch marker leaving a decision node
4163
4685
  // reads as that branch's name only if it sits next to the decision.
4164
4686
  // `FLOWCHART-ROLE-KEYWORDS`: the test is the ROLE, not the geometry. Until
@@ -4169,22 +4691,41 @@ function renderScene(doc,y0){
4169
4691
  const branch=r.first && r.A && r.A.role==='decision' &&
4170
4692
  String(r.text).length<=3 && !String(r.text).includes('\n');
4171
4693
  tPref=branch?0.22:0.5;
4172
- ts=branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72];
4694
+ ts=r.ts?r.ts:(branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72]);
4173
4695
  }
4174
- let best=null,bestS=Infinity;
4175
- for(let si=0;si<sides.length;si++) for(const t of ts){
4176
- const c=cand(r,t,sides[si]);
4696
+ for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]){
4697
+ const c=cand(r,t,sides[si],cl);
4177
4698
  let s=0;
4178
4699
  for(const b of placed) s+=3*ovl(c.box,b);
4179
4700
  for(const o of obst) s+=(o.n===r.A||o.n===r.B?6:2.4)*ovl(c.box,o);
4180
4701
  for(const a of arrowBox) s+=4*ovl(c.box,a);
4181
- for(const g of edgeSegs) if(g.e!==r.e && segHit(g.p,g.q,c.box)) s+=26;
4182
- s+=70*Math.abs(t-tPref)+si*10;
4702
+ // The label's OWN edge is charged like any other. It used to be exempt
4703
+ // (`g.e!==r.e`), which made a label lying across the line it names FREE
4704
+ // — and that is the single commonest way a label stops saying which
4705
+ // line it belongs to, so the exemption was paying for the defect.
4706
+ for(const g of edgeSegs) if(segHit(g.p,g.q,c.box)) s+=26;
4707
+ s+=0.35*c.ex; // price of the slope-clearance displacement
4708
+ s+=ci*30; // price of leaving the preferred carrier
4709
+ // Two identical texts sitting side by side is the defect in its purest
4710
+ // form: neither of them says which line it belongs to, and no overlap
4711
+ // test can see it because they do not overlap.
4712
+ for(const b of placed) if(b.text===r.text &&
4713
+ Math.hypot(b.x+b.w/2-c.box.x-c.box.w/2, b.y+b.h/2-c.box.y-c.box.h/2)<64) s+=34;
4714
+ // the inside of an anti-parallel pair — see `apWant` above
4715
+ if(apWant&&c.side!==apWant) s+=34;
4716
+ // The pull back toward the preferred point is priced in PARAMETER
4717
+ // units, so the same number means 70/L per pixel: cheap along a 900 px
4718
+ // channel leg, ruinous along a 16 px self-loop run. A request that
4719
+ // offers parameters outside [0,1] states its own weight so its escape
4720
+ // positions cost what they are worth in pixels rather than being
4721
+ // priced out by the length of the thing they slide along.
4722
+ s+=(r.tw||70)*Math.abs(t-tPref)+si*10;
4183
4723
  if(c.box.x<2) s+=400; // would fall off the left margin
4184
4724
  if(s<bestS-1e-9){ bestS=s; best=c; }
4185
4725
  }
4186
- lblsvg[r.idx]=textEl(best.x,best.y,r.fs,best.anchor,r.col,r.text,r.halo);
4187
- placed.push(best.box);
4726
+ }
4727
+ lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,r0.halo);
4728
+ placed.push(Object.assign({text:r0.text},best.box));
4188
4729
  W=Math.max(W, best.box.x+best.box.w+4);
4189
4730
  Hh=Math.max(Hh, best.box.y+best.box.h+4-y0-20);
4190
4731
  }
@@ -5151,7 +5692,17 @@ function renderTable(t,y0){
5151
5692
  // and a threshold is a statement about values. `h1..hN` and `1..` are already
5152
5693
  // separate address spaces in this genre (genres/table.md), so the split is
5153
5694
  // the genre's own and not invented here.
5154
- return {svg:svg.join(''), y:yEnd+6, w:totalW+2,
5695
+ // THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE CAPTION IS INK.
5696
+ // `w` was the GRID's width alone, so a caption longer than the table it names
5697
+ // ran past the right edge of the section and was CLIPPED — patterns/table-b
5698
+ // shipped as "Feature Matrix — rowspan/colspan merges with c", losing 86 px
5699
+ // of a sentence that is the only place the figure says what it is about. The
5700
+ // grid is not the figure; the caption is not decoration.
5701
+ // Bold at 13 px is wider than `CH` (a regular-weight advance), so the caption
5702
+ // is measured with the same 8% allowance the raster needed — verified by
5703
+ // rendering, not assumed.
5704
+ const capW=cwMax(t.label)*CH*1.08+2;
5705
+ return {svg:svg.join(''), y:yEnd+6, w:Math.max(totalW+2,capW),
5155
5706
  box:{x0:0, x1:totalW, yA:yTop+yAt[H], yB:yEnd}};
5156
5707
  }
5157
5708
 
@@ -5169,7 +5720,26 @@ function renderChart(b,y0,doc){
5169
5720
  const R=rows.length, C=cLab.length;
5170
5721
  const zmax=Math.max(...rows.flat(), 1);
5171
5722
  const W2=20,H2=10,ZS=130/zmax,BAR=0.72;
5172
- const ox=R*W2+8, oy=y0+18+ZS*zmax+6;
5723
+ // LEFT GUTTER for the row labels. They are anchored `end` at the floor's
5724
+ // left corner and hang LEFTWARD from it, and nothing reserved room for them:
5725
+ // the section's width is measured from the floor's RIGHT corner, and a
5726
+ // section has no mechanism to grow leftwards, so any row label wider than
5727
+ // its corner's own offset was clipped away at x<0 and the reader saw a
5728
+ // sliver or nothing. Measured on the shipped corpus: telemetry-export lost
5729
+ // 16.0 px of "Export ring" (24.6% of the box) and 41.9 px of "gRPC encoder"
5730
+ // (59.1%), and table-experimental shaved "00:05".
5731
+ // The LABEL is not moved. A row label belongs beside its row — that
5732
+ // adjacency is what makes it a row label rather than a caption — so the
5733
+ // ORIGIN moves right instead, by exactly what the widest label overhangs.
5734
+ // That is the "grow the canvas" answer, and it is the right one here
5735
+ // because the space is genuinely needed: no placement of a right-anchored
5736
+ // label at the left edge of the floor can avoid needing a margin, and the
5737
+ // gutter costs only the width it actually uses (0 when no label overhangs,
5738
+ // so every chart whose labels already fitted is byte-unchanged).
5739
+ const rLabPx=l=>cwMax(l)*6.5*10/11; // textEl draws these at font-size 10
5740
+ const ox0=R*W2+8;
5741
+ const gut=Math.max(0,...rLab.map((l,r)=>rLabPx(l)+4-(ox0-(r+0.65)*W2)));
5742
+ const ox=ox0+gut, oy=y0+18+ZS*zmax+6;
5173
5743
  const P=(r,c,z)=>[ox+(c-r)*W2, oy+(c+r)*H2-z*ZS];
5174
5744
  const svg=[];
5175
5745
  svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' — bar3d</text>');