'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MagnifyingGlass as Search } from '@phosphor-icons/react' import { ClaySegmented } from '@/components/ui/ClaySegmented' import { AlignmentStatsBar } from '@/components/alignment/AlignmentStatsBar' import { AlignmentBlock } from '@/components/alignment/AlignmentBlock' import { computeAlignmentStats, parseAlignedFasta } from '@/lib/alignment-stats' import { useTheme } from '@/contexts/theme' import { cssColor } from '@/lib/theme-canvas' // ─── Types ──────────────────────────────────────────────────────────────────── interface TreeNode { id: number name: string length: number bootstrap: number | null children: TreeNode[] parent: TreeNode | null x: number y: number px: number py: number angle?: number radius?: number } type Layout = 'rectangular' | 'circular' export interface PhyloTreeViewerProps { newick: string method?: 'nj' | 'ml' | 'upgma' alignment?: string sequenceType?: 'protein' | 'dna' } // ─── Newick parser ──────────────────────────────────────────────────────────── function parseNewick(s: string): TreeNode { let nextId = 0 const makeNode = (): TreeNode => ({ id: nextId++, name: '', length: 0, bootstrap: null, children: [], parent: null, x: 0, y: 0, px: 0, py: 0, }) const stack: TreeNode[] = [] let cur: TreeNode = makeNode() let i = 0 const n = s.length while (i < n) { const ch = s[i] if (ch === '(') { const child = makeNode() child.px = cur.px; child.py = cur.py cur.children.push(child) stack.push(cur) child.parent = cur cur = child i++ } else if (ch === ',') { const parent = stack[stack.length - 1] const sibling = makeNode() parent.children.push(sibling) sibling.parent = parent cur = sibling i++ } else if (ch === ')') { cur = stack.pop()! i++ while (i < n && s[i] === ' ') i++ if (i < n && s[i] !== ':' && s[i] !== ',' && s[i] !== ')' && s[i] !== ';' && s[i] !== '[') { const [label, ni] = readToken(s, i) i = ni const num = parseFloat(label) if (!isNaN(num) && label.trim() !== '') { cur.bootstrap = num } else { cur.name = label } } if (i < n && s[i] === '[') { while (i < n && s[i] !== ']') i++ i++ } if (i < n && s[i] === ':') { i++ const [lenStr, ni] = readNumber(s, i) cur.length = parseFloat(lenStr) || 0 i = ni } } else if (ch === ':') { i++ const [lenStr, ni] = readNumber(s, i) cur.length = parseFloat(lenStr) || 0 i = ni } else if (ch === ';' || ch === '\n' || ch === '\r') { i++ } else { if (ch === "'") { i++ let name = '' while (i < n && s[i] !== "'") { name += s[i]; i++ } cur.name = name i++ } else { const [name, ni] = readToken(s, i) cur.name = name i = ni } } } return cur } function readToken(s: string, i: number): [string, number] { let tok = '' while (i < s.length && !'(),;:['.includes(s[i])) { tok += s[i]; i++ } return [tok.trim(), i] } function readNumber(s: string, i: number): [string, number] { let tok = '' while (i < s.length && '0123456789.-eE+'.includes(s[i])) { tok += s[i]; i++ } return [tok, i] } // ─── Tree helpers ───────────────────────────────────────────────────────────── function countLeaves(n: TreeNode): number { if (!n.children.length) return 1 return n.children.reduce((s, c) => s + countLeaves(c), 0) } function pruneTree(node: TreeNode, collapsed: Set): TreeNode { if (collapsed.has(node.id) && node.children.length > 0) { return { ...node, id: node.id, children: [], name: `+${countLeaves(node)}`, parent: null } } const children = node.children.map(c => pruneTree(c, collapsed)) const pruned = { ...node, children, parent: null } children.forEach(c => { c.parent = pruned }) return pruned } function computePathSet(nodes: TreeNode[], targetId: number | null): Set { if (targetId === null) return new Set() const map = new Map() for (const n of nodes) map.set(n.id, n) const path = new Set() let id: number | undefined = targetId while (id !== undefined) { path.add(id) const node = map.get(id) id = node?.parent?.id } return path } function findNodeById(nodes: TreeNode[], id: number): TreeNode | null { for (const n of nodes) { if (n.id === id) return n if (n.children.length) { const found = findNodeById(n.children, id) if (found) return found } } return null } function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)) } // ─── Rectangular layout ─────────────────────────────────────────────────────── const SVG_W = 720 const SVG_PAD = { top: 20, right: 160, bottom: 40, left: 20 } function buildRectangularLayout(root: TreeNode, collapsed: Set): { nodes: TreeNode[]; height: number; maxDepth: number; rootNode: TreeNode } { const pruned = pruneTree(root, collapsed) const nodes: TreeNode[] = [] let leafIdx = 0 const totalLeaves = countLeaves(pruned) const rowH = Math.max(18, Math.min(26, 600 / totalLeaves)) const drawH = totalLeaves * rowH const drawW = SVG_W - SVG_PAD.left - SVG_PAD.right function hasBranchLengths(n: TreeNode): boolean { return n.length > 0 || n.children.some(hasBranchLengths) } const useLengths = hasBranchLengths(pruned) function maxDepth(n: TreeNode, d: number): number { if (!n.children.length) return d + n.length return Math.max(...n.children.map(c => maxDepth(c, d + n.length))) } const totalDepth = useLengths ? maxDepth(pruned, 0) : 0 function getMaxDepthSteps(n: TreeNode, d = 0): number { if (!n.children.length) return d return Math.max(...n.children.map(c => getMaxDepthSteps(c, d + 1))) } function assignPositions(n: TreeNode, depth: number, px: number, py: number): void { nodes.push(n) const x = useLengths && totalDepth > 0 ? SVG_PAD.left + (depth / totalDepth) * drawW : SVG_PAD.left + depth * (drawW / (getMaxDepthSteps(pruned) || 1)) n.px = px; n.py = py if (!n.children.length) { n.x = x n.y = SVG_PAD.top + leafIdx * rowH + rowH / 2 leafIdx++ } else { n.children.forEach(c => assignPositions(c, depth + (useLengths ? c.length : 1), x, 0)) n.y = (n.children[0].y + n.children[n.children.length - 1].y) / 2 n.x = x n.children.forEach(c => { c.px = n.x; c.py = n.y }) } } assignPositions(pruned, 0, SVG_PAD.left, SVG_PAD.top + drawH / 2) pruned.px = pruned.x; pruned.py = pruned.y return { nodes, height: drawH + SVG_PAD.top + SVG_PAD.bottom, maxDepth: totalDepth, rootNode: pruned } } // ─── Circular layout ────────────────────────────────────────────────────────── const CIRC_R = 280 const CIRC_CX = 380 const CIRC_CY = 380 const CIRC_SIZE = 760 function buildCircularLayout(root: TreeNode, collapsed: Set): { nodes: TreeNode[]; height: number; maxDepth: number; rootNode: TreeNode } { const pruned = pruneTree(root, collapsed) const nodes: TreeNode[] = [] const total = countLeaves(pruned) let leafIdx = 0 function hasBranchLengths(n: TreeNode): boolean { return n.length > 0 || n.children.some(hasBranchLengths) } const useLengths = hasBranchLengths(pruned) function maxCumDist(n: TreeNode, d: number): number { if (!n.children.length) return d + (useLengths ? n.length : 1) return Math.max(...n.children.map(c => maxCumDist(c, d + (useLengths ? c.length : 1)))) } const maxDist = maxCumDist(pruned, 0) || 1 function assignAngles(n: TreeNode): number { if (!n.children.length) { n.angle = (leafIdx / total) * 2 * Math.PI leafIdx++ return n.angle! } const childAngles = n.children.map(assignAngles) n.angle = (Math.min(...childAngles) + Math.max(...childAngles)) / 2 return n.angle! } function assignRadii(n: TreeNode, cumDist: number): void { n.radius = (cumDist / maxDist) * CIRC_R n.children.forEach(c => assignRadii(c, cumDist + (useLengths ? c.length : 1))) } function toCartesian(n: TreeNode, parent: TreeNode | null): void { nodes.push(n) n.x = CIRC_CX + n.radius! * Math.cos(n.angle!) n.y = CIRC_CY + n.radius! * Math.sin(n.angle!) n.px = parent ? parent.x : n.x n.py = parent ? parent.y : n.y n.children.forEach(c => toCartesian(c, n)) } assignAngles(pruned) assignRadii(pruned, 0) toCartesian(pruned, null) return { nodes, height: CIRC_SIZE, maxDepth: 0, rootNode: pruned } } // ─── Bootstrap colour scale ──────────────────────────────────────────────────── function bootstrapColour(val: number): string { if (val >= 90) return '#4ADE80' if (val >= 70) return '#a3e635' if (val >= 50) return '#fb923c' return '#f87171' } // ─── SVG sub-renderers ──────────────────────────────────────────────────────── function RectangularTree({ nodes, svgH, maxDepth, pathSet, selectedId, hoveredId, onNodeHover, onNodeClick, onNodeDoubleClick, }: { nodes: TreeNode[]; svgH: number; maxDepth: number pathSet: Set; selectedId: number | null; hoveredId: number | null onNodeHover: (id: number | null) => void; onNodeClick: (id: number) => void; onNodeDoubleClick: (id: number) => void }) { const hasHover = hoveredId !== null const drawW = SVG_W - SVG_PAD.left - SVG_PAD.right return ( <> {nodes.map((n, i) => { if (n.px === n.x && n.py === n.y) return null const onPath = pathSet.has(n.id) const dim = hasHover && !onPath const bright = hasHover && onPath return ( {n.length > 0.001 && ( {n.length.toFixed(4)} )} ) })} {nodes.map((n, i) => { const isLeaf = n.children.length === 0 const onPath = pathSet.has(n.id) const dim = hasHover && !onPath const bright = hasHover && onPath const isSelected = selectedId === n.id const blTip = n.length > 0 ? `branch length: ${n.length.toFixed(5)}` : '' return ( onNodeHover(n.id)} onMouseLeave={() => onNodeHover(null)} onClick={(e) => { e.stopPropagation(); onNodeClick(n.id) }} onDoubleClick={(e) => { e.stopPropagation(); onNodeDoubleClick(n.id) }} > {isLeaf ? ( <> {n.name} {n.name}{blTip ? ` · ${blTip}` : ''} ) : ( <> {n.bootstrap !== null && n.bootstrap >= 50 && ( {Math.round(n.bootstrap)} )} {n.name || `N${i}`} Node {n.name || `N${i}`}{blTip ? ` · ${blTip}` : ''}{n.bootstrap !== null ? ` · bootstrap: ${n.bootstrap}` : ''} )} ) })} {maxDepth > 0 && (() => { const scaleVal = parseFloat((maxDepth / 5).toPrecision(1)) const scaleW = (scaleVal / maxDepth) * drawW const bx = SVG_PAD.left const by = svgH - SVG_PAD.bottom + 10 return ( {scaleVal} substitutions/site ) })()} ) } function CircularTree({ nodes, pathSet, selectedId, hoveredId, onNodeHover, onNodeClick, onNodeDoubleClick, }: { nodes: TreeNode[]; pathSet: Set; selectedId: number | null; hoveredId: number | null onNodeHover: (id: number | null) => void; onNodeClick: (id: number) => void; onNodeDoubleClick: (id: number) => void }) { const hasHover = hoveredId !== null return ( <> {nodes.map((n, i) => { if (n.px === n.x && n.py === n.y) return null const onPath = pathSet.has(n.id) const dim = hasHover && !onPath const bright = hasHover && onPath const midX = (n.px + n.x) / 2 const midY = (n.py + n.y) / 2 return ( {n.length > 0.001 && ( {n.length.toFixed(4)} )} ) })} {nodes.filter(n => n.children.length > 0).map((n, i) => { const r = n.radius! if (r < 1 || n.children.length < 2) return null const onPath = pathSet.has(n.id) const dim = hasHover && !onPath const bright = hasHover && onPath const angles = n.children.map(c => c.angle!) const a0 = Math.min(...angles) const a1 = Math.max(...angles) const x0 = CIRC_CX + r * Math.cos(a0) const y0 = CIRC_CY + r * Math.sin(a0) const x1 = CIRC_CX + r * Math.cos(a1) const y1 = CIRC_CY + r * Math.sin(a1) const largeArc = (a1 - a0) > Math.PI ? 1 : 0 return ( ) })} {nodes.map((n, i) => { const isLeaf = n.children.length === 0 const angle = n.angle ?? 0 const deg = (angle * 180) / Math.PI const flip = deg > 90 && deg < 270 const labelX = CIRC_CX + (n.radius! + 10) * Math.cos(angle) const labelY = CIRC_CY + (n.radius! + 10) * Math.sin(angle) const onPath = pathSet.has(n.id) const dim = hasHover && !onPath const bright = hasHover && onPath const isSelected = selectedId === n.id const blTip = n.length > 0 ? `branch length: ${n.length.toFixed(5)}` : '' return ( onNodeHover(n.id)} onMouseLeave={() => onNodeHover(null)} onClick={(e) => { e.stopPropagation(); onNodeClick(n.id) }} onDoubleClick={(e) => { e.stopPropagation(); onNodeDoubleClick(n.id) }} > {isLeaf ? ( <> {n.name} {n.name}{blTip ? ` · ${blTip}` : ''} ) : ( <> {n.bootstrap !== null && n.bootstrap >= 50 && ( {Math.round(n.bootstrap)} )} {n.name || `N${i}`} Node {n.name || `N${i}`}{blTip ? ` · ${blTip}` : ''}{n.bootstrap !== null ? ` · bootstrap: ${n.bootstrap}` : ''} )} ) })} ) } // ─── Node info panel ────────────────────────────────────────────────────────── function NodeInfoPanel({ node, allNodes }: { node: TreeNode; allNodes: TreeNode[] }) { const isLeaf = node.children.length === 0 const leavesInSubtree = isLeaf ? 1 : countLeaves(node) const depth = (() => { let d = 0; let cur: TreeNode | null = node.parent while (cur) { d++; cur = cur.parent } return d })() const leafName = isLeaf ? (node.name || '').split('|').pop()?.split('_')[0] || node.name : '' const isValidAccession = leafName.length >= 4 && /^[A-Z0-9_]+$/.test(leafName) return (
{isLeaf ? node.name || 'Leaf' : 'Internal Node'} {isLeaf ? 'leaf' : 'hub'} · depth {depth}
Branch length: {node.length.toFixed(5)}
{node.bootstrap !== null && (
Bootstrap: {node.bootstrap}
)} {!isLeaf && (
Subtree leaves: {leavesInSubtree}
)}
Node ID: {node.id}
{isLeaf && isValidAccession && (
View on NCBI
)}
) } // ─── Export helpers ─────────────────────────────────────────────────────────── function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url; a.download = filename; a.click() URL.revokeObjectURL(url) } function exportSVG(svgEl: SVGSVGElement, filename: string) { const src = '\n' + new XMLSerializer().serializeToString(svgEl) downloadBlob(new Blob([src], { type: 'image/svg+xml' }), filename) } function exportPNG(svgEl: SVGSVGElement, filename: string) { const { width, height } = svgEl.viewBox.baseVal const scale = 2 const canvas = Object.assign(document.createElement('canvas'), { width: width * scale, height: height * scale, }) const ctx = canvas.getContext('2d')! const img = new Image() const data = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(new XMLSerializer().serializeToString(svgEl)))) img.onload = () => { ctx.fillStyle = cssColor('--tree-canvas', '#06060B') ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.scale(scale, scale) ctx.drawImage(img, 0, 0) canvas.toBlob(b => b && downloadBlob(b, filename), 'image/png') } img.src = data } // ─── Method badge ───────────────────────────────────────────────────────────── const METHOD_META = { nj: { label: 'Neighbor-Joining', colour: 'text-accent-cyan border-accent-cyan/40 bg-accent-cyan/10' }, ml: { label: 'Maximum Likelihood', colour: 'text-violet-400 border-violet-400/40 bg-violet-400/10' }, upgma: { label: 'UPGMA', colour: 'text-amber-400 border-amber-400/40 bg-amber-400/10' }, } // ─── Main component ─────────────────────────────────────────────────────────── export default function PhyloTreeViewer({ newick, method, alignment, sequenceType = 'protein', }: PhyloTreeViewerProps) { const [layout, setLayout] = useState('rectangular') const [showAlignment, setShowAln] = useState(false) const [showRaw, setShowRaw] = useState(false) const [hoveredId, setHoveredId] = useState(null) const [selectedId, setSelectedId] = useState(null) const [collapsedIds, setCollapsedIds] = useState>(new Set()) const { theme } = useTheme() // Resolved at render so toggling the theme re-reads the tokens (re-render below). const treeCanvas = cssColor('--tree-canvas', theme === 'light' ? '#FFFFFF' : '#06060B') const alignmentStats = useMemo( () => (alignment ? computeAlignmentStats(parseAlignedFasta(alignment).seqs) : null), [alignment], ) // zoom / pan const [transform, setTransform] = useState({ scale: 1, x: 0, y: 0 }) const [isDragging, setIsDragging] = useState(false) const dragRef = useRef({ startX: 0, startY: 0, startS: 1, startTx: 0, startTy: 0, moved: false }) const svgRef = useRef(null) const treeData = useMemo(() => { try { const root = parseNewick(newick) if (layout === 'rectangular') { return { ...buildRectangularLayout(root, collapsedIds), error: null as string | null } } else { return { ...buildCircularLayout(root, collapsedIds), error: null as string | null } } } catch (e) { return { nodes: [], height: 400, maxDepth: 0, rootNode: null as TreeNode | null, error: String(e) } } }, [newick, layout, collapsedIds]) const pathSet = useMemo(() => computePathSet(treeData.nodes, hoveredId), [hoveredId, treeData.nodes]) const selectedNode = useMemo(() => { if (selectedId === null) return null return findNodeById(treeData.nodes, selectedId) }, [selectedId, treeData.nodes]) const filename = `phylo_tree_${method ?? 'tree'}` const handleExportSVG = useCallback(() => { if (svgRef.current) exportSVG(svgRef.current, `${filename}.svg`) }, [filename]) const handleExportPNG = useCallback(() => { if (svgRef.current) exportPNG(svgRef.current, `${filename}.png`) }, [filename]) const handleExportNewick = useCallback(() => { downloadBlob(new Blob([newick], { type: 'text/plain' }), `${filename}.nwk`) }, [newick, filename]) // ── Zoom / pan event handlers ────────────────────────────────────────────── const handleWheel = useCallback((e: WheelEvent) => { e.preventDefault() const svg = svgRef.current if (!svg) return const rect = svg.getBoundingClientRect() const mx = e.clientX - rect.left const my = e.clientY - rect.top const delta = e.deltaY > 0 ? 0.88 : 1 / 0.88 const newScale = clamp(transform.scale * delta, 0.08, 12) const ratio = newScale / transform.scale setTransform({ scale: newScale, x: mx - (mx - transform.x) * ratio, y: my - (my - transform.y) * ratio, }) }, [transform]) // Attach non-passive wheel listener so e.preventDefault() blocks page scroll useEffect(() => { const el = svgRef.current if (!el) return el.addEventListener('wheel', handleWheel, { passive: false }) return () => el.removeEventListener('wheel', handleWheel) }, [handleWheel]) const resetView = useCallback(() => { setTransform({ scale: 1, x: 0, y: 0 }) }, []) const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return setIsDragging(true) dragRef.current = { startX: e.clientX, startY: e.clientY, startS: transform.scale, startTx: transform.x, startTy: transform.y, moved: false, } }, [transform]) const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!isDragging) return const drag = dragRef.current const dx = e.clientX - drag.startX const dy = e.clientY - drag.startY if (Math.abs(dx) > 3 || Math.abs(dy) > 3) drag.moved = true setTransform(t => ({ ...t, x: drag.startTx + dx, y: drag.startTy + dy })) }, [isDragging]) const handleMouseUp = useCallback(() => { setIsDragging(false) }, []) // ── Node interaction handlers ─────────────────────────────────────────────── const handleNodeHover = useCallback((id: number | null) => { setHoveredId(id) }, []) const handleNodeClick = useCallback((id: number) => { setIsDragging(false) if (dragRef.current.moved) return setSelectedId(prev => prev === id ? null : id) }, []) const handleNodeDoubleClick = useCallback((id: number) => { setCollapsedIds(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id); else next.add(id) return next }) setSelectedId(null) }, []) const handleSvgClick = useCallback(() => { setSelectedId(null) setHoveredId(null) }, []) const expandAll = useCallback(() => setCollapsedIds(new Set()), []) const collapseAllInternal = useCallback(() => { setCollapsedIds(new Set(treeData.nodes.filter(n => n.children.length > 0).map(n => n.id))) }, [treeData.nodes]) // ── Error state ───────────────────────────────────────────────────────────── if (treeData.error) { return (

Failed to parse Newick string

{treeData.error}
{newick.slice(0, 200)}
) } // ── Render ────────────────────────────────────────────────────────────────── return (
{/* Toolbar */}
{method && ( {METHOD_META[method].label} )} {method === 'ml' && (
Bootstrap: {[['≥90', '#4ADE80'], ['70–89', '#a3e635'], ['50–69', '#fb923c'], ['<50', '#f87171']] .map(([label, colour]) => ( ■ {label} ))}
)}
{[ { label: 'SVG', fn: handleExportSVG }, { label: 'PNG', fn: handleExportPNG }, { label: 'Newick', fn: handleExportNewick }, ].map(({ label, fn }) => ( ))}
{/* Collapse controls */}
{hoveredId !== null ? 'Hover: path highlighted · ' : ''} Double-click internal node to collapse/expand
{/* SVG */}
{layout === 'rectangular' ? ( ) : ( )}

{treeData.nodes.filter(n => n.children.length === 0).length} taxa ·{' '} {sequenceType === 'protein' ? 'protein' : 'nucleotide'} sequences

{/* Selected node info panel */} {selectedNode && ( )} {alignment && (
{showAlignment && ( <> {alignmentStats && }
)}
)}
{showRaw && (
            {newick}
          
)}
) }