@milaboratories/structure-viewer
v0.2.0
Published
Vue component wrapping Mol* (molstar) for 3D macromolecular structure viewing.
Readme
@milaboratories/structure-viewer
Vue component wrapping Mol* for 3D macromolecular structure viewing. Renders a single PDB file from a Platforma blob handle with controls for representation type and color theme, a hover-synced sequence strip, click-to-focus camera, and PDB export.
Usage
<script setup lang="ts">
import { PlStructureViewer } from "@milaboratories/structure-viewer";
import type { LocalBlobHandle, RemoteBlobHandle } from "@platforma-sdk/model";
const handle: LocalBlobHandle | RemoteBlobHandle = /* ... */;
</script>
<template>
<PlStructureViewer :handle="handle" file-name="structure.pdb" />
</template>Props:
| Prop | Type | Default | Notes |
| ---------- | ------------------------------------- | ----------------- | ------------------------------------ |
| handle | LocalBlobHandle \| RemoteBlobHandle | required | Platforma blob handle for a PDB file |
| fileName | string | "structure.pdb" | Suggested name for the export dialog |
The component fills its parent — give it a flex parent or sized container.
File map
src/
├── PlStructureViewer.vue Root component. Composes Toolbar + SequenceStrip
│ + canvas + hover-label overlay.
├── Toolbar.vue Representation / coloring dropdowns + Export.
├── SequenceStrip.vue One polymer chain's residue strip; emits
│ hover/leave/click; receives `isHighlighted` callback.
├── useStructureViewer.ts Composable: plugin lifecycle, load pipeline,
│ representation updates, hover sync, sequence-strip
│ construction, export.
├── types.ts Pure type module.
├── utils.ts Generic helpers: `run` (serialize-and-supersede
│ async wrapper) and `rafThrottle`.
└── index.ts Barrel.Architecture
Plugin spec is hand-rolled
The Mol* PluginContext is constructed with only the behaviors the viewer
actually exercises:
Representation.HighlightLociRepresentation.DefaultLociLabelProviderCamera.FocusLociCamera.CameraControlsCamera.CameraAxisHelperCustomProps.SecondaryStructure
Everything else from DefaultPluginSpec (SelectLoci,
StructureFocusRepresentation, SnapshotControls, the other CustomProps,
all actions, all animations) is omitted.
Load pipeline
useStructureViewer.load() matches Viewer.loadStructureFromData in
mol-plugin-ui/apps/viewer/app.ts:229:
blobDriver.getContent(handle)
→ plugin.builders.data.rawData({ data: text })
→ plugin.builders.structure.parseTrajectory(data, "pdb")
→ plugin.builders.structure.hierarchy.applyPreset(trajectory, "default")The default preset splits the structure into polymer / ligand / water /
branched / ion / lipid / coarse components, each tagged static-<kind>.
Representation updates are component-kind aware
User-facing representation type (cartoon, ball-and-stick, gaussian-surface, putty) only makes sense for polymer atoms. Applying e.g. cartoon to a ligand component would blank it out.
applyRepresentation therefore branches:
- Polymer components (
tags?.includes("structure-component-static-polymer")) get their full representation params replaced (type + color) viacreateStructureRepresentationParams. - All other components keep the preset's default type and only have
their color theme refreshed via
createStructureColorThemeParams.
Both branches batch into one state.data.build() commit.
Concurrent loads supersede
Each call to load() swaps an internal token. The previous in-flight body's
next step(promise) throws StaleOpError when its token goes stale, and
the next call awaits the previous body's settlement before running so plugin
mutations don't interleave. load.abort() swaps the token without
enqueuing — used by dispose() to drop in-flight work on unmount.
applyRepresentation doesn't go through run(). Its invariant is "every
mutation comes from a single state.data.build() whose commit serializes
through Mol*'s own commit queue." Concurrent watcher firings produce two
back-to-back commits; the second wins. Data-state mounting is fenced by
load calling applyRepresentation() before flipping hasContent, so the
user-facing watcher only sees fully-mounted hierarchies.
Bidirectional sequence-strip hover sync
Mol*'s wrapper objects keep an internal mutable markerArray (one byte per
residue: bit 0 = highlighted, bit 1 = selected). Upstream's React
Sequence component imperatively walks the DOM in updateMarker() to
bypass React's reactivity — fine for them, not directly portable.
We bridge the bitmap into Vue reactivity with a single Symbol-valued
shallowRef:
- Composable installs an
onLociMarkprovider onplugin.managers.interactivity.lociHighlights. - The provider calls
wrapper.markResidue(loci, action)for every visible chain; if any wrapper reports a change,markerVersion.value = Symbol(). - Parent's
isResidueHighlighted(strip, idx)readsmarkerVersion.value(tracked) before consultingwrapper.isHighlighted(idx), so dependent bindings re-evaluate on every change. SequenceStripreceives:is-highlighted="(idx) => isResidueHighlighted(strip, idx)"per-strip — child has zero awareness of the marker version (clean prop boundary).
Hover the strip → highlights propagate to the 3D canvas; hover the canvas → highlights propagate to the strip.
Hover events from the strip are coalesced via requestAnimationFrame
(rafThrottle in utils.ts), so molstar updates align with the next paint
instead of an off-axis wall-clock timer (upstream uses an rxjs
throttleTime(50ms) instead).
Chain enumeration is in-scope-minimal
buildSequenceStrips does the minimal walk that covers ImmuneBuilder
antibody PDBs: group atomic polymer units by chainGroupId, instantiate
PolymerSequenceWrapper({ structure, units }) per group, derive the chain
label from StructureProperties.chain.auth_asym_id. About 20 lines, no
helpers imported from upstream.
The four upstream enumeration helpers (getModelEntityOptions,
getChainOptions, getOperatorOptions, getSequenceWrapper in
mol-plugin-ui/sequence.tsx) handle multi-model NMR ensembles,
multi-entity complexes, and crystallographic operators (NCS / HKL / spgr
op) — none of which the in-scope inputs require. They live in a .tsx
file that imports React / react/jsx-runtime / PluginUIComponent /
controls/icons at top level; with molstar's package.json declaring no
sideEffects, naïve consumption drags ~100 KB minified of React UI into
the consumer's bundle (measured: 8 React Component subclasses + JSX
render sites). Doing the walk ourselves avoids that import path entirely.
Limitations
For inputs outside the antibody-PDB shape the viewer still loads and renders, but the sequence strip degrades:
- Crystallographic operators (NCS, HKL, space-group): multiple poses of the same chain collapse into one strip instead of one per operator.
- Multi-model NMR ensembles: chains from different models aren't
differentiated; they merge by
chainGroupId. - Very long polymer chains (>5000 residues): rendered as a giant strip;
upstream falls back to
ChainSequenceWrapper/ElementSequenceWrapperto keep the DOM small. - Ligand / hetero residue sequences: not displayed (upstream uses
HeteroSequenceWrapper); ligands still render in 3D under their preset-default representation. - No selection state: only highlight (hover) is wired. No shift-drag range select, no Ctrl-click to add/remove selection.
- No focus state: clicking a residue zooms the camera but doesn't paint a focus indicator on the strip.
- Sequence-number period is hardcoded to 10; upstream picks 1/5/10 dynamically based on chain length.
- Hover labels rendered with
v-html: trusted molstar label provider output that may include<small>etc. Not sanitized. - Export uses
window.showSaveFilePicker: relies on the File System Access API. Works in Chromium-based hosts (Electron, Chrome). Will reject the export silently on Firefox/Safari.
Revisit the chain-enumeration walk if the consumer ever needs to render generic PDBs.
Reference paths in upstream Mol*
Used during the original port; these are useful anchors when bumping molstar.
| Path | What |
| ------------------------------------------------------ | ----------------------------------------------------- |
| mol-plugin/spec.ts:121 | DefaultPluginSpec.behaviors (whitelist source) |
| mol-plugin-ui/apps/viewer/app.ts:229 | loadStructureFromData shape |
| mol-plugin-ui/state/update-transform.tsx:32 | State Tree update primitive |
| mol-plugin-state/manager/structure/component.ts:295 | updateRepresentationsTheme (color-only update path) |
| mol-plugin-ui/sequence/polymer.ts | PolymerSequenceWrapper class |
| mol-plugin-ui/sequence/sequence.tsx:263 | getSequenceNumber (atomic / coarse branches) |
| mol-plugin-ui/sequence/sequence.tsx:280 | padSeqNum (NBSP padding to width 5) |
| mol-plugin-ui/skin/base/components/sequence.scss:105 | Sequence-number float CSS |
| mol-plugin-state/manager/loci-label.ts:63 | LociLabelManager highlight observable |
