@euphrasiologist/lwphylo 1.1.15 → 1.2.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/dist/index.js CHANGED
@@ -1,2 +1,1282 @@
1
- const n={circular:()=>{let n=200,t=2,e=[0,0],r={},i=0,s=0,c=n*n,a=10;function o(){const e=Math.exp(t);return i=e/(e-1)*n,s=t/n,c=n*n,l}function l(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),l=t-e[0],h=o-e[1],d=l*l+h*h;if(0===d||d>=c)return{x:t,y:o,z:d>=c?1:a};const u=Math.sqrt(d),f=i*(1-Math.exp(-u*s))/u*.75+.25;return{x:e[0]+l*f,y:e[1]+h*f,z:Math.min(f,a)}}return l.radius=function(t){return arguments.length?(n=Math.max(0,+t),o()):n},l.distortion=function(n){return arguments.length?(t=Math.max(0,+n),o()):t},l.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]],l},l.scales=function(n,t){return arguments.length?(r={xscale:n,yscale:t},l):r},l.setScales=l.scales,l.clampZ=function(n){return arguments.length?(a=Math.max(1,+n),l):a},l.focusFromEvent=function(n,t){const e=t.getBoundingClientRect(),r=n.clientX-e.left,i=n.clientY-e.top;return l.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,e,r,i,s){const c=2*Math.PI,a=n=>(n%c+c)%c;let o=a(i),l=a(s);if((o-l+c)%c<(l-o+c)%c){const n=o;o=l,l=n}const h=(l-o+c)%c;if(h<1e-9){const i=t(n,e,r,o);return`M ${i.x} ${i.y}`}const d=h>Math.PI?1:0,u=t(n,e,r,o),f=t(n,e,r,l);return`M ${u.x} ${u.y} A ${r} ${r} 0 ${d} 0 ${f.x} ${f.y}`}function r(n,t=[]){t.push(n);for(let e=0;e<(n.children?.length||0);e++)t=r(n.children[e],t);return t}function i(n,t=!0){var e=[];for(const t of r(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 s(n){const t=2*Math.PI,e=i(n,!0),r=new Map(e.map(n=>[n.thisId,n])),s=new Map(e.map(n=>[n.thisId,n.children||[]]));let c=null;for(const n of e)if(null==n.parentId){c=n.thisId;break}const a=[];!function n(t){const e=s.get(t)||[];if(0!==e.length)for(const t of e)n(t);else a.push(t)}(c);const o=Math.max(1,a.length),l=new Map;a.forEach((n,e)=>{l.set(n,e/o*t)}),function n(e){const r=s.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=l.get(t);n+=Math.cos(e),i+=Math.sin(e)}l.set(e,(Math.atan2(i,n)%t+t)%t)}}(c);const h=new Map;h.set(c,0),function n(t){const e=s.get(t)||[],i=h.get(t)||0;for(const t of e){const e=r.get(t),s=e?.branchLength??0;h.set(t,i+s),n(t)}}(c);for(const n of e){const t=l.get(n.thisId)??0,e=h.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 c(n){const t={};return t.data=s(n),t.radii=function(n){const t=s(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 s=n.angle,c=t.r,a=n.r;i.push({parentId:t.thisId,childId:n.thisId,x0:c*Math.cos(s),y0:c*Math.sin(s),x1:a*Math.cos(s),y1:a*Math.sin(s),isTip:!!n.isTip})}return i}(n),t.arcs=function(n){const t=2*Math.PI;new Map(n.map(n=>[n.thisId,n]));const e=new Map;let r=null;for(const t of n)null==t.parentId&&(r=t.thisId),e.has(t.parentId)||e.set(t.parentId,[]),e.get(t.parentId).push(t);const i=[];for(const s of n){const n=s.thisId;if(n===r)continue;const c=e.get(n)||[];if(c.length<2)continue;const a=c.map(n=>(n.angle%t+t)%t).sort((n,t)=>n-t),o=a[0],l=a[a.length-1],h=l-o,d=t-h;let u,f,p;h<=d?(u=o,f=l,p=h):(u=l,f=o,p=d),p<1e-6||!isFinite(s.r)||s.r<=0||i.push({start:u,end:f,radius:s.r,thisId:n,parentId:s.parentId})}return i}(t.data),t}function a(n){const t=i(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 s=new Map(r.map((n,t)=>[n,t+1]));return function n(r){const i=e.get(r.id);if(!r.children||0===r.children.length){const n=s.get(r.id);return t[i].y0=n,t[i].y1=n,n}const c=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[i].y0=c,t[i].y1=c,c}(n),function n(r,i){const s=e.get(r.id),c=i??0,a=c+(t[s].branchLength??0);t[s].x0=c,t[s].x1=a,r.children&&r.children.length&&r.children.forEach(t=>n(t,a))}(n,0),t.map(n=>{const{y:t,x:e,angle:r,...i}=n;return i})}function o(n){const t=a(n),e=function(n){const t=a(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),s=Math.max(...e),c=t[0].x0;r.push({parentId:n,x0:c,x1:c,y0:i,y1:s,heights:s-i})}return r}(n),r=function(n){const t=a(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,s=n.y0,c=t.y0,a=Math.min(s,c),o=Math.max(s,c);r.push({parentId:n.parentId,childId:n.thisId,x:i,y0:a,y1:o})}return r}(n);new Map(t.map(n=>[n.thisId,n]));const i=t.filter(n=>null!=n.parentId).map(n=>({parentId:n.parentId,childId:n.thisId,thisId:n.thisId,thisLabel:n.thisLabel,isTip:n.isTip,x0:n.x0,x1:n.x1,y0:n.y0,y1:n.y0}));return{data:t,vertical_lines:e,child_vertical_lines:r,horizontal_lines:i}}function l(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 s=r.angle*Math.PI,c=r.branchLength??0;r.x=n.x+c*Math.sin(s),r.y=n.y+c*Math.cos(s),l(r)}return n}function h(n){var t={},e=i(l(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 d(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 u(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 f(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 s=[["data",Object.values(r)]],c=s[0][1].map(n=>n.thisId);var a=[];for(const t in n)"data"!==t&&a.push([t,n[t]]);var o=a.map(n=>[n[0],n[1].filter(n=>c.includes(n.thisId))]);return Object.fromEntries(s.concat(o))}export{e as describeArc,d as parentFisheye,n as phisheye,c as radialLayout,u as readTree,o as rectangleLayout,f as subTree,h as unrooted};
1
+ import * as d3 from 'd3';
2
+
3
+ // Based on the archived d3 fisheye plugin, modernized & hardened.
4
+ // - Works in screen/pixel space (set xscale/yscale).
5
+ // - O(1) math with precomputed constants; avoids unnecessary sqrt.
6
+ // - Stable API: .radius(), .distortion(), .focus(), .scales()
7
+ // - Returns { x, y, z } where z ~= local magnification (clamped).
8
+ // - Adds small ergonomics: .setScales(), .focusFromEvent(), .clampZ()
9
+
10
+ const phisheye = {
11
+ circular: () => {
12
+ let radius = 200; // pixels
13
+ let distortion = 2; // dimensionless
14
+ let focus = [0, 0]; // [fx, fy] in pixels
15
+ let scales = {}; // { xscale, yscale }
16
+ let k0 = 0, k1 = 0; // precomputed factors
17
+ let radius2 = radius * radius;
18
+ let zClamp = 10; // max z (magnification) for stability
19
+
20
+ function ensureScales() {
21
+ if (!scales || typeof scales.xscale !== "function" || typeof scales.yscale !== "function") {
22
+ throw new Error("phisheye.circular: call .scales(xscale, yscale) before using the fisheye.");
23
+ }
24
+ }
25
+
26
+ function rescale() {
27
+ // Same functional form as the classic plugin:
28
+ // k = ((e^d) / (e^d - 1)) * R * (1 - exp(-d * r / R)) / r
29
+ // where d=distortion, R=radius, r=distance to focus.
30
+ const e = Math.exp(distortion);
31
+ k0 = (e / (e - 1)) * radius; // constant multiplier
32
+ k1 = distortion / radius; // exponent scale
33
+ radius2 = radius * radius;
34
+ return fisheye;
35
+ }
36
+
37
+ function fisheye(d) {
38
+ ensureScales();
39
+
40
+ const x0 = scales.xscale(d.x);
41
+ const y0 = scales.yscale(d.y);
42
+
43
+ const dx = x0 - focus[0];
44
+ const dy = y0 - focus[1];
45
+ const dd2 = dx * dx + dy * dy;
46
+
47
+ // At the focus or outside radius -> identity mapping, z ~ 1
48
+ if (dd2 === 0 || dd2 >= radius2) {
49
+ return { x: x0, y: y0, z: dd2 >= radius2 ? 1 : zClamp };
50
+ }
51
+
52
+ const dd = Math.sqrt(dd2);
53
+ // Classic mapping (with a slight blend for gentler core)
54
+ const k = ((k0 * (1 - Math.exp(-dd * k1))) / dd) * 0.75 + 0.25;
55
+
56
+ // New position
57
+ const x = focus[0] + dx * k;
58
+ const y = focus[1] + dy * k;
59
+
60
+ // Local magnification proxy (clamped)
61
+ const z = Math.min(k, zClamp);
62
+
63
+ return { x, y, z };
64
+ }
65
+
66
+ // --- Public API ---
67
+
68
+ fisheye.radius = function(_) {
69
+ if (!arguments.length) return radius;
70
+ radius = Math.max(0, +_);
71
+ return rescale();
72
+ };
73
+
74
+ fisheye.distortion = function(_) {
75
+ if (!arguments.length) return distortion;
76
+ distortion = Math.max(0, +_);
77
+ return rescale();
78
+ };
79
+
80
+ fisheye.focus = function(_) {
81
+ if (!arguments.length) return focus.slice();
82
+ if (!Array.isArray(_) || _.length !== 2) throw new Error("focus expects [x, y] in pixels.");
83
+ focus = [+_[0], +_[1]];
84
+ return fisheye;
85
+ };
86
+
87
+ fisheye.scales = function(xscale, yscale) {
88
+ if (!arguments.length) return scales;
89
+ scales = { xscale, yscale };
90
+ return fisheye;
91
+ };
92
+
93
+ // Convenience alias
94
+ fisheye.setScales = fisheye.scales;
95
+
96
+ // Clamp maximum z (magnification) returned; defaults to 10
97
+ fisheye.clampZ = function(_) {
98
+ if (!arguments.length) return zClamp;
99
+ zClamp = Math.max(1, +_);
100
+ return fisheye;
101
+ };
102
+
103
+ // Convenience: set focus from a pointer event relative to an HTMLElement/SVG
104
+ // Example: svg.on("pointermove", (e) => fe.focusFromEvent(e, svg.node()))
105
+ fisheye.focusFromEvent = function(event, element) {
106
+ const rect = element.getBoundingClientRect();
107
+ const fx = event.clientX - rect.left;
108
+ const fy = event.clientY - rect.top;
109
+ return fisheye.focus([fx, fy]);
110
+ };
111
+
112
+ return rescale();
113
+ }
114
+ };
115
+
116
+ // src/radial/polarToCartesian.js
117
+ function polarToCartesian (cx, cy, r, t) {
118
+ return { x: cx + r * Math.cos(t), y: cy - r * Math.sin(t) };
119
+ }
120
+
121
+ /**
122
+ * Draw the shortest arc between startAngle and endAngle (radians), CCW.
123
+ * If the CW path is shorter, swap start/end so the CCW path is still shortest.
124
+ * Works with Y-inverted screen coords (polarToCartesian already inverts Y).
125
+ */
126
+ function describeArc(cx, cy, radius, startAngle, endAngle) {
127
+ const TAU = Math.PI * 2;
128
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
129
+ let a0 = norm(startAngle);
130
+ let a1 = norm(endAngle);
131
+
132
+ // CCW and CW spans
133
+ const ccw = (a1 - a0 + TAU) % TAU;
134
+ const cw = (a0 - a1 + TAU) % TAU;
135
+
136
+ // Ensure we always take the shorter span *in CCW* by swapping if needed
137
+ if (cw < ccw) {
138
+ const tmp = a0; a0 = a1; a1 = tmp;
139
+ }
140
+
141
+ const delta = (a1 - a0 + TAU) % TAU; // now the shorter CCW span
142
+ if (delta < 1e-9) {
143
+ const p = polarToCartesian(cx, cy, radius, a0);
144
+ return `M ${p.x} ${p.y}`; // degenerate span → no arc
145
+ }
146
+
147
+ const largeArcFlag = delta > Math.PI ? 1 : 0; // should be 0 for “shortest”, but keep for safety
148
+ const sweepFlag = 0; // CCW
149
+
150
+ const p0 = polarToCartesian(cx, cy, radius, a0);
151
+ const p1 = polarToCartesian(cx, cy, radius, a1);
152
+
153
+ return `M ${p0.x} ${p0.y} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${p1.x} ${p1.y}`;
154
+ }
155
+
156
+ /**
157
+ * Recursive function for pre-order traversal of tree (returns array)
158
+ */
159
+ function preorder(node, list = []) {
160
+ list.push(node);
161
+ for (let i = 0; i < (node.children?.length || 0); i++) {
162
+ list = preorder(node.children[i], list);
163
+ }
164
+ return list;
165
+ }
166
+
167
+ /**
168
+ * Convert parsed Newick tree from readTree() into data
169
+ * frame.
170
+ * this is akin to a "phylo" object in R.
171
+ */
172
+
173
+ function fortify (tree, sort = true) {
174
+ var df = [];
175
+
176
+ for (const node of preorder(tree)) {
177
+ if (node.parent === null) {
178
+ df.push({
179
+ 'parentId': null,
180
+ 'parentLabel': null,
181
+ 'thisId': node.id,
182
+ 'thisLabel': node.label,
183
+ 'children': node.children.map(x => x.id),
184
+ 'branchLength': 0.,
185
+ 'isTip': false,
186
+ 'x': node.x,
187
+ 'y': node.y,
188
+ 'angle': node.angle
189
+ });
190
+ }
191
+ else {
192
+ df.push({
193
+ 'parentId': node.parent.id,
194
+ 'parentLabel': node.parent.label,
195
+ 'thisId': node.id,
196
+ 'thisLabel': node.label,
197
+ 'children': node.children.map(x => x.id),
198
+ 'branchLength': node.branchLength,
199
+ 'isTip': (node.children.length == 0),
200
+ 'x': node.x,
201
+ 'y': node.y,
202
+ 'angle': node.angle
203
+ });
204
+ }
205
+ }
206
+
207
+ if (sort) {
208
+ df = df.sort(function (a, b) {
209
+ return a.thisId - b.thisId;
210
+ });
211
+ }
212
+ return (df);
213
+ }
214
+
215
+ /**
216
+ * Compute per-node polar coordinates for radial layout:
217
+ * - Tip angles: evenly spaced 0..2π in tip DFS order
218
+ * - Internal angles: circular mean of child angles
219
+ * - Radii: cumulative branch length from root
220
+ * - x,y: cartesian projection
221
+ *
222
+ * Returns the fortified array with added {angle, r, x, y}.
223
+ */
224
+ function radialData(node) {
225
+ const TAU = Math.PI * 2;
226
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
227
+
228
+ const pd = fortify(node, /*sort*/ true);
229
+ const byId = new Map(pd.map(d => [d.thisId, d]));
230
+ const kids = new Map(pd.map(d => [d.thisId, d.children || []]));
231
+
232
+ // Find root id
233
+ let root = null;
234
+ for (const d of pd) {
235
+ if (d.parentId == null) { root = d.thisId; break; }
236
+ }
237
+
238
+ // Collect tip ids in DFS left->right order to preserve input ordering
239
+ const tipIds = [];
240
+ (function dfs(id) {
241
+ const c = kids.get(id) || [];
242
+ if (c.length === 0) {
243
+ tipIds.push(id);
244
+ return;
245
+ }
246
+ for (const ch of c) dfs(ch);
247
+ })(root);
248
+
249
+ // Assign tip angles evenly spaced 0..2π
250
+ const N = Math.max(1, tipIds.length);
251
+ const angle = new Map();
252
+ tipIds.forEach((id, i) => {
253
+ angle.set(id, (i / N) * TAU);
254
+ });
255
+
256
+ // Internal node angles: circular mean of child angles (post-order)
257
+ (function setInternalAngles(id) {
258
+ const c = kids.get(id) || [];
259
+ for (const ch of c) setInternalAngles(ch);
260
+ if (c.length > 0) {
261
+ let sx = 0, sy = 0;
262
+ for (const ch of c) {
263
+ const th = angle.get(ch);
264
+ sx += Math.cos(th);
265
+ sy += Math.sin(th);
266
+ }
267
+ angle.set(id, norm(Math.atan2(sy, sx)));
268
+ }
269
+ })(root);
270
+
271
+ // Radii: cumulative branch lengths from root (root r=0)
272
+ const radius = new Map();
273
+ radius.set(root, 0);
274
+ (function setR(id) {
275
+ const c = kids.get(id) || [];
276
+ const r0 = radius.get(id) || 0;
277
+ for (const ch of c) {
278
+ const child = byId.get(ch);
279
+ const bl = child?.branchLength ?? 0;
280
+ radius.set(ch, r0 + bl);
281
+ setR(ch);
282
+ }
283
+ })(root);
284
+
285
+ // Enrich pd rows with angle, r, x, y
286
+ for (const d of pd) {
287
+ const th = angle.get(d.thisId) ?? 0;
288
+ const r = radius.get(d.thisId) ?? 0;
289
+ d.angle = th;
290
+ d.r = r;
291
+ d.x = r * Math.cos(th);
292
+ d.y = r * Math.sin(th);
293
+ }
294
+
295
+ return pd;
296
+ }
297
+
298
+ /**
299
+ * Per-edge radial segments (for highlighting and drawing).
300
+ * For each non-root node, draw a radial line from the parent radius to the child radius
301
+ * at the CHILD'S angle.
302
+ *
303
+ * Output: [{ parentId, childId, x0, y0, x1, y1, isTip }]
304
+ */
305
+ function getRadii(node) {
306
+ const data = radialData(node);
307
+ const byId = new Map(data.map(d => [d.thisId, d]));
308
+ const root = data.find(d => d.parentId == null)?.thisId;
309
+
310
+ const segments = [];
311
+ for (const d of data) {
312
+ if (d.thisId === root) continue;
313
+ const parent = byId.get(d.parentId);
314
+ if (!parent) continue;
315
+
316
+ const theta = d.angle;
317
+ const r0 = parent.r;
318
+ const r1 = d.r;
319
+
320
+ segments.push({
321
+ parentId: parent.thisId,
322
+ childId: d.thisId,
323
+ x0: r0 * Math.cos(theta),
324
+ y0: r0 * Math.sin(theta),
325
+ x1: r1 * Math.cos(theta),
326
+ y1: r1 * Math.sin(theta),
327
+ isTip: !!d.isTip
328
+ });
329
+ }
330
+ return segments;
331
+ }
332
+
333
+ /**
334
+ * Build arc descriptors for each internal parent:
335
+ * - One arc per internal node at radius = parent.r
336
+ * - Start/end angles choose the *shortest* wrap-aware span covering the children
337
+ * - Skips degenerate spans (delta ~ 0)
338
+ */
339
+ function getArcs(pd) {
340
+ const TAU = Math.PI * 2;
341
+ const norm = (t) => ((t % TAU) + TAU) % TAU;
342
+ const EPS = 1e-6;
343
+
344
+ // Quick lookups
345
+ new Map(pd.map(d => [d.thisId, d]));
346
+ const childrenByParent = new Map();
347
+ let root = null;
348
+
349
+ for (const d of pd) {
350
+ if (d.parentId == null) root = d.thisId;
351
+ if (!childrenByParent.has(d.parentId)) childrenByParent.set(d.parentId, []);
352
+ childrenByParent.get(d.parentId).push(d);
353
+ }
354
+
355
+ const arcs = [];
356
+
357
+ for (const parent of pd) {
358
+ const pid = parent.thisId;
359
+ if (pid === root) continue; // no arc above root
360
+ const kids = childrenByParent.get(pid) || [];
361
+ if (kids.length < 2) continue; // need at least two children
362
+
363
+ // Collect & sort child angles
364
+ const A = kids.map(k => norm(k.angle)).sort((a, b) => a - b);
365
+ const aMin = A[0], aMax = A[A.length - 1];
366
+
367
+ // Two candidate spans: direct (aMin -> aMax) and wrapped (aMax -> aMin across 2π)
368
+ const direct = aMax - aMin;
369
+ const wrapped = TAU - direct;
370
+
371
+ // Choose the shorter span. We'll draw **CCW** (sweepFlag = 0) in describeArc.
372
+ let start, end, span;
373
+ if (direct <= wrapped) {
374
+ start = aMin;
375
+ end = aMax;
376
+ span = direct;
377
+ } else {
378
+ // wrapped is shorter: go CCW from aMax up through 2π to aMin
379
+ start = aMax;
380
+ end = aMin;
381
+ span = wrapped;
382
+ }
383
+
384
+ if (span < EPS || !isFinite(parent.r) || parent.r <= 0) continue;
385
+
386
+ arcs.push({
387
+ start,
388
+ end,
389
+ radius: parent.r,
390
+ thisId: pid,
391
+ parentId: parent.parentId
392
+ });
393
+ }
394
+
395
+ return arcs;
396
+ }
397
+
398
+ /**
399
+ * Per-child "half" arcs for radial trees.
400
+ *
401
+ * For each non-root node (child), emit an arc at the PARENT's radius that
402
+ * spans between the parent's angle and the child's angle. This is the arc
403
+ * segment that meets the child's spoke and is ideal for root→tip highlighting.
404
+ *
405
+ * Input: pd — the array returned by radialData(node) (each row has .thisId, .parentId, .angle, .r)
406
+ * Output: [{ parentId, childId, radius, start, end }]
407
+ */
408
+ function getChildArcs(pd) {
409
+ const byId = new Map(pd.map(d => [d.thisId, d]));
410
+ const arcs = [];
411
+
412
+ for (const child of pd) {
413
+ if (child.parentId == null) continue; // skip root
414
+ const parent = byId.get(child.parentId);
415
+ if (!parent) continue;
416
+
417
+ arcs.push({
418
+ parentId: parent.thisId,
419
+ childId: child.thisId,
420
+ radius: parent.r, // draw on the parent's circle
421
+ start: parent.angle, // start at parent's angle
422
+ end: child.angle // end at child's angle (describeArc will choose the shortest CCW span)
423
+ });
424
+ }
425
+
426
+ return arcs;
427
+ }
428
+
429
+ /**
430
+ * Simple wrapper for radial layout:
431
+ * - data: per-node { angle, r, x, y, ... }
432
+ * - radii: per-edge radial spokes (parent.r → child.r)
433
+ * - arcs: per-parent arcs spanning all children at parent's radius
434
+ * - child_arcs: per-child half-arcs (parent.angle → child.angle) at parent's radius
435
+ */
436
+ function radialLayout(node) {
437
+ const data = {};
438
+ data.data = radialData(node);
439
+ data.radii = getRadii(node);
440
+ data.arcs = getArcs(data.data);
441
+ data.child_arcs = getChildArcs(data.data);
442
+ return data;
443
+ }
444
+
445
+ /**
446
+ * Iterable mean
447
+ * Poached from https://github.com/d3/d3-array/blob/master/src/mean.js
448
+ * (Other array means buggered up the tree)
449
+ */
450
+
451
+ function mean (values, valueof) {
452
+ let count = 0;
453
+ let sum = 0;
454
+ {
455
+ for (let value of values) {
456
+ if (value != null && (value = +value) >= value) {
457
+ ++count, sum += value;
458
+ }
459
+ }
460
+ }
461
+ if (count) return sum / count;
462
+ }
463
+
464
+ /**
465
+ * Rectangle layout: compute per-node x0,x1 and y0=y1
466
+ * - Tip y is assigned by input order (preserves ladderize/order)
467
+ * - Internal node y is mean of child y's
468
+ * - x1 accumulates branch lengths from root
469
+ */
470
+
471
+ function getHorizontal(node) {
472
+ const pd = fortify(node);
473
+
474
+ // Fast lookup from id -> pd index
475
+ const idIndex = new Map(pd.map((d, i) => [d.thisId, i]));
476
+
477
+ // 1) Leaf order from the INPUT TREE (respects your child order / ladderize)
478
+ const leafIds = [];
479
+ (function dfs(n) {
480
+ if (!n.children || n.children.length === 0) { leafIds.push(n.id); return; }
481
+ n.children.forEach(dfs);
482
+ })(node);
483
+
484
+ // Map each leaf id to a vertical slot (1..N)
485
+ const tipSlot = new Map(leafIds.map((id, i) => [id, i + 1]));
486
+
487
+ // 2) Set Y for tips directly from that order; internal node Y via children mean
488
+ (function setY(n) {
489
+ const i = idIndex.get(n.id);
490
+ if (!n.children || n.children.length === 0) {
491
+ const y = tipSlot.get(n.id);
492
+ pd[i].y0 = y; pd[i].y1 = y;
493
+ return y;
494
+ }
495
+ const ys = n.children.map(setY);
496
+ const y = mean(ys);
497
+ pd[i].y0 = y; pd[i].y1 = y;
498
+ return y;
499
+ })(node);
500
+
501
+ // 3) Set X by accumulating branch lengths down the tree
502
+ (function setX(n, xParent) {
503
+ const i = idIndex.get(n.id);
504
+ const bl = pd[i].branchLength ?? 0;
505
+ const x0 = xParent ?? 0;
506
+ const x1 = x0 + bl;
507
+ pd[i].x0 = x0; pd[i].x1 = x1;
508
+ if (n.children && n.children.length) n.children.forEach(c => setX(c, x1));
509
+ })(node, 0);
510
+
511
+ // Clean up: remove fields not needed downstream without triggering no-unused-vars
512
+ return pd.map((row) => {
513
+ const { y: _y, x: _x, angle: _angle, ...item } = row;
514
+ return item;
515
+ });
516
+ }
517
+
518
+ function getVertical(node) {
519
+ const data = getHorizontal(node);
520
+
521
+ // Group rows by parentId (children that share a parent)
522
+ const byParent = new Map();
523
+ for (const row of data) {
524
+ if (row.parentId == null) continue;
525
+ const a = byParent.get(row.parentId);
526
+ if (a) a.push(row); else byParent.set(row.parentId, [row]);
527
+ }
528
+
529
+ const verticals = [];
530
+ for (const [parentId, kids] of byParent.entries()) {
531
+ if (!kids.length) continue;
532
+ // Works for binary and multifurcations:
533
+ const yvals = kids.map(d => d.y0);
534
+ const y0 = Math.min(...yvals);
535
+ const y1 = Math.max(...yvals);
536
+ // All children share the same junction x (their x0)
537
+ const x = kids[0].x0;
538
+
539
+ verticals.push({
540
+ parentId,
541
+ x0: x,
542
+ x1: x,
543
+ y0,
544
+ y1,
545
+ heights: y1 - y0
546
+ });
547
+ }
548
+
549
+ return verticals;
550
+ }
551
+
552
+ /**
553
+ * Build per-child vertical segments for a rectangular tree:
554
+ * For each non-root node (child), draw a vertical from (parent.x, child.y) to (parent.x, parent.y).
555
+ * This yields exactly one vertical per edge (child->parent), making highlighting trivial.
556
+ *
557
+ * Returns an array of:
558
+ * {
559
+ * parentId: number,
560
+ * childId: number,
561
+ * x: number, // x of the parent junction
562
+ * y0: number, // min(child.y, parent.y)
563
+ * y1: number, // max(child.y, parent.y)
564
+ * }
565
+ */
566
+ function getChildVerticals(node) {
567
+ const data = getHorizontal(node); // has parentId, thisId, x0,x1,y0=y1
568
+
569
+ // Build a quick index to access parent's y by id
570
+ const byId = new Map(data.map(d => [d.thisId, d]));
571
+
572
+ const childVerticals = [];
573
+
574
+ for (const d of data) {
575
+ if (d.parentId == null) continue;
576
+ const parent = byId.get(d.parentId);
577
+ if (!parent) continue;
578
+
579
+ const x = d.x0; // child’s vertical sits at parent.x == child.x0
580
+ const yc = d.y0; // child y
581
+ const yp = parent.y0; // parent y
582
+ const y0 = Math.min(yc, yp);
583
+ const y1 = Math.max(yc, yp);
584
+
585
+ childVerticals.push({
586
+ parentId: d.parentId,
587
+ childId: d.thisId,
588
+ x,
589
+ y0,
590
+ y1
591
+ });
592
+ }
593
+
594
+ return childVerticals;
595
+ }
596
+
597
+ /**
598
+ * Rectangle layout wrapper.
599
+ * Returns:
600
+ * - data: per-node rows (x0,x1,y0=y1,...)
601
+ * - vertical_lines: single spanning vertical per parent (baseline draw)
602
+ * - child_vertical_lines: one vertical per edge (for highlighting)
603
+ * - horizontal_lines: per-edge child horizontals (x0->x1 at y), with labels & tip flags
604
+ */
605
+ function rectangleLayout(node) {
606
+ const data = getHorizontal(node); // per-node
607
+ const vertical_lines = getVertical(node); // parent spans
608
+ const child_vertical_lines = getChildVerticals(node); // per-edge verticals
609
+
610
+ // IMPORTANT: include y0 & y1, and carry isTip/labels for the renderer
611
+ new Map(data.map(d => [d.thisId, d]));
612
+ const horizontal_lines = data
613
+ .filter(d => d.parentId != null)
614
+ .map(d => ({
615
+ parentId: d.parentId,
616
+ childId: d.thisId,
617
+ thisId: d.thisId,
618
+ thisLabel: d.thisLabel,
619
+ isTip: d.isTip,
620
+ x0: d.x0,
621
+ x1: d.x1,
622
+ y0: d.y0,
623
+ y1: d.y0
624
+ }));
625
+
626
+ return { data, vertical_lines, child_vertical_lines, horizontal_lines };
627
+ }
628
+
629
+ /**
630
+ * Convert parsed Newick tree from fortify() into data frame of edges
631
+ * this is akin to a "phylo" object in R, where thisID and parentId
632
+ * are the $edge slot. I think.
633
+ */
634
+
635
+ function edges(df, rectangular = false) {
636
+ const rows = [...df].sort((a, b) => a.thisId - b.thisId);
637
+ const byId = new Map(rows.map((r) => [r.thisId, r]));
638
+ const result = [];
639
+
640
+ for (const row of rows) {
641
+ if (row.parentId == null) continue;
642
+ const parent = byId.get(row.parentId);
643
+ if (!parent) continue;
644
+
645
+ if (rectangular) {
646
+ result.push({ x1: row.x, y1: row.y, id1: row.thisId, x2: parent.x, y2: row.y, id2: undefined });
647
+ result.push({ x1: parent.x, y1: row.y, id1: undefined, x2: parent.x, y2: parent.y, id2: row.parentId });
648
+ } else {
649
+ result.push({ x1: row.x, y1: row.y, id1: row.thisId, x2: parent.x, y2: parent.y, id2: row.parentId });
650
+ }
651
+ }
652
+ return result;
653
+ }
654
+
655
+ /**
656
+ * Recursive function for breadth-first search of a tree
657
+ * the root node is visited first.
658
+ */
659
+
660
+ function levelorder(root) {
661
+ const queue = [root], result = [];
662
+ while (queue.length) {
663
+ const curnode = queue.shift(); // <- FIFO
664
+ result.push(curnode);
665
+ for (const child of curnode.children) queue.push(child);
666
+ }
667
+ return result;
668
+ }
669
+
670
+
671
+ /**
672
+ * Count the number of tips that descend from this node
673
+ */
674
+
675
+ function numTips(thisnode) {
676
+ var result = 0;
677
+ for (const node of levelorder(thisnode)) {
678
+ if (node.children.length == 0) result++;
679
+ }
680
+ return (result);
681
+ }
682
+
683
+ /**
684
+ * Equal-angle layout for unrooted trees.
685
+ * - Precomputes ntips in O(n) to avoid repeated subtree counts
686
+ * - Uses angles in "π units" (0..2) to match existing API
687
+ * - Populates x,y positions from branchLength and angle
688
+ */
689
+
690
+ function annotateTipCounts(root) {
691
+ (function post(n) {
692
+ if (!n.children || n.children.length === 0) {
693
+ n.ntips = 1; return 1;
694
+ }
695
+ let sum = 0;
696
+ for (const c of n.children) sum += post(c);
697
+ n.ntips = sum;
698
+ return sum;
699
+ })(root);
700
+ return root;
701
+ }
702
+
703
+ function equalAngleLayout(node) {
704
+ if (node.parent === null) {
705
+ annotateTipCounts(node);
706
+ node.start = 0.; // guarantees no arcs overlap 0
707
+ node.end = 2.; // *π
708
+ node.angle = 0.; // irrelevant at root
709
+ node.ntips = numTips(node); // safe (already computed), left for compatibility
710
+ node.x = 0;
711
+ node.y = 0;
712
+ }
713
+
714
+ let lastStart = node.start;
715
+
716
+ for (let i = 0; i < node.children.length; i++) {
717
+ const child = node.children[i];
718
+ const arc = (node.end - node.start) * (child.ntips / node.ntips);
719
+
720
+ child.start = lastStart;
721
+ child.end = lastStart + arc;
722
+
723
+ // bisect the arc in π-units
724
+ child.angle = child.start + (child.end - child.start) / 2.;
725
+ lastStart = child.end;
726
+
727
+ // map to coordinates (convert π-units to radians by multiplying by Math.PI)
728
+ const theta = child.angle * Math.PI;
729
+ const bl = (child.branchLength ?? 0);
730
+ child.x = node.x + bl * Math.sin(theta);
731
+ child.y = node.y + bl * Math.cos(theta);
732
+
733
+ equalAngleLayout(child);
734
+ }
735
+
736
+ return node;
737
+ }
738
+
739
+ /**
740
+ * Simple wrapper function for equalAngleLayout()
741
+ */
742
+
743
+ function unrooted (node) {
744
+ var data = {};
745
+ // use the Felsenstein equal angle layout algorithm
746
+ var eq = fortify(equalAngleLayout(node));
747
+ data.data = eq;
748
+ // make the edges dataset
749
+ data.edges = edges(eq);
750
+
751
+ return data;
752
+ }
753
+
754
+ function makeIndexById(rows, key = "thisId") {
755
+ return new Map(rows.map(d => [d[key], d]));
756
+ }
757
+ function parentFisheye(d, data) {
758
+ const byId = makeIndexById(data);
759
+ const parent = byId.get(d.parentId);
760
+ return parent ? { px: parent.fisheye.x, py: parent.fisheye.y } : null;
761
+ }
762
+
763
+ /**
764
+ * Parse a Newick tree string into a doubly-linked list of JS Objects.
765
+ * Assigns labels, branch lengths, and node IDs (tips before internals if input emits them that way).
766
+ *
767
+ * Notes / limitations:
768
+ * - Quoted labels and NHX annotations are not fully supported.
769
+ * - Branch lengths in scientific notation are supported (parseFloat).
770
+ */
771
+
772
+ function readTree(text) {
773
+ // Remove all whitespace (space, tabs, newlines)
774
+ text = String(text).replace(/\s+/g, '');
775
+
776
+ const tokens = text.split(/(;|\(|\)|,)/);
777
+ const root = { parent: null, children: [] };
778
+ let curnode = root;
779
+ let nodeId = 0;
780
+
781
+ for (const token of tokens) {
782
+ if (!token || token === ';') continue;
783
+
784
+ if (token === '(') {
785
+ const child = { parent: curnode, children: [] };
786
+ curnode.children.push(child);
787
+ curnode = child; // descend
788
+ } else if (token === ',') {
789
+ // back to parent, then create sibling
790
+ curnode = curnode.parent;
791
+ const child = { parent: curnode, children: [] };
792
+ curnode.children.push(child);
793
+ curnode = child;
794
+ } else if (token === ')') {
795
+ // ascend one level
796
+ curnode = curnode.parent;
797
+ if (curnode === null) break;
798
+ } else {
799
+ // label/branch-length chunk (e.g., "A:0.01" or "A")
800
+ const nodeinfo = token.split(':');
801
+ if (nodeinfo.length === 1) {
802
+ if (token.startsWith(':')) {
803
+ curnode.label = '';
804
+ curnode.branchLength = parseFloat(nodeinfo[0]);
805
+ } else {
806
+ curnode.label = nodeinfo[0];
807
+ curnode.branchLength = null;
808
+ }
809
+ } else if (nodeinfo.length === 2) {
810
+ curnode.label = nodeinfo[0];
811
+ curnode.branchLength = parseFloat(nodeinfo[1]);
812
+ } else {
813
+ console.warn(token, "Unhandled token with multiple ':' characters");
814
+ curnode.label = nodeinfo[0] || '';
815
+ curnode.branchLength = parseFloat(nodeinfo[nodeinfo.length - 1]);
816
+ }
817
+ curnode.id = nodeId++; // assign then increment
818
+ }
819
+ }
820
+
821
+ // Ensure root has an id if not assigned during parsing
822
+ if (root.id == null) root.id = nodeId;
823
+
824
+ return root;
825
+ }
826
+
827
+ /**
828
+ * Subset a tree given a node - i.e. the node of interests and all the descendents
829
+ */
830
+
831
+ function subTree (tree, node) {
832
+ // Thanks Richard Challis!
833
+ let fullTree = {};
834
+ tree.data.forEach(obj => {
835
+ fullTree[obj.thisId] = { ...obj };
836
+ });
837
+
838
+ let subTree = {};
839
+ const getDescendants = function (rootNodeId) {
840
+ if (fullTree[rootNodeId]) {
841
+ subTree[rootNodeId] = fullTree[rootNodeId];
842
+ if (fullTree[rootNodeId].children) {
843
+ fullTree[rootNodeId].children.forEach(childNodeId => {
844
+ getDescendants(childNodeId);
845
+ });
846
+ }
847
+ }
848
+ };
849
+ // call the recursive function
850
+ getDescendants(node);
851
+
852
+ // in each of the functions, data contains the children key
853
+ const data = [["data", Object.values(subTree)]];
854
+
855
+ const nodes = data[0][1].map(d => d.thisId);
856
+
857
+ var res = [];
858
+ // in all keys except data, push to new array
859
+ for (const node in tree) {
860
+ if (node === "data") continue;
861
+ res.push([node, tree[node]]);
862
+ }
863
+
864
+ var filtered = res.map(d => [
865
+ d[0],
866
+ d[1].filter(d => nodes.includes(d.thisId))
867
+ ]);
868
+
869
+ return Object.fromEntries(data.concat(filtered));
870
+ }
871
+
872
+ function drawPhylogeny(
873
+ treeText,
874
+ {
875
+ layout = "rect", // "rect" or "radial"
876
+ width = 800,
877
+ height = 800,
878
+ margin = { top: 20, right: 300, bottom: 20, left: 50 },
879
+ radialMargin = 80,
880
+ strokeWidth = 1,
881
+ radialMode = "outer" // or "align"
882
+ } = {}
883
+ ) {
884
+ if (layout === "rect") {
885
+ // RECTANGULAR LAYOUT
886
+ const tree_df = rectangleLayout(readTree(treeText));
887
+ const horizontal = tree_df.horizontal_lines;
888
+ const vertical = tree_df.vertical_lines;
889
+ const tips = horizontal.filter((d) => d.isTip);
890
+
891
+ const maxY = d3.max(horizontal, (d) => d.y1);
892
+ const minY = d3.min(horizontal, (d) => d.y1);
893
+ const maxX = d3.max(horizontal, (d) => d.x1);
894
+
895
+ const yScale = d3
896
+ .scaleLinear()
897
+ .domain([minY - 1, maxY + 1])
898
+ .range([margin.top, height - margin.bottom]);
899
+
900
+ const xScale = d3
901
+ .scaleLinear()
902
+ .domain([0, maxX])
903
+ .range([margin.left, width - margin.right]);
904
+
905
+ const svg = d3
906
+ .create("svg")
907
+ .attr("width", width)
908
+ .attr("height", height)
909
+ .attr("font-family", "sans-serif")
910
+ .attr("font-size", 10);
911
+
912
+ const group = svg.append("g");
913
+
914
+ group
915
+ .selectAll(".hline")
916
+ .data(horizontal)
917
+ .join("line")
918
+ .attr("x1", (d) => xScale(d.x0))
919
+ .attr("y1", (d) => yScale(d.y0))
920
+ .attr("x2", (d) => xScale(d.x1))
921
+ .attr("y2", (d) => yScale(d.y1))
922
+ .attr("stroke", "#555")
923
+ .attr("stroke-width", strokeWidth);
924
+
925
+ group
926
+ .selectAll(".vline")
927
+ .data(vertical)
928
+ .join("line")
929
+ .attr("x1", (d) => xScale(d.x0))
930
+ .attr("y1", (d) => yScale(d.y0))
931
+ .attr("x2", (d) => xScale(d.x1))
932
+ .attr("y2", (d) => yScale(d.y1))
933
+ .attr("stroke", "#555")
934
+ .attr("stroke-width", strokeWidth);
935
+
936
+ group
937
+ .selectAll(".tip-dot")
938
+ .data(tips)
939
+ .join("circle")
940
+ .attr("cx", (d) => xScale(d.x1))
941
+ .attr("cy", (d) => yScale(d.y1))
942
+ .attr("r", 2)
943
+ .attr("fill", "black");
944
+
945
+ svg
946
+ .append("g")
947
+ .selectAll("text")
948
+ .data(tips)
949
+ .join("text")
950
+ .attr("x", (d) => xScale(d.x1) + 4)
951
+ .attr("y", (d) => yScale(d.y1))
952
+ .attr("dy", "0.32em")
953
+ .attr("font-size", 10)
954
+ .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
955
+
956
+ return svg.node();
957
+ } else if (layout === "radial") {
958
+ const parsedTree = readTree(treeText);
959
+ const rad = radialLayout(parsedTree);
960
+
961
+ // ===== MODE =====
962
+ const TIP_MODE = radialMode; // "align" (shorten to original tips) or "outer" (project to one circle)
963
+ const isOuter = TIP_MODE === "outer";
964
+
965
+ // visuals (0 = let spokes reach the dots)
966
+ const DOT_R = 3;
967
+ const END_CAP = 0;
968
+
969
+ // ===== SCALES / BOUNDS =====
970
+ const maxRadius = d3.max(rad.data, (d) => d.r) ?? 0;
971
+ const scaleRadial = maxRadius + 2 * radialMargin;
972
+ const w = width,
973
+ h = height;
974
+ const centerX = w / 2,
975
+ centerY = h / 2;
976
+
977
+ const xScaleRadial = d3
978
+ .scaleLinear()
979
+ .domain([-scaleRadial, scaleRadial])
980
+ .range([0, w]);
981
+ const yScaleRadial = d3
982
+ .scaleLinear()
983
+ .domain([-scaleRadial, scaleRadial])
984
+ .range([h, 0]);
985
+ const radiusPx = (r) => r * (w / (2 * scaleRadial));
986
+
987
+ // ===== INDEXES / HELPERS =====
988
+ const byId = new Map(rad.data.map((d) => [d.thisId, d]));
989
+ const tips = rad.data.filter((d) => d.isTip);
990
+ const tipMaxR = tips.length ? d3.max(tips, (d) => d.r) : 0;
991
+
992
+ // Robust child-id extractor (handles multiple shapes)
993
+ function childIdOf(spoke) {
994
+ // prefer explicit child id fields; fall back to thisId; last-ditch id1 (seen in some edge shapes)
995
+ return spoke.childId ?? spoke.thisId ?? spoke.id1 ?? null;
996
+ }
997
+
998
+ // Shorten the *screen-space* end of a spoke by END_CAP px
999
+ function shortenSpokePx(x0, y0, x1, y1) {
1000
+ const X0 = xScaleRadial(x0),
1001
+ Y0 = yScaleRadial(y0);
1002
+ const X1 = xScaleRadial(x1),
1003
+ Y1 = yScaleRadial(y1);
1004
+ const dx = X1 - X0,
1005
+ dy = Y1 - Y0;
1006
+ const len = Math.hypot(dx, dy) || 1;
1007
+ const t = Math.max(0, (len - END_CAP) / len);
1008
+ return { X0, Y0, X1s: X0 + dx * t, Y1s: Y0 + dy * t, len };
1009
+ }
1010
+
1011
+ // ===== SVG ROOT =====
1012
+ const svg = d3
1013
+ .create("svg")
1014
+ .attr("width", w)
1015
+ .attr("height", h)
1016
+ .attr("font-family", "sans-serif")
1017
+ .attr("font-size", 10);
1018
+
1019
+ const group = svg.append("g");
1020
+
1021
+ // ===== ARCS (parent circles) =====
1022
+ group
1023
+ .append("g")
1024
+ .attr("class", "phylo_arcs")
1025
+ .selectAll("path")
1026
+ .data(rad.arcs)
1027
+ .join("path")
1028
+ .attr("d", (d) =>
1029
+ describeArc(
1030
+ centerX,
1031
+ centerY,
1032
+ Math.max(0, radiusPx(d.radius)),
1033
+ d.start,
1034
+ d.end
1035
+ )
1036
+ )
1037
+ .attr("fill", "none")
1038
+ .attr("stroke", "#777")
1039
+ .attr("stroke-width", strokeWidth);
1040
+
1041
+ // ===== RADII (spokes) =====
1042
+ group
1043
+ .append("g")
1044
+ .attr("class", "phylo_radii")
1045
+ .selectAll("line")
1046
+ .data(rad.radii)
1047
+ .join("line")
1048
+ .each(function(s, _) {
1049
+ // parent end (data space)
1050
+ const x0 = s.x0,
1051
+ y0 = s.y0;
1052
+
1053
+ // child end (data space), shape-agnostic
1054
+ const cid = childIdOf(s);
1055
+ const node = cid != null ? byId.get(cid) : undefined;
1056
+ const isTipSpoke = !!(node && node.isTip);
1057
+
1058
+ // default to the original child endpoint from the spoke record
1059
+ let x1 = s.x1,
1060
+ y1 = s.y1;
1061
+
1062
+ // In "outer" mode, project only *tip* spokes to the common circle
1063
+ if (isOuter && isTipSpoke) {
1064
+ x1 = tipMaxR * Math.cos(node.angle);
1065
+ y1 = tipMaxR * Math.sin(node.angle);
1066
+ }
1067
+
1068
+ // Shorten in screen space so the spoke doesn’t pierce the dot (END_CAP can be 0)
1069
+ const { X0, Y0, X1s, Y1s} = shortenSpokePx(x0, y0, x1, y1);
1070
+
1071
+ d3.select(this)
1072
+ .attr("x1", X0)
1073
+ .attr("y1", Y0)
1074
+ .attr("x2", X1s)
1075
+ .attr("y2", Y1s)
1076
+ .attr("stroke", "#777")
1077
+ .attr("stroke-width", strokeWidth);
1078
+ });
1079
+
1080
+ // ===== TIP DOTS =====
1081
+ group
1082
+ .append("g")
1083
+ .attr("class", "phylo_tip_dots")
1084
+ .selectAll("circle")
1085
+ .data(tips)
1086
+ .join("circle")
1087
+ .each(function(d, _) {
1088
+ // dot at original tip (align) or projected circle (outer)
1089
+ const x = isOuter ? tipMaxR * Math.cos(d.angle) : d.x;
1090
+ const y = isOuter ? tipMaxR * Math.sin(d.angle) : d.y;
1091
+
1092
+ d3.select(this)
1093
+ .attr("cx", xScaleRadial(x))
1094
+ .attr("cy", yScaleRadial(y))
1095
+ .attr("r", DOT_R)
1096
+ .attr("fill", "black")
1097
+ .attr("stroke", "black")
1098
+ .attr("stroke-width", 1.5);
1099
+ });
1100
+
1101
+ // ===== LABELS (unchanged) =====
1102
+ // Labels — make them follow the tip position used by the current mode
1103
+ group
1104
+ .append("g")
1105
+ .attr("class", "phylo_labels")
1106
+ .selectAll("g.label")
1107
+ .data(tips) // <— bind only tip nodes
1108
+ .join("g")
1109
+ .attr("class", "label")
1110
+ .attr("transform", (d) => {
1111
+ // same tip position rule as dots/spokes:
1112
+ // - "outer": snap to common ring (tipMaxR)
1113
+ // - otherwise (e.g. "align"/"phylo"): true tip radius
1114
+ const r = isOuter ? tipMaxR : d.r;
1115
+ const x = r * Math.cos(d.angle);
1116
+ const y = r * Math.sin(d.angle);
1117
+ return `translate(${xScaleRadial(x)},${yScaleRadial(y)})`;
1118
+ })
1119
+ .each(function(d) {
1120
+ // rotate so text reads outward; flip when on the left side
1121
+ let angle = (-d.angle * 180) / Math.PI;
1122
+ let xoff = 10; // radial padding for text (px)
1123
+ let anchor = "start";
1124
+ if (d.angle > Math.PI / 2 && d.angle < (3 * Math.PI) / 2) {
1125
+ angle += 180;
1126
+ xoff *= -1;
1127
+ anchor = "end";
1128
+ }
1129
+ d3.select(this)
1130
+ .append("g")
1131
+ .attr("transform", `rotate(${angle})`)
1132
+ .append("text")
1133
+ .attr("x", xoff)
1134
+ .attr("alignment-baseline", "middle")
1135
+ .attr("text-anchor", anchor)
1136
+ .attr("font-size", 10)
1137
+ .attr("fill", "black")
1138
+ .text((d) => d.thisLabel?.replace(/_/g, " ") ?? "");
1139
+ });
1140
+
1141
+ return svg.node();
1142
+ } else if (layout === "unrooted") {
1143
+ // UNROOTED LAYOUT
1144
+ const parsedTree = readTree(treeText);
1145
+ const unrootedPhylo = unrooted(parsedTree);
1146
+
1147
+ const w = width;
1148
+ const h = height;
1149
+
1150
+ // Get spatial extent
1151
+ const xExtent = d3.extent(unrootedPhylo.data, (d) => d.x);
1152
+ const yExtent = d3.extent(unrootedPhylo.data, (d) => d.y);
1153
+
1154
+ // Find maximum absolute distance from center (0,0)
1155
+ const maxX = Math.max(Math.abs(xExtent[0]), Math.abs(xExtent[1]));
1156
+ const maxY = Math.max(Math.abs(yExtent[0]), Math.abs(yExtent[1]));
1157
+ const maxRadius = Math.max(maxX, maxY);
1158
+
1159
+ // Add some margin
1160
+ const scaleUnroot = maxRadius + 2 * radialMargin;
1161
+
1162
+ const xScaleUnroot = d3
1163
+ .scaleLinear()
1164
+ .domain([-scaleUnroot, scaleUnroot])
1165
+ .range([0, w]);
1166
+
1167
+ const yScaleUnroot = d3
1168
+ .scaleLinear()
1169
+ .domain([-scaleUnroot, scaleUnroot])
1170
+ .range([h, 0]);
1171
+
1172
+ const svg = d3
1173
+ .create("svg")
1174
+ .attr("width", w)
1175
+ .attr("height", h)
1176
+ .attr("font-family", "sans-serif")
1177
+ .attr("font-size", 10);
1178
+
1179
+ const group = svg.append("g");
1180
+
1181
+ // Edges
1182
+ group
1183
+ .append("g")
1184
+ .attr("class", "phylo_lines")
1185
+ .selectAll("line")
1186
+ .data(unrootedPhylo.edges)
1187
+ .join("line")
1188
+ .attr("x1", (d) => xScaleUnroot(d.x1))
1189
+ .attr("y1", (d) => yScaleUnroot(d.y1))
1190
+ .attr("x2", (d) => xScaleUnroot(d.x2))
1191
+ .attr("y2", (d) => yScaleUnroot(d.y2))
1192
+ .attr("stroke-width", strokeWidth)
1193
+ .attr("stroke", "#777");
1194
+
1195
+ // Nodes
1196
+ group
1197
+ .append("g")
1198
+ .attr("class", "phylo_points")
1199
+ .selectAll("circle")
1200
+ .data(unrootedPhylo.data)
1201
+ .join("circle")
1202
+ .attr("class", "dot")
1203
+ .attr("r", (d) => (d.isTip ? 4 : 0))
1204
+ .attr("cx", (d) => xScaleUnroot(d.x))
1205
+ .attr("cy", (d) => yScaleUnroot(d.y))
1206
+ .attr("stroke", "black")
1207
+ .attr("stroke-width", 2)
1208
+ .attr("fill", (d) => (d.isTip ? "black" : "white"));
1209
+
1210
+ // Tip labels
1211
+ const tipEdges = new Map();
1212
+ const nodesById = new Map(unrootedPhylo.data.map((d) => [d.thisId, d]));
1213
+
1214
+ unrootedPhylo.edges.forEach((edge) => {
1215
+ const tipNode = nodesById.get(edge.id1);
1216
+ if (tipNode?.isTip) {
1217
+ tipEdges.set(edge.id1, edge);
1218
+ }
1219
+ });
1220
+
1221
+ group
1222
+ .append("g")
1223
+ .attr("class", "phylo_labels")
1224
+ .selectAll("g")
1225
+ .data(unrootedPhylo.data.filter((d) => d.isTip))
1226
+ .join("g")
1227
+ .attr("transform", (d) => {
1228
+ const x = xScaleUnroot(d.x);
1229
+ const y = yScaleUnroot(d.y);
1230
+ return `translate(${x},${y})`;
1231
+ })
1232
+ .each(function(d) {
1233
+ const edge = tipEdges.get(d.thisId);
1234
+ if (!edge) {
1235
+ console.warn(
1236
+ "No incoming edge found for tip node:",
1237
+ d.thisId,
1238
+ d.thisLabel
1239
+ );
1240
+ return;
1241
+ }
1242
+
1243
+ // Compute angle of the incoming edge (screen coords)
1244
+ const x1 = xScaleUnroot(edge.x1);
1245
+ const y1 = yScaleUnroot(edge.y1);
1246
+ const x2 = xScaleUnroot(edge.x2);
1247
+ const y2 = yScaleUnroot(edge.y2);
1248
+
1249
+ const dx = x2 - x1;
1250
+ const dy = y2 - y1;
1251
+ let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
1252
+
1253
+ // Flip label if upside down
1254
+ let xOffset = -10;
1255
+ let anchor = "end";
1256
+ if (angle > 90 || angle < -90) {
1257
+ angle += 180;
1258
+ anchor = "start";
1259
+ xOffset = 10;
1260
+ }
1261
+
1262
+ // Draw label rotated along branch direction
1263
+ d3.select(this)
1264
+ .append("g")
1265
+ .attr("transform", `rotate(${angle})`)
1266
+ .append("text")
1267
+ .attr("x", xOffset)
1268
+ .attr("alignment-baseline", "middle")
1269
+ .attr("text-anchor", anchor)
1270
+ .attr("font-size", 10)
1271
+ .attr("fill", "black")
1272
+ .text(d.thisLabel?.replace(/_/g, " ") ?? "");
1273
+ });
1274
+
1275
+ return svg.node();
1276
+ } else {
1277
+ throw new Error("Unsupported layout type. Use 'rect' or 'radial'.");
1278
+ }
1279
+ }
1280
+
1281
+ export { describeArc, drawPhylogeny, parentFisheye, phisheye, radialLayout, readTree, rectangleLayout, subTree, unrooted };
2
1282
  //# sourceMappingURL=index.js.map