@euphrasiologist/lwphylo 1.1.7 → 1.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -0
- package/package.json +9 -5
- package/src/phisheye/phisheye.js +111 -65
- package/src/radial/describeArc.js +22 -12
- package/src/radial/getArcs.js +47 -56
- package/src/radial/getRadii.js +32 -38
- package/src/radial/polarToCartesian.js +5 -7
- package/src/radial/radialData.js +71 -89
- package/src/radial/radialLayout.js +8 -9
- package/src/radial/reflectAngle.js +7 -17
- package/src/rectangle/getChildVerticals.js +47 -0
- package/src/rectangle/getHorizontal.js +13 -7
- package/src/rectangle/getVertical.js +2 -1
- package/src/rectangle/rectangleLayout.js +23 -10
- package/src/unrooted/equalAngleLayout.js +36 -25
- package/src/unrooted/unrooted.js +1 -1
- package/src/utils/edges.js +13 -42
- package/src/utils/indexing.js +64 -0
- package/src/utils/meanAngle.js +10 -35
- package/src/utils/numTips.js +15 -20
- package/src/utils/parentFisheye.js +9 -11
- package/src/utils/preorder.js +33 -6
- package/src/utils/readTree.js +53 -63
- package/dist/@euphrasiologist/lwphylo.js +0 -822
- package/dist/@euphrasiologist/lwphylo.min.js +0 -2
package/dist/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const n={circular:()=>{let n=200,t=2,e=[0,0],r={},i=0,c=0,a=n*n,s=10;function o(){const e=Math.exp(t);return i=e/(e-1)*n,c=t/n,a=n*n,h}function h(n){!function(){if(!r||"function"!=typeof r.xscale||"function"!=typeof r.yscale)throw new Error("phisheye.circular: call .scales(xscale, yscale) before using the fisheye.")}();const t=r.xscale(n.x),o=r.yscale(n.y),h=t-e[0],l=o-e[1],u=h*h+l*l;if(0===u||u>=a)return{x:t,y:o,z:u>=a?1:s};const d=Math.sqrt(u),f=i*(1-Math.exp(-d*c))/d*.75+.25;return{x:e[0]+h*f,y:e[1]+l*f,z:Math.min(f,s)}}return h.radius=function(t){return arguments.length?(n=Math.max(0,+t),o()):n},h.distortion=function(n){return arguments.length?(t=Math.max(0,+n),o()):t},h.focus=function(n){if(!arguments.length)return e.slice();if(!Array.isArray(n)||2!==n.length)throw new Error("focus expects [x, y] in pixels.");return e=[+n[0],+n[1]],h},h.scales=function(n,t){return arguments.length?(r={xscale:n,yscale:t},h):r},h.setScales=h.scales,h.clampZ=function(n){return arguments.length?(s=Math.max(1,+n),h):s},h.focusFromEvent=function(n,t){const e=t.getBoundingClientRect(),r=n.clientX-e.left,i=n.clientY-e.top;return h.focus([r,i])},o()}};function t(n,t,e,r){return{x:n+e*Math.cos(r),y:t+e*Math.sin(r)}}function e(n){const t=n%(2*Math.PI);return t<0?t+2*Math.PI:t}function r(n,r,i,c,a){let s=e(c),o=e(a)-s;o<=-Math.PI?o+=2*Math.PI:o>Math.PI&&(o-=2*Math.PI);const h=Math.abs(o)>Math.PI?1:0,l=o>=0?1:0,u=t(n,r,i,s),d=t(n,r,i,s+o);return["M",u.x,u.y,"A",i,i,0,h,l,d.x,d.y].join(" ")}function i(n,t=[]){t.push(n);for(let e=0;e<(n.children?.length||0);e++)t=i(n.children[e],t);return t}function c(n,t=!0){var e=[];for(const t of i(n))null===t.parent?e.push({parentId:null,parentLabel:null,thisId:t.id,thisLabel:t.label,children:t.children.map(n=>n.id),branchLength:0,isTip:!1,x:t.x,y:t.y,angle:t.angle}):e.push({parentId:t.parent.id,parentLabel:t.parent.label,thisId:t.id,thisLabel:t.label,children:t.children.map(n=>n.id),branchLength:t.branchLength,isTip:0==t.children.length,x:t.x,y:t.y,angle:t.angle});return t&&(e=e.sort(function(n,t){return n.thisId-t.thisId})),e}function a(n){const t=2*Math.PI,e=c(n,!0),r=new Map(e.map(n=>[n.thisId,n])),i=new Map(e.map(n=>[n.thisId,n.children||[]]));let a=null;for(const n of e)if(null==n.parentId){a=n.thisId;break}const s=[];!function n(t){const e=i.get(t)||[];if(0!==e.length)for(const t of e)n(t);else s.push(t)}(a);const o=Math.max(1,s.length),h=new Map;s.forEach((n,e)=>{h.set(n,e/o*t)}),function n(e){const r=i.get(e)||[];for(const t of r)n(t);if(r.length>0){let n=0,i=0;for(const t of r){const e=h.get(t);n+=Math.cos(e),i+=Math.sin(e)}h.set(e,(Math.atan2(i,n)%t+t)%t)}}(a);const l=new Map;l.set(a,0),function n(t){const e=i.get(t)||[],c=l.get(t)||0;for(const t of e){const e=r.get(t),i=e?.branchLength??0;l.set(t,c+i),n(t)}}(a);for(const n of e){const t=h.get(n.thisId)??0,e=l.get(n.thisId)??0;n.angle=t,n.r=e,n.x=e*Math.cos(t),n.y=e*Math.sin(t)}return e}function s(n){const t=n%(2*Math.PI);return t<0?t+2*Math.PI:t}function o(n,t){n=s(n);let e=(t=s(t))-n;return e<=-Math.PI?e+=2*Math.PI:e>Math.PI&&(e-=2*Math.PI),[n,n+e]}function h(n){const t={};return t.data=a(n),t.radii=function(n){const t=a(n),e=new Map(t.map(n=>[n.thisId,n])),r=t.find(n=>null==n.parentId)?.thisId,i=[];for(const n of t){if(n.thisId===r)continue;const t=e.get(n.parentId);if(!t)continue;const c=n.angle,a=t.r,s=n.r;i.push({parentId:t.thisId,childId:n.thisId,x0:a*Math.cos(c),y0:a*Math.sin(c),x1:s*Math.cos(c),y1:s*Math.sin(c),isTip:!!n.isTip})}return i}(n),t.arcs=function(n){const t=new Map(n.map(n=>[n.thisId,n])),e=n.find(n=>null==n.parentId)?.thisId,r=[];for(const i of n){if(i.thisId===e)continue;const n=t.get(i.parentId);if(!n)continue;const[c,a]=o(n.angle,i.angle);isFinite(n.r)&&0!==n.r&&c!==a&&r.push({parentId:n.thisId,childId:i.thisId,radius:n.r,start:c,end:a})}return r}(t.data),t}function l(n){const t=c(n),e=new Map(t.map((n,t)=>[n.thisId,t])),r=[];!function n(t){t.children&&0!==t.children.length?t.children.forEach(n):r.push(t.id)}(n);const i=new Map(r.map((n,t)=>[n,t+1]));return function n(r){const c=e.get(r.id);if(!r.children||0===r.children.length){const n=i.get(r.id);return t[c].y0=n,t[c].y1=n,n}const a=function(n){let t=0,e=0;for(let r of n)null!=r&&(r=+r)>=r&&(++t,e+=r);if(t)return e/t}(r.children.map(n));return t[c].y0=a,t[c].y1=a,a}(n),function n(r,i){const c=e.get(r.id),a=i??0,s=a+(t[c].branchLength??0);t[c].x0=a,t[c].x1=s,r.children&&r.children.length&&r.children.forEach(t=>n(t,s))}(n,0),t.map(n=>{const{y:t,x:e,angle:r,...i}=n;return i})}function u(n){const t=l(n),e=function(n){const t=l(n),e=new Map;for(const n of t){if(null==n.parentId)continue;const t=e.get(n.parentId);t?t.push(n):e.set(n.parentId,[n])}const r=[];for(const[n,t]of e.entries()){if(!t.length)continue;const e=t.map(n=>n.y0),i=Math.min(...e),c=Math.max(...e),a=t[0].x0;r.push({parentId:n,x0:a,x1:a,y0:i,y1:c,heights:c-i})}return r}(n),r=function(n){const t=l(n),e=new Map(t.map(n=>[n.thisId,n])),r=[];for(const n of t){if(null==n.parentId)continue;const t=e.get(n.parentId);if(!t)continue;const i=n.x0,c=n.y0,a=t.y0,s=Math.min(c,a),o=Math.max(c,a);r.push({parentId:n.parentId,childId:n.thisId,x:i,y0:s,y1:o})}return r}(n),i=t.filter(n=>null!=n.parentId).map(n=>({parentId:n.parentId,childId:n.thisId,x0:n.x0,x1:n.x1,y:n.y0}));return{data:t,vertical_lines:e,child_vertical_lines:r,horizontal_lines:i}}function d(n){null===n.parent&&(function n(t){if(!t.children||0===t.children.length)return t.ntips=1,1;let e=0;for(const r of t.children)e+=n(r);return t.ntips=e,e}(n),n.start=0,n.end=2,n.angle=0,n.ntips=function(n){var t=0;for(const e of function(n){const t=[n],e=[];for(;t.length;){const n=t.shift();e.push(n);for(const e of n.children)t.push(e)}return e}(n))0==e.children.length&&t++;return t}(n),n.x=0,n.y=0);let t=n.start;for(let e=0;e<n.children.length;e++){const r=n.children[e],i=(n.end-n.start)*(r.ntips/n.ntips);r.start=t,r.end=t+i,r.angle=r.start+(r.end-r.start)/2,t=r.end;const c=r.angle*Math.PI,a=r.branchLength??0;r.x=n.x+a*Math.sin(c),r.y=n.y+a*Math.cos(c),d(r)}return n}function f(n){var t={},e=c(d(n));return t.data=e,t.edges=function(n,t=!1){const e=[...n].sort((n,t)=>n.thisId-t.thisId),r=new Map(e.map(n=>[n.thisId,n])),i=[];for(const n of e){if(null==n.parentId)continue;const e=r.get(n.parentId);e&&(t?(i.push({x1:n.x,y1:n.y,id1:n.thisId,x2:e.x,y2:n.y,id2:void 0}),i.push({x1:e.x,y1:n.y,id1:void 0,x2:e.x,y2:e.y,id2:n.parentId})):i.push({x1:n.x,y1:n.y,id1:n.thisId,x2:e.x,y2:e.y,id2:n.parentId}))}return i}(e),t}function p(n,t){const e=function(n,t="thisId"){return new Map(n.map(n=>[n[t],n]))}(t),r=e.get(n.parentId);return r?{px:r.fisheye.x,py:r.fisheye.y}:null}function I(n){const t=(n=String(n).replace(/\s+/g,"")).split(/(;|\(|\)|,)/),e={parent:null,children:[]};let r=e,i=0;for(const n of t)if(n&&";"!==n)if("("===n){const n={parent:r,children:[]};r.children.push(n),r=n}else if(","===n){r=r.parent;const n={parent:r,children:[]};r.children.push(n),r=n}else if(")"===n){if(r=r.parent,null===r)break}else{const t=n.split(":");1===t.length?n.startsWith(":")?(r.label="",r.branchLength=parseFloat(t[0])):(r.label=t[0],r.branchLength=null):2===t.length?(r.label=t[0],r.branchLength=parseFloat(t[1])):(console.warn(n,"Unhandled token with multiple ':' characters"),r.label=t[0]||"",r.branchLength=parseFloat(t[t.length-1])),r.id=i++}return null==e.id&&(e.id=i),e}function g(n,t){let e={};n.data.forEach(n=>{e[n.thisId]={...n}});let r={};const i=function(n){e[n]&&(r[n]=e[n],e[n].children&&e[n].children.forEach(n=>{i(n)}))};i(t);const c=[["data",Object.values(r)]],a=c[0][1].map(n=>n.thisId);var s=[];for(const t in n)"data"!==t&&s.push([t,n[t]]);var o=s.map(n=>[n[0],n[1].filter(n=>a.includes(n.thisId))]);return Object.fromEntries(c.concat(o))}export{r as describeArc,p as parentFisheye,n as phisheye,h as radialLayout,I as readTree,u as rectangleLayout,g as subTree,f as unrooted};
|
|
2
|
+
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@euphrasiologist/lwphylo",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.9",
|
|
4
4
|
"description": "A lightweight, low level javascript library to plot phylogenies from Newick files.",
|
|
5
5
|
"main": "dist/@euphrasiologist/lwphylo.js",
|
|
6
6
|
"unpkg": "dist/@euphrasiologist/lwphylo.min.js",
|
|
@@ -33,9 +33,13 @@
|
|
|
33
33
|
},
|
|
34
34
|
"homepage": "https://github.com/Euphrasiologist/lwPhylo#readme",
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"eslint": "
|
|
37
|
-
"
|
|
38
|
-
"rollup": "
|
|
39
|
-
"rollup
|
|
36
|
+
"@eslint/js": "^9.37.0",
|
|
37
|
+
"@rollup/plugin-commonjs": "^28.0.6",
|
|
38
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
39
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
40
|
+
"eslint": "^9.37.0",
|
|
41
|
+
"globals": "^16.4.0",
|
|
42
|
+
"jsdom": "27",
|
|
43
|
+
"rollup": "^4.52.4"
|
|
40
44
|
}
|
|
41
45
|
}
|
package/src/phisheye/phisheye.js
CHANGED
|
@@ -1,69 +1,115 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Based on the archived d3 fisheye plugin, modernized & hardened.
|
|
2
|
+
// - Works in screen/pixel space (set xscale/yscale).
|
|
3
|
+
// - O(1) math with precomputed constants; avoids unnecessary sqrt.
|
|
4
|
+
// - Stable API: .radius(), .distortion(), .focus(), .scales()
|
|
5
|
+
// - Returns { x, y, z } where z ~= local magnification (clamped).
|
|
6
|
+
// - Adds small ergonomics: .setScales(), .focusFromEvent(), .clampZ()
|
|
3
7
|
|
|
4
8
|
const phisheye = {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
scales = {
|
|
59
|
-
xscale: _,
|
|
60
|
-
yscale: __
|
|
61
|
-
};
|
|
62
|
-
return fisheye;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
return rescale();
|
|
9
|
+
circular: () => {
|
|
10
|
+
let radius = 200; // pixels
|
|
11
|
+
let distortion = 2; // dimensionless
|
|
12
|
+
let focus = [0, 0]; // [fx, fy] in pixels
|
|
13
|
+
let scales = {}; // { xscale, yscale }
|
|
14
|
+
let k0 = 0, k1 = 0; // precomputed factors
|
|
15
|
+
let radius2 = radius * radius;
|
|
16
|
+
let zClamp = 10; // max z (magnification) for stability
|
|
17
|
+
|
|
18
|
+
function ensureScales() {
|
|
19
|
+
if (!scales || typeof scales.xscale !== "function" || typeof scales.yscale !== "function") {
|
|
20
|
+
throw new Error("phisheye.circular: call .scales(xscale, yscale) before using the fisheye.");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rescale() {
|
|
25
|
+
// Same functional form as the classic plugin:
|
|
26
|
+
// k = ((e^d) / (e^d - 1)) * R * (1 - exp(-d * r / R)) / r
|
|
27
|
+
// where d=distortion, R=radius, r=distance to focus.
|
|
28
|
+
const e = Math.exp(distortion);
|
|
29
|
+
k0 = (e / (e - 1)) * radius; // constant multiplier
|
|
30
|
+
k1 = distortion / radius; // exponent scale
|
|
31
|
+
radius2 = radius * radius;
|
|
32
|
+
return fisheye;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fisheye(d) {
|
|
36
|
+
ensureScales();
|
|
37
|
+
|
|
38
|
+
const x0 = scales.xscale(d.x);
|
|
39
|
+
const y0 = scales.yscale(d.y);
|
|
40
|
+
|
|
41
|
+
const dx = x0 - focus[0];
|
|
42
|
+
const dy = y0 - focus[1];
|
|
43
|
+
const dd2 = dx * dx + dy * dy;
|
|
44
|
+
|
|
45
|
+
// At the focus or outside radius -> identity mapping, z ~ 1
|
|
46
|
+
if (dd2 === 0 || dd2 >= radius2) {
|
|
47
|
+
return { x: x0, y: y0, z: dd2 >= radius2 ? 1 : zClamp };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const dd = Math.sqrt(dd2);
|
|
51
|
+
// Classic mapping (with a slight blend for gentler core)
|
|
52
|
+
const k = ((k0 * (1 - Math.exp(-dd * k1))) / dd) * 0.75 + 0.25;
|
|
53
|
+
|
|
54
|
+
// New position
|
|
55
|
+
const x = focus[0] + dx * k;
|
|
56
|
+
const y = focus[1] + dy * k;
|
|
57
|
+
|
|
58
|
+
// Local magnification proxy (clamped)
|
|
59
|
+
const z = Math.min(k, zClamp);
|
|
60
|
+
|
|
61
|
+
return { x, y, z };
|
|
66
62
|
}
|
|
67
|
-
}
|
|
68
63
|
|
|
69
|
-
|
|
64
|
+
// --- Public API ---
|
|
65
|
+
|
|
66
|
+
fisheye.radius = function(_) {
|
|
67
|
+
if (!arguments.length) return radius;
|
|
68
|
+
radius = Math.max(0, +_);
|
|
69
|
+
return rescale();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
fisheye.distortion = function(_) {
|
|
73
|
+
if (!arguments.length) return distortion;
|
|
74
|
+
distortion = Math.max(0, +_);
|
|
75
|
+
return rescale();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
fisheye.focus = function(_) {
|
|
79
|
+
if (!arguments.length) return focus.slice();
|
|
80
|
+
if (!Array.isArray(_) || _.length !== 2) throw new Error("focus expects [x, y] in pixels.");
|
|
81
|
+
focus = [+_[0], +_[1]];
|
|
82
|
+
return fisheye;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
fisheye.scales = function(xscale, yscale) {
|
|
86
|
+
if (!arguments.length) return scales;
|
|
87
|
+
scales = { xscale, yscale };
|
|
88
|
+
return fisheye;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Convenience alias
|
|
92
|
+
fisheye.setScales = fisheye.scales;
|
|
93
|
+
|
|
94
|
+
// Clamp maximum z (magnification) returned; defaults to 10
|
|
95
|
+
fisheye.clampZ = function(_) {
|
|
96
|
+
if (!arguments.length) return zClamp;
|
|
97
|
+
zClamp = Math.max(1, +_);
|
|
98
|
+
return fisheye;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Convenience: set focus from a pointer event relative to an HTMLElement/SVG
|
|
102
|
+
// Example: svg.on("pointermove", (e) => fe.focusFromEvent(e, svg.node()))
|
|
103
|
+
fisheye.focusFromEvent = function(event, element) {
|
|
104
|
+
const rect = element.getBoundingClientRect();
|
|
105
|
+
const fx = event.clientX - rect.left;
|
|
106
|
+
const fy = event.clientY - rect.top;
|
|
107
|
+
return fisheye.focus([fx, fy]);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return rescale();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export default phisheye;
|
|
115
|
+
|
|
@@ -1,22 +1,32 @@
|
|
|
1
|
-
import polarToCartesian from "./polarToCartesian.js"
|
|
1
|
+
import polarToCartesian from "./polarToCartesian.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Describe
|
|
5
|
-
*
|
|
4
|
+
* Describe an SVG arc path between two angles (radians).
|
|
5
|
+
* Always draws the MINOR arc (≤ π). Angles are normalized to [0, 2π).
|
|
6
6
|
*/
|
|
7
|
+
function normalize(theta) {
|
|
8
|
+
const t = theta % (2 * Math.PI);
|
|
9
|
+
return t < 0 ? t + 2 * Math.PI : t;
|
|
10
|
+
}
|
|
7
11
|
|
|
8
|
-
export default function (
|
|
12
|
+
export default function describeArc(cx, cy, radius, startAngle, endAngle) {
|
|
13
|
+
let a0 = normalize(startAngle);
|
|
14
|
+
let a1 = normalize(endAngle);
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
// Choose the minor arc direction
|
|
17
|
+
let diff = a1 - a0;
|
|
18
|
+
if (diff <= -Math.PI) diff += 2 * Math.PI;
|
|
19
|
+
else if (diff > Math.PI) diff -= 2 * Math.PI;
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
const largeArcFlag = Math.abs(diff) > Math.PI ? 1 : 0; // for completeness (will be 0 with minor arc)
|
|
22
|
+
const sweepFlag = diff >= 0 ? 1 : 0;
|
|
15
23
|
|
|
16
|
-
|
|
24
|
+
const start = polarToCartesian(cx, cy, radius, a0);
|
|
25
|
+
const end = polarToCartesian(cx, cy, radius, a0 + diff);
|
|
26
|
+
|
|
27
|
+
return [
|
|
17
28
|
"M", start.x, start.y,
|
|
18
|
-
"A", radius, radius, 0,
|
|
29
|
+
"A", radius, radius, 0, largeArcFlag, sweepFlag, end.x, end.y
|
|
19
30
|
].join(" ");
|
|
31
|
+
}
|
|
20
32
|
|
|
21
|
-
return d;
|
|
22
|
-
}
|
package/src/radial/getArcs.js
CHANGED
|
@@ -1,61 +1,52 @@
|
|
|
1
|
-
import reflectAngle from "./reflectAngle.js"
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* arc
|
|
2
|
+
* Build *per-edge* minor arcs for a radial tree.
|
|
3
|
+
* For each non-root node (child), we draw a small arc at the *parent's* radius
|
|
4
|
+
* from the parent's angle to the child's angle (minor arc ≤ π).
|
|
5
|
+
*
|
|
6
|
+
* Input: a "radialData" array with fields:
|
|
7
|
+
* thisId, parentId, angle (radians), r (radius at this node)
|
|
8
|
+
*
|
|
9
|
+
* Output: [{ parentId, childId, radius, start, end }]
|
|
10
|
+
* where start/end are angles (radians), radius is parent's r
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
13
|
+
function normalize(theta) {
|
|
14
|
+
const t = theta % (2 * Math.PI);
|
|
15
|
+
return t < 0 ? t + 2 * Math.PI : t;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function minorArc(a, b) {
|
|
19
|
+
// returns [start, end] representing the minor arc from a -> b
|
|
20
|
+
a = normalize(a); b = normalize(b);
|
|
21
|
+
let diff = b - a;
|
|
22
|
+
if (diff <= -Math.PI) diff += 2 * Math.PI;
|
|
23
|
+
else if (diff > Math.PI) diff -= 2 * Math.PI;
|
|
24
|
+
return [a, a + diff]; // draw from a to a+diff
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function getArcs(pd) {
|
|
28
|
+
const byId = new Map(pd.map(d => [d.thisId, d]));
|
|
29
|
+
// Find root thisId
|
|
30
|
+
const root = pd.find(d => d.parentId == null)?.thisId;
|
|
31
|
+
|
|
32
|
+
const arcs = [];
|
|
33
|
+
for (const d of pd) {
|
|
34
|
+
if (d.thisId === root) continue;
|
|
35
|
+
const parent = byId.get(d.parentId);
|
|
36
|
+
if (!parent) continue;
|
|
37
|
+
|
|
38
|
+
// minor arc from parent.angle -> child.angle at radius parent.r
|
|
39
|
+
const [start, end] = minorArc(parent.angle, d.angle);
|
|
40
|
+
|
|
41
|
+
if (!isFinite(parent.r) || parent.r === 0 || start === end) continue;
|
|
42
|
+
arcs.push({
|
|
43
|
+
parentId: parent.thisId,
|
|
44
|
+
childId: d.thisId,
|
|
45
|
+
radius: parent.r,
|
|
46
|
+
start,
|
|
47
|
+
end
|
|
48
|
+
});
|
|
37
49
|
}
|
|
50
|
+
return arcs;
|
|
51
|
+
}
|
|
38
52
|
|
|
39
|
-
for (let i = 0; i < pd.length; i++) {
|
|
40
|
-
if (pd[i].thisId !== root) {
|
|
41
|
-
data.push({
|
|
42
|
-
'start': reflectAngle(Math.min(pd[i].angle, sisterAngle(pd[i].parentId)), "Y"),
|
|
43
|
-
'end': reflectAngle(Math.max(pd[i].angle, sisterAngle(pd[i].parentId)), "Y"),
|
|
44
|
-
'radius': parentRadius(pd[i].parentId),
|
|
45
|
-
'thisId': pd[i].thisId,
|
|
46
|
-
'parentId': pd[i].parentId
|
|
47
|
-
|
|
48
|
-
})
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
// TODO: understand why this works (and it may not in every case)... test!
|
|
52
|
-
for (let i = 0; i < data.length; i++) {
|
|
53
|
-
if (Math.sign(data[i].start) !== Math.sign(data[i].end)) {
|
|
54
|
-
data[i].end = Math.abs(data[i].end);
|
|
55
|
-
data[i].start = -Math.abs(data[i].start);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return data.filter(d => d.start !== d.end & d.radius !== 0);
|
|
60
|
-
|
|
61
|
-
}
|
package/src/radial/getRadii.js
CHANGED
|
@@ -1,43 +1,37 @@
|
|
|
1
|
-
import radialData from "./radialData.js"
|
|
1
|
+
import radialData from "./radialData.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Per-edge radial segments (for highlighting and drawing).
|
|
5
|
+
* For each non-root node, draw a radial line from the parent radius to the child radius
|
|
6
|
+
* at the CHILD'S angle.
|
|
7
|
+
*
|
|
8
|
+
* Output: [{ parentId, childId, x0, y0, x1, y1, isTip }]
|
|
5
9
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
export default function getRadii(node) {
|
|
11
|
+
const data = radialData(node);
|
|
12
|
+
const byId = new Map(data.map(d => [d.thisId, d]));
|
|
13
|
+
const root = data.find(d => d.parentId == null)?.thisId;
|
|
14
|
+
|
|
15
|
+
const segments = [];
|
|
16
|
+
for (const d of data) {
|
|
17
|
+
if (d.thisId === root) continue;
|
|
18
|
+
const parent = byId.get(d.parentId);
|
|
19
|
+
if (!parent) continue;
|
|
20
|
+
|
|
21
|
+
const theta = d.angle;
|
|
22
|
+
const r0 = parent.r;
|
|
23
|
+
const r1 = d.r;
|
|
24
|
+
|
|
25
|
+
segments.push({
|
|
26
|
+
parentId: parent.thisId,
|
|
27
|
+
childId: d.thisId,
|
|
28
|
+
x0: r0 * Math.cos(theta),
|
|
29
|
+
y0: r0 * Math.sin(theta),
|
|
30
|
+
x1: r1 * Math.cos(theta),
|
|
31
|
+
y1: r1 * Math.sin(theta),
|
|
32
|
+
isTip: !!d.isTip
|
|
33
|
+
});
|
|
18
34
|
}
|
|
35
|
+
return segments;
|
|
36
|
+
}
|
|
19
37
|
|
|
20
|
-
var arcs = [];
|
|
21
|
-
// find root
|
|
22
|
-
var root = data.map(d => d.parentId === null ? d.thisId : null).filter(d => d != null)[0];
|
|
23
|
-
|
|
24
|
-
for (var i = 0; i < data.length; i++) {
|
|
25
|
-
if (data[i].thisId !== root) {
|
|
26
|
-
|
|
27
|
-
arcs.push({
|
|
28
|
-
'thisId': data[i].thisId,
|
|
29
|
-
'thisLabel': data[i].thisLabel,
|
|
30
|
-
'x0': data[i].x,
|
|
31
|
-
// radius of the parent * cos(angle)
|
|
32
|
-
'x1': getRadius(data[i]) * Math.cos(data[i].angle),
|
|
33
|
-
'y0': data[i].y,
|
|
34
|
-
// radius of the parent * sin(angle)
|
|
35
|
-
'y1': getRadius(data[i]) * Math.sin(data[i].angle),
|
|
36
|
-
'isTip': data[i].isTip
|
|
37
|
-
})
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return arcs;
|
|
42
|
-
|
|
43
|
-
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Convert polar
|
|
3
|
-
* https://stackoverflow.com/questions/5736398/how-to-calculate-the-svg-path-for-an-arc-of-a-circle
|
|
2
|
+
* Convert polar to Cartesian: angle in radians, 0 at +x, CCW positive.
|
|
4
3
|
*/
|
|
5
|
-
|
|
6
|
-
export default function (centerX, centerY, radius, angleInRadians) {
|
|
4
|
+
export default function polarToCartesian(cx, cy, r, angle) {
|
|
7
5
|
return {
|
|
8
|
-
x:
|
|
9
|
-
y:
|
|
6
|
+
x: cx + r * Math.cos(angle),
|
|
7
|
+
y: cy + r * Math.sin(angle)
|
|
10
8
|
};
|
|
11
|
-
}
|
|
9
|
+
}
|
package/src/radial/radialData.js
CHANGED
|
@@ -1,102 +1,84 @@
|
|
|
1
1
|
import fortify from "../utils/fortify.js"
|
|
2
|
-
import numTips from "../utils/numTips.js"
|
|
3
|
-
import mean from "../utils/mean.js"
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
|
-
*
|
|
4
|
+
* Compute per-node polar coordinates for radial layout:
|
|
5
|
+
* - Tip angles: evenly spaced 0..2π in tip DFS order
|
|
6
|
+
* - Internal angles: circular mean of child angles
|
|
7
|
+
* - Radii: cumulative branch length from root
|
|
8
|
+
* - x,y: cartesian projection
|
|
9
|
+
*
|
|
10
|
+
* Returns the fortified array with added {angle, r, x, y}.
|
|
7
11
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
for (
|
|
19
|
-
if (
|
|
20
|
-
pd[i].angle = (tipID / tip_number) * 2 * Math.PI;
|
|
21
|
-
tipID += 1;
|
|
22
|
-
}
|
|
12
|
+
export default function radialData(node) {
|
|
13
|
+
const TAU = Math.PI * 2;
|
|
14
|
+
const norm = (t) => ((t % TAU) + TAU) % TAU;
|
|
15
|
+
|
|
16
|
+
const pd = fortify(node, /*sort*/ true);
|
|
17
|
+
const byId = new Map(pd.map(d => [d.thisId, d]));
|
|
18
|
+
const kids = new Map(pd.map(d => [d.thisId, d.children || []]));
|
|
19
|
+
|
|
20
|
+
// Find root id
|
|
21
|
+
let root = null;
|
|
22
|
+
for (const d of pd) {
|
|
23
|
+
if (d.parentId == null) { root = d.thisId; break; }
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (pd[i].thisId === child_2) {
|
|
33
|
-
var angle_2 = pd[i].angle;
|
|
34
|
-
}
|
|
26
|
+
// Collect tip ids in DFS left->right order to preserve input ordering
|
|
27
|
+
const tipIds = [];
|
|
28
|
+
(function dfs(id) {
|
|
29
|
+
const c = kids.get(id) || [];
|
|
30
|
+
if (c.length === 0) {
|
|
31
|
+
tipIds.push(id);
|
|
32
|
+
return;
|
|
35
33
|
}
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (pd[i].thisId === current_parentId) {
|
|
57
|
-
var branchLength = pd[i].r;
|
|
34
|
+
for (const ch of c) dfs(ch);
|
|
35
|
+
})(root);
|
|
36
|
+
|
|
37
|
+
// Assign tip angles evenly spaced 0..2π
|
|
38
|
+
const N = Math.max(1, tipIds.length);
|
|
39
|
+
const angle = new Map();
|
|
40
|
+
tipIds.forEach((id, i) => {
|
|
41
|
+
angle.set(id, (i / N) * TAU);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Internal node angles: circular mean of child angles (post-order)
|
|
45
|
+
(function setInternalAngles(id) {
|
|
46
|
+
const c = kids.get(id) || [];
|
|
47
|
+
for (const ch of c) setInternalAngles(ch);
|
|
48
|
+
if (c.length > 0) {
|
|
49
|
+
let sx = 0, sy = 0;
|
|
50
|
+
for (const ch of c) {
|
|
51
|
+
const th = angle.get(ch);
|
|
52
|
+
sx += Math.cos(th);
|
|
53
|
+
sy += Math.sin(th);
|
|
58
54
|
}
|
|
55
|
+
angle.set(id, norm(Math.atan2(sy, sx)));
|
|
59
56
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
} else { // it's a node
|
|
75
|
-
// radius is branch length at root?
|
|
76
|
-
pd[i].r = pd[i].branchLength;
|
|
77
|
-
pd[i].x = pd[i].branchLength * Math.cos(pd[i].angle);
|
|
78
|
-
pd[i].y = pd[i].branchLength * Math.sin(pd[i].angle);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
} else {
|
|
82
|
-
// the x0 is that of the parent
|
|
83
|
-
var parent_branchLength = getParentBranchLength(pd[i].parentId);
|
|
84
|
-
// the x1 is the sum of parent and current branchlength
|
|
85
|
-
pd[i].r = parent_branchLength + pd[i].branchLength;
|
|
86
|
-
// at the same time we can make x and y from polar coordinates
|
|
87
|
-
// this is creating some NaN's, may cause problems later.
|
|
88
|
-
pd[i].x = (parent_branchLength + pd[i].branchLength) * Math.cos(pd[i].angle);
|
|
89
|
-
pd[i].y = (parent_branchLength + pd[i].branchLength) * Math.sin(pd[i].angle);
|
|
57
|
+
})(root);
|
|
58
|
+
|
|
59
|
+
// Radii: cumulative branch lengths from root (root r=0)
|
|
60
|
+
const radius = new Map();
|
|
61
|
+
radius.set(root, 0);
|
|
62
|
+
(function setR(id) {
|
|
63
|
+
const c = kids.get(id) || [];
|
|
64
|
+
const r0 = radius.get(id) || 0;
|
|
65
|
+
for (const ch of c) {
|
|
66
|
+
const child = byId.get(ch);
|
|
67
|
+
const bl = child?.branchLength ?? 0;
|
|
68
|
+
radius.set(ch, r0 + bl);
|
|
69
|
+
setR(ch);
|
|
90
70
|
}
|
|
71
|
+
})(root);
|
|
72
|
+
|
|
73
|
+
// Enrich pd rows with angle, r, x, y
|
|
74
|
+
for (const d of pd) {
|
|
75
|
+
const th = angle.get(d.thisId) ?? 0;
|
|
76
|
+
const r = radius.get(d.thisId) ?? 0;
|
|
77
|
+
d.angle = th;
|
|
78
|
+
d.r = r;
|
|
79
|
+
d.x = r * Math.cos(th);
|
|
80
|
+
d.y = r * Math.sin(th);
|
|
91
81
|
}
|
|
92
82
|
|
|
93
|
-
// at this point the first element is the root
|
|
94
|
-
pd[0].r = 0;
|
|
95
|
-
pd[0].x = 0;
|
|
96
|
-
pd[0].y = 0;
|
|
97
|
-
|
|
98
|
-
// return the original sorted data
|
|
99
|
-
//pd.sort((a,b) => a.thisId - b.thisId)
|
|
100
|
-
|
|
101
83
|
return pd;
|
|
102
|
-
}
|
|
84
|
+
}
|