@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 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.7",
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": "6",
37
- "jsdom": "15",
38
- "rollup": "1",
39
- "rollup-plugin-terser": "5"
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
  }
@@ -1,69 +1,115 @@
1
- // based on https://github.com/d3/d3-plugins/blob/master/fisheye/fisheye.js
2
- // now archived d3 code?
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
- circular: () => {
6
- var radius = 200,
7
- distortion = 2,
8
- k0,
9
- k1,
10
- focus = [0, 0],
11
- scales = {};
12
-
13
- function fisheye(d) {
14
- var dx = scales.xscale(d.x) - focus[0],
15
- dy = scales.yscale(d.y) - focus[1],
16
- dd = Math.sqrt(dx * dx + dy * dy);
17
- if (!dd || dd >= radius)
18
- return {
19
- x: scales.xscale(d.x),
20
- y: scales.yscale(d.y),
21
- z: dd >= radius ? 1 : 10
22
- };
23
- var k = ((k0 * (1 - Math.exp(-dd * k1))) / dd) * .75 + .25;
24
- return {
25
- x: focus[0] + dx * k,
26
- y: focus[1] + dy * k,
27
- z: Math.min(k, 10)
28
- };
29
- }
30
-
31
- function rescale() {
32
- k0 = Math.exp(distortion);
33
- k0 = (k0 / (k0 - 1)) * radius;
34
- k1 = distortion / radius;
35
- return fisheye;
36
- }
37
-
38
- fisheye.radius = function (_) {
39
- if (!arguments.length) return radius;
40
- radius = +_;
41
- return rescale();
42
- };
43
-
44
- fisheye.distortion = function (_) {
45
- if (!arguments.length) return distortion;
46
- distortion = +_;
47
- return rescale();
48
- };
49
-
50
- fisheye.focus = function (_) {
51
- if (!arguments.length) return focus;
52
- focus = _;
53
- return fisheye;
54
- };
55
-
56
- fisheye.scales = function (_, __) {
57
- if (!arguments.length) return scales;
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
- export default phisheye
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 the arc to draw
5
- * https://stackoverflow.com/questions/5736398/how-to-calculate-the-svg-path-for-an-arc-of-a-circle
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 (x, y, radius, startAngle, endAngle) {
12
+ export default function describeArc(cx, cy, radius, startAngle, endAngle) {
13
+ let a0 = normalize(startAngle);
14
+ let a1 = normalize(endAngle);
9
15
 
10
- var start = polarToCartesian(x, y, radius, startAngle);
11
- var end = polarToCartesian(x, y, radius, endAngle);
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
- // TODO: refers to middle zero below... but no large arcs needed..?
14
- // var largeArcFlag = endAngle - startAngle <= Math.PI ? "0" : "1";
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
- var d = [
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, 0, 0, end.x, end.y
29
+ "A", radius, radius, 0, largeArcFlag, sweepFlag, end.x, end.y
19
30
  ].join(" ");
31
+ }
20
32
 
21
- return d;
22
- }
@@ -1,61 +1,52 @@
1
- import reflectAngle from "./reflectAngle.js"
2
-
3
1
  /**
4
- * Takes parent data and returns a start value (radians),
5
- * end value (radians), and radius of circle to draw an
6
- * arc from
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
- export default function (pd) {
10
- // must return start of arc and end
11
- // these are pairs of edges that have the same parent
12
- // start is the min(angle) of the children and end is the max(angle)
13
- // radius is the radius of the parent
14
- // origin is 0, 0
15
-
16
- var data = [];
17
- var root = pd.map(d => d.parentId === null ? d.thisId : null).filter(d => d != null)[0];
18
-
19
-
20
- // get the branchlength of the parentID
21
- function sisterAngle(current_parentId) {
22
- for (var i = 0; i < pd.length; i++) {
23
- if (pd[i].parentId === current_parentId) {
24
- var sister_angle = pd[i].angle;
25
- }
26
- }
27
- return sister_angle;
28
- }
29
-
30
- function parentRadius(current_parentId) {
31
- for (var i = 0; i < pd.length; i++) {
32
- if (pd[i].thisId === current_parentId) {
33
- var parent_r = pd[i].r;
34
- }
35
- }
36
- return parent_r;
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
- }
@@ -1,43 +1,37 @@
1
- import radialData from "./radialData.js"
1
+ import radialData from "./radialData.js";
2
2
 
3
3
  /**
4
- * Take a parsed tree and get the radii of each of the nodes.
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
- export default function (node) {
8
- var data = radialData(node);
9
-
10
- // for the current iteration of the loop find the matching parentId
11
- function getRadius(current_node) {
12
- for (var i = 0; i < data.length; i++) {
13
- if (data[i].thisId === current_node.parentId) {
14
- var radius = data[i].r;
15
- }
16
- }
17
- return radius;
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 co-ordinates to cartesian
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: centerX + (radius * Math.cos(angleInRadians)),
9
- y: centerY + (radius * Math.sin(angleInRadians))
6
+ x: cx + r * Math.cos(angle),
7
+ y: cy + r * Math.sin(angle)
10
8
  };
11
- }
9
+ }
@@ -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
- * Take a parsed tree and get the important data from them (i.e. radii, arcs).
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
- export default function (node) {
10
- // should start out the same as get_horizontal
11
- // it's very similar in fact, but keeping separate for clarity.
12
- var pd = fortify(node);
13
-
14
- var tip_number = numTips(node);
15
-
16
- // make tip angles
17
- var tipID = 1;
18
- for (let i = 0; i < pd.length; i++) {
19
- if (pd[i].isTip == true) {
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
- // probably incredibly inefficient for large trees.
26
- // gets the y values of two child branches by looping through the whole tree...
27
- function internalNodeAngle(child_1, child_2) {
28
- for (var i = 0; i < pd.length; i++) {
29
- if (pd[i].thisId === child_1) {
30
- var angle_1 = pd[i].angle;
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
- return mean([angle_1, angle_2]); // see utils.js
37
- }
38
-
39
- // if the node is not a tip...
40
- for (let i = 0; i < pd.length; i++) {
41
- if (pd[i].isTip === false) {
42
- // then y0 === y1 and is the mean of the parental nodes
43
- pd[i].angle = internalNodeAngle(pd[i].children[0], pd[i].children[1]);
44
- }
45
- }
46
-
47
- // find root
48
- var root = pd.map(d => d.parentId === null ? d.thisId : null).filter(d => d != null)[0];
49
-
50
- // sort the data temporarily in decreasing parentId
51
- pd.sort((a, b) => b.thisId - a.thisId);
52
-
53
- // get the branchlength of the parentID
54
- function getParentBranchLength(current_parentId) {
55
- for (var i = 0; i < pd.length; i++) {
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
- return branchLength;
61
- }
62
-
63
- // assign depths (branch lengths) to radii
64
- for (let i = 0; i < pd.length; i++) {
65
- // special cases where parent is the root.
66
- if (pd[i].parentId === root) {
67
-
68
- if (pd[i].isTip === true) {
69
- // radius is branch length at root?
70
- pd[i].r = pd[i].branchLength;
71
- pd[i].x = pd[i].branchLength;
72
- pd[i].y = 0;
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
+ }