From 39dcc8bd6f4cf31604be0a37bfc975149f6cf6da Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Wed, 11 Nov 2026 16:52:15 +0100 Subject: [PATCH] frontend: Rugnux live stage - numbers and short labels only, a glance at the density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stage says nothing in sentences any more: the phase word stands alone (the run's message only where it failed), the bars carry counts and the step's title, the clock its caption, the hero tiles a one-word caption and the number, the triage a chip per condition that fired and a count of the clean and the unmeasured ones, the model panel "Model · refined". The verdict, the lattice probe, the step notes and every explanation are in the tooltips or under Details. The hero reads the whole-merge statistics (d_min, CC1/2, I/sigma, completeness, R_meas or R_split, multiplicity) from the status's `overall` row where the broker sends one, and only otherwise out of the summary's words; multiplicity joins the tiles. The structure viewer is a glance at the density, not a viewer to work in: cartoon, 2mFo-DFc at 1 sigma, mFo-DFc at +-3 sigma, the anomalous map where there is signal, and one toggle for the difference map. The representation, waters, blob and level controls and the mouse legend are gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HVeDQsU5a8AJUdJPgWevAK --- frontend/src/components/RugnuxHero.tsx | 119 +++++----- frontend/src/components/RugnuxPage.tsx | 76 +++---- .../src/components/RugnuxStructureViewer.tsx | 203 +++--------------- frontend/src/components/RugnuxTriage.tsx | 67 +++--- 4 files changed, 147 insertions(+), 318 deletions(-) diff --git a/frontend/src/components/RugnuxHero.tsx b/frontend/src/components/RugnuxHero.tsx index f750a3429..9897b2701 100644 --- a/frontend/src/components/RugnuxHero.tsx +++ b/frontend/src/components/RugnuxHero.tsx @@ -1,11 +1,12 @@ import {Box, Tooltip, Typography} from "@mui/material"; import {green, red} from "@mui/material/colors"; import {keyframes} from "@mui/system"; -import {rugnux_live_status} from "../client"; +import {rugnux_live_shell, rugnux_live_status} from "../client"; -// The headline of a rugnux live run as one row of big numbers, read left to right the way a crystallographer +// The headline of a rugnux live run as big numbers with one-word captions, in the order a crystallographer // reads a merge: how far, how clean, how complete, in what symmetry, and whether the model fits. "—" until // the merge the numbers are of exists (the end of a rotation run, each provisional merge of a serial one). +// Everything explanatory is in the tooltips. const fadeIn = keyframes` from { opacity: 0; transform: scale(.94); } @@ -36,14 +37,13 @@ type Tone = "plain" | "pending" | "good" | "bad"; const VALUE_COLOUR: Record = {plain: "text.primary", pending: "text.disabled", good: green[300], bad: red[300]}; -function Tile({value, caption, sub, tip, tone}: - { value: React.ReactNode, caption: string, sub?: string, tip: React.ReactNode, tone: Tone }) { +function Tile({value, caption, tip, tone}: { value: React.ReactNode, caption: string, tip: React.ReactNode, tone: Tone }) { return ( - - + + color: "text.secondary", whiteSpace: "nowrap"}}> {caption} {value} - - {sub ?? ""} - ); } -// The run-wide merging statistics exist only as the words of the report's Signal and Completeness rows -// ("I/sigma 35.2 R_meas 4.8 % CC1/2 1.000 ISa 30.2", "85.7 % over the full written range"); they are -// read back out of them here. A status that carried them as numbers would make this go away. +// The whole-merge statistics: `overall` of the status (a shell row over the whole range) where the broker sends +// it; an older broker has them only as the words of the summary's Signal and Completeness rows ("I/sigma 35.2 +// R_meas 4.8 % CC1/2 1.000 ISa 30.2", "85.7 % over the full written range"), read back out of them here. function summaryNumber(s: rugnux_live_status, row: RegExp, key: RegExp): number | undefined { const text = s.summary.find(r => row.test(r.label))?.value; const m = text?.match(key); return m ? parseFloat(m[1]) : undefined; } +function overallStatistics(s: rugnux_live_status) { + const o = (s as rugnux_live_status & { overall?: rugnux_live_shell }).overall; + const pct = (x: number | undefined) => x === undefined ? undefined : 100 * x; + return { + d_min: o?.d_min ?? s.d_min, + cc_half: o?.cc_half ?? summaryNumber(s, /^signal/i, /CC1\/2\s+([\d.]+)/), + i_over_sigma: o?.i_over_sigma ?? summaryNumber(s, /^signal/i, /I\/sigma\s+([\d.]+)/), + completeness: pct(o?.completeness) ?? summaryNumber(s, /^completeness/i, /([\d.]+)\s*%/), + r_meas: pct(o?.r_meas) ?? summaryNumber(s, /^signal/i, /R_meas\s+([\d.]+)\s*%/), + r_split: pct(o?.r_split) ?? summaryNumber(s, /^signal/i, /R_split\s+([\d.]+)\s*%/), + multiplicity: o?.multiplicity, + }; +} + const FIT_TIP = "Fit over random: how far the placed model's R-work stands below that of the same model refitted " + "in random orientations, in standard deviations of those. It is the test rugnux places a model by. " + "Green: rugnux placed the model; red: it did not."; // The model tile: green where rugnux placed the model, red where it did not, plain where there was none. -// Once refined, the refined R-work / R-free are the number. modelLoaded: a model is uploaded now. +// Once refined, the refined R-free is the number. modelLoaded: a model is uploaded now. function ModelTile({s, modelLoaded}: { s: rugnux_live_status, modelLoaded: boolean }) { const m = s.model; - const fit = m?.fit_sigma != null ? `${m.fit_sigma >= 0 ? "+" : ""}${m.fit_sigma.toFixed(1)} σ` : undefined; + const fit = m?.fit_sigma != null ? `fit over random ${m.fit_sigma >= 0 ? "+" : ""}${m.fit_sigma.toFixed(1)} σ` : ""; if (m && m.placement !== "NOT_PLACED") { const how = m.placement === "MR" - ? `MR · ${m.copies ?? 1} ${m.copies === 1 || m.copies === undefined ? "copy" : "copies"}` + ? `placed by molecular replacement, ${m.copies ?? 1} ${m.copies === 1 || m.copies === undefined ? "copy" : "copies"}` : "fits where it is"; const rf = m.refinement?.performed ? m.refinement : undefined; if (rf && rf.r_work != null && rf.r_free != null) - return R-work and R-free of the placed model after rugnux refined it against these data, R-free - on the merge's own free set.{rf.cancelled && " The deadline cut the refinement short."} -

{FIT_TIP}}/>; + return R-free {rf.r_free.toFixed(3)} / R-work {rf.r_work.toFixed(3)} after rugnux refined the model + against these data, R-free on the merge's own free set.{rf.cancelled && " The deadline cut the refinement short."} +
{how}, {fit}.

{FIT_TIP}}/>; if (m.r_work != null && m.r_free != null) - return ; - return ; + return R-free {m.r_free.toFixed(3)} / R-work {m.r_work.toFixed(3)} of the model as placed, unrefined. +
{how}, {fit}.

{FIT_TIP}}/>; + return {how}, {fit}.

{FIT_TIP}}/>; } if (m) { // "NOT_PLACED: no group of ..." -> "no group of ..." const why = m.reason || m.mr_decision.replace(/^[A-Z_]+: /, ""); - return {FIT_TIP}{why && <>

{why}}}/>; - } - if (s.state === "Running" && modelLoaded) { - const step = s.steps.length > 0 ? s.steps[s.steps.length - 1].title : ""; - const sub = step === "Molecular replacement" ? "searching…" : step === "Refinement" ? "refining…" - : "placed after the merge"; - return ; + return {why}

{FIT_TIP}}/>; } + if (s.state === "Running" && modelLoaded) + return ; if (s.state === "Running" || !modelLoaded) - return ; - return ; + return ; + return ; } // column: the tiles as a rail, top to bottom, sharing the rail's height @@ -120,15 +124,9 @@ function RugnuxHero({s, modelLoaded, column}: { s: rugnux_live_status, modelLoad // the numbers are of a merge that exists: rotation - the run is over; serial - a provisional merge is in const merged = s.d_min !== undefined || (s.shells !== undefined && s.shells.length > 0); const pending: Tone = merged ? "plain" : "pending"; - const later = merged ? undefined : s.stills ? "at the first provisional merge" : "when the run is over"; const num = (v: number | undefined, fmt: (x: number) => string) => v !== undefined ? fmt(v) : "—"; const pct = (x: number) => `${x.toFixed(1)} %`; - - const ccHalf = summaryNumber(s, /^signal/i, /CC1\/2\s+([\d.]+)/); - const iSigma = summaryNumber(s, /^signal/i, /I\/sigma\s+([\d.]+)/); - const rMeas = summaryNumber(s, /^signal/i, /R_meas\s+([\d.]+)\s*%/); - const rSplit = summaryNumber(s, /^signal/i, /R_split\s+([\d.]+)\s*%/); - const completeness = summaryNumber(s, /^completeness/i, /([\d.]+)\s*%/); + const o = overallStatistics(s); const cell = s.summary.find(r => /^unit cell/i.test(r.label))?.value; const mrGroup = s.model?.mr_space_group; const indexed = s.stills && s.stills.images_analysed > 0 @@ -137,31 +135,30 @@ function RugnuxHero({s, modelLoaded, column}: { s: rugnux_live_status, modelLoad return ( *": {display: "flex", flexDirection: "column", justifyContent: "center"}} - : {gridTemplateColumns: {xs: "repeat(2, minmax(0, 1fr))", md: "repeat(4, minmax(0, 1fr))", lg: "repeat(8, minmax(0, 1fr))"}})}}> - `${x.toFixed(2)} Å`)} caption="Resolution" sub={later} tone={pending} - tip="High-resolution limit of the merge (d_min); the Resolution row of the result says where the CC½ fall-off is fitted."/> - x.toFixed(3))} caption="CC½" sub={later ?? "all reflections"} tone={pending} - tip="CC½ of the whole merge, the half-dataset correlation; the plot on the right has it by shell."/> - x.toFixed(1))} caption="I/σ" sub={later ?? "mean over the merge"} tone={pending} + : {gridTemplateColumns: {xs: "repeat(3, minmax(0, 1fr))", md: "repeat(9, minmax(0, 1fr))"}})}}> + `${x.toFixed(2)} Å`)} caption="Resolution" tone={pending} + tip="High-resolution limit of the merge (d_min)."/> + x.toFixed(3))} caption="CC½" tone={pending} + tip="CC½ of the whole merge; by shell in the plot."/> + x.toFixed(1))} caption="I/σ" tone={pending} tip="Mean I/σ of the merged reflections over the whole written range."/> - + {s.stills - ? - : } + x.toFixed(1))} caption="Multiplicity" tone={pending} + tip="Mean multiplicity of the merged reflections."/> {s.stills ? - : x.toFixed(1))} caption="ISa" sub={later ?? (s.isa === undefined ? "not determined" : "error model")} - tone={pending} + tip={`${s.stills.images_indexed.toLocaleString()} of ${s.stills.images_analysed.toLocaleString()} images analysed since the pre-pass were indexed.`}/> + : x.toFixed(1))} caption="ISa" tone={pending} tip="ISa: the I/σ the strongest reflections level off at, from the error model (XDS's meaning). It measures the crystal and the instrument, not the exposure."/>} : "—"} caption="Space group" tone={s.space_group ? "plain" : pending} - sub={later ?? (mrGroup && s.space_group && mrGroup !== s.space_group ? `model placed in ${mrGroup}` : cell)} - tip={<>Space group rugnux determined from the data{cell && <>, unit cell {cell}}. Where molecular - replacement placed the model in another group, the tile says which.}/> + tip={<>{s.space_group ?? "Determined from the data when the run is over"}{cell && <>, unit cell {cell}} + {mrGroup && s.space_group && mrGroup !== s.space_group && <>. The model was placed in {mrGroup}}}/> ); diff --git a/frontend/src/components/RugnuxPage.tsx b/frontend/src/components/RugnuxPage.tsx index f643ac638..41e20ff1b 100644 --- a/frontend/src/components/RugnuxPage.tsx +++ b/frontend/src/components/RugnuxPage.tsx @@ -3,7 +3,7 @@ import {green, indigo, lime, orange, red, grey} from "@mui/material/colors"; import {keyframes} from "@mui/system"; import {useQuery} from "@tanstack/react-query"; import {getConfigRugnuxOptions, getRugnuxStatusOptions} from "../client/@tanstack/react-query.gen"; -import {rugnux_live_status, rugnux_live_step, rugnux_live_stills} from "../client"; +import {rugnux_live_status, rugnux_live_step} from "../client"; import {useTokenRequired} from "../auth"; import EmptyState from "./EmptyState"; import RugnuxTriage from "./RugnuxTriage"; @@ -76,23 +76,6 @@ function phase(s: rugnux_live_status): { label: string, colour: string } { return {label: s.state, colour: grey[500]}; } -// The serial pre-pass in one line: its state, and what it decided for the whole collection -function prepassLine(st: rugnux_live_stills) { - if (st.prepass === "Holding") - return `Pre-pass: holding ${st.images_held} / ${st.images_needed} images`; - if (st.prepass === "Running") - return `Pre-pass on the first ${st.images_held} images`; - if (st.prepass === "Failed") - return "Pre-pass failed: the stated beam centre and mask are used"; - const shift = st.beam_x_pxl !== undefined && st.beam_y_pxl !== undefined - ? Math.hypot(st.beam_x_pxl - st.file_beam_x_pxl, st.beam_y_pxl - st.file_beam_y_pxl) : undefined; - const distance = st.distance_mm !== undefined ? st.distance_mm - st.file_distance_mm : undefined; - return `Pre-pass on ${st.images_held} images in ${st.prepass_s?.toFixed(1)} s` - + (shift !== undefined ? ` · beam centre moved ${shift.toFixed(1)} px` : "") - + (distance !== undefined ? ` · distance ${distance >= 0 ? "+" : ""}${distance.toFixed(2)} mm` : "") - + (st.analysis_rate_hz !== undefined ? ` · GPU analysis ${st.analysis_rate_hz.toFixed(0)} Hz` : ""); -} - // A thin progress bar under its caption line: fraction undefined = busy without a measure (striped, full). function Bar({caption, detail, fraction, colour, busy}: { caption: string, detail: string, fraction?: number, colour: string, busy: boolean }) { @@ -125,29 +108,29 @@ function Header({s, deadline_s}: { s: rugnux_live_status, deadline_s: number }) const collecting = running && lastImage === undefined; const rate = s.images_received > 1 && s.elapsed_s > 0 ? (s.images_received - 1) / (lastImage ?? s.elapsed_s) : 0; const remaining = collecting && rate > 0 ? (s.images_expected - s.images_received) / rate : undefined; - const detectorDetail = `${s.images_received.toLocaleString()} / ${s.images_expected.toLocaleString()} images` + const detectorDetail = `${s.images_received.toLocaleString()} / ${s.images_expected.toLocaleString()}` + (rate > 0 ? ` · ${rate.toFixed(0)} Hz` : "") - + (remaining !== undefined ? ` · ${remaining.toFixed(0)} s to go` : lastImage !== undefined ? ` · collected in ${lastImage.toFixed(1)} s` : ""); + + (remaining !== undefined ? ` · ${remaining.toFixed(0)} s left` : ""); const step = s.steps.length > 0 ? s.steps[s.steps.length - 1] : undefined; let rugnuxDetail: string, rugnuxFraction: number | undefined; if (running && step) { - rugnuxDetail = `${step.title} · step ${s.steps.length}` + (s.step_progress !== undefined ? ` · ${(100 * s.step_progress).toFixed(0)} %` : ""); + rugnuxDetail = step.title + (s.step_progress !== undefined ? ` · ${(100 * s.step_progress).toFixed(0)} %` : ""); rugnuxFraction = s.step_progress; } else if (running) { - rugnuxDetail = "waiting for images"; + rugnuxDetail = "waiting"; rugnuxFraction = 0; } else { - rugnuxDetail = s.cut ? `cut (${s.cut}) after ${s.steps.length} steps` : `${s.steps.length} steps · ${s.elapsed_s.toFixed(1)} s`; + rugnuxDetail = s.cut ? `cut (${s.cut})` : `${s.steps.length} steps`; rugnuxFraction = 1; } const latency = s.last_image_to_written_s; const clock = latency !== undefined - ? {caption: "last image → all written", value: latency, colour: p.colour} + ? {caption: "last image → written", value: latency, colour: p.colour} : lastImage !== undefined - ? {caption: "since the last image", value: s.elapsed_s - lastImage, colour: indigo[200]} - : {caption: "since the first image", value: s.elapsed_s, colour: "text.primary"}; + ? {caption: "since last image", value: s.elapsed_s - lastImage, colour: indigo[200]} + : {caption: "since first image", value: s.elapsed_s, colour: "text.primary"}; const deadline = running && lastImage !== undefined && deadline_s > 0 ? lastImage + deadline_s - s.elapsed_s : undefined; return ( @@ -159,9 +142,8 @@ function Header({s, deadline_s}: { s: rugnux_live_status, deadline_s: number }) "@media (prefers-reduced-motion: reduce)": {animation: "none"}})}}> {p.label} - - {s.message || s.verdict_text || (s.stills ? prepassLine(s.stills) : s.probe) || "Rugnux live"} - + {s.state === "Failed" && s.message && + {s.message}} s - {deadline !== undefined ? `deadline in ${Math.max(0, deadline).toFixed(0)} s` - : s.cut ? `analysis cut (${s.cut})${s.cut_s !== undefined ? ` at ${s.cut_s.toFixed(1)} s` : ""}` - : s.start_to_first_image_s !== undefined ? `START → first image ${s.start_to_first_image_s.toFixed(1)} s` : ""} + {deadline !== undefined ? `deadline in ${Math.max(0, deadline).toFixed(0)} s` : ""} @@ -190,7 +170,7 @@ function Later({text}: { text: string }) { } // The run's steps as a console - the running one large with its progress, the ones behind it as a list with -// their durations - filling the centre until there is a model to show. +// their durations - filling the centre until there is a model to show. Their notes are in the Details fold. function RunConsole({s}: { s: rugnux_live_status }) { const running = s.state === "Running"; const current = running && s.steps.length > 0 && s.steps[s.steps.length - 1].duration_s === undefined @@ -200,11 +180,7 @@ function RunConsole({s}: { s: rugnux_live_status }) { - - {step.title} - {step.notes.length > 0 && - {step.notes[step.notes.length - 1]}} - + {step.title} {step.duration_s !== undefined ? `${step.duration_s.toFixed(1)} s` : ""} @@ -218,13 +194,9 @@ function RunConsole({s}: { s: rugnux_live_status }) { "@media (prefers-reduced-motion: reduce)": {animation: "none"}}}> {current.title}{s.step_progress !== undefined && {(100 * s.step_progress).toFixed(0)} %} - {current.notes.length > 0 && {current.notes[current.notes.length - 1]}} } {s.steps.length === 0 && } {done.map(row)} - {s.probe && - Lattice probe{s.probe_s !== undefined && ` at ${s.probe_s.toFixed(1)} s`}: {s.probe} - } ); } @@ -248,13 +220,11 @@ function Centre({s}: { s: rugnux_live_status }) { ? `${m.placement} ${m.r_work} ${m.r_free} ${m.refinement?.r_free} ${s.refined_model}` : `${s.elapsed_s}`; return ( - {m.placement === "MR" ? "Molecular replacement" : "Model"}{m.predicted && " · predicted model"} - {s.refined_model && " · refined"} · {m.model_name}}> + }> + anomalousSignal={s.anomalous_signal}/> @@ -280,7 +250,7 @@ function useHeight() { function Dashboard({s, deadline_s, modelLoaded}: { s: rugnux_live_status, deadline_s: number, modelLoaded: boolean }) { const [plotsRef, plotsHeight] = useHeight(); const attention = s.triage?.filter(i => i.severity === "serious" || i.severity === "attention").length ?? 0; - const later = s.stills ? "at the first provisional merge" : "when the run is over"; + const later = s.stills ? "after the first merge" : "after the run"; const hairline = {borderLeft: {lg: "1px solid"}, borderColor: {lg: "divider"}, pl: {lg: 2.5}}; return ( @@ -296,16 +266,16 @@ function Dashboard({s, deadline_s, modelLoaded}: { s: rugnux_live_status, deadli *": {flexShrink: 0}}} - label={<>Triage{attention > 0 && · {attention} need attention}}> - {s.triage && s.triage.length > 0 ? : } + label={<>Triage{attention > 0 && · {attention}}}> + {s.triage && s.triage.length > 0 ? : } - By resolution{s.shells_merge && ` · merge of ${s.shells_merge}`}}> + By resolution{s.stills && s.shells_merge && ` · ${s.shells_merge}`}}> {s.shells && s.shells.length > 0 ? - : } + : } @@ -327,13 +297,15 @@ function Details({s, deadline_s}: { s: rugnux_live_status, deadline_s: number }) + + {s.probe && + Lattice probe{s.probe_s !== undefined && ` at ${s.probe_s.toFixed(1)} s`}: {s.probe}} {s.stills && } {s.model && } - {s.summary.length > 0 && diff --git a/frontend/src/components/RugnuxStructureViewer.tsx b/frontend/src/components/RugnuxStructureViewer.tsx index d85ecaa12..48dc7bee6 100644 --- a/frontend/src/components/RugnuxStructureViewer.tsx +++ b/frontend/src/components/RugnuxStructureViewer.tsx @@ -1,6 +1,5 @@ import React, {useCallback, useEffect, useRef, useState} from "react"; -import {Alert, Box, IconButton, Popover, Slider, Switch, Tooltip, Typography} from "@mui/material"; -import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; +import {Alert, Box, Tooltip, Typography} from "@mui/material"; import type {PluginContext} from "molstar/lib/mol-plugin/context"; import {authHeaders, noteResponse} from "../auth"; import MolstarCanvas from "./structure/MolstarCanvas"; @@ -9,78 +8,21 @@ import {applyModelDisplay, clearScene, getStructureAtoms, getStructureBox, Loade import {loadMapLayer, MAP_COLORS, MapLayer, setLayerVisible, updateLayerSigma} from "./structure/loadMaps"; import {fullCellFromCcp4, prepareMap} from "./structure/densityCrop"; import {revealCamera} from "./structure/reveal"; -import {Blob, BLOB_COLORS, loadBlobs, setBlobsVisible} from "./structure/loadBlobs"; -// The model rugnux live placed - refined, where the run refined it, with the unexplained blobs of its difference map as -// markers (off at first) - 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 + +// A glance at the model rugnux live placed - refined, where the run refined it - in its density: cartoon, +// 2mFo-DFc at 1 sigma, mFo-DFc at +-3 sigma and (where the data carry anomalous signal) the anomalous difference +// map at 4 sigma, 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, 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, the first model makes an entrance (reveal.ts), and -// the controls are a toolbar laid over the canvas rather than aaredb's side panel, so the molecule has the whole -// panel. Mol* is only loaded with this component (React.lazy in RugnuxPage), and every Mol* import lives in -// ./structure. +// meshes, crop and carve - on a black stage. It is not a viewer to work in: what it shows is gone with the next +// collection, so the only control is a toggle for the difference map; every map is carved within the same +// radius of the atoms (aaredb carves 2Fo-Fc only), 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'; const hex = (c: number) => '#' + c.toString(16).padStart(6, '0'); -// A map as a chip on the toolbar: its colour, name and level; dimmed while off. Clicking it opens the switch and -// the level slider in a popover, so the canvas keeps the space. -const MapChip: React.FC<{ - label: string; - dotColor: string; - on: boolean; - onToggle: (v: boolean) => void; - sigma: number; - min: number; - max: number; - step: number; - onSigma: (v: number) => void; - disabled?: boolean; -}> = ({label, dotColor, on, onToggle, sigma, min, max, step, onSigma, disabled}) => { - const [anchor, setAnchor] = useState(null); - return <> - setAnchor(e.currentTarget)} disabled={disabled} active={on}> - - {label} - {sigma.toFixed(1)} σ - - setAnchor(null)} - anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} transformOrigin={{vertical: 'top', horizontal: 'right'}}> - - - {label} - onToggle(e.target.checked)}/> - - onSigma(v as number)} aria-label={`${label} contour level`} - valueLabelDisplay="auto" valueLabelFormat={(v) => `${v.toFixed(1)} σ`} - sx={{color: dotColor, mt: 1}}/> - - - ; -}; - -// A toolbar item: a small pill, lit while active -const ToolButton: React.FC<{ children: React.ReactNode; active?: boolean; disabled?: boolean; title?: string; - onClick: (e: React.MouseEvent) => void }> = ({children, active, disabled, title, onClick}) => ( - - - {children} - - -); - -const MOUSE = [['left drag', 'rotate'], ['right drag', 'zoom'], ['middle drag', 'pan'], ['middle click', 'center on residue'], - ['scroll', 'clip']] as const; - async function fetchBytes(path: string, signal: AbortSignal): Promise { const res = await fetch(path, {headers: authHeaders(), signal}); noteResponse(res); @@ -95,16 +37,11 @@ const REVEAL_MS = 1600; const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; const nextFrames = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); -const BLOB_LABELS: [string, string][] = [['LIGAND', 'ligand'], ['WATER', 'water'], ['ION', 'ion'], - ['MODEL_ERROR', 'model error'], ['UNASSIGNED', 'other']]; - // 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; -// blobs: what the refined model leaves unexplained; fill: take the parent's height (the live page's stage) -// instead of the viewer's own. -function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, blobs, fill}: - { resultKey: string, anomalous: boolean, anomalousSignal: boolean, - blobs: Blob[], fill?: boolean }) { +// fill: take the parent's height (the live page's stage) instead of the viewer's own. +function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, fill}: + { resultKey: string, anomalous: boolean, anomalousSignal: boolean, fill?: boolean }) { const pluginRef = useRef(null); const [pluginReady, setPluginReady] = useState(0); const [error, setError] = useState(); @@ -119,18 +56,12 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, blobs, fi // 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), 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 + // aaredb factory defaults (state/viewerDefaults.ts:16-23); anomalous as a difference map, +4 sigma + const SIGMA: Record = {twofofc: 1.0, fofc: 3.0, anom: 4.0}; + const REPR: ReprMode = 'cartoon'; 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); - // The blobs can be dozens of waters: off until asked for - const [showBlobs, setShowBlobs] = useState(false); - const blobRefsRef = useRef([]); - const displayRef = useRef({show, sigma, reprMode, showWaters, showBlobs}); - displayRef.current = {show, sigma, reprMode, showWaters, showBlobs}; + const showRef = useRef(show); + showRef.current = show; const onPluginReady = useCallback((plugin: PluginContext) => { pluginRef.current = plugin; @@ -151,7 +82,6 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, blobs, fi try { await clearScene(plugin); layersRef.current = {}; - blobRefsRef.current = []; setHasMap({twofofc: false, fofc: false, anom: false}); const [cif, map2, map1, mapA] = await Promise.all([ fetchBytes("/rugnux/model.cif", abort.signal), @@ -168,13 +98,10 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, blobs, fi const loaded = await loadModel(plugin, cif, 'model', {resetCamera: firstScene, format: 'mmcif'}); sceneLoadedRef.current = true; modelRefsRef.current = loaded.refs; - const d = displayRef.current; - applyModelDisplay(plugin, loaded.refs, d.reprMode, d.showWaters); + applyModelDisplay(plugin, loaded.refs, REPR, false); const box = getStructureBox(loaded.structure); const crop = box ? {...box, marginA: 6} : null; const atoms = getStructureAtoms(loaded.structure); - if (blobs.length > 0) - blobRefsRef.current = await loadBlobs(plugin, blobs, d.showBlobs); setModelLoading(false); setMapsLoading(true); const maps: [Kind, '2fofc' | 'fofc' | 'anom', ArrayBuffer | undefined][] = @@ -183,9 +110,9 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, blobs, fi if (!bytes || abort.signal.aborted) continue; const computed = prepareMap(name, fullCellFromCcp4(bytes), crop, atoms.length ? atoms : null); - const layer = await loadMapLayer(plugin, computed, name, d.sigma[kind]); + const layer = await loadMapLayer(plugin, computed, name, SIGMA[kind]); layersRef.current[kind] = layer; - if (!displayRef.current.show[kind]) + if (!showRef.current[kind]) setLayerVisible(plugin, layer, false); setHasMap(h => ({...h, [kind]: true})); } @@ -208,19 +135,6 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, blobs, fi return () => abort.abort(); }, [pluginReady, resultKey, anomalous]); - // σ sliders: debounced param-only updates (re-mesh, no recompute) - aaredb StructureView.tsx:321-330 - const applySigma = useCallback((kind: Kind, value: number) => { - setSigma(s => ({...s, [kind]: value})); - if (sigmaTimer.current) - clearTimeout(sigmaTimer.current); - sigmaTimer.current = setTimeout(() => { - const plugin = pluginRef.current; - const layer = layersRef.current[kind]; - if (plugin && layer) - void updateLayerSigma(plugin, layer, value); - }, 90); - }, []); - const toggleLayer = useCallback((kind: Kind, visible: boolean) => { setShow(s => ({...s, [kind]: visible})); const plugin = pluginRef.current; @@ -229,25 +143,6 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, blobs, fi setLayerVisible(plugin, layer, visible); }, []); - const setDisplay = useCallback((mode: ReprMode, waters: boolean) => { - setReprMode(mode); - setShowWaters(waters); - const plugin = pluginRef.current; - if (plugin) - applyModelDisplay(plugin, modelRefsRef.current, mode, waters); - }, []); - - const toggleBlobs = useCallback((visible: boolean) => { - setShowBlobs(visible); - const plugin = pluginRef.current; - if (plugin) - setBlobsVisible(plugin, blobRefsRef.current, visible); - }, []); - - const mapsDisabled = mapsLoading; - const [help, setHelp] = useState(null); - const blobKinds = BLOB_LABELS.filter(([k]) => blobs.some(b => b.kind === k)) - .map(([k, label]) => `${blobs.filter(b => b.kind === k).length} ${label}`).join(' · '); return ( @@ -261,54 +156,18 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, blobs, fi {modelLoading ? 'loading model…' : 'computing density maps…'} } {error && {error}} - - {/* the toolbar, over the top edge of the canvas */} - - {(['cartoon', 'sticks', 'both'] as const).map((m) => ( - setDisplay(m, showWaters)}> - {m === 'both' ? 'cartoon + sticks' : m} - - ))} - setDisplay(reprMode, !showWaters)}>waters - - {hasMap.twofofc && toggleLayer('twofofc', v)} sigma={sigma.twofofc} - min={0.5} max={4} step={0.1} onSigma={(v) => applySigma('twofofc', v)} - disabled={mapsDisabled}/>} - {hasMap.fofc && toggleLayer('fofc', v)} sigma={sigma.fofc} - min={1} max={8} step={0.5} onSigma={(v) => applySigma('fofc', v)} - disabled={mapsDisabled}/>} - {hasMap.anom && toggleLayer('anom', v)} sigma={sigma.anom} - min={1} max={8} step={0.5} onSigma={(v) => applySigma('anom', v)} - disabled={mapsDisabled}/>} - {blobs.length > 0 && - toggleBlobs(!showBlobs)} - title={`Unexplained mFo−DFc blobs: ${blobKinds}`}> - {BLOB_LABELS.filter(([k]) => blobs.some(b => b.kind === k)).map(([k]) => - )} - {blobs.length} blobs - } - setHelp(e.currentTarget)} aria-label="mouse controls" - sx={{color: 'text.secondary', bgcolor: 'rgba(8,12,26,.8)'}}> - - - setHelp(null)} - anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} transformOrigin={{vertical: 'top', horizontal: 'right'}}> - - {MOUSE.map(([k, v]) => ( - - {k} - {v} - - ))} + {/* the one control: the difference map on or off */} + {hasMap.fofc && ( + + toggleLayer('fofc', !show.fofc)} disabled={mapsLoading} + sx={{position: 'absolute', top: 8, right: 8, display: 'inline-flex', alignItems: 'center', gap: 0.75, + px: 1, height: 26, borderRadius: '13px', border: '1px solid', cursor: 'pointer', + borderColor: show.fofc ? 'primary.main' : 'divider', bgcolor: 'rgba(8,12,26,.8)', + color: show.fofc ? 'text.primary' : 'text.secondary', fontSize: '0.75rem', fontFamily: 'inherit'}}> + + Fo−Fc - - - {mapsLoading && - computing density maps…} + )} {!modelLoading && !mapsLoading && !hasMap.twofofc && !error && No maps} diff --git a/frontend/src/components/RugnuxTriage.tsx b/frontend/src/components/RugnuxTriage.tsx index ca70fb9a3..6fa0cc35e 100644 --- a/frontend/src/components/RugnuxTriage.tsx +++ b/frontend/src/components/RugnuxTriage.tsx @@ -1,12 +1,12 @@ -import {Box, Tooltip, Typography} from "@mui/material"; +import {Box, Tooltip} from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import WarningIcon from "@mui/icons-material/Warning"; import ErrorIcon from "@mui/icons-material/Error"; import {rugnux_triage_item} from "../client"; // The pathology checklist of the rugnux report (the SUMMARY of _report.txt): a condition whose warning fired -// (orange) or that left nothing usable (red) gets a line with the report's words; one looked for and not found -// (green) or not looked for on this run (grey) is a badge with its name, the words on hover. +// (orange) or that left nothing usable (red) is a chip with its name; the ones looked for and not found (green) +// and the ones not looked for on this run (grey) are counted. The report's words are on hover. const ORDER: Record = {serious: 0, attention: 1}; @@ -16,18 +16,6 @@ const ICON: Record = { ok: , }; -function TriageLine({item}: { item: rugnux_triage_item }) { - return ( - - - {ICON[item.severity]} - {item.label} - {item.text} - - - ); -} - function tip(item: rugnux_triage_item) { return ( @@ -38,34 +26,47 @@ function tip(item: rugnux_triage_item) { ); } -// A condition as a badge: its name only, the report's words on hover -function Badge({item}: { item: rugnux_triage_item }) { - const measured = item.severity !== "not_measured"; +const CHIP_SX = {display: "inline-flex", alignItems: "center", gap: 0.5, px: 1.25, height: 28, borderRadius: "14px", + border: "1px solid", fontSize: "0.85rem", fontWeight: 600, whiteSpace: "nowrap" as const}; + +// A condition that fired: a chip in its colour, the report's words on hover +function Problem({item}: { item: rugnux_triage_item }) { return ( - - - {measured && } - {item.label} + + + {ICON[item.severity]}{item.label} ); } -// Problems one to a line, so their words have the width; the fine ones as a row of badges, the unmeasured ones -// greyed among them. +// The conditions of one kind counted, their names (and words) on hover +function Count({items, label, colour, icon}: { items: rugnux_triage_item[], label: string, colour: string, icon?: React.ReactNode }) { + if (items.length === 0) + return null; + const list = ( + + {items.map(i => {i.label}{i.text && `: ${i.text}`})} + + ); + return ( + + + {icon}{items.length} {label} + + + ); +} + +// The conditions that fired as chips, the clean and the unmeasured ones as counts. export default function RugnuxTriage({items}: { items: rugnux_triage_item[] }) { const problems = items.filter(i => i.severity === "serious" || i.severity === "attention") .sort((a, b) => ORDER[a.severity] - ORDER[b.severity]); - const rest = items.filter(i => i.severity === "ok" || i.severity === "not_measured"); return ( - - {problems.map(item => )} - {problems.length === 0 && Nothing needs attention} - - {rest.map(item => )} - + + {problems.map(item => )} + i.severity === "ok")} label="OK" colour="success.main" icon={ICON.ok}/> + i.severity === "not_measured")} label="not measured" colour="text.disabled"/> ); }