CLAUDE.md
September 25, 2026 · View on GitHub
Project overview
react-msaview (JBrowseMSA) is an interactive multiple sequence alignment viewer. It renders phylogenetic trees alongside protein/DNA alignments using HTML5 canvas with a tiled rendering system for scalability.
Writing
docs/WRITING.md lists the prose habits to avoid in docs, captions and
comments, with rewrites taken from this repo. Read it before writing any of
them, and follow it in this file too: agents copy the prose here as house voice.
Key packages
packages/lib: main React component library (the core viewer)packages/app: demo app deployed at gmod.org/JBrowseMSApackages/cli: domain/exon GFFs (InterPro precomputed matches, InterProScan, RefSeq exon models) and headless SVG export. The viewer no longer scans for domains, so the CLI is the only way to produce a domain filepackages/msa-parsers: parsers for Stockholm, FASTA, Clustal, Newick, EMF, A3M, GFFpackages/examples: the live examples the website's /examples page mounts.src/examples/catalog.tsholds each example's name, category and description, anddata/holds the alignments, trees and GFFs as files, whichscripts/screenshots/writeExampleData.mjscopies into the apppackages/svgcanvas: vendored ESM fork of svgcanvas for SVG exportpackages/r-msaview: R htmlwidget package with ggtree/Biostrings/treeio interop.R/msaview.Ris the one function taking every prop as an argument, andR/layers.Ris the same surface composed with+, the way ggplot2 and ggtree add a geom. A layer is aset/appendpair over the props, applied by+.msaview, so a layer adds no prop of its own and the embed-API parity check readsmsaview.Ralone.msaandtreestay arguments; every other argument has a layer, whichtest-layer-coverage.Rcheckspackages/python: themsaview-widgetanywidget (importmsaview). Its traits are theMSAViewerprops in snake case, andsrc/render.tsmaps them ontomount(). The builtmsaview/static/widget.jsis gitignored and shipped in the wheel
Tutorials
docs/tutorials/*.md are reader-facing walkthroughs of the data preparation
done outside the viewer: sequences to alignment to tree to annotations, ending
on a #data= URL that opens the result. Each step of a tutorial consumes what
the step before produced. Every command in it has been run, and the prose quotes
the numbers it printed. docs/tutorials/scripts/build_<topic>.sh runs the whole
pipeline, and the ## Reproduce it end to end section curls it.
Adding one means a file there plus an entry in website/src/lib/tutorials.ts;
website/src/pages/tutorials/[slug].astro globs the directory and index.astro
reads the list. Write a tutorial instead of a viewer feature whenever the work
is data preparation. See viewer-not-analysis-tool in the memory and
agent-docs/ideas/data-layers.md.
The index is the site's gallery, and it is cards only — every piece of content
under /tutorials is a page of its own. Each entry names a thumb, one of that
page's own figures, which astro:assets crops to 5:3 and re-encodes at build
time, so a card cannot drift from its page and no thumbnail file lands in
docs/media.
tutorials/jbrowse_integration.astro is the one carded page with no markdown
behind it: it holds the connected JBrowse 2 sessions. lib/jbrowseLinks.ts
writes each one as a session spec and serializes it into a URL, and
lib/f12CombinedLinks.ts holds a generated URL thousands of percent-encoded
characters long. [slug].astro builds only slugs the markdown glob produces, so
a hand-written page at a /tutorials/<name> route never collides with it.
There is no second showcase page: the standalone figure wall at /gallery was a
static copy of what /examples runs live and the tutorials build, so it is gone
and /gallery redirects to the index. docs/media holds only what a rendered
page shows; a figure that stops being shown loses its screenshot spec too.
Figure bytes live in s3://jbrowse.org/msaview-figures/, not in git, and
media.lock tracks one content-addressed line per file. docs/media is
gitignored apart from the ten figures the published READMEs link by relative
path, so regenerating a figure leaves git status clean and the bytes reach
anyone else only through pnpm media:push. Every script that reads the
directory pulls first. scripts/media-store/README.md carries the design and
the numbers behind it.
Architecture decisions
-
packages/lib/src/model.tsis a large MST model (~2000 lines). Do not attempt to modularize or split it into smaller files. Inline changes within the file are fine. -
The viewer uses MobX-state-tree for state management. Components use
observerfrom mobx-react to reactively re-render. -
Canvas rendering uses a tiled block system (
calculateBlocks.ts) to avoid rendering entire large alignments at once. -
Domain visualization is a core feature; do not remove it. That means the GFF path:
Annotations → Open annotation file...(GFF3 as the CLI writes it, or an InterProScan JSON response, converted to GFF on the way in), the overlay, the legend, the filter dialog,annotationsByRow. The dialog stores its file indata.gff, so the annotations travel in the snapshot and the shared URL like every other layer. The viewer does not produce the file.react-msaview-cli interprobuilds it from precomputed matches in seconds, where the EBI iprscan5 queue the viewer used to submit to took fifteen minutes. A host that computes its spans passes them as thefeatureslayer, JSON recordsfeaturesLayer.tsconverts to annotations, cached per record sofeatureColorscan key on them outside a reaction.allAnnotationsputs them after the GFF's, and every overlay reader takes that list. A span takes its color from its own GFFcolor=attribute first, then from afeatureFillencoding's scale over a field of the feature table, then fromfillPalettewhere no encoding is set and grey where one is, andmodel.featureColorsresolves that once per change of the features, the encodings or the palette. AfeatureLabelencoding names the fieldrenderBoxFeatureCanvasBlockdraws inside each span wherever the text fits. -
The color scheme is a categorical scale over residue letters, and the public
residueEncodingprop names the channel it paints:fillcolors the cell,colorcolors the letter. The MST property behind it staysbgColor, because that name travels in the shared URL. The vocabulary is the one jbrowse-components uses forLinearMarkDisplay(marks, encodings, channels, scales); name new public API to match. ThecolorSchemeprop takes a scheme name or a scale's{map}, which the model keeps ascustomColorSchemeand which wins overcolorSchemeNamewhile set. Test for a per-column scheme withdynamicColorSchemeName, which is undefined under a map. -
A domain box and the scale contend for the same channel, so
domainUnderlineinmodel.tsdecides who gets it. UnderresidueEncoding: 'color'with the letters big enough to draw, the overlay gives up its fill and marks each span with adomainUnderlineHeightbar along the bottom of the row. A filled box wins the row back wherever the letters cannot carry the scale: sub-row layout, which stacks its boxes clear of the letters anyway, and zoomed out pastminLetterRowHeight, where the box is the only thing left to read. -
rowPanelsis the row-scale counterpart ofcolumnTracks:components/rowpanels/RowPanels.tsxmounts one canvas column per record between the tree and the alignment, tiled byblocksY, andrenderRowPanel.tsdispatches on the record'skindfor the live view and the SVG export both.rowPanelsWidthcomes out ofmsaAreaWidthinmodel.ts, which is what moves the alignment, the minimap and the tracks right by the panels; the headers take their own band inTopAreaand export as arotate(-90)text each. Row panels stay out of the track machinery, which is column space.renderStrip.tsdraws thestripkind, a cell per row.featuresdraws the GFF's spans per row throughcomponents/msa/drawFeatureSpans.ts, the one span mark: the alignment's overlay (renderBoxFeatureCanvasBlock.ts) and the panel each hand it an x mapping, the fills, the labels and a row geometry, so a strand arrow is drawn in one place. The head is the lastheadLengthpixels of the feature, tapering to a point at its end the way gggenes and gggenomes draw one, and a feature shorter than the head is all head. A glyph therefore covers its own span and nothing past it, which is what lets the genes of an operon butt together instead of biting triangles out of each other. AheadRiselifts the head above a band too thin to taper, which the underline bar is.resolvedRowPanelsresolves a panel's spans to panel pixels:x: "column"scalesdomainBandsbycolWidth, andx: "position"packs each row's features in residue positions throughpackDomainLanes, which is generic over{startCol, endCol}, and maps their extent onto the panel width. The packing runs on the pixel spans and shrinks each one by a tenth before testing overlap, because adjacent bacterial genes commonly share a few bases and a stop codon over the next start put a whole operon on two lanes. Bothxmodes hand their unlaned spans topanelLanes, which dispatches on the record'sposition:strandpilepacks each strand on its own and lays every row out on one grid, sized by the deepest row on each side, so the line between the strands holds still down the panel. Apositionis a lane assignment and nothing else -- no mode of it reachesdrawFeatureSpans. Thealigntransform's per-row shift comes fromfeatureAlignShifts. A record's ownencodingresolves throughresolveScaleandfeatureFields.tsthe way the top-levelfeatureFillandfeatureLabeldo, and falls back to them. A tree, agffand afeaturespanel make a figure with no alignment at all:dataInitializedismsa || tree,numColumnsis 0, and the alignment panel is zero columns wide on screen and in the export. -
The tree overview (
components/tree/TreeOverview.tsx,renderTreeOverview.ts) is the brush on the row scale, behindshowTreeOverview. It drawsget tree()rather thanroot, so the whole tree stays on screen while the view shows one subtree, and its height joins theMath.maxof the top band inmsaAreaHeight.treeOverviewImagecaches the tree and the clade rectangles on an offscreen canvas, since a 230k-branch tree cannot be redrawn as the focus box follows the pointer, and the SVG export runs the samerenderTreeOverviewonto a svgcanvas Context. -
A
cladesrecord'smarkpicks what it draws.highlightfills the rows,bracketdraws a bar with the record'slabel, andcollapseandfocusseedcollapsedandshowOnlyonce inafterCreate, so the collapse the tree,hideGapsEffectiveand the alignment all read is the one the branch menu writes.cladeGutterWidthincomponents/tree/cladeBrackets.tsis the column the bracket takes out of the right of the tree area, which the tip labels and thetreeWidthautorun both give way to, so the bar lands between the labels and the first row panel. The canvas draws the bar and the label is DOM text (CladeLabels.tsx) on screen and a<text>in the export, since the canvas layer has no rotation. -
model.legendsis the one list both legend renderings read:components/msa/AnnotationLegend.tsxon screen andLegendSVGinrenderToSvg.tsxfor the export. A producer contributes{ id, title, entries }, andlegendRowsflattens the list into the rows both renderings stack top to bottom, giving each legend a title row once there is more than one. The domain overlay, the row-table encodings and the row panels are its producers, each keyed by the field its scale reads. A strip'slegendoverrides that key, so a matrix of columns over one set of colors lists one legend. The property behind the overlay's collapse toggle staysshowDomainLegend, because that name travels in the shared URL. -
The viewer calls no remote compute queue and runs no analysis long enough to freeze the tab. The one exception is neighbor joining, capped at
maxNeighborJoiningRows, because on a small alignment it is faster than installing an aligner. A new analysis is a tutorial plus a snapshot layer (docs/layers.md), not a menu item; seeviewer-not-analysis-tool. -
The alignment background on screen comes from
components/msa/msaRaster.ts: one pixel per cell, built lazily in 512-pixel tiles and blitted withdrawImage, so a zoom frame costs a few blits instead of afillRectper visible cell. The cache keys on every input to a cell's color. The tile map inside it also keys on the cells-per-pixel span, per axis: below one device pixel per cell a tile averages cells into a pixel, the browser's smoothing blurs both axes, and at fit-to-width only the columns are narrow.MSACanvasBlockdecides whether the raster applies and passesrasterTilestorenderMSABlock. The SVG export draws the same background as one<image>(rasterImageHref) where a canvas can be read back, and falls back to per-cell rects where it cannot (jsdom). Letters stayfillText, since a glyph sprite atlas measured 2-3x slower. -
contrastTextFn(theme)inutil.tspicks a letter's color from the background of the cell it lands on, memoized per theme. A dynamic scheme has no letter->color table to precompute from, and a text track has its owncolors. -
The minimap bar draws the same raster sampled down to at most 2000 columns.
-
Blocks are positioned at their offsets only;
scrollX/scrollYlive on one transformed container per panel (MSACanvas,TreeCanvas,TrackBlocks). Anythingposition: fixedinside those containers has to be portaled, since a transformed ancestor becomes its containing block. -
Node ids are path-derived from
tree, which isinputTreeaftertreeRootreroots it (rerootTree.ts).collapsed,rotatedandshowOnlyhold those ids, sosetTreeRootclears all three and the seeding clade marks apply again.treeRootnames tips, so it survives a reload of the same tree.buildTreeRootsorts each node's children bytreeOrder, then reverses therotatedones.rerootTree.tsimports nothing and works on the plainparseNewicknode, so it can move into@gmod/newick, which the jbrowse-components tree sidebar also uses. -
hierarchy.tsno longer implements the tree traversals. They live in@gmod/newicknow, shared with the tree sidebar in jbrowse-components, and the file is a typing shim that re-exports them plus this viewer's own layout helpers. There is no d3 dependency. -
packages/svgcanvasimplements only the calls the renderers make: rectangles, paths, arcs and glyphs. It dropped gradients, patterns, clipping, rotation, bezier curves, stroked text, shadows anddrawImage, and it omits attributes whose value equals the SVG default (stroke="none"on a fill, an emptystroke-dasharray, a matrix transform duplicating x/y). Adding a renderer call means adding it there.renderToSvgsplices each layer's serialized markup into the React page as a string, so React never parses it. -
Tracks (conservation, sequence logo, the position ruler, the Stockholm text tracks) carry a
kinddiscriminator and share one draw module,components/tracks/drawTracks.ts.drawTrackBlockthere applies the transform and dispatches onkind. The live view calls it fromcomponents/tracks/TrackBlocks.tsx, the canvas host for every kind, and the SVG export calls it throughrenderAllTracks. Adding a track kind means a newkind, a draw function in that module, a case indrawTrackBlock, and a case incomponents/tracks/TrackTooltipContent.tsxfor that track's reading at the hovered column, with no second rendering path or component.components/Track.tsxhosts every kind's hover:useTrackHoverthere sets the model's mouse column and anchors the tooltip, so the column statistics live on the tracks that draw them and the alignment's own tooltip stays about the cell under the cursor. A track model carries aheightKeynaming the height its divider writes: thekindfor a computed track, so conservation and property conservation resize together, andown:<id>for acolumnTrackstrack, which resizes alone.trackHeightsholds one number per key, absent until the user drags, anddefaultTrackHeightsanswers until then. A track without a key -- the ruler, a text track -- has no divider. Only the last turned-on track of a key carries the handle, and the drag divides across the group, so the group's bottom edge follows the cursor. That handle covers only the alignment, soTrackdraws a hairline under every track but the last, which reaches across the labels too. -
turnedOffTracksrecords only the user's explicit show/hide choices. An id is absent until they touch that track, and the value then means "off", so a hidden-by-default track (seedefaultOffTracksinmodel.ts) adds nothing to the shared URL. -
@jbrowse/coreis an external in the downstream jbrowse-plugin-msaview UMD build. The bundle resolves it at runtime against the core the host jbrowse-web ships, which is often much older than the one in this workspace. Importing a freshly added core export therefore typechecks and passes tests here but isundefinedon a deployed host (TypeError: X is not a function). Prefer long-established core exports; when a new one is a trivial helper, inline it (seestatusMessageTextinpackages/lib/src/fetchUtils.ts).Core can also drop a long-established export, and a bundle that built and booted then throws the first time a user reaches the code that reads it. Core removed
renderToStaticMarkupfrom the@jbrowse/core/utilbarrel to keep react-dom out of the RPC worker. Only the SVG export called it, so jbrowse-plugin-msaview 3.4.0 and -tview 2.2.1 loaded normally and failed on export;packages/lib/src/renderToStaticMarkup.tsnow inlines it. Check every core import whose only caller is off the boot path by hand. This repo's tests do not reach it, and the downstream plugin's host-compat probe asserts only that the app boots and the plugin global is defined.
Key entry points
packages/lib/src/model.ts: the main MsaView state model (properties, actions, getters, autoruns)packages/lib/src/components/MSAViewer.tsx: zero-config declarative wrapperpackages/lib/src/components/Loading.tsx: exported as MSAView, handles loading/import statespackages/lib/src/components/msa/renderMSABlock.ts: core MSA canvas renderingpackages/lib/src/components/tree/renderTreeCanvas.ts: tree canvas renderingpackages/lib/src/index.ts: public API exports (MSAView, MSAViewer, MSAModelF)