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/.claude-plugin/plugin.json +2 -2
- package/dist/figdown.js +613 -43
- package/dist/figdown.mjs +613 -43
- package/examples/evpn-fabric.svg +1 -1
- package/examples/showcase/arp-resolution.svg +1 -1
- package/examples/showcase/ethernet-frame.svg +1 -1
- package/examples/showcase/l2-forwarding-logic.svg +1 -1
- package/examples/showcase/tcp-handshake.svg +1 -1
- package/examples/showcase/tcp-header.svg +1 -1
- package/examples/showcase/tcp-state-machine.svg +1 -1
- package/guide/expressing.md +2 -2
- package/guide/layout.md +21 -13
- package/integrations/mcp-server/README.md +174 -0
- package/integrations/mcp-server/server.js +593 -0
- package/package.json +8 -3
- package/skill/figdown/SKILL.md +14 -4
- package/skill/figdown/figdown.html +611 -41
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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;
|
|
4241
|
-
else if(side==='below') { bx=mx-w/2; by=my+3;
|
|
4242
|
-
else if(side==='right') { bx=mx+6;
|
|
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
|
-
|
|
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
|
|
4272
|
-
|
|
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
|
-
|
|
4279
|
-
|
|
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
|
-
|
|
4284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>');
|