diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d24cf6086..0ab6a4cdc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -105,8 +105,9 @@ function PanelColumns({ columns = 2, children }: { columns?: number; children: R // An item either renders a panel inside the app (render) or links out to an // external page in a new tab (href), e.g. the source repository. +// wide: the page uses the screen's width instead of the 1300 px reading column type NavItem = { id: string; label: string; icon: ReactNode; disabled?: boolean; - href?: string; render?: () => ReactNode }; + href?: string; render?: () => ReactNode; wide?: boolean }; type NavSection = { subheader?: string; items: NavItem[] }; /** Documentation / API reference shown inside the app area, with a new-tab escape hatch. */ @@ -192,7 +193,7 @@ function App() { { id: 'data-collection', label: 'Data collection', icon: , render: () => ( )}, - { id: 'rugnux', label: 'Rugnux live', icon: , render: () => ( + { id: 'rugnux', label: 'Rugnux live', icon: , wide: true, render: () => ( )}, ]}, @@ -308,7 +309,7 @@ function App() { - + {activeItem.render?.()} diff --git a/frontend/src/components/RugnuxPage.tsx b/frontend/src/components/RugnuxPage.tsx index 9e062c3d8..90a951e34 100644 --- a/frontend/src/components/RugnuxPage.tsx +++ b/frontend/src/components/RugnuxPage.tsx @@ -7,21 +7,26 @@ import {useTokenRequired} from "../auth"; import EmptyState from "./EmptyState"; import RugnuxTimeline from "./RugnuxTimeline"; import RugnuxSettings from "./RugnuxSettings"; -import {CutAlert, lastImageTime, ProbeAlert, RunMessage, StateChip, StepTable, VerdictAlert} from "./RugnuxParts"; +import {CutAlert, lastImageTime, ModelScores, ProbeAlert, RunMessage, StateChip, StepTable, VerdictAlert} from "./RugnuxParts"; import BoltIcon from "@mui/icons-material/Bolt"; import {lazy, Suspense} from "react"; +import {keyframes} from "@mui/system"; // Mol* is large: loaded only when there is a model to show. const RugnuxStructureViewer = lazy(() => import("./RugnuxStructureViewer")); -// The summary rows rugnux writes; the ones a crystallographer looks at first get a stronger tile, and -// space group and cell share a row. +// The summary rows rugnux writes; the ones a crystallographer looks at first get a stronger tile. const KEY_ROW = /unit cell|space group|resolution|signal|r-?free|model/i; -const PAIRED_ROW = /space group|unit cell/i; -function Section({title, children}: { title: string, children: React.ReactNode }) { +// The model section's entrance under the timeline, once the run has placed a model +const slideIn = keyframes` + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: none; } +`; + +function Section({title, children, sx}: { title: string, children: React.ReactNode, sx?: object }) { return ( - + {title} @@ -86,12 +91,12 @@ function SummaryTiles({s}: { s: rugnux_live_status }) { if (s.summary.length === 0) return null; return ( - + {s.summary.map((row, i) => { const key = KEY_ROW.test(row.label); const short = row.value.length <= 24; return ( - step.title === "Molecular replacement")?.duration_s; + return ( +
+ + {s.placed_model && + Loading viewer...}> + + + } +
+ ); +} + function RugnuxPage({measuring}: { measuring: boolean }) { const {data: settings} = useQuery({...getConfigRugnuxOptions(), refetchInterval: 2000}); const enabled = settings?.enable === true; @@ -138,7 +160,9 @@ function RugnuxPage({measuring}: { measuring: boolean }) {
- + {s.model && s.state !== 'Running' && } +
@@ -155,12 +179,6 @@ function RugnuxPage({measuring}: { measuring: boolean }) {
- {s.placed_model && s.state !== 'Running' && -
- Loading viewer...}> - - -
} ); diff --git a/frontend/src/components/RugnuxParts.tsx b/frontend/src/components/RugnuxParts.tsx index cbd6502a0..dbb4209d4 100644 --- a/frontend/src/components/RugnuxParts.tsx +++ b/frontend/src/components/RugnuxParts.tsx @@ -1,7 +1,7 @@ import {Alert, AlertTitle, Box, Chip, Paper, Table, TableBody, TableCell, TableContainer, TableRow, Typography} from "@mui/material"; import {indigo, lime, deepOrange, grey} from "@mui/material/colors"; import {SxProps, Theme} from "@mui/material/styles"; -import {rugnux_live_status, rugnux_live_step} from "../client"; +import {rugnux_live_model, rugnux_live_status, rugnux_live_step} from "../client"; // Pieces of the rugnux live status shared by the dashboard card and the Rugnux page. @@ -113,3 +113,58 @@ export function StepTable({steps}: { steps: rugnux_live_step[] }) { ); } + +function ScoreTile({label, value, sub, accent}: { label: string, value: string, sub?: string, accent?: boolean }) { + return ( + + {label} + + {value} + {sub && {sub}} + + ); +} + +const PLACEMENT: Record = {MR: "MR", RIGID_BODY: "Rigid body", NOT_PLACED: "Not placed"}; + +// What the run made of the model, in the numbers of the report's MODEL VALIDATION section; mrSeconds is how long +// the molecular-replacement step took. Nothing is refined, so the R-factors are those of the model as placed. +// The strongest anomalous peak is shown only where the data carry anomalous signal: without it, it is noise. +export function ModelScores({m, mrSeconds, anomalousSignal}: + { m: rugnux_live_model, mrSeconds?: number, anomalousSignal: boolean }) { + const tiles: { label: string, value: string, sub?: string, accent?: boolean }[] = []; + let placedSub = m.reason || m.mr_decision; + if (m.placement === "MR" && m.copies !== undefined) + placedSub = `${m.copies} ${m.copies === 1 ? "copy" : "copies"} in ${m.mr_space_group}` + + (mrSeconds !== undefined ? `, found in ${mrSeconds.toFixed(1)} s` : ""); + else if (m.placement === "RIGID_BODY") + placedSub = m.rigid_body_shift_A !== undefined + ? `moved ${m.rigid_body_angle_deg?.toFixed(1)}° and ${m.rigid_body_shift_A.toFixed(2)} Å` + : "fits where it is"; + tiles.push({label: "Placement", value: PLACEMENT[m.placement] ?? m.placement, sub: placedSub, + accent: m.placement !== "NOT_PLACED"}); + if (m.tf_z !== undefined) + tiles.push({label: "TFZ", value: m.tf_z.toFixed(1), + sub: [m.rf_z !== undefined && `RFZ ${m.rf_z.toFixed(1)}`, m.llg_z !== undefined && `LLG-Z ${m.llg_z.toFixed(1)}`, + m.mr_d_min !== undefined && `search to ${m.mr_d_min.toFixed(1)} Å`].filter(Boolean).join(" · ")}); + if (m.r_free !== undefined) + tiles.push({label: "R-free", value: m.r_free.toFixed(3), accent: true, + sub: `R-work ${m.r_work?.toFixed(3)} · model as placed, unrefined`}); + if (m.cc_model !== undefined) + tiles.push({label: "CC (model, data)", value: m.cc_model.toFixed(2), sub: "intensities, all reflections"}); + if (m.fit_sigma !== undefined) + tiles.push({label: "Fit over random", value: `${m.fit_sigma >= 0 ? "+" : ""}${m.fit_sigma.toFixed(1)} σ`, + sub: "R-work against the model in random orientations"}); + if (m.map_atom_sigma !== undefined) + tiles.push({label: "2Fo−Fc at atoms", value: `${m.map_atom_sigma.toFixed(1)} σ`, sub: "mean at the atom centres"}); + if (anomalousSignal && m.anomalous_site && m.anomalous_site_sigma !== undefined) + tiles.push({label: "Anomalous peak", value: `${m.anomalous_site_sigma.toFixed(1)} σ`, sub: m.anomalous_site}); + return ( + + {tiles.map(t => )} + + ); +} diff --git a/frontend/src/components/RugnuxStructureViewer.tsx b/frontend/src/components/RugnuxStructureViewer.tsx index cd92d0108..180bc7a58 100644 --- a/frontend/src/components/RugnuxStructureViewer.tsx +++ b/frontend/src/components/RugnuxStructureViewer.tsx @@ -7,11 +7,14 @@ import {applyModelDisplay, clearScene, getStructureAtoms, getStructureBox, Loade from "./structure/loadModel"; import {loadMapLayer, MAP_COLORS, MapLayer, setLayerVisible, updateLayerSigma} from "./structure/loadMaps"; import {fullCellFromCcp4, prepareMap} from "./structure/densityCrop"; +import {revealCamera} from "./structure/reveal"; // The model rugnux live placed, with its 2mFo-DFc, mFo-DFc and (where the merge kept the Bijvoet pairs) anomalous // difference maps, in Mol*. Set up as aaredb's structure viewer (gitea.psi.ch/mx/aaredb, pages/StructureView.tsx // and components/Structure/, the PSI MX group's own code): its plugin, Coot mouse controls, model presets, map -// meshes, crop and carve, panel and defaults - on a black stage. Mol* is only loaded with this component +// meshes, crop and carve, panel and defaults - on a black stage. Departures for the live page: every map is carved +// within the same radius of the atoms (aaredb carves 2Fo-Fc only), Fo-Fc starts at +-3.5 sigma, the anomalous map +// is shown at first only where the data carry anomalous signal, and the first model makes an entrance (reveal.ts). Mol* is only loaded with this component // (React.lazy in RugnuxPage), and every Mol* import lives in ./structure. type Kind = 'twofofc' | 'fofc' | 'anom'; @@ -60,8 +63,14 @@ async function fetchBytes(path: string, signal: AbortSignal): Promise window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; +const nextFrames = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + +// resultKey: changes when a new result is there to load; anomalous: it has an anomalous difference map; +// anomalousSignal: the data carry usable anomalous signal, so that map is worth showing from the start. +function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal}: + { resultKey: string, anomalous: boolean, anomalousSignal: boolean }) { const pluginRef = useRef(null); const [pluginReady, setPluginReady] = useState(0); const [error, setError] = useState(); @@ -73,10 +82,14 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom // First load frames the molecule; later results keep the user's camera and clip (aaredb StructureView.tsx:117-121) const sceneLoadedRef = useRef(false); const [hasMap, setHasMap] = useState>({twofofc: false, fofc: false, anom: false}); + // The stage stays dark until the first model and its maps are in, then fades up as the camera eases in. + const [revealed, setRevealed] = useState(false); - // aaredb factory defaults (state/viewerDefaults.ts:16-23); anomalous as a difference map, +4 sigma - const [show, setShow] = useState>({twofofc: true, fofc: true, anom: true}); - const [sigma, setSigma] = useState>({twofofc: 1.0, fofc: 3.0, anom: 4.0}); + // aaredb factory defaults (state/viewerDefaults.ts:16-23), but Fo-Fc at +-3.5 sigma rather than 3: carved or not, + // a difference map at 3 sigma over a whole molecule shows dozens of noise peaks; anomalous as a difference map, + // +4 sigma + const [show, setShow] = useState>({twofofc: true, fofc: true, anom: anomalousSignal}); + const [sigma, setSigma] = useState>({twofofc: 1.0, fofc: 3.5, anom: 4.0}); const [reprMode, setReprMode] = useState('cartoon'); const [showWaters, setShowWaters] = useState(false); const displayRef = useRef({show, sigma, reprMode, showWaters}); @@ -87,8 +100,8 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom setPluginReady(n => n + 1); }, []); - // The model, then its maps (aaredb StructureView.tsx maybeLoad / computeAndAttachMaps, 212-295): 2Fo-Fc cropped - // to the model's box + 6 A and carved 4.5 A around the atoms; the difference maps cropped only. + // The model, then its maps (aaredb StructureView.tsx maybeLoad / computeAndAttachMaps, 212-295): each map cropped + // to the model's box + 6 A and carved 4.5 A around the atoms. useEffect(() => { const plugin = pluginRef.current; if (!plugin) @@ -113,7 +126,8 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom setError("No placed model"); return; } - const loaded = await loadModel(plugin, cif, 'model', {resetCamera: !sceneLoadedRef.current, format: 'mmcif'}); + const firstScene = !sceneLoadedRef.current; + const loaded = await loadModel(plugin, cif, 'model', {resetCamera: firstScene, format: 'mmcif'}); sceneLoadedRef.current = true; modelRefsRef.current = loaded.refs; const d = displayRef.current; @@ -128,14 +142,18 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom for (const [kind, name, bytes] of maps) { if (!bytes || abort.signal.aborted) continue; - const computed = prepareMap(name, fullCellFromCcp4(bytes), crop, - kind === 'twofofc' && atoms.length ? atoms : null); + const computed = prepareMap(name, fullCellFromCcp4(bytes), crop, atoms.length ? atoms : null); const layer = await loadMapLayer(plugin, computed, name, d.sigma[kind]); layersRef.current[kind] = layer; if (!displayRef.current.show[kind]) setLayerVisible(plugin, layer, false); setHasMap(h => ({...h, [kind]: true})); } + if (firstScene && !abort.signal.aborted) { + await nextFrames(); // the camera reset loadModel asked for is applied on a draw + if (!reducedMotion()) + revealCamera(plugin, REVEAL_MS); + } } catch (e) { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : String(e)); @@ -143,6 +161,7 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom if (!abort.signal.aborted) { setModelLoading(false); setMapsLoading(false); + setRevealed(true); } } })(); @@ -182,11 +201,15 @@ function RugnuxStructureViewer({resultKey, anomalous}: { resultKey: string, anom return ( - - - {modelLoading && !error && + + + + {(modelLoading || (mapsLoading && !revealed)) && !error && - loading model… + + {modelLoading ? 'loading model…' : 'computing density maps…'} } diff --git a/frontend/src/components/structure/densityCrop.ts b/frontend/src/components/structure/densityCrop.ts index 36fb3bbf9..3cf1597fe 100644 --- a/frontend/src/components/structure/densityCrop.ts +++ b/frontend/src/components/structure/densityCrop.ts @@ -1,6 +1,6 @@ // The density preparation of aaredb's density worker (gitea.psi.ch/mx/aaredb, the PSI MX group's own code, // frontend/public/structure-worker/density-worker.js at 8ece2c2): crop the periodic map to the model's box plus a -// margin, carve the 2Fo-Fc map around the atoms, encode as CCP4 with the full cell's mean and RMS. aaredb computes +// margin, carve it around the atoms, encode as CCP4 with the full cell's mean and RMS. aaredb computes // the full-cell map from an MTZ with gemmi wasm; here the broker serves it (/rugnux/map.ccp4), so it is first read // back into aaredb's full-cell z-fastest grid (fullCellFromCcp4). @@ -226,8 +226,9 @@ function carveBlock(block: Float32Array, start: number[], dims: number[], n: num } } -/* One map as the worker's onmessage loop makes it: cropped to `crop`, carved around `carveAtoms` (2Fo-Fc only - - * never a difference map: unmodelled blobs sit away from every atom), with the full cell's mean and RMS. */ +/* One map as the worker's onmessage loop makes it: cropped to `crop`, carved around `carveAtoms`, with the full + * cell's mean and RMS. aaredb carves the 2Fo-Fc map only; the live page carves the difference maps as well, within + * the same radius, so what it shows is the density about the model - unmodelled blobs further away are left out. */ export function prepareMap(name: string, full: FullCellMap, crop: CropBox | null, carveAtoms: Float32Array | null, carveRadiusA = 4.5): ComputedMap { const [nx, ny, nz] = full.n; diff --git a/frontend/src/components/structure/reveal.ts b/frontend/src/components/structure/reveal.ts new file mode 100644 index 000000000..14a9e8103 --- /dev/null +++ b/frontend/src/components/structure/reveal.ts @@ -0,0 +1,16 @@ +import { PluginContext } from 'molstar/lib/mol-plugin/context'; +import { Quat, Vec3 } from 'molstar/lib/mol-math/linear-algebra'; + +// The entrance of a freshly placed model: the camera starts further out and turned a little about the vertical, +// then eases in to the view a plain reset frames. Only the camera moves, and it ends exactly where the reset put +// it, so nothing after the reveal differs from a viewer without one. +export function revealCamera(plugin: PluginContext, durationMs: number): void { + const camera = plugin.canvas3d?.camera; + if (!camera) return; + const end = camera.getSnapshot(); + const dir = Vec3.sub(Vec3(), end.position, end.target); + Vec3.transformQuat(dir, dir, Quat.setAxisAngle(Quat(), end.up, -0.6)); // about 35 degrees + Vec3.scale(dir, dir, 2.2); + camera.setState({ ...end, position: Vec3.add(Vec3(), end.target, dir) }, 0); + camera.setState(end, durationMs, { easing: 'cubic-out' }); +}