figdown 0.3.0 → 0.3.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.
@@ -121,7 +121,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
121
121
  // input to that promise, and under core §13 a 0.x renderer may differ from
122
122
  // the next — which makes the recorded version the only thing that can
123
123
  // explain a diff between two renderings of one source.
124
- const FIGDOWN_VERSION = '0.3.0';
124
+ const FIGDOWN_VERSION = '0.3.1';
125
125
  // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
126
126
  // counter does NOT reset (core §13.0.4 — `N` counts source states of the
127
127
  // engine and only ever increases), so 0.1 is followed by
@@ -3535,6 +3535,44 @@ function renderScene(doc,y0){
3535
3535
  return rk[u]=r;
3536
3536
  };
3537
3537
  nodes.forEach(n=>n.rank=rankOf(find(n.id)));
3538
+ // SPINE CHAIN (item 27, ordering phase). A figure whose reading order is a
3539
+ // chain must draw that chain in ONE lane; the incumbent barycenter sweep
3540
+ // cannot, because a chain node and its branch sibling share one desired
3541
+ // position and the sibling — declared first — takes the slot, so the chain
3542
+ // steps aside once per rank and a logical column renders as a staircase
3543
+ // (spine.fd: +165 px per rank, 2208 px wide for a 200 px column).
3544
+ //
3545
+ // The chain is found HERE, before any coordinate exists, and it is the
3546
+ // longest rank-consecutive path of real nodes. Deterministic throughout:
3547
+ // longest-path DP taken in decreasing rank, every tie broken by document
3548
+ // order, so one document has exactly one chain.
3549
+ const chainNext=new Map(), chainPrev=new Map();
3550
+ const CHAIN_MIN=4; // nodes; below this a figure has no spine to
3551
+ { // hold and the incumbent sweep is left alone
3552
+ const sc=new Map();
3553
+ for(const e of doc.edges){
3554
+ const A=byId[e.a],B=byId[e.b];
3555
+ if(!A||!B||isBack.has(e)||e.a===e.b) continue;
3556
+ if(e.op!=='->') continue; // a chain is a READING order, and
3557
+ // only a directed edge states one
3558
+ if(B.rank!==A.rank+1) continue; // a chain is rank-consecutive
3559
+ if(pinned(e.a)||pinned(e.b)) continue; // a pin is the author's word
3560
+ if(!sc.has(A)) sc.set(A,[]); sc.get(A).push(B);
3561
+ }
3562
+ const len=new Map(), nxt=new Map();
3563
+ for(const n of [...nodes].sort((p,q)=>q.rank-p.rank||p.di-q.di)){
3564
+ let best=null,bl=0;
3565
+ for(const s of sc.get(n)||[]){
3566
+ const l=len.get(s)||1;
3567
+ if(l>bl||(l===bl&&best&&s.di<best.di)){ bl=l; best=s; }
3568
+ }
3569
+ len.set(n,bl+1); if(best) nxt.set(n,best);
3570
+ }
3571
+ let head=null;
3572
+ for(const n of nodes) if(!head||len.get(n)>len.get(head)) head=n; // ties: doc order
3573
+ if(head&&len.get(head)>=CHAIN_MIN)
3574
+ for(let n=head,m=nxt.get(n);m;n=m,m=nxt.get(n)){ chainNext.set(n,m); chainPrev.set(m,n); }
3575
+ }
3538
3576
  // positions: children spread around their parents' lane (barycenter
3539
3577
  // sweeps down/up/down). Edges that span multiple layers get invisible
3540
3578
  // waypoint slots so they no longer cut through intermediate nodes.
@@ -3544,6 +3582,33 @@ function renderScene(doc,y0){
3544
3582
  const lblPx=s=>cwMax(s)*6.5;
3545
3583
  const lay=[...nodes]; // layout participants
3546
3584
  const chains=new Map(); // edge -> [A, ...waypoints, B]
3585
+ // Bus eligibility, TOPOLOGICAL half (item 26 stage 1). Three or more forward
3586
+ // `->` edges arriving at one target with one label, one stroke and one dash,
3587
+ // no endpoint labels and no pinned endpoint. It is computed here, before the
3588
+ // geometry, because the render pass needs the group SET whole: the figure
3589
+ // decides all-or-none, so it has to know how many groups it is deciding for.
3590
+ //
3591
+ // Placement is deliberately NOT touched. A member still reserves its
3592
+ // waypoint column, which is the cost item 27 records; suppressing those
3593
+ // columns was implemented and measured (spine.fd 2208 -> 1318 px and a
3594
+ // visibly better drawing) and is NOT landed here, because the suppression
3595
+ // has to be decided before coordinates exist while adoption can only be
3596
+ // decided after, and a figure that suppresses and then declines draws
3597
+ // straight through its own boxes (bfd-session: score 30 -> 35 with a new
3598
+ // `thru`). That belongs with item 27's ordering change, priced.
3599
+ const busGroups=[];
3600
+ {
3601
+ const g=new Map();
3602
+ for(const e of doc.edges){
3603
+ const A=byId[e.a], B=byId[e.b];
3604
+ if(!A||!B||e.a===e.b||isBack.has(e)) continue;
3605
+ if(e.op!=='->'||e.tail||e.head) continue;
3606
+ if(pinned(e.a)||pinned(e.b)||B.rank<=A.rank) continue;
3607
+ const k=e.b+' '+(e.mid||'')+' '+(e.stroke||'')+' '+(e.style||'');
3608
+ if(!g.has(k)) g.set(k,[]); g.get(k).push(e);
3609
+ }
3610
+ for(const [,m] of g) if(m.length>=3) busGroups.push(m);
3611
+ }
3547
3612
  for(const e of doc.edges){
3548
3613
  const A=byId[e.a], B=byId[e.b];
3549
3614
  if(!A||!B||isBack.has(e)) continue;
@@ -3610,7 +3675,92 @@ function renderScene(doc,y0){
3610
3675
  const center=n=>n.cross+cs(n)/2;
3611
3676
  ranksArr.forEach(lane=>{ if(!lane) return; let c=0; // seed: doc order
3612
3677
  lane.forEach((n,k)=>{ n.cross=c; c+=cs(n)+(k<lane.length-1?gapOf(n,lane[k+1]):0); }); });
3613
- const place=(lane,des)=>{ // order by desired center, resolve overlaps,
3678
+ // WHERE THE HOLD YIELDS, WHICH IS MOST OF THE RULE. Holding a chain node on
3679
+ // its chain neighbour puts every OTHER neighbour of that node on one side of
3680
+ // it, and where the figure diverges or converges that is the wrong drawing:
3681
+ // item 27's Brandes-Köpf rejection measured this exact mechanism from the
3682
+ // other end — aligning on ONE neighbour where the barycentre uses the AVERAGE
3683
+ // made 11 of 19 figures worse, and "the average is what a human draws". So
3684
+ // the hold is dropped wherever it would displace a spread the reader reads.
3685
+ //
3686
+ // `realDeg` is degree as the READER sees it at one rank boundary: real
3687
+ // neighbours, plus the waypoints of long edges whose far end is OFF the
3688
+ // chain. A long edge that leaves the chain and rejoins it later is not a
3689
+ // spread — counting it would drop the hold on exactly the columns this pass
3690
+ // exists to create (bfd-session's ADMINDOWN is entered by UP and by two
3691
+ // waypoints of edges that left DOWN and INIT) — while a long edge arriving
3692
+ // from elsewhere is one, and its target belongs at the average (that is
3693
+ // packet-ingress's `Forward`, entered by `IPv4 checksum OK?` beside it and by
3694
+ // two waypoints from the IPv6 and ARP branches).
3695
+ const onChain=n=>chainNext.has(n)||chainPrev.has(n);
3696
+ const realDeg=(m,side)=>{
3697
+ let k=0;
3698
+ for(const s of (side===1?succs:preds).get(m)||[]){
3699
+ if(!s.virtual){ k++; continue; }
3700
+ const o=side===1?s.homeB:s.homeA;
3701
+ if(o&&!onChain(o)) k++;
3702
+ }
3703
+ return k;
3704
+ };
3705
+ const realFan=(n,dir)=>{
3706
+ const m=(dir===1?chainPrev:chainNext).get(n); return m?realDeg(m,dir):0;
3707
+ };
3708
+ // PROSPECTIVE BUSES, AND WHY THE HOLD YIELDS TO THEM RATHER THAN SERVING
3709
+ // THEM. Item 26 records the trap this pass had to answer: a bus member's
3710
+ // waypoint column can be suppressed only BEFORE coordinates exist, while the
3711
+ // bus is adopted only AFTER, so a figure that suppresses and then declines
3712
+ // routes through its own boxes (bfd-session 30 -> 35 with a new `thru`).
3713
+ // Nothing here suppresses anything. It takes the one direction of that
3714
+ // decision which is safe under a decline: it WITHHOLDS the hold from the
3715
+ // source of a bus group that is topologically eligible, and withholding is
3716
+ // the incumbent behaviour — a figure that declines is drawn exactly as it is
3717
+ // drawn today, with nothing to undo. Holding them is the unsafe direction:
3718
+ // it stacks the sources of one convergence into a single column, and a bus
3719
+ // leg dropping from the earliest then pierces the latest — patterns/
3720
+ // flowchart-a loses the trunk it gained that way, measured.
3721
+ //
3722
+ // ...and only for a group that could ever BE a rail. A bus drops every source
3723
+ // onto one cross-axis rail, so a group whose sources sit on top of each other
3724
+ // along the FLOW axis — one source an ancestor of another — is unadoptable
3725
+ // whatever ordering does, and withholding there would cost the column and buy
3726
+ // nothing (bfd-session's three `admin disable` edges leave DOWN, INIT and UP,
3727
+ // and DOWN reaches both of the others).
3728
+ const busSrc=new Set();
3729
+ {
3730
+ const fwd=new Map();
3731
+ for(const e of doc.edges){
3732
+ if(!byId[e.a]||!byId[e.b]||isBack.has(e)||e.a===e.b) continue;
3733
+ if(!fwd.has(e.a)) fwd.set(e.a,[]); fwd.get(e.a).push(e.b);
3734
+ }
3735
+ const reaches=(u,v)=>{ // forward-DAG reachability
3736
+ const seen=new Set([u]), st=[u];
3737
+ while(st.length){ const x=st.pop();
3738
+ for(const y of fwd.get(x)||[]){ if(y===v) return true;
3739
+ if(!seen.has(y)){ seen.add(y); st.push(y); } } }
3740
+ return false;
3741
+ };
3742
+ for(const m of busGroups){
3743
+ const s=m.map(e=>e.a);
3744
+ let stacked=false;
3745
+ for(const a of s) for(const b of s) if(a!==b&&reaches(a,b)) stacked=true;
3746
+ if(!stacked) for(const a of s) busSrc.add(a);
3747
+ }
3748
+ }
3749
+ // A chain node is HELD — it follows its chain neighbour rather than the
3750
+ // average of all of them — unless it is a bus source (above), unless the
3751
+ // neighbour it would follow spreads three or more ways into this rank, or
3752
+ // unless the node itself is where three or more come together (block-a's
3753
+ // Collector, lifted off the middle lane by BK, is the recorded instance of
3754
+ // the latter).
3755
+ const held=(n,dir)=>onChain(n)
3756
+ &&!(n.id&&busSrc.has(n.id))
3757
+ &&realFan(n,dir)<3&&realDeg(n,-dir)<3;
3758
+ // A whole LANE keeps its barycentre recentring if anything in it converges,
3759
+ // even where the chain node itself does not: recentring on the chain node
3760
+ // moves every other member of that lane, and a convergence is read from the
3761
+ // spread of its inputs.
3762
+ const laneConverges=(lane,dir)=>lane.some(n=>!n.virtual&&realDeg(n,-dir)>=3);
3763
+ const place=(lane,des,dir)=>{ // order by desired center, resolve overlaps,
3614
3764
  const arr=lane.map(n=>({n,d:des.get(n)})); // recenter the lane
3615
3765
  arr.sort((p,q)=>p.d-q.d||p.n.di-q.n.di);
3616
3766
  let cEnd=-Infinity;
@@ -3618,7 +3768,25 @@ function renderScene(doc,y0){
3618
3768
  x.n.cross=Math.max(x.d-cs(x.n)/2, cEnd);
3619
3769
  cEnd=x.n.cross+cs(x.n)+(i<arr.length-1?gapOf(x.n,arr[i+1].n):0);
3620
3770
  });
3621
- const err=arr.reduce((s,x)=>s+center(x.n)-x.d,0)/arr.length;
3771
+ // Recentre. Normally on the lane's MEAN error, which shares the packing
3772
+ // displacement out over every member — and that is exactly what walks a
3773
+ // chain sideways, since the chain node is one member among many. When the
3774
+ // lane carries the chain (at most one node per rank, by construction) the
3775
+ // lane is recentred on THAT node instead: it lands on its desired position
3776
+ // exactly, its siblings keep the order and spacing the sort gave them, and
3777
+ // the chain is straight by construction rather than by iteration.
3778
+ // Two more lanes keep the mean. A lane holding a PINNED node, because the
3779
+ // pin's coordinate is the author's word and does not move with the lane, so
3780
+ // sliding the lane against it can only put free nodes on a fixed one
3781
+ // (reference/block's `Drop?` diamond landed on the pinned `Rule set` that
3782
+ // way, `novlp 1`). And the chain's LAST lane in the sweep direction, where
3783
+ // there is no next step to keep aligned, so the hold buys no straightness
3784
+ // and only redistributes that lane's other members (annotated-datapath
3785
+ // redrew for no gain until this clause was added).
3786
+ const anc=(lane.some(n=>!n.virtual&&pinned(n.id))||laneConverges(lane,dir))
3787
+ ?null:arr.find(x=>held(x.n,dir)&&(dir===1?chainNext:chainPrev).has(x.n));
3788
+ const err=anc?center(anc.n)-anc.d
3789
+ :arr.reduce((s,x)=>s+center(x.n)-x.d,0)/arr.length;
3622
3790
  arr.forEach(x=>{ x.n.cross-=err; });
3623
3791
  lane.length=0; arr.forEach(x=>lane.push(x.n));
3624
3792
  };
@@ -3633,6 +3801,13 @@ function renderScene(doc,y0){
3633
3801
  for(const n of lane){
3634
3802
  const ref=(dir===1?preds:succs).get(n);
3635
3803
  let d=ref&&ref.length ? ref.reduce((s,m)=>s+center(m),0)/ref.length : center(n);
3804
+ // A chain node follows its CHAIN neighbour alone, not the average of
3805
+ // its neighbours: a branch that leaves the chain and rejoins it later
3806
+ // otherwise drags the chain off its own lane, which is the drift this
3807
+ // pass exists to remove. Its other neighbours still order themselves
3808
+ // around it in the sweep below.
3809
+ const cn=(dir===1?chainPrev:chainNext).get(n);
3810
+ if(cn&&held(n,dir)) d=center(cn);
3636
3811
  // Waypoint excursion bound (item 17): a multi-rank forward edge's dummy
3637
3812
  // vertices may follow the barycenter freely WITHIN the cross-axis band
3638
3813
  // their own endpoints span — that is where the ordering that separates
@@ -3653,7 +3828,7 @@ function renderScene(doc,y0){
3653
3828
  }
3654
3829
  des.set(n,d);
3655
3830
  }
3656
- place(lane,des);
3831
+ place(lane,des,dir);
3657
3832
  }
3658
3833
  };
3659
3834
  sweep(1); sweep(-1); sweep(1);
@@ -3675,24 +3850,44 @@ function renderScene(doc,y0){
3675
3850
  if(horiz) n.x=M-n.x-n.w; else n.y=y0+20+(M-(n.y-y0-20))-n.h; }
3676
3851
  }
3677
3852
  // Two-level coordinates (`PIN-COORDINATE-SCOPE`): a pinned GROUP anchors its local origin in
3678
- // canvas px; a pinned MEMBER is group-local (relative to that origin);
3853
+ // canvas px; a pinned MEMBER of it is group-local (relative to that origin);
3679
3854
  // ungrouped pins are canvas px. Moving a group = editing one pin line.
3855
+ //
3856
+ // A member of an UNPINNED group has NO anchored origin to be relative to, so
3857
+ // its pin is canvas px exactly like an ungrouped node's. This is `LAYOUT-STABILITY` rigidity:
3858
+ // the pin is the author's word and MUST land where written, whether or not
3859
+ // the node is a group member. The prior code derived an unpinned group's
3860
+ // origin from its members' AUTO-LAYOUT extent and then added the member pin
3861
+ // to it, so the pin was neither honoured (it read canvas 400 as origin+400)
3862
+ // nor stable (the origin moved whenever an unrelated edit reshaped the auto
3863
+ // layout — a pinned member drifted 160.9px under a synthetic added edge,
3864
+ // violating `RENDERING-DETERMINISM` stability; task #47). The pin now wins and the group BOX grows
3865
+ // to CONTAIN the member wherever it lands (box is measured from final member
3866
+ // positions below), rather than the member being repositioned to fit the box.
3680
3867
  const gOrigin={};
3868
+ // Pass 1: a pinned group anchors its origin in canvas px (`ELEMENT-GEOMETRY-DIRECTIVE`: only a pin
3869
+ // carrying `at=` anchors one). An unpinned group gets no origin here, so its
3870
+ // members fall to the canvas-px branch below.
3681
3871
  for(const g of doc.groups){
3682
3872
  const p=doc.pins[g.id];
3683
- // `ELEMENT-GEOMETRY-DIRECTIVE`: only a pin that carries `at=` anchors an origin.
3684
- if(p&&p.fx!==null){ gOrigin[g.id]={x:p.fx, y:y0+20+p.fy}; }
3685
- else{
3686
- const mem=nodes.filter(n=>n.group===g.id);
3687
- if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
3688
- y:Math.min(...mem.map(n=>n.y))};
3689
- }
3873
+ if(p&&p.fx!==null) gOrigin[g.id]={x:p.fx, y:y0+20+p.fy};
3690
3874
  }
3875
+ // Pass 2: place pinned nodes. A member of a PINNED group is group-local; an
3876
+ // ungrouped node OR a member of an UNPINNED group is canvas px.
3691
3877
  for(const n of nodes){ const p=doc.pins[n.id]; if(!p||p.fx===null) continue;
3692
3878
  const o=n.group?gOrigin[n.group]:null;
3693
3879
  if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
3694
3880
  else { n.x=p.fx; n.y=y0+20+p.fy; }
3695
3881
  }
3882
+ // Pass 3: an unpinned group has no anchor of its own; its display origin
3883
+ // (drag anchor / data-gx,gy) is the top-left of its members' FINAL positions,
3884
+ // so it reflects any pinned members and matches the group box drawn below.
3885
+ for(const g of doc.groups){
3886
+ if(gOrigin[g.id]) continue;
3887
+ const mem=nodes.filter(n=>n.group===g.id);
3888
+ if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
3889
+ y:Math.min(...mem.map(n=>n.y))};
3890
+ }
3696
3891
  // Boundary adjacency in pinned scenes (presentation-only): auto-layout ranks
3697
3892
  // a degree-1 boundary relative to the free lanes, so in a scene where the
3698
3893
  // real content is pinned to a compact box the boundary can drift to a far
@@ -3794,6 +3989,27 @@ function renderScene(doc,y0){
3794
3989
  const B=byId[t], m=g.length;
3795
3990
  g.forEach((e,k)=>{ chPlan.get(e).ex=B.x+B.w*(m-k)/(m+1); });
3796
3991
  }
3992
+ // RETURN LANES — the other axis. A back edge got a lane in ONE axis and
3993
+ // not the other: each route was handed its own COLUMN out in the channel
3994
+ // and then every route into one target came home along that target's
3995
+ // CENTRE line, so N returns drew as one line. bfd-session put three of
3996
+ // them (452 px, 263 px, 263 px of shared ink) on y=46, and the figure
3997
+ // showed one horizontal stroke with three arrowheads stacked on it.
3998
+ // The entry now fans across the target's border exactly as a ring hub
3999
+ // entry fans across its top, and the ORDER is what keeps the returns from
4000
+ // crossing one another: an outer return has to pass every inner column on
4001
+ // its way in, so it must arrive BEYOND where those columns stop —
4002
+ // innermost ring takes the lane furthest from the channel's turn-in side,
4003
+ // outermost the nearest. The fraction is stored, not the coordinate,
4004
+ // because the three entry forms need it on different edges of the box
4005
+ // (right border, bottom border, detour into the bottom). One back edge
4006
+ // into a target still lands on the centre line (m=1 -> 1/2), so every
4007
+ // figure without a fan-in is byte-unchanged.
4008
+ for(const t in byT){
4009
+ const g=byT[t].filter(e=>!chPlan.get(e).ringOK&&e.a!==e.b);
4010
+ const m=g.length;
4011
+ g.forEach((e,k)=>{ chPlan.get(e).ef=(m-k)/(m+1); });
4012
+ }
3797
4013
  // ring return rows run above the top rank; shift the whole scene down
3798
4014
  // when they would spill into the title band. The shift is uniform
3799
4015
  // (relative geometry, incl. pins, is preserved) and meta.top reports
@@ -3904,6 +4120,167 @@ function renderScene(doc,y0){
3904
4120
  const v=chain[1+Math.floor((chain.length-3)/2)];
3905
4121
  occR=Math.max(occR, v.x+v.w/2+9+lblPx(e.mid));
3906
4122
  });
4123
+ // ── merge bus (item 26 stage 1) ──────────────────────────────────────────
4124
+ // Three or more edges that arrive at the SAME target carrying the SAME
4125
+ // (or no) label are one statement — "all of these go there" — and a drawing
4126
+ // tool draws it once: each source drops to a shared rail, the rail runs to
4127
+ // one trunk, the trunk enters the target with ONE arrowhead and ONE label,
4128
+ // and the joins are marked with junction dots. Drawing three lines to one
4129
+ // box and repeating one label three times is what this removes.
4130
+ //
4131
+ // Each member still emits its OWN full path from its source outline to the
4132
+ // target outline — shape-check asserts exactly that, and `data-edge` carries
4133
+ // one source line — so the shared trunk is stroked once per member. That
4134
+ // coincidence is the convention and not a defect, and the members say so:
4135
+ // every bus path carries `data-bus="<target>"`, which is what lets a reader
4136
+ // (and layout-lint) tell a deliberate trunk from two edges hidden under each
4137
+ // other.
4138
+ //
4139
+ // ── THE FIGURE-LEVEL STYLE DECISION (item 26's unresolved tension) ────────
4140
+ // A bus is axis-aligned by construction, so a figure that takes one has
4141
+ // taken an orthogonal convention. Item 26 records the failure mode: keeping
4142
+ // the incumbent PER EDGE leaves a figure with diagonal and orthogonal routes
4143
+ // mixed, and the mixture itself reads unprofessional (`dhcp-client` was
4144
+ // rejected on exactly that). So the decision is taken ONCE PER FIGURE and it
4145
+ // is ALL-OR-NONE:
4146
+ //
4147
+ // 1. enumerate every eligible group (the topological test above: three or
4148
+ // more forward `->` edges, one target, one label, one stroke and dash,
4149
+ // no endpoint labels, no pinned endpoint, no source an ancestor of
4150
+ // another source);
4151
+ // 2. build and test each one — every leg must clear every node it does not
4152
+ // touch and every group box it does not belong to, the sources must all
4153
+ // lie on one side of the target along the flow axis with room for a
4154
+ // rail, and the bus must not cross more of the figure than the routes
4155
+ // it replaces (item 26's "kept unless strictly beaten", moved from the
4156
+ // edge to the group);
4157
+ // 3. IF ANY ELIGIBLE GROUP FAILS, THE FIGURE ADOPTS NO BUS AT ALL.
4158
+ //
4159
+ // Clause 3 is the whole of the style rule. A figure with one convergence
4160
+ // merged into a trunk and another left as a fan is the mixed drawing; a
4161
+ // figure where every convergence is a trunk, or none is, is one drawing
4162
+ // either way. There is deliberately no per-edge escape.
4163
+ const busRoute=new Map();
4164
+ {
4165
+ const RAIL_GAP=22, RAIL_CLEAR=12, RAIL_ROOM=30;
4166
+ const fLo=n=>horiz?n.x:n.y, fHi=n=>horiz?n.x+n.w:n.y+n.h;
4167
+ const cC =n=>horiz?n.y+n.h/2:n.x+n.w/2;
4168
+ const P=(f,c)=>horiz?[f,c]:[c,f]; // (flow,cross) -> [x,y]
4169
+ const gObs=[];
4170
+ 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}); }
4171
+ const obsFor=(s,t)=>{
4172
+ 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}));
4173
+ const inG=(b,q)=>q[0]>b.x&&q[0]<b.x+b.w&&q[1]>b.y&&q[1]<b.y+b.h;
4174
+ const ps=[s.x+s.w/2,s.y+s.h/2], pt=[t.x+t.w/2,t.y+t.h/2];
4175
+ for(const b of gObs) if(!inG(b,ps)&&!inG(b,pt)) o.push(b);
4176
+ return o;
4177
+ };
4178
+ // The incumbent a bus is measured against, reconstructed exactly as the
4179
+ // edge loop would draw it in THIS layout: a multi-rank edge follows its
4180
+ // waypoint chain, everything else is the straight border-to-border line.
4181
+ // Placement is untouched by the bus, so this is a like-for-like comparison
4182
+ // inside one drawing — not a comparison across two layouts, which is the
4183
+ // mistake item 27 was rejected for.
4184
+ const incumbent=e=>{
4185
+ const A=byId[e.a], B=byId[e.b], ch=chains.get(e);
4186
+ const pp=[];
4187
+ if(ch) for(const v of ch.slice(1,-1)) pp.push([v.x+v.w/2,v.y+v.h/2]);
4188
+ const first=pp.length?pp[0]:[B.x+B.w/2,B.y+B.h/2];
4189
+ const last =pp.length?pp[pp.length-1]:[A.x+A.w/2,A.y+A.h/2];
4190
+ return [borderPoint(A,first[0],first[1]),...pp,borderPoint(B,last[0],last[1])];
4191
+ };
4192
+ // crossing count of a polyline against the rest of the figure's incumbent
4193
+ // geometry — the term item 26's score weights highest, and the only one on
4194
+ // which "never worse" is worth promising for a construct whose whole point
4195
+ // is to share ink.
4196
+ const busMem=new Set(); for(const m of busGroups) for(const e of m) busMem.add(e);
4197
+ const others=[];
4198
+ for(const e of edges){
4199
+ if(!byId[e.a]||!byId[e.b]||e.a===e.b) continue;
4200
+ if(busMem.has(e)) continue;
4201
+ if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)) continue; // channel routes: not reconstructible here
4202
+ others.push(incumbent(e));
4203
+ }
4204
+ const xseg=(a,b,c,d)=>{
4205
+ const rx=b[0]-a[0], ry=b[1]-a[1], sx=d[0]-c[0], sy=d[1]-c[1];
4206
+ const den=rx*sy-ry*sx; if(Math.abs(den)<1e-9) return false;
4207
+ 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;
4208
+ return t>1e-6&&t<1-1e-6&&u>1e-6&&u<1-1e-6;
4209
+ };
4210
+ // ...and a route that pierces a box counts the same as a crossing, because
4211
+ // a short crossing-free line that goes straight through a node is not a
4212
+ // better drawing than a long one that goes round it.
4213
+ const pierceCount=(rts,mem)=>{
4214
+ let n=0;
4215
+ rts.forEach((r,i)=>{
4216
+ const A=byId[mem[i].a], B=byId[mem[i].b];
4217
+ const obs=obsFor(A,B);
4218
+ for(let k=0;k+1<r.length;k++) if(segHitsObs(r[k],r[k+1],obs)){ n++; break; }
4219
+ });
4220
+ return n;
4221
+ };
4222
+ const crossCount=rts=>{
4223
+ let n=0;
4224
+ const pairs=rts.map(r=>r).concat(others);
4225
+ for(let i=0;i<rts.length;i++) for(let j=0;j<pairs.length;j++){
4226
+ if(pairs[j]===rts[i]) continue;
4227
+ if(j<rts.length&&j<i) continue; // count each member pair once
4228
+ for(let a=0;a+1<rts[i].length;a++) for(let b=0;b+1<pairs[j].length;b++)
4229
+ if(xseg(rts[i][a],rts[i][a+1],pairs[j][b],pairs[j][b+1])) n++;
4230
+ }
4231
+ return n;
4232
+ };
4233
+ const built=[];
4234
+ let figureOK=busGroups.length>0;
4235
+ for(const mem of busGroups){
4236
+ if(!figureOK) break;
4237
+ const T=byId[mem[0].b], src=mem.map(e=>byId[e.a]);
4238
+ let dir=0;
4239
+ if(src.every(s=>fLo(T)-fHi(s)>=RAIL_ROOM)) dir=1;
4240
+ else if(src.every(s=>fLo(s)-fHi(T)>=RAIL_ROOM)) dir=-1;
4241
+ else { figureOK=false; break; } // no room for a rail
4242
+ const railF=dir>0
4243
+ ? Math.min(fLo(T)-RAIL_CLEAR, Math.max(fLo(T)-RAIL_GAP, Math.max(...src.map(fHi))+RAIL_CLEAR))
4244
+ : Math.max(fHi(T)+RAIL_CLEAR, Math.min(fHi(T)+RAIL_GAP, Math.min(...src.map(fLo))-RAIL_CLEAR));
4245
+ const tc=cC(T);
4246
+ const cand=[]; let ok=true;
4247
+ for(const e of mem){
4248
+ const s=byId[e.a], cs=cC(s);
4249
+ const j=P(railF,cs), h=P(railF,tc);
4250
+ const pts=Math.abs(cs-tc)<0.5
4251
+ ? [borderPoint(s,h[0],h[1]), h, borderPoint(T,h[0],h[1])]
4252
+ : [borderPoint(s,j[0],j[1]), j, h, borderPoint(T,h[0],h[1])];
4253
+ const obs=obsFor(s,T);
4254
+ for(let i=0;i+1<pts.length;i++) if(segHitsObs(pts[i],pts[i+1],obs)) ok=false;
4255
+ if(!ok) break;
4256
+ cand.push({e,cs,pts});
4257
+ }
4258
+ if(!ok){ figureOK=false; break; } // a leg pierces something
4259
+ const bpts=cand.map(c=>c.pts), ipts=mem.map(incumbent);
4260
+ const bc=crossCount(bpts)+pierceCount(bpts,mem);
4261
+ const ic=crossCount(ipts)+pierceCount(ipts,mem);
4262
+ if(bc>ic){
4263
+ figureOK=false; break; // not beaten: keep the incumbents
4264
+ }
4265
+ // junction dots mark the interior joins only: the two ends of the rail
4266
+ // are corners, not junctions, and a dot on a corner is wrong.
4267
+ const xs=cand.map(c=>c.cs).concat([tc]);
4268
+ const cLo=Math.min(...xs), cHi=Math.max(...xs);
4269
+ const dots=[];
4270
+ for(const c of cand) if(c.cs>cLo+0.5&&c.cs<cHi-0.5) dots.push(P(railF,c.cs));
4271
+ 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));
4272
+ // one label and one arrowhead for the whole bus: the member whose rail
4273
+ // run is longest carries the label, document order breaks the tie.
4274
+ let lead=cand[0], best=-1;
4275
+ for(const c of cand){ const d=Math.abs(c.cs-tc); if(d>best+0.5){ best=d; lead=c; } }
4276
+ built.push({T,cand,dots,lead});
4277
+ }
4278
+ // Nothing to undo when the figure declines: the bus is a routing pass and
4279
+ // the layout it declines is the layout it already had.
4280
+ if(figureOK) for(const g of built)
4281
+ g.cand.forEach((c,i)=>busRoute.set(c.e,{pts:c.pts,bus:g.T.id,lead:c===g.lead,
4282
+ dots:i===g.cand.length-1?g.dots:null, arrow:i===g.cand.length-1}));
4283
+ }
3907
4284
  for(const e of edges){
3908
4285
  const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
3909
4286
  // an edge is pure stroke: `stroke=` and `fill=` name the same channel
@@ -3923,6 +4300,29 @@ function renderScene(doc,y0){
3923
4300
  const m1='', m2=''; // markers removed — arrowTri() paints triangles above nodes in lblsvg
3924
4301
  const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
3925
4302
  const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
4303
+ const bus=busRoute.get(e);
4304
+ if(bus){
4305
+ const pts=bus.pts;
4306
+ // data-bus is written LAST so every reader that keys on the
4307
+ // `d=… fill=none stroke=… stroke-width=1.6` prefix is unaffected.
4308
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+' data-bus="'+esc(bus.bus)+'"/>');
4309
+ noteSegs(e,pts);
4310
+ for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+4-y0-20); }
4311
+ if(bus.dots) for(const d of bus.dots)
4312
+ lblsvg.push('<circle cx="'+d[0]+'" cy="'+d[1]+'" r="3" fill="'+col+'" stroke="none"/>');
4313
+ // the trunk is drawn once by every member; the label and the arrowhead
4314
+ // are drawn ONCE for the bus, which is the whole point of merging it.
4315
+ if(bus.lead&&e.mid){ // longest rail run carries the one label
4316
+ let bi=0,bl=-1;
4317
+ for(let i=0;i+1<pts.length;i++){
4318
+ const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
4319
+ if(l>bl){ bl=l; bi=i; }
4320
+ }
4321
+ reqLabel({p:pts[bi],q:pts[bi+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:bi===0});
4322
+ }
4323
+ if(bus.arrow&&wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
4324
+ continue;
4325
+ }
3926
4326
  if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
3927
4327
  // ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
3928
4328
  // Edge labels are DEFERRED: every label is registered against its
@@ -3940,6 +4340,32 @@ function renderScene(doc,y0){
3940
4340
  // convention of every drawing tool — never a lap of the figure
3941
4341
  // through the back-edge channel. Side order r,l,b,t; first side
3942
4342
  // whose loop box overlaps no other node wins (deterministic).
4343
+ //
4344
+ // THE LOOP AND THE CHANNEL SHARE THIS SIDE, AND THAT IS A KNOWN,
4345
+ // MEASURED, UNFIXED DEFECT. A loop hangs off one side of the box on
4346
+ // the box's MID line; a channel back edge leaves and enters on the
4347
+ // SAME side (right under vertical flow, bottom under horizontal) at
4348
+ // rows near that same mid line — so a state that both loops and takes
4349
+ // a channel route has a line drawn across a 20 px ornament. It is
4350
+ // CROSSING, not shared ink: measured over the whole corpus, no
4351
+ // self-loop shares more than 0 px of collinear ink with anything.
4352
+ // bfd-session is the only figure where it bites (turnstile's two loops
4353
+ // are clean), and there it is 12 crossings over four loops.
4354
+ //
4355
+ // THE OBVIOUS FIX WAS BUILT AND REJECTED, so it is not re-attempted
4356
+ // blind: treat the channel side as occupied and take the next free
4357
+ // side. bfd-session's crossings fall 15 -> 4 and every loop comes
4358
+ // clean — but DOWN, INIT and UP have only 'l' free (their 'b' and 't'
4359
+ // boxes sit on the spine, which loopHit does not test), and the left
4360
+ // of a scene is only PADL=18 px wide. Their three trigger labels were
4361
+ // placed at x = -102.6, -73.4 and -57.1 and CLIPPED OFF THE CANVAS —
4362
+ // three labels lost to buy eleven crossings, which is the wrong trade
4363
+ // in the direction label placement has been moving all week.
4364
+ // WHAT WOULD REOPEN IT: a left-margin mechanism for the scene (the
4365
+ // uniform-shift pattern bShift/chShift already use, applied before the
4366
+ // label pass), so a loop and its label can hang off the left at all.
4367
+ // Until then the loop stays on the channel side and the crossing is
4368
+ // recorded rather than papered over.
3943
4369
  const scy=A.y+A.h/2, scx=A.x+A.w/2;
3944
4370
  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]]
3945
4371
  :sd==='l'?[[A.x,scy-8],[A.x-20,scy-8],[A.x-20,scy+8],[A.x,scy+8]]
@@ -3956,7 +4382,15 @@ function renderScene(doc,y0){
3956
4382
  for(const p of sp){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
3957
4383
  esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
3958
4384
  noteSegs(e,sp);
3959
- 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});
4385
+ // A self-loop's outer run is 16 px long, so sliding the label ALONG it
4386
+ // buys ~15 px and no escape at all from a line crossing it — and a
4387
+ // back edge leaves the same node on the same side at the same mid-y,
4388
+ // which is how bfd-session drew three self-loop labels with a line
4389
+ // through them. Parameters outside [0,1] are offered too: they park the
4390
+ // box just above or just below the loop, still hard against it, which
4391
+ // is a placement a reader still reads as belonging to the loop.
4392
+ 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,
4393
+ ts:[0.5,0.2,0.8,-0.7,1.7,-1.4,2.4],tw:10});
3960
4394
  if(e.tail) seg(sp[0],sp[1],0.5,e.tail,10);
3961
4395
  if(e.head) seg(sp[3],sp[2],0.5,e.head,10);
3962
4396
  if(wantsStart) arrowTri(sp[0],sp[1],col);
@@ -3972,47 +4406,68 @@ function renderScene(doc,y0){
3972
4406
  const lane=r=>(ranksArr[r]||[]).filter(n=>!n.virtual);
3973
4407
  const P=chPlan.get(e), ring=P.ring;
3974
4408
  const pts=[];
4409
+ // WHERE A BACK-EDGE LABEL GOES. It used to be registered on
4410
+ // the CHANNEL run — the long leg out in the side channel, past every node
4411
+ // in the figure. That is the furthest point on the route from either
4412
+ // endpoint, and every back edge's channel run is in the same channel, so
4413
+ // the labels landed in one column with nothing but proximity to say which
4414
+ // line each named (bfd-session parked three of them around x=1100 while
4415
+ // its four states occupied x 57-200). The label now rides the first
4416
+ // stretch of the route AS IT LEAVES THE SOURCE, where the reader can see
4417
+ // which box the line comes out of. The stub is capped so the candidate
4418
+ // parameters land the box beside the source rather than halfway to the
4419
+ // channel; a shorter first leg just uses all of itself. The cap has to
4420
+ // scale with the LABEL, not be a constant: at the middle of a stub the
4421
+ // box spans the midpoint plus and minus half its width, so a stub
4422
+ // shorter than the label puts the box back on top of the source box
4423
+ // whatever parameter is chosen (bfd-session's "Detect expired, Echo
4424
+ // failed" is 169 px wide and a fixed 64 px stub buried it in INIT).
4425
+ const srcStub=(pp,wpx)=>{
4426
+ const a=pp[0], b=pp[1], L=Math.hypot(b[0]-a[0],b[1]-a[1])||1;
4427
+ const k=Math.min(1,Math.max(64,wpx+24)/L);
4428
+ return [a,[a[0]+(b[0]-a[0])*k, a[1]+(b[1]-a[1])*k]];
4429
+ };
3975
4430
  if(horiz){ // channel runs below the lanes
3976
4431
  const chY=occB+28+P.slot; // labels ride ON the channel
3977
4432
  const colR=r=>Math.max(...lane(r).map(n=>n.x+n.w));
3978
4433
  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);
3979
- 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;
4434
+ 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;
3980
4435
  if(A!==B&&blockedV(A.y+A.h,chY,sx,A)){
3981
4436
  const gx=colR(A.rank)+10+ring*7;
3982
4437
  pts.push([outSide(A,'r'),A.y+A.h/2],[gx,A.y+A.h/2],[gx,chY]);
3983
4438
  } else pts.push([sx,outSide(A,'b')],[sx,chY]);
3984
4439
  if(A!==B&&blockedV(B.y+B.h,chY,tx,B)){
3985
4440
  const gx=colR(B.rank)+10+ring*7;
3986
- pts.push([gx,chY],[gx,B.y+B.h/2],[outSide(B,'r'),B.y+B.h/2]);
4441
+ pts.push([gx,chY],[gx,B.y+B.h*P.ef],[outSide(B,'r'),B.y+B.h*P.ef]);
3987
4442
  } else pts.push([tx,chY],[tx,outSide(B,'b')]);
3988
4443
  if(e.mid){
3989
- const c1=pts.findIndex(p=>p[1]===chY);
3990
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
4444
+ const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[1]===chY);
4445
+ 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});
3991
4446
  }
3992
4447
  } else if(P.ringOK){ // concentric ring: under, around, over, in
3993
4448
  const sx=A.x+A.w/2;
3994
4449
  const gy=occB+14+ring*12, chX=occR+28+P.slot, topY=chTop-14-ring*12;
3995
4450
  pts.push([sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]);
3996
4451
  if(e.mid){
3997
- const c1=pts.findIndex(p=>p[0]===chX);
3998
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
4452
+ const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
4453
+ 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});
3999
4454
  }
4000
4455
  } else { // channel runs right of the lanes
4001
4456
  const chX=occR+28+P.slot;
4002
4457
  const laneB=r=>Math.max(...lane(r).map(n=>n.y+n.h));
4003
4458
  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);
4004
- 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;
4459
+ 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;
4005
4460
  if(A!==B&&blockedH(A.x+A.w,chX,sy,A)){
4006
4461
  const gy=laneB(A.rank)+10+ring*7;
4007
4462
  pts.push([A.x+A.w/2,outSide(A,'b')],[A.x+A.w/2,gy],[chX,gy]);
4008
4463
  } else pts.push([outSide(A,'r'),sy],[chX,sy]);
4009
4464
  if(A!==B&&blockedH(B.x+B.w,chX,ty,B)){
4010
4465
  const gy=laneB(B.rank)+10+ring*7;
4011
- pts.push([chX,gy],[B.x+B.w/2,gy],[B.x+B.w/2,outSide(B,'b')]);
4466
+ pts.push([chX,gy],[B.x+B.w*P.ef,gy],[B.x+B.w*P.ef,outSide(B,'b')]);
4012
4467
  } else pts.push([chX,ty],[outSide(B,'r'),ty]);
4013
4468
  if(e.mid){
4014
- const c1=pts.findIndex(p=>p[0]===chX);
4015
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
4469
+ const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
4470
+ 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});
4016
4471
  }
4017
4472
  }
4018
4473
  // non-incident nodes are obstacles for the channel runs too: a run
@@ -4209,6 +4664,21 @@ function renderScene(doc,y0){
4209
4664
  // So the scaffolding is hoisted and only the LOOP keeps the guard.
4210
4665
  {
4211
4666
  const obst=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h,n}));
4667
+ // An `external` is never DRAWN as a shape, so its 12x12 anchor is not ink
4668
+ // and is rightly excluded above — but its LABEL is ink, and this pass could
4669
+ // not see it. arp-resolution put a 234 px edge label straight through
4670
+ // "rest of the LAN / (hosts C, D, ...)". The label box is added here with
4671
+ // the same geometry the node pass below emits it at, so the obstacle and
4672
+ // the drawing cannot disagree.
4673
+ for(const n of nodes){
4674
+ if(!n.boundary||!n.label) continue;
4675
+ const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
4676
+ const bw=lblPx(n.label), bl=String(n.label).split('\n').length, bh=13*bl;
4677
+ let ox,oy;
4678
+ if(Math.abs(bdx)>=Math.abs(bdy)){ ox=bdx>=0?cx+10:cx-10-bw; oy=cy+3.5-13*bl/2-1.5; }
4679
+ else { ox=cx-bw/2; oy=(bdy>=0?cy+17:cy-10)-13*bl/2-1.5; }
4680
+ obst.push({x:ox,y:oy,w:bw,h:bh,n:null});
4681
+ }
4212
4682
  const ovl=(a,b)=>{
4213
4683
  const ix=Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x);
4214
4684
  const iy=Math.min(a.y+a.h,b.y+b.h)-Math.max(a.y,b.y);
@@ -4229,26 +4699,54 @@ function renderScene(doc,y0){
4229
4699
  return t1>t0;
4230
4700
  };
4231
4701
  const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
4232
- const cand=(r,t,side)=>{
4702
+ // SLOPE CLEARANCE (`cl`): "3 px above the line" clears the line only where
4703
+ // the box touches it. The offsets are axis-aligned while the segment is
4704
+ // not, so on a diagonal the line keeps climbing across the box's WIDTH and
4705
+ // re-enters it — which is why a label could sit squarely across its own
4706
+ // edge and the drawing showed a strikethrough. Over half a box the line
4707
+ // rises |dy/dx|*w/2, so that much extra offset is exactly what puts the
4708
+ // whole box on one side of the line. It is offered as a SECOND candidate
4709
+ // per side (cl=1) rather than imposed, priced per pixel of displacement
4710
+ // below: a label 7 px further out to stop being struck is worth it, a
4711
+ // 90 px shove for a long label on a 45 degree line is not, and the scorer
4712
+ // decides which case it is holding.
4713
+ const cand=(r,t,side,cl)=>{
4233
4714
  const lines=String(r.text).split('\n'), n=lines.length;
4234
4715
  const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
4235
4716
  const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
4236
4717
  const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
4237
4718
  const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
4719
+ const sdx=Math.abs(r.q[0]-r.p[0]), sdy=Math.abs(r.q[1]-r.p[1]);
4720
+ let ex=0;
4721
+ if(cl){
4722
+ if(side==='above'||side==='below') ex=sdx>1e-9?Math.min(1,sdy/sdx)*w/2:0;
4723
+ else if(side==='right'||side==='left') ex=sdy>1e-9?Math.min(1,sdx/sdy)*h/2:0;
4724
+ }
4238
4725
  let bx,by,x,anchor=n>1?'middle':'start';
4239
4726
  if(side==='on') { bx=mx-w/2; by=my-4-up; anchor='middle'; }
4240
- else if(side==='above') { bx=mx-w/2; by=my-3-h; anchor='middle'; }
4241
- else if(side==='below') { bx=mx-w/2; by=my+3; anchor='middle'; }
4242
- else if(side==='right') { bx=mx+6; by=my-h/2; }
4243
- else { bx=mx-6-w; by=my-h/2; }
4727
+ else if(side==='above') { bx=mx-w/2; by=my-3-h-ex; anchor='middle'; }
4728
+ else if(side==='below') { bx=mx-w/2; by=my+3+ex; anchor='middle'; }
4729
+ else if(side==='right') { bx=mx+6+ex; by=my-h/2; }
4730
+ else { bx=mx-6-w-ex; by=my-h/2; }
4244
4731
  x=anchor==='middle'?bx+w/2:bx;
4245
- return {x,y:by+up,anchor,t,side,box:{x:bx,y:by,w,h}};
4732
+ return {x,y:by+up,anchor,t,side,ex,box:{x:bx,y:by,w,h}};
4246
4733
  };
4247
4734
  const placed=[];
4248
- if(lblReq.length) for(const r of lblReq){
4735
+ // A request may name a SECOND carrying segment (`alt`). Back edges do: the
4736
+ // stub leaving the source is the preferred carrier because it says which
4737
+ // box the line comes out of, but on a figure where two edges leave the same
4738
+ // node the stub can only put the label where an earlier one already sits
4739
+ // (flowchart-b drew "no" twice, one under the other, and neither said which
4740
+ // line it named). The alternate carrier — the channel run — is offered at a
4741
+ // flat surcharge so it is taken only when the stub really has nowhere.
4742
+ if(lblReq.length) for(const r0 of lblReq){
4743
+ const carriers=[[r0.p,r0.q]].concat(r0.alt?[r0.alt]:[]);
4744
+ let best=null,bestS=Infinity;
4745
+ for(let ci=0;ci<carriers.length;ci++){
4746
+ const r=ci?Object.assign({},r0,{p:carriers[ci][0],q:carriers[ci][1]}):r0;
4249
4747
  const dx=r.q[0]-r.p[0], dy=r.q[1]-r.p[1];
4250
4748
  const across=Math.abs(dx)>=Math.abs(dy);
4251
- let sides, ts, tPref;
4749
+ let sides, ts, tPref, apWant=null;
4252
4750
  if(r.kind==='end'){
4253
4751
  // endpoint labels keep their historical spot as first choice
4254
4752
  sides=['on'].concat(across?['above','below']:['right','left']);
@@ -4256,6 +4754,30 @@ function renderScene(doc,y0){
4256
4754
  ts=[r.t0,r.t0-0.06,r.t0+0.06,r.t0-0.12,r.t0+0.12].map(CLAMP);
4257
4755
  } else {
4258
4756
  sides=across?['above','below']:['right','left'];
4757
+ // ANTI-PARALLEL PAIRS: the label belongs on the OUTSIDE of its own
4758
+ // stroke. `apOff` moved the two strokes of an A->B / B->A pair to
4759
+ // opposite sides of the pair's centre line so they stop coinciding —
4760
+ // "so opposite directions land on opposite sides" — and the label
4761
+ // rides its own offset segment. But which SIDE of that segment the
4762
+ // text lands on was decided here, independently, by score, and the two
4763
+ // strokes are only 7 px apart, so the two candidate sets are nearly
4764
+ // identical. Both labels took the same side and the pair drew as two
4765
+ // lines of text stacked 1.6 px apart (tcp-state-machine: "passive OPEN
4766
+ // / create TCB" directly over "CLOSE / delete TCB", 117 px of shared
4767
+ // width, one of them lying across the partner's stroke).
4768
+ // The offset vector IS the index that decided which side the stroke
4769
+ // took, so `apWant` is read straight off it. It is not merely ORDERED
4770
+ // first: measured on that pair, the outside candidate cost 52 and the
4771
+ // stacked one 36, because a stack that does not actually OVERLAP costs
4772
+ // the scorer NOTHING while the outside position crossed one edge (26).
4773
+ // Ordering is worth 10 and could not move it. The wrong side is
4774
+ // therefore PRICED, in the band the identical-text term already uses
4775
+ // (34): an anti-parallel pair is exactly two lines a reader must tell
4776
+ // apart, and a label on the inside of its own stroke — between the two,
4777
+ // or beyond the partner — has stopped saying which one it names, which
4778
+ // is the same defect that term exists to charge for.
4779
+ const apv=apOff.get(r.e);
4780
+ apWant=apv?(across?(apv[1]<0?'above':'below'):(apv[0]<0?'left':'right')):null;
4259
4781
  // flowchart convention: a short branch marker leaving a decision node
4260
4782
  // reads as that branch's name only if it sits next to the decision.
4261
4783
  // `FLOWCHART-ROLE-KEYWORDS`: the test is the ROLE, not the geometry. Until
@@ -4266,22 +4788,41 @@ function renderScene(doc,y0){
4266
4788
  const branch=r.first && r.A && r.A.role==='decision' &&
4267
4789
  String(r.text).length<=3 && !String(r.text).includes('\n');
4268
4790
  tPref=branch?0.22:0.5;
4269
- ts=branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72];
4791
+ 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]);
4270
4792
  }
4271
- let best=null,bestS=Infinity;
4272
- for(let si=0;si<sides.length;si++) for(const t of ts){
4273
- const c=cand(r,t,sides[si]);
4793
+ for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]){
4794
+ const c=cand(r,t,sides[si],cl);
4274
4795
  let s=0;
4275
4796
  for(const b of placed) s+=3*ovl(c.box,b);
4276
4797
  for(const o of obst) s+=(o.n===r.A||o.n===r.B?6:2.4)*ovl(c.box,o);
4277
4798
  for(const a of arrowBox) s+=4*ovl(c.box,a);
4278
- for(const g of edgeSegs) if(g.e!==r.e && segHit(g.p,g.q,c.box)) s+=26;
4279
- s+=70*Math.abs(t-tPref)+si*10;
4799
+ // The label's OWN edge is charged like any other. It used to be exempt
4800
+ // (`g.e!==r.e`), which made a label lying across the line it names FREE
4801
+ // — and that is the single commonest way a label stops saying which
4802
+ // line it belongs to, so the exemption was paying for the defect.
4803
+ for(const g of edgeSegs) if(segHit(g.p,g.q,c.box)) s+=26;
4804
+ s+=0.35*c.ex; // price of the slope-clearance displacement
4805
+ s+=ci*30; // price of leaving the preferred carrier
4806
+ // Two identical texts sitting side by side is the defect in its purest
4807
+ // form: neither of them says which line it belongs to, and no overlap
4808
+ // test can see it because they do not overlap.
4809
+ for(const b of placed) if(b.text===r.text &&
4810
+ 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;
4811
+ // the inside of an anti-parallel pair — see `apWant` above
4812
+ if(apWant&&c.side!==apWant) s+=34;
4813
+ // The pull back toward the preferred point is priced in PARAMETER
4814
+ // units, so the same number means 70/L per pixel: cheap along a 900 px
4815
+ // channel leg, ruinous along a 16 px self-loop run. A request that
4816
+ // offers parameters outside [0,1] states its own weight so its escape
4817
+ // positions cost what they are worth in pixels rather than being
4818
+ // priced out by the length of the thing they slide along.
4819
+ s+=(r.tw||70)*Math.abs(t-tPref)+si*10;
4280
4820
  if(c.box.x<2) s+=400; // would fall off the left margin
4281
4821
  if(s<bestS-1e-9){ bestS=s; best=c; }
4282
4822
  }
4283
- lblsvg[r.idx]=textEl(best.x,best.y,r.fs,best.anchor,r.col,r.text,r.halo);
4284
- placed.push(best.box);
4823
+ }
4824
+ lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,r0.halo);
4825
+ placed.push(Object.assign({text:r0.text},best.box));
4285
4826
  W=Math.max(W, best.box.x+best.box.w+4);
4286
4827
  Hh=Math.max(Hh, best.box.y+best.box.h+4-y0-20);
4287
4828
  }
@@ -5248,7 +5789,17 @@ function renderTable(t,y0){
5248
5789
  // and a threshold is a statement about values. `h1..hN` and `1..` are already
5249
5790
  // separate address spaces in this genre (genres/table.md), so the split is
5250
5791
  // the genre's own and not invented here.
5251
- return {svg:svg.join(''), y:yEnd+6, w:totalW+2,
5792
+ // THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE CAPTION IS INK.
5793
+ // `w` was the GRID's width alone, so a caption longer than the table it names
5794
+ // ran past the right edge of the section and was CLIPPED — patterns/table-b
5795
+ // shipped as "Feature Matrix — rowspan/colspan merges with c", losing 86 px
5796
+ // of a sentence that is the only place the figure says what it is about. The
5797
+ // grid is not the figure; the caption is not decoration.
5798
+ // Bold at 13 px is wider than `CH` (a regular-weight advance), so the caption
5799
+ // is measured with the same 8% allowance the raster needed — verified by
5800
+ // rendering, not assumed.
5801
+ const capW=cwMax(t.label)*CH*1.08+2;
5802
+ return {svg:svg.join(''), y:yEnd+6, w:Math.max(totalW+2,capW),
5252
5803
  box:{x0:0, x1:totalW, yA:yTop+yAt[H], yB:yEnd}};
5253
5804
  }
5254
5805
 
@@ -5266,7 +5817,26 @@ function renderChart(b,y0,doc){
5266
5817
  const R=rows.length, C=cLab.length;
5267
5818
  const zmax=Math.max(...rows.flat(), 1);
5268
5819
  const W2=20,H2=10,ZS=130/zmax,BAR=0.72;
5269
- const ox=R*W2+8, oy=y0+18+ZS*zmax+6;
5820
+ // LEFT GUTTER for the row labels. They are anchored `end` at the floor's
5821
+ // left corner and hang LEFTWARD from it, and nothing reserved room for them:
5822
+ // the section's width is measured from the floor's RIGHT corner, and a
5823
+ // section has no mechanism to grow leftwards, so any row label wider than
5824
+ // its corner's own offset was clipped away at x<0 and the reader saw a
5825
+ // sliver or nothing. Measured on the shipped corpus: telemetry-export lost
5826
+ // 16.0 px of "Export ring" (24.6% of the box) and 41.9 px of "gRPC encoder"
5827
+ // (59.1%), and table-experimental shaved "00:05".
5828
+ // The LABEL is not moved. A row label belongs beside its row — that
5829
+ // adjacency is what makes it a row label rather than a caption — so the
5830
+ // ORIGIN moves right instead, by exactly what the widest label overhangs.
5831
+ // That is the "grow the canvas" answer, and it is the right one here
5832
+ // because the space is genuinely needed: no placement of a right-anchored
5833
+ // label at the left edge of the floor can avoid needing a margin, and the
5834
+ // gutter costs only the width it actually uses (0 when no label overhangs,
5835
+ // so every chart whose labels already fitted is byte-unchanged).
5836
+ const rLabPx=l=>cwMax(l)*6.5*10/11; // textEl draws these at font-size 10
5837
+ const ox0=R*W2+8;
5838
+ const gut=Math.max(0,...rLab.map((l,r)=>rLabPx(l)+4-(ox0-(r+0.65)*W2)));
5839
+ const ox=ox0+gut, oy=y0+18+ZS*zmax+6;
5270
5840
  const P=(r,c,z)=>[ox+(c-r)*W2, oy+(c+r)*H2-z*ZS];
5271
5841
  const svg=[];
5272
5842
  svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' — bar3d</text>');