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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HVeDQsU5a8AJUdJPgWevAK
This commit is contained in:
1 parent
2ce722d1ec
commit
9122bda2e6
5 files changed
+272
-244
No files matched your search
@@ -47,7 +47,7 @@ function Tile({value, caption, sub, tip, tone}:
|
||||
{caption}
|
||||
</Typography>
|
||||
<Typography key={String(value)} component="div"
|
||||
sx={{fontSize: "clamp(1.5rem, 1.9vw, 3.4rem)", lineHeight: 1.15, fontWeight: 800,
|
||||
sx={{fontSize: "clamp(1.5rem, 1.6vw, 3rem)", lineHeight: 1.15, fontWeight: 800,
|
||||
color: VALUE_COLOUR[tone], fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap",
|
||||
overflow: "hidden", textOverflow: "ellipsis", animation: `${fadeIn} 500ms ease-out`,
|
||||
"@media (prefers-reduced-motion: reduce)": {animation: "none"}}}>
|
||||
@@ -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 (
|
||||
<Box sx={{display: "grid", gap: 1, gridTemplateColumns: {xs: "repeat(2, minmax(0, 1fr))", md: "repeat(4, minmax(0, 1fr))",
|
||||
lg: "repeat(8, minmax(0, 1fr))"}}}>
|
||||
<Box sx={{display: "grid", gap: 1, minHeight: 0, ...(column
|
||||
? {gridTemplateColumns: "1fr", gridAutoRows: "minmax(0, 1fr)", flexGrow: 1, "& > *": {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))"}})}}>
|
||||
<Tile value={num(s.d_min, x => `${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."/>
|
||||
<Tile value={num(ccHalf, x => x.toFixed(3))} caption="CC½" sub={later ?? "all reflections"} tone={pending}
|
||||
|
||||
@@ -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 (
|
||||
<Box sx={{position: "relative", height: 30, borderRadius: 1.5, bgcolor: "rgba(255,255,255,.07)", overflow: "hidden"}}>
|
||||
<Box sx={{position: "absolute", left: 0, top: 0, bottom: 0, width: `${width}%`, bgcolor: colour,
|
||||
transition: "width 400ms ease-out", opacity: fraction === undefined && busy ? 0.5 : 1,
|
||||
...(busy && runningSx)}}/>
|
||||
<Box sx={{position: "absolute", inset: 0, display: "flex", alignItems: "center", px: 1.5, gap: 2,
|
||||
mixBlendMode: "screen"}}>
|
||||
<Typography sx={{fontWeight: 800, fontSize: "0.95rem", letterSpacing: ".04em", whiteSpace: "nowrap", color: "#fff"}}>
|
||||
{caption}
|
||||
</Typography>
|
||||
<Typography sx={{fontSize: "0.9rem", color: "#fff", whiteSpace: "nowrap", overflow: "hidden",
|
||||
textOverflow: "ellipsis", ml: "auto", fontVariantNumeric: "tabular-nums"}}>
|
||||
<Box sx={{minWidth: 0}}>
|
||||
<Box sx={{display: "flex", alignItems: "baseline", gap: 2, mb: 0.5}}>
|
||||
<Typography sx={{...LABEL_SX, color: colour}}>{caption}</Typography>
|
||||
<Typography noWrap sx={{fontSize: "0.85rem", color: "text.secondary", ml: "auto", fontVariantNumeric: "tabular-nums"}}>
|
||||
{detail}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{position: "relative", height: 8, borderRadius: 4, bgcolor: "rgba(255,255,255,.08)", overflow: "hidden"}}>
|
||||
<Box sx={{position: "absolute", left: 0, top: 0, bottom: 0, width: `${width}%`, bgcolor: colour,
|
||||
borderRadius: 4, transition: "width 400ms ease-out", opacity: fraction === undefined && busy ? 0.5 : 1,
|
||||
...(busy && runningSx)}}/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", md: "minmax(0, 1fr) auto"}, gap: 3, alignItems: "center"}}>
|
||||
<Stack spacing={1} sx={{minWidth: 0}}>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline" sx={{minWidth: 0}}>
|
||||
<Typography sx={{fontSize: "1.35rem", fontWeight: 900, letterSpacing: ".08em", textTransform: "uppercase",
|
||||
color: p.colour, whiteSpace: "nowrap", ...(running && {animation: `${pulse} 2.4s ease-in-out infinite`,
|
||||
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", md: "minmax(0, 1fr) auto"}, gap: 4, alignItems: "center"}}>
|
||||
<Box sx={{minWidth: 0}}>
|
||||
<Stack direction="row" spacing={2} alignItems="baseline" sx={{minWidth: 0, mb: 1.5}}>
|
||||
<Typography sx={{fontSize: "1.5rem", fontWeight: 900, letterSpacing: ".08em", textTransform: "uppercase",
|
||||
color: p.colour, whiteSpace: "nowrap", lineHeight: 1, ...(running && {animation: `${pulse} 2.4s ease-in-out infinite`,
|
||||
"@media (prefers-reduced-motion: reduce)": {animation: "none"}})}}>
|
||||
{p.label}
|
||||
</Typography>
|
||||
@@ -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"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Bar caption="Detector" detail={detectorDetail} colour={lime[700]} busy={collecting}
|
||||
fraction={s.images_expected > 0 ? s.images_received / s.images_expected : undefined}/>
|
||||
<Bar caption="Rugnux" detail={rugnuxDetail} busy={running} fraction={rugnuxFraction}
|
||||
colour={s.state === "Failed" ? red[700] : s.state === "Done" ? green[700] : indigo[500]}/>
|
||||
</Stack>
|
||||
<Box sx={{textAlign: "right", minWidth: 240}}>
|
||||
<Typography sx={{fontSize: "0.75rem", fontWeight: 700, letterSpacing: ".1em", textTransform: "uppercase",
|
||||
color: "text.secondary"}}>
|
||||
{clock.caption}
|
||||
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", md: "1fr 1fr"}, gap: 4}}>
|
||||
<Bar caption="Detector" detail={detectorDetail} colour={lime[600]} busy={collecting}
|
||||
fraction={s.images_expected > 0 ? s.images_received / s.images_expected : undefined}/>
|
||||
<Bar caption="Rugnux" detail={rugnuxDetail} busy={running} fraction={rugnuxFraction}
|
||||
colour={s.state === "Failed" ? red[400] : s.state === "Done" ? green[500] : indigo[300]}/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{textAlign: "right", minWidth: 220}}>
|
||||
<Typography sx={LABEL_SX}>{clock.caption}</Typography>
|
||||
<Typography sx={{fontSize: "3.6rem", lineHeight: 1, fontWeight: 900, color: clock.colour, fontVariantNumeric: "tabular-nums"}}>
|
||||
{clock.value.toFixed(1)}<Box component="span" sx={{fontSize: "1.4rem", ml: 0.5, color: "text.secondary"}}>s</Box>
|
||||
</Typography>
|
||||
<Typography sx={{fontSize: "4rem", lineHeight: 1, fontWeight: 900, color: clock.colour,
|
||||
fontVariantNumeric: "tabular-nums"}}>
|
||||
{clock.value.toFixed(1)}<Box component="span" sx={{fontSize: "1.6rem", ml: 0.5, color: "text.secondary"}}>s</Box>
|
||||
</Typography>
|
||||
<Typography sx={{fontSize: "0.85rem", color: deadline !== undefined && deadline < 10 ? orange[300] : "text.secondary", minHeight: "1.3em"}}>
|
||||
<Typography sx={{fontSize: "0.8rem", color: deadline !== undefined && deadline < 10 ? orange[300] : "text.secondary", minHeight: "1.3em"}}>
|
||||
{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 (
|
||||
<Paper elevation={0} sx={{p: 2, display: "flex", flexDirection: "column", minHeight: 0, minWidth: 0, ...sx}}>
|
||||
<Typography sx={{fontSize: "0.72rem", fontWeight: 700, letterSpacing: ".1em", textTransform: "uppercase",
|
||||
color: "text.secondary", mb: 1}}>
|
||||
{title}
|
||||
</Typography>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function Later({text}: { text: string }) {
|
||||
return <Typography sx={{color: "text.disabled", m: "auto", textAlign: "center"}}>{text}</Typography>;
|
||||
return <Typography sx={{color: "text.disabled", m: "auto", textAlign: "center", px: 2}}>{text}</Typography>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Box key={i} sx={{display: "grid", gridTemplateColumns: "28px 1fr auto", alignItems: "center", gap: 1.5,
|
||||
py: 0.75, borderBottom: "1px solid", borderColor: "divider", opacity: open && !running ? 0.6 : 1}}>
|
||||
{open && running
|
||||
? <Box sx={{width: 14, height: 14, borderRadius: "50%", bgcolor: indigo[300], mx: "auto",
|
||||
animation: `${pulse} 1.2s ease-in-out infinite`, "@media (prefers-reduced-motion: reduce)": {animation: "none"}}}/>
|
||||
: <CheckIcon sx={{fontSize: 18, color: open ? "text.disabled" : green[400], mx: "auto"}}/>}
|
||||
<Box sx={{minWidth: 0}}>
|
||||
<Typography sx={{fontWeight: open && running ? 800 : 600}}>{step.title}</Typography>
|
||||
{step.notes.length > 0 &&
|
||||
<Typography noWrap sx={{fontSize: "0.82rem", color: "text.secondary"}}>{step.notes[step.notes.length - 1]}</Typography>}
|
||||
</Box>
|
||||
<Typography sx={{fontVariantNumeric: "tabular-nums", color: "text.secondary", whiteSpace: "nowrap"}}>
|
||||
{open ? (running && s.step_progress !== undefined ? `${(100 * s.step_progress).toFixed(0)} %` : "") : `${step.duration_s!.toFixed(1)} s`}
|
||||
</Typography>
|
||||
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) => (
|
||||
<Box key={i} sx={{display: "grid", gridTemplateColumns: "24px 1fr auto", alignItems: "center", gap: 1.5, py: 0.6,
|
||||
borderBottom: "1px solid", borderColor: "divider"}}>
|
||||
<CheckIcon sx={{fontSize: 18, color: step.duration_s === undefined ? "text.disabled" : green[400]}}/>
|
||||
<Box sx={{minWidth: 0}}>
|
||||
<Typography sx={{fontWeight: 600}}>{step.title}</Typography>
|
||||
{step.notes.length > 0 &&
|
||||
<Typography noWrap sx={{fontSize: "0.82rem", color: "text.secondary"}}>{step.notes[step.notes.length - 1]}</Typography>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
<Typography sx={{fontVariantNumeric: "tabular-nums", color: "text.secondary", whiteSpace: "nowrap"}}>
|
||||
{step.duration_s !== undefined ? `${step.duration_s.toFixed(1)} s` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box sx={{overflowY: "auto", minHeight: 0}}>
|
||||
<Box sx={{overflowY: "auto", minHeight: 0, display: "flex", flexDirection: "column", gap: 2}}>
|
||||
{current && <Box sx={{py: 2}}>
|
||||
<Typography sx={{...LABEL_SX, color: indigo[300]}}>now</Typography>
|
||||
<Typography sx={{fontSize: "2rem", fontWeight: 800, lineHeight: 1.1, animation: `${pulse} 2.4s ease-in-out infinite`,
|
||||
"@media (prefers-reduced-motion: reduce)": {animation: "none"}}}>
|
||||
{current.title}{s.step_progress !== undefined && <Box component="span" sx={{color: indigo[300], ml: 2}}>{(100 * s.step_progress).toFixed(0)} %</Box>}
|
||||
</Typography>
|
||||
{current.notes.length > 0 && <Typography sx={{color: "text.secondary"}}>{current.notes[current.notes.length - 1]}</Typography>}
|
||||
</Box>}
|
||||
{s.steps.length === 0 && <Later text={running ? "Rugnux starts on the first images" : "No steps ran"}/>}
|
||||
{s.steps.map(row)}
|
||||
{s.probe && <Typography sx={{mt: 1.5, fontSize: "0.9rem", color: s.probe.startsWith("no lattice") ? orange[300] : "text.secondary"}}>
|
||||
<Box>{done.map(row)}</Box>
|
||||
{s.probe && <Typography sx={{fontSize: "0.9rem", color: s.probe.startsWith("no lattice") ? orange[300] : "text.secondary"}}>
|
||||
Lattice probe{s.probe_s !== undefined && ` at ${s.probe_s.toFixed(1)} s`}: {s.probe}
|
||||
</Typography>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box sx={{display: "flex", flexDirection: "column", minHeight: 0, minWidth: 0, ...sx}}>
|
||||
<Typography sx={{...LABEL_SX, mb: 1}}>{label}</Typography>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Panel title={<>{m.placement === "MR" ? "Molecular replacement" : "Model"}{m.predicted && " · predicted model"}
|
||||
{s.refined_model && " · refined"} · {m.model_name}</>} sx={{flexGrow: 1}}>
|
||||
<Division sx={{flexGrow: 1}} label={<>{m.placement === "MR" ? "Molecular replacement" : "Model"}{m.predicted && " · predicted model"}
|
||||
{s.refined_model && " · refined"} · {m.model_name}</>}>
|
||||
<Box sx={{flexGrow: 1, minHeight: 0}}>
|
||||
<Suspense fallback={<Later text="Loading viewer..."/>}>
|
||||
<RugnuxStructureViewer resultKey={resultKey} anomalous={s.anomalous_map} fill
|
||||
anomalousSignal={s.anomalous_signal} refined={s.refined_model}
|
||||
anomalousSignal={s.anomalous_signal}
|
||||
blobs={s.refined_model ? m.refinement?.blobs ?? [] : []}/>
|
||||
</Suspense>
|
||||
</Box>
|
||||
</Panel>
|
||||
</Division>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Panel title="Run" sx={{flexGrow: 1}}>
|
||||
<StepSequence s={s}/>
|
||||
</Panel>
|
||||
);
|
||||
return <Division sx={{flexGrow: 1}} label="Run"><RunConsole s={s}/></Division>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Box sx={{display: "grid", gridTemplateRows: "auto auto minmax(0, 1fr)", gap: 2, height: "100%", minHeight: 0}}>
|
||||
<Box sx={{display: "grid", gridTemplateRows: "auto minmax(0, 1fr)", gap: 2.5, height: "100%", minHeight: 0}}>
|
||||
<Header s={s} deadline_s={deadline_s}/>
|
||||
<RugnuxHero s={s} modelLoaded={modelLoaded}/>
|
||||
<Box sx={{display: "grid", gap: 2, minHeight: 0,
|
||||
gridTemplateColumns: {xs: "1fr", lg: "minmax(0, 11fr) minmax(0, 9fr)"},
|
||||
<Box sx={{display: "grid", gap: 2.5, minHeight: 0,
|
||||
gridTemplateColumns: {xs: "1fr", lg: "220px minmax(0, 1fr) minmax(380px, 30%)"},
|
||||
gridTemplateRows: {xs: "auto", lg: "minmax(0, 1fr)"}}}>
|
||||
<Box sx={{minHeight: {xs: 480, lg: 0}, display: "flex", flexDirection: "column"}}>
|
||||
<Stage s={s}/>
|
||||
<Division label="Merge">
|
||||
<RugnuxHero s={s} modelLoaded={modelLoaded} column/>
|
||||
</Division>
|
||||
<Box sx={{minHeight: {xs: 520, lg: 0}, display: "flex", flexDirection: "column", ...hairline}}>
|
||||
<Centre s={s}/>
|
||||
</Box>
|
||||
<Box sx={{display: "flex", flexDirection: "column", gap: 2, minHeight: 0}}>
|
||||
<Panel title={<>Triage{attention > 0 && <Box component="span" sx={{color: orange[300]}}> · {attention} need attention</Box>}</>}
|
||||
sx={{flex: "0 1 auto", maxHeight: "45%", overflowY: "auto", "& > *": {flexShrink: 0}}}>
|
||||
<Box sx={{display: "flex", flexDirection: "column", gap: 2.5, minHeight: 0, ...hairline}}>
|
||||
<Division sx={{flex: "0 1 auto", maxHeight: "40%", overflowY: "auto", "& > *": {flexShrink: 0}}}
|
||||
label={<>Triage{attention > 0 && <Box component="span" sx={{color: orange[300]}}> · {attention} need attention</Box>}</>}>
|
||||
{s.triage && s.triage.length > 0 ? <RugnuxTriage items={s.triage}/> : <Later text={`Checked ${later}`}/>}
|
||||
</Panel>
|
||||
<Panel title={<>By resolution{s.shells_merge && ` · merge of ${s.shells_merge}`}</>} sx={{flex: "1 1 0"}}>
|
||||
</Division>
|
||||
<Division sx={{flex: "1 1 0"}} label={<>By resolution{s.shells_merge && ` · merge of ${s.shells_merge}`}</>}>
|
||||
<Box ref={plotsRef} sx={{flexGrow: 1, minHeight: {xs: 400, lg: 0}, display: "flex"}}>
|
||||
{s.shells && s.shells.length > 0
|
||||
? <Box sx={{width: "100%"}}>
|
||||
@@ -304,7 +307,7 @@ function Dashboard({s, deadline_s, modelLoaded}: { s: rugnux_live_status, deadli
|
||||
</Box>
|
||||
: <Later text={`Merging statistics ${later}`}/>}
|
||||
</Box>
|
||||
</Panel>
|
||||
</Division>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -368,7 +371,8 @@ function RugnuxPage({measuring}: { measuring: boolean }) {
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<ThemeProvider theme={liveTheme}>
|
||||
<Box sx={{bgcolor: "background.default", color: "text.primary", borderRadius: 2, p: 2,
|
||||
<Box sx={{bgcolor: "background.default", color: "text.primary", borderRadius: 2, p: 2.5, pt: 2,
|
||||
borderTop: "4px solid", borderColor: s && !empty ? phase(s).colour : "divider",
|
||||
height: {lg: "calc(100vh - 196px)"}, minHeight: {lg: 720}, overflow: "hidden"}}>
|
||||
{empty || !s
|
||||
? <Box sx={{height: "100%", display: "flex", "& .MuiSvgIcon-root, & p": {color: "text.secondary"}}}>
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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}) => (
|
||||
<Typography sx={{fontSize: '0.6786rem', letterSpacing: '.05em', textTransform: 'uppercase',
|
||||
color: 'text.secondary', mt: 2, mb: 1}}>{children}</Typography>
|
||||
);
|
||||
|
||||
// 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}) => (
|
||||
<Box sx={{mb: 1}}>
|
||||
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
|
||||
<Box sx={{width: 10, height: 10, borderRadius: '3px', bgcolor: dotColor, flex: 'none'}}/>
|
||||
<Typography sx={{fontSize: '0.8125rem', flex: 1}}>{label}</Typography>
|
||||
<Typography sx={{fontSize: '0.8125rem', color: 'text.secondary', width: 44, textAlign: 'right'}}>
|
||||
{sigma.toFixed(1)} σ</Typography>
|
||||
<Switch size="small" checked={on} disabled={disabled} onChange={(e) => onToggle(e.target.checked)}/>
|
||||
}> = ({label, dotColor, on, onToggle, sigma, min, max, step, onSigma, disabled}) => {
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
return <>
|
||||
<ToolButton onClick={(e) => setAnchor(e.currentTarget)} disabled={disabled} active={on}>
|
||||
<Box sx={{width: 9, height: 9, borderRadius: '2px', bgcolor: dotColor, flex: 'none'}}/>
|
||||
{label}
|
||||
<Box component="span" sx={{color: 'text.secondary', ml: 0.25}}>{sigma.toFixed(1)} σ</Box>
|
||||
</ToolButton>
|
||||
<Popover open={!!anchor} anchorEl={anchor} onClose={() => setAnchor(null)}
|
||||
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} transformOrigin={{vertical: 'top', horizontal: 'right'}}>
|
||||
<Box sx={{p: 2, width: 260}}>
|
||||
<Box sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
|
||||
<Typography sx={{fontSize: '0.875rem', fontWeight: 600}}>{label}</Typography>
|
||||
<Switch size="small" checked={on} onChange={(e) => onToggle(e.target.checked)}/>
|
||||
</Box>
|
||||
<Slider size="small" value={sigma} min={min} max={max} step={step} disabled={!on}
|
||||
onChange={(_, v) => onSigma(v as number)} aria-label={`${label} contour level`}
|
||||
valueLabelDisplay="auto" valueLabelFormat={(v) => `${v.toFixed(1)} σ`}
|
||||
sx={{color: dotColor, mt: 1}}/>
|
||||
</Box>
|
||||
</Popover>
|
||||
</>;
|
||||
};
|
||||
|
||||
// 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<HTMLElement>) => void }> = ({children, active, disabled, title, onClick}) => (
|
||||
<Tooltip title={title ?? ''}>
|
||||
<Box component="button" type="button" disabled={disabled} onClick={onClick}
|
||||
sx={{display: 'inline-flex', alignItems: 'center', gap: 0.75, px: 1, height: 28, borderRadius: '14px',
|
||||
border: '1px solid', borderColor: active ? 'primary.main' : 'divider', cursor: disabled ? 'default' : 'pointer',
|
||||
bgcolor: 'rgba(8,12,26,.8)', color: active ? 'text.primary' : 'text.secondary', fontSize: '0.78rem',
|
||||
fontFamily: 'inherit', opacity: disabled ? 0.5 : 1, whiteSpace: 'nowrap',
|
||||
'&:hover': {bgcolor: disabled ? 'rgba(8,12,26,.8)' : 'rgba(40,48,80,.9)'}}}>
|
||||
{children}
|
||||
</Box>
|
||||
<Slider size="small" value={sigma} min={min} max={max} step={step} disabled={disabled || !on}
|
||||
onChange={(_, v) => 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}}}/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
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<ArrayBuffer | undefined> {
|
||||
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<PluginContext | null>(null);
|
||||
const [pluginReady, setPluginReady] = useState(0);
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -219,92 +245,72 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, refined,
|
||||
}, []);
|
||||
|
||||
const mapsDisabled = mapsLoading;
|
||||
const [help, setHelp] = useState<HTMLElement | null>(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 (
|
||||
<Box sx={{display: 'grid', gridTemplateColumns: {xs: '1fr', md: '1fr 300px'}, border: '1px solid',
|
||||
borderColor: 'divider', borderRadius: 1, overflow: 'hidden', height: fill ? '100%' : undefined}}>
|
||||
<Box sx={{position: 'relative', bgcolor: '#000',
|
||||
height: fill ? '100%' : {xs: '55vh', md: 'clamp(520px, 64vh, 820px)'}, minHeight: fill ? 0 : undefined}}>
|
||||
<Box sx={{position: 'absolute', inset: 0, opacity: revealed ? 1 : 0,
|
||||
transition: 'opacity 900ms ease-out', '@media (prefers-reduced-motion: reduce)': {transition: 'none'}}}>
|
||||
<MolstarCanvas onReady={onPluginReady}/>
|
||||
</Box>
|
||||
{(modelLoading || (mapsLoading && !revealed)) && !error && <Box sx={{position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', pointerEvents: 'none'}}>
|
||||
<Typography sx={{fontSize: '0.875rem', color: '#8aa0b5'}}>
|
||||
{modelLoading ? 'loading model…' : 'computing density maps…'}</Typography>
|
||||
</Box>}
|
||||
<Box sx={{position: 'relative', bgcolor: '#000', borderRadius: 1, overflow: 'hidden',
|
||||
height: fill ? '100%' : {xs: '55vh', md: 'clamp(520px, 64vh, 820px)'}, minHeight: fill ? 0 : undefined}}>
|
||||
<Box sx={{position: 'absolute', inset: 0, opacity: revealed ? 1 : 0,
|
||||
transition: 'opacity 900ms ease-out', '@media (prefers-reduced-motion: reduce)': {transition: 'none'}}}>
|
||||
<MolstarCanvas onReady={onPluginReady}/>
|
||||
</Box>
|
||||
<Box sx={{borderLeft: {md: '1px solid'}, borderColor: {md: 'divider'}, p: 1.75, overflowY: 'auto', minHeight: 0}}>
|
||||
{error && <Alert severity="warning" sx={{mb: 1}}>{error}</Alert>}
|
||||
<SectionTitle>{refined ? 'Model · refined' : 'Model'}</SectionTitle>
|
||||
<Box sx={{display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center', mb: 0.75}}>
|
||||
{(['cartoon', 'sticks', 'both'] as const).map((m) => (
|
||||
<Typography key={m} role="button" tabIndex={0}
|
||||
onClick={() => 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}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
|
||||
<Typography sx={{fontSize: '0.8125rem'}}>Waters</Typography>
|
||||
<Switch size="small" checked={showWaters} onChange={(e) => setDisplay(reprMode, e.target.checked)}/>
|
||||
</Box>
|
||||
{(modelLoading || (mapsLoading && !revealed)) && !error && <Box sx={{position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', pointerEvents: 'none'}}>
|
||||
<Typography sx={{fontSize: '0.875rem', color: '#8aa0b5'}}>
|
||||
{modelLoading ? 'loading model…' : 'computing density maps…'}</Typography>
|
||||
</Box>}
|
||||
{error && <Alert severity="warning" sx={{position: 'absolute', top: 8, left: 8}}>{error}</Alert>}
|
||||
|
||||
<SectionTitle>Maps</SectionTitle>
|
||||
{hasMap.twofofc && <MapRow label="2Fo−Fc" dotColor={hex(MAP_COLORS.twofofc)} on={show.twofofc}
|
||||
onToggle={(v) => toggleLayer('twofofc', v)} sigma={sigma.twofofc}
|
||||
min={0.5} max={4} step={0.1} onSigma={(v) => applySigma('twofofc', v)}
|
||||
disabled={mapsDisabled}/>}
|
||||
{hasMap.fofc && <MapRow label="Fo−Fc (±)" dotColor={hex(MAP_COLORS.fofcPos)} on={show.fofc}
|
||||
onToggle={(v) => toggleLayer('fofc', v)} sigma={sigma.fofc}
|
||||
min={1} max={8} step={0.5} onSigma={(v) => applySigma('fofc', v)}
|
||||
disabled={mapsDisabled}/>}
|
||||
{hasMap.anom && <MapRow label="Anomalous (+)" dotColor={hex(MAP_COLORS.anom)} on={show.anom}
|
||||
onToggle={(v) => toggleLayer('anom', v)} sigma={sigma.anom}
|
||||
min={1} max={8} step={0.5} onSigma={(v) => applySigma('anom', v)}
|
||||
disabled={mapsDisabled}/>}
|
||||
{mapsLoading && <Typography sx={{fontSize: '0.8125rem', color: 'text.secondary'}}>
|
||||
computing density maps…</Typography>}
|
||||
{!modelLoading && !mapsLoading && !hasMap.twofofc &&
|
||||
<Typography sx={{fontSize: '0.8125rem', color: 'text.secondary'}}>No maps</Typography>}
|
||||
|
||||
{blobs.length > 0 && <>
|
||||
<SectionTitle>Unexplained blobs</SectionTitle>
|
||||
<Box sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
|
||||
<Typography sx={{fontSize: '0.8125rem'}}>{blobs.length} in mFo−DFc</Typography>
|
||||
<Switch size="small" checked={showBlobs} disabled={modelLoading}
|
||||
onChange={(e) => toggleBlobs(e.target.checked)}/>
|
||||
</Box>
|
||||
<Box sx={{display: 'flex', flexWrap: 'wrap', columnGap: 1.5, rowGap: 0.5, mt: 0.5}}>
|
||||
{BLOB_LABELS.filter(([k]) => blobs.some(b => b.kind === k)).map(([k, label]) => (
|
||||
<Box key={k} sx={{display: 'flex', alignItems: 'center', gap: 0.5}}>
|
||||
<Box sx={{width: 9, height: 9, borderRadius: '50%', bgcolor: hex(BLOB_COLORS[k]),
|
||||
border: '1px solid', borderColor: 'divider'}}/>
|
||||
<Typography sx={{fontSize: '0.75rem', color: 'text.secondary'}}>
|
||||
{blobs.filter(b => b.kind === k).length} {label}</Typography>
|
||||
{/* the toolbar, over the top edge of the canvas */}
|
||||
<Box sx={{position: 'absolute', top: 8, right: 8, left: 8, display: 'flex', flexWrap: 'wrap', gap: 0.75,
|
||||
justifyContent: 'flex-end', alignItems: 'center'}}>
|
||||
{(['cartoon', 'sticks', 'both'] as const).map((m) => (
|
||||
<ToolButton key={m} active={reprMode === m} onClick={() => setDisplay(m, showWaters)}>
|
||||
{m === 'both' ? 'cartoon + sticks' : m}
|
||||
</ToolButton>
|
||||
))}
|
||||
<ToolButton active={showWaters} onClick={() => setDisplay(reprMode, !showWaters)}>waters</ToolButton>
|
||||
<Box sx={{width: 8}}/>
|
||||
{hasMap.twofofc && <MapChip label="2Fo−Fc" dotColor={hex(MAP_COLORS.twofofc)} on={show.twofofc}
|
||||
onToggle={(v) => toggleLayer('twofofc', v)} sigma={sigma.twofofc}
|
||||
min={0.5} max={4} step={0.1} onSigma={(v) => applySigma('twofofc', v)}
|
||||
disabled={mapsDisabled}/>}
|
||||
{hasMap.fofc && <MapChip label="Fo−Fc" dotColor={hex(MAP_COLORS.fofcPos)} on={show.fofc}
|
||||
onToggle={(v) => toggleLayer('fofc', v)} sigma={sigma.fofc}
|
||||
min={1} max={8} step={0.5} onSigma={(v) => applySigma('fofc', v)}
|
||||
disabled={mapsDisabled}/>}
|
||||
{hasMap.anom && <MapChip label="anomalous" dotColor={hex(MAP_COLORS.anom)} on={show.anom}
|
||||
onToggle={(v) => toggleLayer('anom', v)} sigma={sigma.anom}
|
||||
min={1} max={8} step={0.5} onSigma={(v) => applySigma('anom', v)}
|
||||
disabled={mapsDisabled}/>}
|
||||
{blobs.length > 0 &&
|
||||
<ToolButton active={showBlobs} disabled={modelLoading} onClick={() => toggleBlobs(!showBlobs)}
|
||||
title={`Unexplained mFo−DFc blobs: ${blobKinds}`}>
|
||||
{BLOB_LABELS.filter(([k]) => blobs.some(b => b.kind === k)).map(([k]) =>
|
||||
<Box key={k} sx={{width: 8, height: 8, borderRadius: '50%', bgcolor: hex(BLOB_COLORS[k])}}/>)}
|
||||
{blobs.length} blobs
|
||||
</ToolButton>}
|
||||
<IconButton size="small" onClick={(e) => setHelp(e.currentTarget)} aria-label="mouse controls"
|
||||
sx={{color: 'text.secondary', bgcolor: 'rgba(8,12,26,.8)'}}>
|
||||
<HelpOutlineIcon fontSize="small"/>
|
||||
</IconButton>
|
||||
<Popover open={!!help} anchorEl={help} onClose={() => setHelp(null)}
|
||||
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} transformOrigin={{vertical: 'top', horizontal: 'right'}}>
|
||||
<Box sx={{p: 1.5, width: 220}}>
|
||||
{MOUSE.map(([k, v]) => (
|
||||
<Box key={k} sx={{display: 'flex', justifyContent: 'space-between', py: '2px'}}>
|
||||
<Typography component="span" sx={{fontSize: '0.8rem', color: 'text.secondary'}}>{k}</Typography>
|
||||
<Typography component="span" sx={{fontSize: '0.8rem'}}>{v}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>}
|
||||
|
||||
<SectionTitle>Controls</SectionTitle>
|
||||
{([
|
||||
['left drag', 'rotate'],
|
||||
['right drag', 'zoom'],
|
||||
['middle drag', 'pan'],
|
||||
['middle click', 'center on residue'],
|
||||
['scroll', 'clip'],
|
||||
] as const).map(([k, v]) => (
|
||||
<Box key={k} sx={{display: 'flex', justifyContent: 'space-between', py: '1px'}}>
|
||||
<Typography component="span" sx={{fontSize: '0.75rem', color: 'text.secondary'}}>{k}</Typography>
|
||||
<Typography component="span" sx={{fontSize: '0.75rem'}}>{v}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Popover>
|
||||
</Box>
|
||||
{mapsLoading && <Typography sx={{position: 'absolute', bottom: 8, right: 12, fontSize: '0.8rem', color: '#8aa0b5'}}>
|
||||
computing density maps…</Typography>}
|
||||
{!modelLoading && !mapsLoading && !hasMap.twofofc && !error &&
|
||||
<Typography sx={{position: 'absolute', bottom: 8, right: 12, fontSize: '0.8rem', color: '#8aa0b5'}}>No maps</Typography>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, number> = {serious: 0, attention: 1};
|
||||
|
||||
@@ -17,41 +17,55 @@ const ICON: Record<string, React.ReactNode> = {
|
||||
};
|
||||
|
||||
function TriageLine({item}: { item: rugnux_triage_item }) {
|
||||
const tip = (
|
||||
<Box sx={{whiteSpace: "pre-line"}}>
|
||||
<b>{item.label}</b>{item.text && `: ${item.text}`}
|
||||
{item.detail && <Box sx={{mt: 1}}>{item.detail}</Box>}
|
||||
<Box sx={{mt: 1, opacity: 0.7}}>{item.codes.join(" ")}</Box>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Tooltip title={tip} arrow placement="bottom-start">
|
||||
<Box sx={{display: "flex", alignItems: "center", gap: 0.75, minWidth: 0}}>
|
||||
<Tooltip title={tip(item)} arrow placement="bottom-start">
|
||||
<Box sx={{display: "flex", alignItems: "center", gap: 0.75, minWidth: 0, py: 0.25}}>
|
||||
{ICON[item.severity]}
|
||||
<Typography variant="body2" sx={{fontWeight: 600, whiteSpace: "nowrap"}}>{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{fontWeight: 700, whiteSpace: "nowrap"}}>{item.label}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" noWrap sx={{minWidth: 0}}>{item.text}</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Box sx={{whiteSpace: "pre-line"}}>
|
||||
<b>{item.label}</b>{item.text && `: ${item.text}`}
|
||||
{item.detail && <Box sx={{mt: 1}}>{item.detail}</Box>}
|
||||
<Box sx={{mt: 1, opacity: 0.7}}>{item.codes.join(" ")}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Tooltip title={measured ? tip(item) : `${item.label}: not measured on this run`} arrow placement="bottom-start">
|
||||
<Box sx={{display: "inline-flex", alignItems: "center", gap: 0.5, px: 1, height: 24, borderRadius: "12px",
|
||||
border: "1px solid", borderColor: measured ? "success.dark" : "divider", fontSize: "0.78rem",
|
||||
color: measured ? "text.primary" : "text.disabled", whiteSpace: "nowrap"}}>
|
||||
{measured && <CheckCircleIcon color="success" sx={{fontSize: 14}}/>}
|
||||
{item.label}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Box>
|
||||
{problems.map(item => <TriageLine key={item.label} item={item}/>)}
|
||||
<Box sx={{display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", columnGap: 3,
|
||||
rowGap: 0.25, mt: problems.length > 0 ? 0.5 : 0}}>
|
||||
{fine.map(item => <TriageLine key={item.label} item={item}/>)}
|
||||
{problems.length === 0 && <Typography variant="body2" color="success.light" sx={{py: 0.25}}>Nothing needs attention</Typography>}
|
||||
<Box sx={{display: "flex", flexWrap: "wrap", gap: 0.75, mt: 1}}>
|
||||
{rest.map(item => <Badge key={item.label} item={item}/>)}
|
||||
</Box>
|
||||
{notMeasured.length > 0 &&
|
||||
<Typography variant="caption" color="text.disabled" sx={{display: "block", mt: 0.5}}>
|
||||
Not measured: {notMeasured.map(i => i.label).join(", ")}
|
||||
</Typography>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user