From 3d8688ac6613cb12d14911627a6203b8c6f4ffb8 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:08:54 +0100 Subject: [PATCH] feat(tree): show best placement LWR with missing values and a saved threshold Compute the best reported weight in the existing query list, retain absent/invalid weights as missing throughout the viewer, and save a threshold with view settings. Avoid assigning a best location or weighted counts when candidate weights are incomplete. Frontend-only reduction requested on upstream #63. bun 1.3.10: 46 tests, TypeScript and production build passed. --- frontend/src/tree/Inspector.tsx | 6 +- frontend/src/tree/layout.ts | 18 ++++-- frontend/src/tree/model.ts | 14 ++++- frontend/src/tree/placementLwr.test.ts | 81 ++++++++++++++++++++++++++ frontend/src/tree/ui.ts | 2 + frontend/src/tree/viewDoc.ts | 2 + frontend/src/views/TreeView.tsx | 22 ++++--- 7 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 frontend/src/tree/placementLwr.test.ts diff --git a/frontend/src/tree/Inspector.tsx b/frontend/src/tree/Inspector.tsx index 7f02702c..771af359 100644 --- a/frontend/src/tree/Inspector.tsx +++ b/frontend/src/tree/Inspector.tsx @@ -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 names: Map - 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 @@ -66,7 +66,7 @@ export function Inspector({ d, ix, names, placements, onRename, onStyle, onRotat
Placements here
} diff --git a/frontend/src/tree/layout.ts b/frontend/src/tree/layout.ts index e1655ee6..394de055 100644 --- a/frontend/src/tree/layout.ts +++ b/frontend/src/tree/layout.ts @@ -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' @@ -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 @@ -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 }, @@ -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. */ @@ -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 { @@ -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) ?? [] @@ -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() 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: [] } diff --git a/frontend/src/tree/model.ts b/frontend/src/tree/model.ts index de557a87..e9657cc4 100644 --- a/frontend/src/tree/model.ts +++ b/frontend/src/tree/model.ts @@ -23,7 +23,7 @@ export interface SrcNode { export interface Placement { edgeNum: number - lwr: number + lwr: number | null distal: number pendant: number logLik: number | null @@ -216,6 +216,8 @@ 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) => { const rows = (pq.p ?? []) as number[][] const nm = Array.isArray(pq.nm) ? (pq.nm as [string, number][]) : null @@ -223,17 +225,23 @@ export function parseJplace(text: string): SourceTree { 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) } diff --git a/frontend/src/tree/placementLwr.test.ts b/frontend/src/tree/placementLwr.test.ts new file mode 100644 index 00000000..502c69c8 --- /dev/null +++ b/frontend/src/tree/placementLwr.test.ts @@ -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, 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) + }) +}) diff --git a/frontend/src/tree/ui.ts b/frontend/src/tree/ui.ts index 84f2bf29..5f513cac 100644 --- a/frontend/src/tree/ui.ts +++ b/frontend/src/tree/ui.ts @@ -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) diff --git a/frontend/src/tree/viewDoc.ts b/frontend/src/tree/viewDoc.ts index d889c500..5c876341 100644 --- a/frontend/src/tree/viewDoc.ts +++ b/frontend/src/tree/viewDoc.ts @@ -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 ?? {}) }, diff --git a/frontend/src/views/TreeView.tsx b/frontend/src/views/TreeView.tsx index 9d7d3aa2..7ad82cb8 100644 --- a/frontend/src/views/TreeView.tsx +++ b/frontend/src/views/TreeView.tsx @@ -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' @@ -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) => { @@ -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 ( p.query === selQuery).map((p, k) => ( - ))} {settings.placementLabels && layout.placements.filter(p => p.rank === 0).map((p, k) => ( @@ -679,16 +680,21 @@ function TreeCanvas({ source, tree, src, toast }: { {src.pqueries.length > 0 && (
Placements
+ +

LWRs are from this file. Open the raw placement file for raw LWRs; accumulation changes the weights.

- + {queryRows.map(r => ( selectQuery(r.qi)} tabIndex={0} aria-selected={selQuery === r.qi} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectQuery(r.qi) } }}> - + ))}
QueryLWRLocation
QueryBest LWRLocation
{r.name}{fmt(r.lwr, 3)}{r.n > 1 ? ` (${r.n})` : ''}{fmtLwr(r.lwr)}{r.n > 1 ? ` (${r.n})` : ''} + {r.lwr !== null && r.lwr < settings.bestLwrThreshold && · below threshold} {r.where}