Skip to content

Auto streamtrees - #2080

Draft
trvrb wants to merge 31 commits into
v3from
auto-streamtrees
Draft

Auto streamtrees#2080
trvrb wants to merge 31 commits into
v3from
auto-streamtrees

Conversation

@trvrb

@trvrb trvrb commented Jul 2, 2026

Copy link
Copy Markdown
Member

This is currently an experimental prototype branch. I'm only doing a draft PR to get a Heroku review app up.

jameshadfield and others added 25 commits June 30, 2026 11:15
The previous interface was slightly cumbersome - I often found myself
wanting to source both datasets & narratives from some directory X and
having to type in the rather long `--datasetDir X --narrativeDir X`,
and often I'd misspell / pluralise the argument names; using a simple
positional argument `X` is much nicer. The ability to use multiple
directories is also functionality I've long wanted.

The previous CLI interface is preserved in its entirety for backwards
compatibility and is mutually exclusive with the new positional-args
style.
Stands up the default getDataset handler on an ephemeral port and makes
real HTTP requests against the JSONs in test/fetched-jsons, covering both
the main (v2) dataset and the tip-frequencies sidecar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convert all server-side code (cli/, auspice.js, index.js, src/version.js) and
the test file from CommonJS require/module.exports syntax to ES module
import/export syntax. This also addresses the downstream effects on webpack
bundling for the frontend code in src/.

package.json: Added "type": "module" to declare the package as ESM. This makes
Node treat all .js files as ES modules by default. Replaced the "main" field
with "exports" to use the modern package entry point specification. Added
babel-plugin-transform-import-meta as a dev dependency for Jest compatibility.

Server code (cli/, auspice.js, index.js, src/version.js): Converted all
require() calls to import statements and all module.exports/exports assignments
to named or default exports. For packages that use CommonJS internally (argparse,
webpack config), createRequire(import.meta.url) is used to obtain a require
function that can load CJS modules from an ESM context.

__dirname shim: Since __dirname is not available in ES modules, files that
reference it (cli/utils.js, cli/build.js, cli/develop.js, cli/view.js) now
derive it via path.dirname(fileURLToPath(import.meta.url)). The test file also
uses this pattern.

Dynamic require(handlersPath) in cli/view.js: The user-provided custom handlers
path was previously loaded with require(). This is now await import(handlersPath),
which required making customRouteHandlers() and run() async functions.

Config file renames: webpack.config.js and babel.config.js are renamed to .cjs
since they use CommonJS syntax (module.exports) and would otherwise be treated as
ESM due to the package-level "type": "module" setting.

Babel transform for Jest: Jest runs tests through babel-jest which transforms ESM
to CJS. However, babel leaves import.meta.url untouched, causing a runtime error
when Jest executes the transformed code. Added babel-plugin-transform-import-meta
(test env only) to convert import.meta.url to its CJS equivalent during testing.
The test/server-fetch.test.js file itself was also converted to ESM syntax.

webpack.config.cjs updates: Added resolve.fullySpecified: false (both top-level
and as a per-module rule) to allow extensionless imports in the bundled frontend
code -- without this, webpack enforces mandatory file extensions for all imports
in "type": "module" packages. The JS/TS rule also sets type: "javascript/auto"
so webpack parses source files in mixed mode, recognizing both require() and
import syntax.

module.hot to import.meta.webpackHot: In src/root.js and src/store.ts, the
webpack HMR API was accessed via module.hot which is a CJS-only global. Replaced
with import.meta.webpackHot, the ESM equivalent. The HMR accept callback in
store.ts now uses dynamic import() instead of require() to reload reducers.

Frontend require() calls for assets: Several React components used require() to
load images (SVG, PNG, JPG). These are converted to static import statements at
the top of each file, which webpack asset/resource loader handles identically.
Affected files: navBar/content.js, splash/splash.js, framework/fine-print.js,
framework/footer-descriptions.js, info/byline.js.

Dynamic extension loading (src/util/extensions.ts): The extensions.ts file
retains its synchronous require(`@extensions/...`) pattern. This works because
webpack's javascript/auto parser mode (set via the module rule) handles require()
at build time even in a "type": "module" package. An earlier approach using
top-level await with dynamic import() caused async module semantics to cascade
through the dependency graph (via util/globals.js), which broke component
rendering by wrapping module exports in async module namespace objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Convert 8 cli files from .js to .ts, leveraging Node 24's native type
stripping to run them directly without a build step. tsc is used for
type-checking only (noEmit).

Files converted:
- cli/utils.ts
- cli/server/getDataset.ts
- cli/server/getDatasetHelpers.ts
- cli/server/getAvailable.ts
- cli/server/getNarrative.ts
- cli/server/processPaths.ts
- cli/view.ts
- cli/develop.ts
- cli/build.ts

Supporting changes:
- Add cli/tsconfig.json extending root with nodenext module resolution
  and allowImportingTsExtensions for Node-native .ts imports
- Add allowImportingTsExtensions to root tsconfig.json (needed because
  cli .ts files are pulled in transitively via test imports)
- Exclude cli/**/* from root tsconfig include/add to exclude (prevents
  checking cli files under the wrong moduleResolution)
- Update package.json type-check script to run both tsconfigs
- Update all import paths across 11 files (.js -> .ts extensions)
- Update webpack.config.cjs require for cli/utils.ts
- Add eslint-disable for explicit-function-return-type and
  consistent-type-assertions in converted files (incremental migration)
- Apply chalk 2.x workaround (type assertion) for nodenext compat
- Fix TypeScript strict errors: unknown in catch blocks, Object.entries
  casting for Set methods, undefined-in-object check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The docs and code had fallen out of sync over the years.

* `parseNarrativeFile` was not only deprecated as the docs indicated
  ("This function is deprecated as of vXXX"), it no longer existed in the codebase.
* The docs for the getNarrative route incorrectly indicated the `type` query as optional
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Triggered by existing warnings:

WARNING: You are currently running a version of TypeScript which is not officially supported by @typescript-eslint/typescript-estree.
Errors were new since version bump in parent commit
This is an old and now unused test that's been fully superseded by the Playwright smoke tests. It was the only test that used "chai" (and "mocha", although that wasn't listed as a dep).

I only noticed this as the eslint version bump flagged up an error in the test file
This functionality never worked as well as hoped. Bigger picture,
this represents a move away from pre-provisioned datasets / narratives
and towards "bring your own data", which is the main use case after all!
(This was from an ancient method of deploying auspice)
to be compatible in a world without the main 'data/' directory.
This made me realise our "smoke-tests" are basically a single test!
Nonetheless it's worth keeping around the architecture for future improvements
This is a relic of earlier times where it was kept in-sync with the
canonical nextstrain datasets. We maintain similar functionality in
the fetch-test-data script but for testing purposes only.

This is a key part of the bigger re-design which moves auspice to a
visualisation program/library which you point at your own data.
It has previously been useful to run Auspice as a stand-alone Heroku
(review-) app, but this has been superseded by standing up a testing
nextstrain.org review app as the range of datasets/narratives available
is much greater, or a auspice.us app for datasets not available via
nextstrain.org. It's simpler (and cheaper!) to drop the Auspice app entirely
Slightly out of date!!!
[v3] [cli] Allow one or more positional path args
[v3] Various JS/CS CJS/ESM updates
Automatically partition a large tree into streamtrees by a tip-count threshold
(AUTO_STREAM_TIP_THRESHOLD) as an alternative to author-defined branch labels, so
any big tree can be aggregated for performance/legibility.

Streams render "in place": each stream start is positioned at the midpoint of its
member-tip display-order band during setDisplayOrder (not overridden afterwards),
so the surrounding tree doesn't move when streams toggle, disjoint clades can't
overlap, and the connecting tees reach each stream. This reworks the stream
display-order layout, which previously collapsed streams into KDE-derived heights.

Also adds a "Show stream labels" toggle (default off), since the synthetic
auto-partition names are visual noise, and fixes a redux state-mutation error that
prevented re-enabling streamtrees after toggling them off.

On the 35k-tip spike-sm tree this collapses ~134k SVG elements to ~6k.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Colour the stream connector (joiner + backbone) by the stream's dominant colour
  category (most member tips) instead of a fixed gray, so thin streams show their
  colour rather than a gray line.
- In streamtree mode render all branches, tees and stream connectors at a uniform
  thickness (branchStrokeWidth); per-node branch thickness is redundant/confusing
  when streams already encode count via their height. Normal (non-stream) mode is
  unchanged.
- Tune defaults: branchStrokeWidth 1.5, auto-partition tip threshold 200.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Fix zoom/filter throwing in dev by exempting tree.streams from the redux
  immutability check (processStreams mutates it in place, like tree.nodes which is
  already exempt) — clicking a ripple/backbone/branch to zoom now works.
- Branch thickness in streamtree mode now matches the stream backbone width for any
  branch with visible descendants (tipCount > 0), while fully-filtered branches
  (no visible descendants) stay thin. Fixes date-filtered trunk branches rendering
  thinner than the backbone they connect to, and keeps clade-filtered dead-ends thin.
- End the stream backbone at the farthest-right visible tip (streamVisibleMax)
  rather than the full unfiltered extent, so date filtering retracts the right side;
  the left side still extends back to connect with the parent lineage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
drawStreams used `.transition("500")`, where "500" is a d3 transition *name*, not
a duration — so stream ripples/connectors/labels animated at d3's default 250ms
while branches used the real 500ms transitionTime, leaving stream transitions
visibly out of sync (e.g. when toggling divergence <-> time). Thread the actual
transitionTime through drawStreams and use `.transition().duration(transitionTime)`
so streams and branches share one duration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the absolute AUTO_STREAM_TIP_THRESHOLD (which needed per-tree tuning) with
AUTO_STREAM_TARGET_COUNT: autoPartitionStreams now greedily splits the largest clade
(by fullTipCount) until it reaches ~N streams, so a single N gives consistent
on-screen stream density regardless of tree size (verified ~consistent counts on
1.5k-tip ebola and 35k-tip spike-sm). Same output contract, so downstream stream
processing/rendering is unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@trvrb
trvrb temporarily deployed to auspice-auto-streamtree-m8jd0y July 2, 2026 20:57 Inactive
Auto-streamtrees previously computed a static partition (by fullTipCount)
once at load/toggle/label-change, so zooming or filtering showed a subset
of a fixed partition rather than ~N streams of whatever is on screen.

Now the partition is re-derived on discrete on-screen-tip changes (zoom,
clade/trait filter, date settle) from the visible tipCount, always splitting
the visible tips into ~AUTO_STREAM_TARGET_COUNT streams — coarse when zoomed
out, finer when zoomed/filtered in. Continuous events (date-slider drag,
animation frames) keep the cheap in-place processStreams via the quickdraw
gate, so drags/playback stay smooth. Streams render in place (xy-identical),
so re-granularizing subdivides within existing bands without moving the tree.

Also guards two stale-hovered-node crashes surfaced by re-partitioning: the
clear pass now only resets stream membership (streamName/inStream) and keeps
render data so live ripple hover/leave handlers don't deref deleted
streamCategories; and the hover tooltip bails when the hovered node is no
longer a stream in the current map (stale streamName key).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@trvrb
trvrb temporarily deployed to auspice-auto-streamtree-m8jd0y July 2, 2026 22:24 Inactive
trvrb and others added 2 commits July 2, 2026 16:59
Performance (treeStreams.ts): speed up the per-re-partition KDE without
changing output (verified sub-pixel-identical ripple paths on ebola/ncov).
- Replace the O(n^2) `categoryInfo.nodes.includes(...)` per-node filter with a
  Set lookup, and bucket observedCategories' node indices in one pass rather
  than re-filtering the full list per category.
- Hoist the per-stream `new Set(visibility)` to one `everythingVisible` compute.
- Fix the skipPivots guard: it tested `Object.hasOwn(s, 'streamPivots')` on the
  StreamSummary, but pivots live on the start node, so the pivot block always
  re-ran; key it off `nodes[s.startNode]` so the in-place drag path skips it.
- 3-sigma cutoff on the KDE inner loop: evaluate each tip's gaussian only over
  the pivot window within +/-3 sigma (evenly-spaced grid), turning it from
  O(tips x allPivots) into O(tips x ~30). Tail truncation is <0.3% of peak.
- Add perf.js timers around processStreams / autoPartitionStreams.

Transitions: animate stream updates in place so a coarse<->fine re-partition
cross-dissolves instead of hard-swapping.
- change.ts: give streams their own transition budget (streamTransitionTime),
  suppressed only by the rapid-render guard (keeps drags/playback snappy), not
  by skipTreeAnimation -- so streams animate even on trees too large to animate
  tip-by-tip.
- renderers.ts: fade entering stream groups in (exiting groups already fade out).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On a re-partition, a splitting stream now animates the coarse parent's
stacked area breaking apart into its finer child streams (a true area-morph)
instead of cross-fading. Each entering child ripple starts as its
display-order slice of the parent's already-rendered pixel band and tweens to
its final shape; because children bands tile the parent band (tip displayOrder
is partition-invariant), the slices refill the parent's silhouette at t=0.

Mechanics: snapshot the old streams + rendered shapes in reactD3Interface/
change.ts before they're overwritten, thread through change() to drawStreams,
which classifies each new stream against the old partition (split / merge /
1:1 / none) and injects a d3 attrTween at the ripple enter for splits. Streams
get their own transition budget so this animates even on large trees. Merges
(zoom-out / coarsening) and unclassifiable cases fall back to the existing
cross-fade; NaN-guarded and a no-op when no snapshot is present.

New module streamMorph.ts: snapshot, old<->new correspondence, split t=0
carve, pivot-value resampling, point interpolation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The auto-partition target stream count was a hardcoded constant
(AUTO_STREAM_TARGET_COUNT = 100). Make it a runtime controls field
(streamTreeTargetCount) driven by a new sidebar toggle modeled on the
animation SLOW/MEDIUM/FAST buttons, showing FINE / MEDIUM / COARSE
(150 / 100 / 50). Changing it ships a fresh streams map, so the coarse<->fine
change flows through the existing split/merge area-morph.

- treeStreams.ts: AUTO_STREAM_TARGET_COUNTS presets (constant kept as the
  MEDIUM default); autoPartitionStreams already took the count as a param.
- controls reducer: streamTreeTargetCount field + default + reducer case.
- new CHANGE_STREAM_TREE_TARGET_COUNT action + changeStreamTreeTargetCount
  thunk (re-partitions over the current view; auto-only); tree reducer stores
  the fresh streams map via a fallthrough case.
- thread controls.streamTreeTargetCount into all three autoPartitionStreams
  call sites (initial load, branch-label switch, zoom/filter re-partition), so
  every re-partition honors the chosen granularity.
- choose-stream-trees.tsx: three SidebarButtons, shown only in auto mode.
- URL sync: ?streamTarget= written on change (default omitted) and
  validated/restored on load, mirroring ?streamLabel.
- en sidebar i18n: Fine / Coarse / Streamtree granularity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@trvrb
trvrb temporarily deployed to auspice-auto-streamtree-m8jd0y July 3, 2026 02:16 Inactive
trvrb and others added 2 commits July 2, 2026 19:51
Completes the streamtree area-morph. On a coarsening re-partition (zoom-out,
FINE->COARSE, any merge), the fine child streams now visibly converge into the
coarse parent rather than cross-fading. Since one SVG path per (stream,
category) can't depict K children in the entering parent, we morph the exiting
child paths (separate DOM elements) toward their slice of the new parent, while
the new parent fades in at its final shape via the existing enter path.

This is the reverse of the split carve, so it reuses that geometry: the split
carve is extracted into a shared carveSlice helper, and merge calls it with the
roles swapped (new parent envelope/band as the target, each child's old shape
as the start). The snapshot now records the ripple key so merge targets match
the exiting DOM ripples; computeEnvelope/computeBand are extracted for reuse.
prepareStreamMorph fills in the merge branch (mergeExit + mergingOldNames), and
drawStreams' streamGroup exit morphs the merging children (attrTween to target,
then fade + remove); areaGenerator is hoisted so the exit callback can use it.

Where the new parent is invisible (e.g. the coarsened outside on a zoom-in) the
merge falls back to the existing cross-fade. Split behavior is unchanged;
NaN-guarded and a no-op when no snapshot / transitionTime 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a sidebar toggle (under "Show stream labels") that switches the streamtree
layout between the current render-in-place layout (xy-concordant with the rect
tree; the default, off) and the original "v3" layout (on), where each stream
gets its own KDE-sized display-order band and the tree re-flows. This makes the
two strategies easy to compare live.

The v3 algorithm is restored verbatim from git (commit 82dfcda, i.e. the parent
of the render-in-place rework): StreamInfo / traverseConnectedStreams /
setDisplayOrdersForStream, and the global weightToDisplayOrderScaleFactor path
(previously dead) becomes live again in v3 mode. setDisplayOrderRecursively now
threads a `false | "inPlace" | StreamInfo` mode, and setRippleDisplayOrders
branches between the global scale (v3) and the per-stream band-fit scale
(in-place); the ripple-centering loop is shared.

New controls field streamTreeUpdateLayout (default false) + toggle action,
mirroring showStreamTreeLabels through the controls -> prop -> phylotree.params
plumbing. Toggling routes through the existing `updateLayout` change flag (NOT
streamDefinitionChange), so it re-runs setDisplayOrder + mapToScreen on the
smooth modifySVG path; the partition is unchanged, so ripples morph via the
keyed update transition and branches/tips glide.

Session-only (no URL sync), mirroring the stream-labels toggle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@trvrb
trvrb temporarily deployed to auspice-auto-streamtree-m8jd0y July 3, 2026 03:35 Inactive
@jameshadfield
jameshadfield force-pushed the v3 branch 2 times, most recently from fb4fe78 to 4d9e17a Compare July 13, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants