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