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