Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions frontend/src/tree/Inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
import { useEffect, useState } from 'react'
import { indexTree, nodeKey, type DNode, type NodeStyle } from './model'
import styles from './Tree.module.css'
import { fmt } from './ui'
import { fmt, fmtLwr } from './ui'
import { StyleEditor } from './controls'

export function Inspector({ d, ix, names, placements, onRename, onStyle, onRotate, onToggle, onReroot, onParent, onQuery }: {
d: DNode
ix: ReturnType<typeof indexTree>
names: Map<string, string>
placements: { name: string; lwr: number; rank: number; query: number }[]
placements: { name: string; lwr: number | null; rank: number; query: number }[]
onRename: (key: string, name: string | null) => void
onStyle: (style: NodeStyle | null) => void
onRotate: () => void
Expand Down Expand Up @@ -66,7 +66,7 @@ export function Inspector({ d, ix, names, placements, onRename, onStyle, onRotat
<div className="card-title" style={{ marginTop: 12 }}>Placements here</div>
<ul className={styles.placeList}>
{placements.map((p, k) => (
<li key={k}><button className={styles.linkBtn} onClick={() => onQuery(p.query)}>{p.name}</button> LWR {fmt(p.lwr, 3)}{p.rank ? ` (candidate ${p.rank + 1})` : ''}</li>
<li key={k}><button className={styles.linkBtn} onClick={() => onQuery(p.query)}>{p.name}</button> LWR {fmtLwr(p.lwr)}{p.rank ? ` (candidate ${p.rank + 1})` : ''}</li>
))}
</ul>
</>}
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/tree/layout.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// © 2026 Joshua Benjamin Jewell. All rights reserved.
// Licensed under the GNU Affero General Public License version 3 (AGPLv3).
import { pointOnBranch, type DisplayTree, type DNode, type PQuery, type Rgba, type TreeIndex } from './model'
import { bestLwr, pointOnBranch, type DisplayTree, type DNode, type PQuery, type Rgba, type TreeIndex } from './model'

export type SymbolShape = 'circle' | 'square' | 'triangle' | 'diamond'
export type Dataset = 'placements' | 'support'
Expand Down Expand Up @@ -44,6 +44,7 @@ export interface TreeSettings {
showInternalNames: boolean
/** Count each query at its best placement, or spread it over candidates by LWR. */
placements: 'best' | 'all'
bestLwrThreshold: number
placementMinLwr: number
placementLabels: boolean
symbols: Record<Dataset, SymbolStyle>
Expand All @@ -64,7 +65,7 @@ export const DEFAULT_SETTINGS: TreeSettings = {
arc: 360, arcStart: 0, scaleBar: true, scaleBarLength: null, ladderize: 'none', ladderizeBy: 'tips',
fontSize: 11, rowHeight: 14, size: 700, zoom: 1,
showSupport: true, supportMin: 0, showInternalNames: true,
placements: 'best', placementMinLwr: 0.1, placementLabels: false, wedge: 'isosceles',
placements: 'best', bestLwrThreshold: 0.95, placementMinLwr: 0.1, placementLabels: false, wedge: 'isosceles',
collapsedCounts: true, tipLabels: 'tips', tipJustify: 'left',
symbols: {
placements: { show: true, shape: 'circle', fill: { hex: '#e8590c', alpha: 0.6 }, stroke: { hex: '#e8590c', alpha: 1 }, minSize: 5, maxSize: 18 },
Expand Down Expand Up @@ -96,7 +97,7 @@ export interface LNode {
export interface LPlacement {
query: number
rank: number
lwr: number
lwr: number | null
x: number
y: number
/** Placements inside a collapsed clade are drawn at the clade. */
Expand All @@ -112,7 +113,7 @@ export interface LSymbol {
y: number
value: number
size: number
queries: { query: number; lwr: number; rank: number }[]
queries: { query: number; lwr: number | null; rank: number }[]
}

export interface DatasetScale {
Expand Down Expand Up @@ -376,12 +377,15 @@ export function layoutTree(t: DisplayTree, ix: TreeIndex, pqueries: PQuery[], s:
}

// Placements: the distal length is measured from the source child end of the branch.
const bestWeights = pqueries.map(bestLwr)
const placements: LPlacement[] = []
if (pqueries.length) {
pqueries.forEach((q, qi) => {
// Without weights, several candidates have no identifiable best location.
if (s.placements === 'best' && q.places.length > 1 && bestWeights[qi] === null) return
q.places.forEach((p, rank) => {
if (s.placements === 'best' && rank > 0) return
if (rank > 0 && p.lwr < s.placementMinLwr) return
if (rank > 0 && p.lwr !== null && p.lwr < s.placementMinLwr) return
const e = ix.edgeByNum.get(p.edgeNum)
if (e === undefined) return
const cands = t.byEdge.get(e) ?? []
Expand Down Expand Up @@ -413,7 +417,9 @@ export function layoutTree(t: DisplayTree, ix: TreeIndex, pqueries: PQuery[], s:
if (s.symbols.placements.show && placements.length) {
const groups = new Map<number, LSymbol>()
for (const p of placements) {
const w = pqueries[p.query].mult * (s.placements === 'all' ? p.lwr : 1)
const weight = s.placements === 'all' ? p.lwr : 1
if (weight === null || (s.placements === 'all' && bestWeights[p.query] === null)) continue
const w = pqueries[p.query].mult * weight
let g = groups.get(p.node.id)
if (!g) {
g = { dataset: 'placements', node: p.node, x: 0, y: 0, value: 0, size: 0, queries: [] }
Expand Down
14 changes: 11 additions & 3 deletions frontend/src/tree/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface SrcNode {

export interface Placement {
edgeNum: number
lwr: number
lwr: number | null
distal: number
pendant: number
logLik: number | null
Expand Down Expand Up @@ -216,24 +216,32 @@ export function parseJplace(text: string): SourceTree {
const iEdge = col('edge_num'), iLwr = col('like_weight_ratio'), iDist = col('distal_length')
const iPend = col('pendant_length'), iLik = col('likelihood')
if (iEdge < 0) throw new Error('jplace fields lack edge_num')
const weight = (v: unknown): number | null =>
typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1 ? v : null
tree.pqueries = (doc.placements ?? []).map((pq: Record<string, unknown>) => {
const rows = (pq.p ?? []) as number[][]
const nm = Array.isArray(pq.nm) ? (pq.nm as [string, number][]) : null
const names: string[] = Array.isArray(pq.n) ? (pq.n as string[]) : nm ? nm.map(x => x[0]) : []
const mult = nm ? nm.reduce((a, x) => a + (Number(x[1]) || 0), 0) : Math.max(1, names.length)
const places = rows.map(r => ({
edgeNum: r[iEdge],
lwr: iLwr >= 0 ? r[iLwr] : 1,
lwr: iLwr >= 0 ? weight(r[iLwr]) : null,
distal: iDist >= 0 ? r[iDist] : 0,
pendant: iPend >= 0 ? r[iPend] : 0,
logLik: iLik >= 0 ? r[iLik] : null,
})).sort((a, b) => b.lwr - a.lwr)
})).sort((a, b) => (b.lwr ?? -1) - (a.lwr ?? -1))
return { names, mult, places }
})
tree.meta = { ...(doc.metadata ?? {}), version: doc.version }
return tree
}

/** Best reported LWR is unknown if any candidate's weight is missing. */
export function bestLwr(q: PQuery): number | null {
if (!q.places.length || q.places.some(p => p.lwr === null)) return null
return q.places[0].lwr
}

export function parseTreeFile(format: 'jplace' | 'newick', text: string): SourceTree {
return format === 'jplace' ? parseJplace(text) : parseNewick(text)
}
Expand Down
81 changes: 81 additions & 0 deletions frontend/src/tree/placementLwr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: MPL-2.0
import { describe, expect, test } from 'bun:test'
import { bestLwr, buildDisplay, foldOps, indexTree, parseJplace } from './model'
import { DEFAULT_SETTINGS, layoutTree } from './layout'
import { mergeSettings, readDoc } from './viewDoc'
import { fmtLwr } from './ui'

const tree = '((A:1{0},B:1{1}):1{2},C:1{3},D:1{4}){5};'
const source = (p: unknown[][], fields = ['edge_num', 'like_weight_ratio']) => parseJplace(JSON.stringify({
tree, fields, placements: [{ n: ['q'], p }],
}))
const layout = (src: ReturnType<typeof source>, placements: 'best' | 'all') => {
const ix = indexTree(src)
return layoutTree(buildDisplay(ix, foldOps([]), new Map()), ix, src.pqueries, { ...DEFAULT_SETTINGS, placements })
}

describe('best placement LWR', () => {
test('uses the maximum reported weight without rounding or normalizing', () => {
const src = source([[0, 0.4], [1, 0.6]])
expect(bestLwr(src.pqueries[0])).toBe(0.6)
expect(src.pqueries[0].places[0].edgeNum).toBe(1)
expect(bestLwr(source([[0, 0]]).pqueries[0])).toBe(0)
expect(fmtLwr(0)).toBe('0')
})

test('absent field and absent candidate weights stay missing', () => {
const noField = source([[0]], ['edge_num'])
expect(noField.pqueries[0].places[0].lwr).toBeNull()
expect(bestLwr(noField.pqueries[0])).toBeNull()
expect(bestLwr(source([[0]]).pqueries[0])).toBeNull()
expect(bestLwr(source([]).pqueries[0])).toBeNull()
expect(fmtLwr(null)).toBe('Missing')
})

test('invalid values cannot become an apparent best weight', () => {
for (const value of [null, '1', true, -0.1, 1.1]) {
const q = source([[0, value], [1, 0.6]]).pqueries[0]
expect(q.places.some(p => p.lwr === null)).toBe(true)
expect(bestLwr(q)).toBeNull()
}
})

test('weights follow field order and preserve tied candidates', () => {
const src = source([[0.5, 0], [0.5, 1]], ['like_weight_ratio', 'edge_num'])
expect(bestLwr(src.pqueries[0])).toBe(0.5)
expect(src.pqueries[0].places).toHaveLength(2)
})

test('missing weights are not replaced by weighted placement counts', () => {
const src = source([[0, 0.6], [1, null]])
expect(layout(src, 'all').placements).toHaveLength(2)
expect(layout(src, 'all').symbols).toHaveLength(0)
expect(layout(src, 'best').placements).toHaveLength(0)
const single = layout(source([[0]], ['edge_num']), 'best')
expect(single.placements[0].lwr).toBeNull()
expect(single.symbols[0].value).toBe(1) // one query, not an invented LWR
expect(Number.isFinite(single.symbols[0].x)).toBe(true)
})

test('view changes do not change the raw best LWR', () => {
const src = source([[0, 0.4], [1, 0.6]])
const ix = indexTree(src)
buildDisplay(ix, foldOps([{ op: 'reroot', split: ix.split[1], at: 0.5 }]), new Map())
expect(bestLwr(src.pqueries[0])).toBe(0.6)
})
})

describe('best LWR threshold setting', () => {
test('older views get the default and saved thresholds survive loading', () => {
expect(mergeSettings({}).bestLwrThreshold).toBe(0.95)
expect(readDoc({ settings: { bestLwrThreshold: 0.8 } }).settings!.bestLwrThreshold).toBe(0.8)
})

test('invalid saved settings cannot disable comparison silently', () => {
for (const bestLwrThreshold of [null, '0.5', NaN, Infinity]) {
expect(mergeSettings({ bestLwrThreshold }).bestLwrThreshold).toBe(0.95)
}
expect(mergeSettings({ bestLwrThreshold: -1 }).bestLwrThreshold).toBe(0)
expect(mergeSettings({ bestLwrThreshold: 2 }).bestLwrThreshold).toBe(1)
})
})
2 changes: 2 additions & 0 deletions frontend/src/tree/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export const PLACE_SELECTED = '#c2255c'
export const SELECT_COLOUR = '#228be6'

export const fmt = (x: number, digits = 4) => Number.isFinite(x) ? Number(x.toPrecision(digits)).toString() : ''

export const fmtLwr = (x: number | null) => x === null ? 'Missing' : fmt(x, 3)
2 changes: 2 additions & 0 deletions frontend/src/tree/viewDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export function mergeSettings(raw: unknown): TreeSettings {
const out: TreeSettings = {
...DEFAULT_SETTINGS, ...saved,
placements: saved.placements === 'all' ? 'all' : 'best',
bestLwrThreshold: typeof saved.bestLwrThreshold === 'number' && Number.isFinite(saved.bestLwrThreshold)
? Math.min(1, Math.max(0, saved.bestLwrThreshold)) : DEFAULT_SETTINGS.bestLwrThreshold,
symbols: {
placements: { ...DEFAULT_SETTINGS.symbols.placements, ...(sym.placements ?? {}) },
support: { ...DEFAULT_SETTINGS.symbols.support, ...(sym.support ?? {}) },
Expand Down
22 changes: 14 additions & 8 deletions frontend/src/views/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import { useApi } from '../hooks/useApi'
import { Skeleton } from '../components/Skeleton'
import { useToast } from '../components/Toast'
import {
buildDisplay, foldOps, indexTree, mapSupport, midpointOp, nodeKey, parseTreeFile, pointOnBranch,
bestLwr, buildDisplay, foldOps, indexTree, mapSupport, midpointOp, nodeKey, parseTreeFile, pointOnBranch,
rerootAbove, tipsBelow, toNewick, unmatchedOps,
type DNode, type SourceTree, type TreeOp,
} from '../tree/model'
import { DEFAULT_SETTINGS, layoutTree, symbolPath, type Dataset, type SymbolStyle, type TreeSettings } from '../tree/layout'
import styles from '../tree/Tree.module.css'
import { mergeSettings, readDoc, type TreeViewDoc } from '../tree/viewDoc'
import { fmt, PLACE_SELECTED } from '../tree/ui'
import { fmt, fmtLwr, PLACE_SELECTED } from '../tree/ui'
import { TreeShapes } from '../tree/TreeShapes'
import { Legend, legendItems } from '../tree/Legend'
import { ImportPanel } from '../tree/ImportPanel'
Expand Down Expand Up @@ -382,11 +382,12 @@ function TreeCanvas({ source, tree, src, toast }: {
}, [])

const queryRows = useMemo(() => src.pqueries.map((q, qi) => {
const best = q.places[0]
const lwr = bestLwr(q)
const best = lwr !== null || q.places.length === 1 ? q.places[0] : undefined
const edge = best ? ix.edgeByNum.get(best.edgeNum) : undefined
const onEdge = edge !== undefined ? display.byEdge.get(edge)?.[0] : undefined
const dn = onEdge && best ? pointOnBranch(onEdge, best.distal).node : undefined
return { qi, name: q.names.join(', '), lwr: best?.lwr ?? 0, n: q.places.length, where: dn ? describe(dn) : '', dn }
return { qi, name: q.names.join(', '), lwr, n: q.places.length, where: dn ? describe(dn) : '', dn }
}), [src, ix, display, describe])

const selectQuery = (qi: number) => {
Expand Down Expand Up @@ -579,7 +580,7 @@ function TreeCanvas({ source, tree, src, toast }: {
const title = sy.dataset === 'support'
? `Support ${sy.node.support}`
: `${fmt(sy.value, 3)} placement${sy.value === 1 ? '' : 's'}\n` + sy.queries
.map(q => `${src.pqueries[q.query].names.join(', ')} (LWR ${fmt(q.lwr, 3)})`).join('\n')
.map(q => `${src.pqueries[q.query].names.join(', ')} (LWR ${fmtLwr(q.lwr)})`).join('\n')
return (
<path key={k} d={symbolPath(st.shape, sy.x, sy.y, sy.size)}
fill={st.fill.hex} fillOpacity={st.fill.alpha}
Expand All @@ -595,7 +596,7 @@ function TreeCanvas({ source, tree, src, toast }: {
)
})}
{selQuery !== null && layout.placements.filter(p => p.query === selQuery).map((p, k) => (
<circle key={`sq${k}`} data-ui="" cx={p.x} cy={p.y} r={4 + 4 * Math.sqrt(p.lwr)}
<circle key={`sq${k}`} data-ui="" cx={p.x} cy={p.y} r={4 + 4 * Math.sqrt(p.lwr ?? 0)}
fill="none" stroke={PLACE_SELECTED} strokeWidth={2} pointerEvents="none" />
))}
{settings.placementLabels && layout.placements.filter(p => p.rank === 0).map((p, k) => (
Expand Down Expand Up @@ -679,16 +680,21 @@ function TreeCanvas({ source, tree, src, toast }: {
{src.pqueries.length > 0 && (
<div className="card">
<div className="card-title">Placements</div>
<label>Best LWR threshold <input type="number" min={0} max={1} step={0.05}
value={settings.bestLwrThreshold} className={styles.num}
onChange={e => set('bestLwrThreshold', Math.min(1, Math.max(0, Number(e.target.value) || 0)))} /></label>
<p className={styles.muted}>LWRs are from this file. Open the raw placement file for raw LWRs; accumulation changes the weights.</p>
<div className={styles.queryList}>
<table>
<thead><tr><th>Query</th><th>LWR</th><th>Location</th></tr></thead>
<thead><tr><th>Query</th><th>Best LWR</th><th>Location</th></tr></thead>
<tbody>
{queryRows.map(r => (
<tr key={r.qi} className={selQuery === r.qi ? styles.rowOn : undefined} onClick={() => selectQuery(r.qi)}
tabIndex={0} aria-selected={selQuery === r.qi}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectQuery(r.qi) } }}>
<td>{r.name}</td>
<td title={`${r.n} candidate${r.n === 1 ? '' : 's'}`}>{fmt(r.lwr, 3)}{r.n > 1 ? ` (${r.n})` : ''}</td>
<td title={`${r.n} candidate${r.n === 1 ? '' : 's'}`}>{fmtLwr(r.lwr)}{r.n > 1 ? ` (${r.n})` : ''}
{r.lwr !== null && r.lwr < settings.bestLwrThreshold && <span className={styles.muted}> · below threshold</span>}</td>
<td className={styles.where}>{r.where}</td>
</tr>
))}
Expand Down
Loading