From 9122bda2e6367b4ea0885a628bf098f40aa2ca57 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Wed, 11 Nov 2026 16:42:21 +0100 Subject: [PATCH] frontend: Rugnux live stage - the viewer gets its panel, triage as badges, hero numbers as a rail The structure viewer's side panel becomes a toolbar laid over the top of the canvas: the representation and waters as pills, each map as a chip showing its colour and level that opens the switch and the sigma slider in a popover, the unexplained blobs as one toggle, and the mouse controls behind a "?". The molecule has the whole panel. The triage keeps a line only for a condition that needs attention; the ones looked for and clean are badges with their names (the report's words on hover), the unmeasured ones greyed among them. The stage drops its nested cards for hairline divisions: the phase word with the two progress bars side by side under it, thin, and the clock at the right; the hero numbers as a rail down the left edge, the model (or the run's steps, the running one large with its progress) filling the centre, the triage over the shell plots at the right. The stage's top edge carries the phase's colour. The shell plots' type and tick count follow the panel's size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HVeDQsU5a8AJUdJPgWevAK --- frontend/src/components/RugnuxHero.tsx | 10 +- frontend/src/components/RugnuxPage.tsx | 210 ++++++++--------- frontend/src/components/RugnuxShellPlots.tsx | 12 +- .../src/components/RugnuxStructureViewer.tsx | 218 +++++++++--------- frontend/src/components/RugnuxTriage.tsx | 66 +++--- 5 files changed, 272 insertions(+), 244 deletions(-) diff --git a/frontend/src/components/RugnuxHero.tsx b/frontend/src/components/RugnuxHero.tsx index b6b2f2895..f750a3429 100644 --- a/frontend/src/components/RugnuxHero.tsx +++ b/frontend/src/components/RugnuxHero.tsx @@ -47,7 +47,7 @@ function Tile({value, caption, sub, tip, tone}: {caption} @@ -115,7 +115,8 @@ function ModelTile({s, modelLoaded}: { s: rugnux_live_status, modelLoaded: boole tip="The run ended without placing the model - the steps and warnings say why."/>; } -function RugnuxHero({s, modelLoaded}: { s: rugnux_live_status, modelLoaded: boolean }) { +// column: the tiles as a rail, top to bottom, sharing the rail's height +function RugnuxHero({s, modelLoaded, column}: { s: rugnux_live_status, modelLoaded: boolean, column?: boolean }) { // 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"; @@ -134,8 +135,9 @@ function RugnuxHero({s, modelLoaded}: { s: rugnux_live_status, modelLoaded: bool ? 100 * s.stills.images_indexed / s.stills.images_analysed : undefined; 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} diff --git a/frontend/src/components/RugnuxPage.tsx b/frontend/src/components/RugnuxPage.tsx index 0cbc46b37..f643ac638 100644 --- a/frontend/src/components/RugnuxPage.tsx +++ b/frontend/src/components/RugnuxPage.tsx @@ -18,12 +18,13 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import CheckIcon from "@mui/icons-material/Check"; import {lazy, Suspense, useEffect, useRef, useState} from "react"; -// The live page as one control-room screen: a dark stage that holds, top to bottom, the state and the two -// progress bars (the detector's and rugnux's), the hero numbers of the merge, and the stage proper - the placed -// model in Mol* on the left, the triage list and the merging statistics by shell on the right. While there is -// no model the left half shows the run's steps. Everything else - the timeline, step notes, summary rows, -// warnings, the serial pre-pass geometry - sits under a "Details" fold below the stage, and the settings -// under that, on the page's ordinary light background. +// The live page as one control-room screen: a dark stage the height of the window, edged in the colour of the +// run's phase. Across the top, the phase, the two progress bars (the detector's and rugnux's) and the clock the +// run is judged by. Below, three columns: the hero numbers of the merge as a rail at the left, the placed model +// in Mol* filling the centre (the run's steps until there is one), and at the right the triage list over the +// merging statistics by shell. Everything else - the timeline, step notes, summary rows, warnings, the serial +// pre-pass geometry - sits under a "Details" fold below the stage, and the settings under that, on the page's +// ordinary light background. // Mol* is large: loaded only when there is a model to show. const RugnuxStructureViewer = lazy(() => import("./RugnuxStructureViewer")); @@ -34,9 +35,9 @@ const liveTheme = createTheme({ mode: "dark", primary: {main: indigo[300]}, secondary: {main: lime[500]}, - background: {default: "#0c1122", paper: "#151c33"}, + background: {default: "#0b0f1d", paper: "#151c33"}, text: {primary: "#e9edf7", secondary: "#9aa5c4", disabled: "#596483"}, - divider: "rgba(154,165,196,.22)", + divider: "rgba(154,165,196,.2)", }, shape: {borderRadius: 8}, }); @@ -51,13 +52,16 @@ const pulse = keyframes` `; const runningSx = { - backgroundImage: "linear-gradient(135deg, rgba(255,255,255,.22) 25%, transparent 25%, transparent 50%, " - + "rgba(255,255,255,.22) 50%, rgba(255,255,255,.22) 75%, transparent 75%, transparent)", + backgroundImage: "linear-gradient(135deg, rgba(255,255,255,.25) 25%, transparent 25%, transparent 50%, " + + "rgba(255,255,255,.25) 50%, rgba(255,255,255,.25) 75%, transparent 75%, transparent)", backgroundSize: "40px 40px", animation: `${stripes} 1s linear infinite`, "@media (prefers-reduced-motion: reduce)": {animation: "none"}, }; +const LABEL_SX = {fontSize: "0.7rem", fontWeight: 700, letterSpacing: ".1em", textTransform: "uppercase" as const, + color: "text.secondary", whiteSpace: "nowrap" as const, overflow: "hidden", textOverflow: "ellipsis"}; + // What the run is doing, in one word, with the colour the rest of the screen takes from it. function phase(s: rugnux_live_status): { label: string, colour: string } { if (s.state === "Running") @@ -82,31 +86,30 @@ function prepassLine(st: rugnux_live_stills) { 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` : "") - + (st.distance_mm !== undefined ? ` · distance ${(st.distance_mm - st.file_distance_mm) >= 0 ? "+" : ""}${(st.distance_mm - st.file_distance_mm).toFixed(2)} mm` : "") + + (distance !== undefined ? ` · distance ${distance >= 0 ? "+" : ""}${distance.toFixed(2)} mm` : "") + (st.analysis_rate_hz !== undefined ? ` · GPU analysis ${st.analysis_rate_hz.toFixed(0)} Hz` : ""); } -// A progress bar with its caption inside: fraction undefined = busy without a measure (striped, full). +// 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 }) { const width = fraction === undefined ? (busy ? 100 : 0) : 100 * Math.min(1, Math.max(0, fraction)); return ( - - - - - {caption} - - + + + {caption} + {detail} + + + ); } @@ -148,11 +151,11 @@ function Header({s, deadline_s}: { s: rugnux_live_status, deadline_s: number }) const deadline = running && lastImage !== undefined && deadline_s > 0 ? lastImage + deadline_s - s.elapsed_s : undefined; return ( - - - - + + + {p.label} @@ -160,21 +163,19 @@ function Header({s, deadline_s}: { s: rugnux_live_status, deadline_s: number }) {s.message || s.verdict_text || (s.stills ? prepassLine(s.stills) : s.probe) || "Rugnux live"} - 0 ? s.images_received / s.images_expected : undefined}/> - - - - - {clock.caption} + + 0 ? s.images_received / s.images_expected : undefined}/> + + + + + {clock.caption} + + {clock.value.toFixed(1)}s - - {clock.value.toFixed(1)}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` : ""} @@ -184,58 +185,61 @@ function Header({s, deadline_s}: { s: rugnux_live_status, deadline_s: number }) ); } -function Panel({title, children, sx}: { title: React.ReactNode, children: React.ReactNode, sx?: object }) { - return ( - - - {title} - - {children} - - ); -} - function Later({text}: { text: string }) { - return {text}; + return {text}; } -// The run's steps as a sequence, the running one with its progress: what fills the stage while there is no -// model to show. -function StepSequence({s}: { s: rugnux_live_status }) { +// 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. +function RunConsole({s}: { s: rugnux_live_status }) { const running = s.state === "Running"; - const row = (step: rugnux_live_step, i: number) => { - const open = step.duration_s === undefined; - return ( - - {open && running - ? - : } - - {step.title} - {step.notes.length > 0 && - {step.notes[step.notes.length - 1]}} - - - {open ? (running && s.step_progress !== undefined ? `${(100 * s.step_progress).toFixed(0)} %` : "") : `${step.duration_s!.toFixed(1)} s`} - + const current = running && s.steps.length > 0 && s.steps[s.steps.length - 1].duration_s === undefined + ? s.steps[s.steps.length - 1] : undefined; + const done = current ? s.steps.slice(0, -1) : s.steps; + const row = (step: rugnux_live_step, i: number) => ( + + + + {step.title} + {step.notes.length > 0 && + {step.notes[step.notes.length - 1]}} - ); - }; + + {step.duration_s !== undefined ? `${step.duration_s.toFixed(1)} s` : ""} + + + ); return ( - + + {current && + now + + {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 && } - {s.steps.map(row)} - {s.probe && + {done.map(row)} + {s.probe && Lattice probe{s.probe_s !== undefined && ` at ${s.probe_s.toFixed(1)} s`}: {s.probe} } ); } -function Stage({s}: { s: rugnux_live_status }) { +// One division of the stage: a small-caps label, then its content filling the rest +function Division({label, children, sx}: { label: React.ReactNode, children: React.ReactNode, sx?: object }) { + return ( + + {label} + {children} + + ); +} + +function Centre({s}: { s: rugnux_live_status }) { const m = s.model; // The model shows once a rotation run is over; serial data place it on each provisional merge, and the viewer // reloads when the model step has a new result, not every time the status is polled. @@ -244,23 +248,19 @@ function Stage({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}} sx={{flexGrow: 1}}> + {m.placement === "MR" ? "Molecular replacement" : "Model"}{m.predicted && " · predicted model"} + {s.refined_model && " · refined"} · {m.model_name}}> }> - + ); } - return ( - - - - ); + return ; } // Height of an element in px, kept up to date as the page resizes: the shell plots need theirs in pixels. @@ -281,22 +281,25 @@ function Dashboard({s, deadline_s, modelLoaded}: { s: rugnux_live_status, deadli 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 hairline = {borderLeft: {lg: "1px solid"}, borderColor: {lg: "divider"}, pl: {lg: 2.5}}; return ( - +
- - - - + + + + + - - Triage{attention > 0 && · {attention} need attention}} - sx={{flex: "0 1 auto", maxHeight: "45%", overflowY: "auto", "& > *": {flexShrink: 0}}}> + + *": {flexShrink: 0}}} + label={<>Triage{attention > 0 && · {attention} need attention}}> {s.triage && s.triage.length > 0 ? : } - - By resolution{s.shells_merge && ` · merge of ${s.shells_merge}`}} sx={{flex: "1 1 0"}}> + + By resolution{s.shells_merge && ` · merge of ${s.shells_merge}`}}> {s.shells && s.shells.length > 0 ? @@ -304,7 +307,7 @@ function Dashboard({s, deadline_s, modelLoaded}: { s: rugnux_live_status, deadli : } - + @@ -368,7 +371,8 @@ function RugnuxPage({measuring}: { measuring: boolean }) { return ( - {empty || !s ? diff --git a/frontend/src/components/RugnuxShellPlots.tsx b/frontend/src/components/RugnuxShellPlots.tsx index 8b93f6e0e..75193134e 100644 --- a/frontend/src/components/RugnuxShellPlots.tsx +++ b/frontend/src/components/RugnuxShellPlots.tsx @@ -30,9 +30,11 @@ function RugnuxShellPlots({shells, merge, height = 300}: { shells: rugnux_live_s const theme = useTheme(); const dark = theme.palette.mode === "dark"; const grid = dark ? "rgba(255,255,255,.12)" : "#eee"; + // type that grows with the panel, so a 4k screen does not get 11 px labels + const font = Math.round(Math.min(17, Math.max(11, height / 24))); const x = shells.map(sh => 1 / (sh.d_min * sh.d_min)); - // At most six d labels, the finest shell always among them - const step = Math.ceil(shells.length / 6); + // At most six d labels (four on a small panel), the finest shell always among them + const step = Math.ceil(shells.length / (height < 320 ? 4 : 6)); const ticks = shells.filter((_, i) => (shells.length - 1 - i) % step === 0); return ( @@ -59,16 +61,16 @@ function RugnuxShellPlots({shells, merge, height = 300}: { shells: rugnux_live_s style={{width: "100%", height}} data={data} layout={{ - title: {text: panel.title, font: {size: 14}}, + title: {text: panel.title, font: {size: font + 3}}, height, margin: {t: 32, b: 40, l: 50, r: 12}, paper_bgcolor: "rgba(0,0,0,0)", plot_bgcolor: "rgba(0,0,0,0)", - font: {color: theme.palette.text.secondary, size: 11}, + font: {color: theme.palette.text.secondary, size: font}, showlegend: data.length > 1, legend: panel.legendLeft ? {x: 0, xanchor: "left", y: 1} : {x: 1, xanchor: "right", y: 1}, xaxis: { - title: {text: "d [Å]", standoff: 4}, + title: {text: "d [Å]", standoff: 4}, tickangle: 0, gridcolor: grid, zerolinecolor: grid, tickvals: ticks.map(sh => 1 / (sh.d_min * sh.d_min)), ticktext: ticks.map(sh => sh.d_min.toFixed(2)), diff --git a/frontend/src/components/RugnuxStructureViewer.tsx b/frontend/src/components/RugnuxStructureViewer.tsx index 74c4f41d2..d85ecaa12 100644 --- a/frontend/src/components/RugnuxStructureViewer.tsx +++ b/frontend/src/components/RugnuxStructureViewer.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useEffect, useRef, useState} from "react"; -import {Alert, Box, Slider, Switch, Typography} from "@mui/material"; +import {Alert, Box, IconButton, Popover, Slider, Switch, Tooltip, Typography} from "@mui/material"; +import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; import type {PluginContext} from "molstar/lib/mol-plugin/context"; import {authHeaders, noteResponse} from "../auth"; import MolstarCanvas from "./structure/MolstarCanvas"; @@ -14,22 +15,20 @@ import {Blob, BLOB_COLORS, loadBlobs, setBlobsVisible} from "./structure/loadBlo // 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 // 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. Departures for the live page: every map is carved +// 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, 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. +// 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. type Kind = 'twofofc' | 'fofc' | 'anom'; const hex = (c: number) => '#' + c.toString(16).padStart(6, '0'); -const SectionTitle: React.FC<{ children: React.ReactNode }> = ({children}) => ( - {children} -); - -// one map row: colored dot · name · switch · σ slider + readout (aaredb StructureView.tsx:47-77) -const MapRow: React.FC<{ +// 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; @@ -40,21 +39,48 @@ const MapRow: React.FC<{ step: number; onSigma: (v: number) => void; disabled?: boolean; -}> = ({label, dotColor, on, onToggle, sigma, min, max, step, onSigma, disabled}) => ( - - - - {label} - - {sigma.toFixed(1)} σ - onToggle(e.target.checked)}/> +}> = ({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} - onSigma(v as number)} aria-label={`${label} contour level`} - sx={{mx: 0.5, width: 'calc(100% - 8px)', color: dotColor, '& .MuiSlider-thumb': {width: 12, height: 12}}}/> - + ); +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); @@ -74,11 +100,11 @@ const BLOB_LABELS: [string, string][] = [['LIGAND', 'ligand'], ['WATER', 'water' // 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; -// refined: the broker serves the refined model and its maps; 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, refined, blobs, fill}: +// 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, - refined: boolean, blobs: Blob[], fill?: boolean }) { + blobs: Blob[], fill?: boolean }) { const pluginRef = useRef(null); const [pluginReady, setPluginReady] = useState(0); const [error, setError] = useState(); @@ -219,92 +245,72 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, refined, }, []); 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 ( - - - - - - {(modelLoading || (mapsLoading && !revealed)) && !error && - - {modelLoading ? 'loading model…' : 'computing density maps…'} - } + + + - - {error && {error}} - {refined ? 'Model · refined' : 'Model'} - - {(['cartoon', 'sticks', 'both'] as const).map((m) => ( - setDisplay(m, showWaters)} - onKeyDown={(e) => { if (e.key === 'Enter') setDisplay(m, showWaters); }} - sx={{fontSize: '0.8125rem', px: 1, py: '3px', borderRadius: '8px', - border: '1px solid', cursor: 'pointer', - color: reprMode === m ? 'primary.main' : 'text.secondary', - borderColor: reprMode === m ? 'primary.main' : 'divider'}} - >{m === 'both' ? 'cartoon + sticks' : m} - ))} - - - Waters - setDisplay(reprMode, e.target.checked)}/> - + {(modelLoading || (mapsLoading && !revealed)) && !error && + + {modelLoading ? 'loading model…' : 'computing density maps…'} + } + {error && {error}} - Maps - {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}/>} - {mapsLoading && - computing density maps…} - {!modelLoading && !mapsLoading && !hasMap.twofofc && - No maps} - - {blobs.length > 0 && <> - Unexplained blobs - - {blobs.length} in mFo−DFc - toggleBlobs(e.target.checked)}/> - - - {BLOB_LABELS.filter(([k]) => blobs.some(b => b.kind === k)).map(([k, label]) => ( - - - - {blobs.filter(b => b.kind === k).length} {label} + {/* 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} ))} - } - - Controls - {([ - ['left drag', 'rotate'], - ['right drag', 'zoom'], - ['middle drag', 'pan'], - ['middle click', 'center on residue'], - ['scroll', 'clip'], - ] as const).map(([k, v]) => ( - - {k} - {v} - - ))} + + {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 0fea3f0e8..ca70fb9a3 100644 --- a/frontend/src/components/RugnuxTriage.tsx +++ b/frontend/src/components/RugnuxTriage.tsx @@ -4,9 +4,9 @@ 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), one line per condition: -// green - looked for and not found, orange - its warning fired, red - nothing usable came out. -// What this run could not look for is named in one grey line at the end. +// 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. const ORDER: Record = {serious: 0, attention: 1}; @@ -17,41 +17,55 @@ const ICON: Record = { }; function TriageLine({item}: { item: rugnux_triage_item }) { - const tip = ( - - {item.label}{item.text && `: ${item.text}`} - {item.detail && {item.detail}} - {item.codes.join(" ")} - - ); return ( - - + + {ICON[item.severity]} - {item.label} + {item.label} {item.text} ); } -// Problems one to a line, so their words have the width; the fine ones packed in columns. -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 fine = items.filter(i => i.severity === "ok"); - const notMeasured = items.filter(i => i.severity === "not_measured"); +function tip(item: rugnux_triage_item) { return ( + + {item.label}{item.text && `: ${item.text}`} + {item.detail && {item.detail}} + {item.codes.join(" ")} + + ); +} + +// 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"; + return ( + + + {measured && } + {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. +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 => )} - 0 ? 0.5 : 0}}> - {fine.map(item => )} + {problems.length === 0 && Nothing needs attention} + + {rest.map(item => )} - {notMeasured.length > 0 && - - Not measured: {notMeasured.map(i => i.label).join(", ")} - } ); }