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
package/dist/figdown.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// figdown.mjs — FigDown embeddable library (0.3.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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;
|
|
4136
|
-
else if(side==='below') { bx=mx-w/2; by=my+3;
|
|
4137
|
-
else if(side==='right') { bx=mx+6;
|
|
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
|
-
|
|
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
|
|
4167
|
-
|
|
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
|
-
|
|
4174
|
-
|
|
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
|
-
|
|
4179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>');
|