frontend: Rugnux live stage - numbers and short labels only, a glance at the density
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HVeDQsU5a8AJUdJPgWevAK
This commit is contained in:
1 parent
9122bda2e6
commit
39dcc8bd6f
4 files changed
+147
-318
No files matched your search
@@ -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<Tone, string> = {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 (
|
||||
<Tooltip title={tip} arrow placement="bottom">
|
||||
<Box sx={{px: 1.5, py: 1, minWidth: 0, borderLeft: "3px solid",
|
||||
<Tooltip title={tip} arrow placement="right">
|
||||
<Box sx={{px: 1.5, py: 0.5, minWidth: 0, borderLeft: "3px solid",
|
||||
borderColor: tone === "good" ? green[400] : tone === "bad" ? red[400] : "divider"}}>
|
||||
<Typography sx={{fontSize: "0.7rem", fontWeight: 700, letterSpacing: ".1em", textTransform: "uppercase",
|
||||
color: "text.secondary", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}}>
|
||||
color: "text.secondary", whiteSpace: "nowrap"}}>
|
||||
{caption}
|
||||
</Typography>
|
||||
<Typography key={String(value)} component="div"
|
||||
@@ -53,66 +53,70 @@ function Tile({value, caption, sub, tip, tone}:
|
||||
"@media (prefers-reduced-motion: reduce)": {animation: "none"}}}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography sx={{fontSize: "0.78rem", color: "text.secondary", minHeight: "1.3em", whiteSpace: "nowrap",
|
||||
overflow: "hidden", textOverflow: "ellipsis"}}>
|
||||
{sub ?? ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 <Tile value={rf.r_free.toFixed(3)} caption="R-free · refined"
|
||||
sub={`R-work ${rf.r_work.toFixed(3)} · ${how}${fit ? ` · fit ${fit}` : ""}`} tone="good"
|
||||
tip={<>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."}
|
||||
<br/><br/>{FIT_TIP}</>}/>;
|
||||
return <Tile value={rf.r_free.toFixed(3)} caption="R-free" tone="good"
|
||||
tip={<>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."}
|
||||
<br/>{how}, {fit}.<br/><br/>{FIT_TIP}</>}/>;
|
||||
if (m.r_work != null && m.r_free != null)
|
||||
return <Tile value={m.r_free.toFixed(3)} caption="R-free · as placed"
|
||||
sub={`R-work ${m.r_work.toFixed(3)} · ${how}${fit ? ` · fit ${fit}` : ""}`} tone="good" tip={FIT_TIP}/>;
|
||||
return <Tile value={fit ?? "Placed"} caption="Model" sub={how} tone="good" tip={FIT_TIP}/>;
|
||||
return <Tile value={m.r_free.toFixed(3)} caption="R-free" tone="good"
|
||||
tip={<>R-free {m.r_free.toFixed(3)} / R-work {m.r_work.toFixed(3)} of the model as placed, unrefined.
|
||||
<br/>{how}, {fit}.<br/><br/>{FIT_TIP}</>}/>;
|
||||
return <Tile value="placed" caption="Model" tone="good" tip={<>{how}, {fit}.<br/><br/>{FIT_TIP}</>}/>;
|
||||
}
|
||||
if (m) {
|
||||
// "NOT_PLACED: no group of ..." -> "no group of ..."
|
||||
const why = m.reason || m.mr_decision.replace(/^[A-Z_]+: /, "");
|
||||
return <Tile value={fit ?? "Not placed"} caption="Model" sub={why} tone="bad"
|
||||
tip={<>{FIT_TIP}{why && <><br/><br/>{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 <Tile value="—" caption="Model" sub={sub} tone="pending" tip={FIT_TIP}/>;
|
||||
return <Tile value="not placed" caption="Model" tone="bad" tip={<>{why}<br/><br/>{FIT_TIP}</>}/>;
|
||||
}
|
||||
if (s.state === "Running" && modelLoaded)
|
||||
return <Tile value="—" caption="Model" tone="pending" tip="Placed after the merge, by molecular replacement or where it is."/>;
|
||||
if (s.state === "Running" || !modelLoaded)
|
||||
return <Tile value="none" caption="Model" sub="upload one in the settings" tone="plain"
|
||||
tip="No model was uploaded for this run, so none was placed. Molecular replacement runs on the next collection after a model is uploaded."/>;
|
||||
return <Tile value="—" caption="Model" sub="not used in this run" tone="plain"
|
||||
tip="The run ended without placing the model - the steps and warnings say why."/>;
|
||||
return <Tile value="none" caption="Model" tone="plain"
|
||||
tip="No model was uploaded for this run, so none was placed. Molecular replacement runs on the next collection after a model is uploaded in the settings below."/>;
|
||||
return <Tile value="—" caption="Model" tone="plain" tip="The run ended without placing the model - the steps and warnings say why."/>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<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}
|
||||
tip="CC½ of the whole merge, the half-dataset correlation; the plot on the right has it by shell."/>
|
||||
<Tile value={num(iSigma, x => 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))"}})}}>
|
||||
<Tile value={num(o.d_min, x => `${x.toFixed(2)} Å`)} caption="Resolution" tone={pending}
|
||||
tip="High-resolution limit of the merge (d_min)."/>
|
||||
<Tile value={num(o.cc_half, x => x.toFixed(3))} caption="CC½" tone={pending}
|
||||
tip="CC½ of the whole merge; by shell in the plot."/>
|
||||
<Tile value={num(o.i_over_sigma, x => x.toFixed(1))} caption="I/σ" tone={pending}
|
||||
tip="Mean I/σ of the merged reflections over the whole written range."/>
|
||||
<Tile value={num(completeness, pct)} caption="Completeness" sub={later ?? "full written range"} tone={pending}
|
||||
tip="Completeness of the merge over the full written resolution range."/>
|
||||
<Tile value={num(o.completeness, pct)} caption="Complete" tone={pending}
|
||||
tip="Completeness of the merge over the full written range."/>
|
||||
{s.stills
|
||||
? <Tile value={num(rSplit, pct)} caption="R split" sub={later ?? "half-set agreement"} tone={pending}
|
||||
? <Tile value={num(o.r_split, pct)} caption="R split" tone={pending}
|
||||
tip="R_split between the two half-sets of the serial merge."/>
|
||||
: <Tile value={num(rMeas, pct)} caption="R meas" sub={later ?? "all reflections"} tone={pending}
|
||||
: <Tile value={num(o.r_meas, pct)} caption="R meas" tone={pending}
|
||||
tip="R_meas of the whole merge, the multiplicity-independent merging R."/>}
|
||||
<Tile value={num(o.multiplicity, x => x.toFixed(1))} caption="Multiplicity" tone={pending}
|
||||
tip="Mean multiplicity of the merged reflections."/>
|
||||
{s.stills
|
||||
? <Tile value={num(indexed, pct)} caption="Indexed" tone={indexed !== undefined ? "plain" : "pending"}
|
||||
sub={`${s.stills.images_indexed.toLocaleString()} of ${s.stills.images_analysed.toLocaleString()} images`}
|
||||
tip="Images the GPU analysis indexed, of those analysed since the pre-pass."/>
|
||||
: <Tile value={num(s.isa, x => 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.`}/>
|
||||
: <Tile value={num(s.isa, x => 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."/>}
|
||||
<Tile value={s.space_group ? <SpaceGroup hm={s.space_group}/> : "—"} 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}</>}</>}/>
|
||||
<ModelTile s={s} modelLoaded={modelLoaded}/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
</Typography>
|
||||
<Typography noWrap sx={{color: "text.secondary", minWidth: 0}}>
|
||||
{s.message || s.verdict_text || (s.stills ? prepassLine(s.stills) : s.probe) || "Rugnux live"}
|
||||
</Typography>
|
||||
{s.state === "Failed" && s.message &&
|
||||
<Typography noWrap sx={{color: "text.secondary", minWidth: 0}}>{s.message}</Typography>}
|
||||
</Stack>
|
||||
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", md: "1fr 1fr"}, gap: 4}}>
|
||||
<Bar caption="Detector" detail={detectorDetail} colour={lime[600]} busy={collecting}
|
||||
@@ -176,9 +158,7 @@ function Header({s, deadline_s}: { s: rugnux_live_status, deadline_s: number })
|
||||
{clock.value.toFixed(1)}<Box component="span" sx={{fontSize: "1.4rem", ml: 0.5, color: "text.secondary"}}>s</Box>
|
||||
</Typography>
|
||||
<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` : ""}
|
||||
{deadline !== undefined ? `deadline in ${Math.max(0, deadline).toFixed(0)} s` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -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 }) {
|
||||
<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 noWrap sx={{fontWeight: 600, minWidth: 0}}>{step.title}</Typography>
|
||||
<Typography sx={{fontVariantNumeric: "tabular-nums", color: "text.secondary", whiteSpace: "nowrap"}}>
|
||||
{step.duration_s !== undefined ? `${step.duration_s.toFixed(1)} s` : ""}
|
||||
</Typography>
|
||||
@@ -218,13 +194,9 @@ function RunConsole({s}: { s: rugnux_live_status }) {
|
||||
"@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"}/>}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Division sx={{flexGrow: 1}} label={<>{m.placement === "MR" ? "Molecular replacement" : "Model"}{m.predicted && " · predicted model"}
|
||||
{s.refined_model && " · refined"} · {m.model_name}</>}>
|
||||
<Division sx={{flexGrow: 1}} label={s.refined_model ? "Model · refined" : "Model · placed"}>
|
||||
<Box sx={{flexGrow: 1, minHeight: 0}}>
|
||||
<Suspense fallback={<Later text="Loading viewer..."/>}>
|
||||
<RugnuxStructureViewer resultKey={resultKey} anomalous={s.anomalous_map} fill
|
||||
anomalousSignal={s.anomalous_signal}
|
||||
blobs={s.refined_model ? m.refinement?.blobs ?? [] : []}/>
|
||||
anomalousSignal={s.anomalous_signal}/>
|
||||
</Suspense>
|
||||
</Box>
|
||||
</Division>
|
||||
@@ -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 (
|
||||
<Box sx={{display: "grid", gridTemplateRows: "auto minmax(0, 1fr)", gap: 2.5, height: "100%", minHeight: 0}}>
|
||||
@@ -296,16 +266,16 @@ function Dashboard({s, deadline_s, modelLoaded}: { s: rugnux_live_status, deadli
|
||||
</Box>
|
||||
<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}`}/>}
|
||||
label={<>Triage{attention > 0 && <Box component="span" sx={{color: orange[300]}}> · {attention}</Box>}</>}>
|
||||
{s.triage && s.triage.length > 0 ? <RugnuxTriage items={s.triage}/> : <Later text={later}/>}
|
||||
</Division>
|
||||
<Division sx={{flex: "1 1 0"}} label={<>By resolution{s.shells_merge && ` · merge of ${s.shells_merge}`}</>}>
|
||||
<Division sx={{flex: "1 1 0"}} label={<>By resolution{s.stills && s.shells_merge && ` · ${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%"}}>
|
||||
<RugnuxShellPlots shells={s.shells} height={Math.max(170, (plotsHeight - 16) / 2)}/>
|
||||
</Box>
|
||||
: <Later text={`Merging statistics ${later}`}/>}
|
||||
: <Later text={later}/>}
|
||||
</Box>
|
||||
</Division>
|
||||
</Box>
|
||||
@@ -327,13 +297,15 @@ function Details({s, deadline_s}: { s: rugnux_live_status, deadline_s: number })
|
||||
</Button>
|
||||
<Collapse in={open} unmountOnExit>
|
||||
<Stack spacing={3} sx={{mt: 2}}>
|
||||
<VerdictAlert s={s}/>
|
||||
{s.probe && <Alert severity={s.probe.startsWith("no lattice") ? "warning" : "info"}>
|
||||
Lattice probe{s.probe_s !== undefined && ` at ${s.probe_s.toFixed(1)} s`}: {s.probe}</Alert>}
|
||||
<RugnuxTimeline s={s} deadline_s={deadline_s}/>
|
||||
{s.stills && <RugnuxStills st={s.stills}/>}
|
||||
{s.model && <ModelScores m={s.model} mrSeconds={mrSeconds} anomalousSignal={s.anomalous_signal}/>}
|
||||
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", lg: "1fr 1fr"}, gap: 3, alignItems: "start"}}>
|
||||
<StepTable steps={s.steps}/>
|
||||
<Stack spacing={2}>
|
||||
<VerdictAlert s={s}/>
|
||||
{s.summary.length > 0 &&
|
||||
<Box component="table" sx={{borderCollapse: "collapse", "& td": {py: 0.5, pr: 2, verticalAlign: "top"},
|
||||
"& td:first-of-type": {fontWeight: 600, whiteSpace: "nowrap"}}}>
|
||||
|
||||
@@ -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<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>
|
||||
</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);
|
||||
@@ -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<PluginContext | null>(null);
|
||||
const [pluginReady, setPluginReady] = useState(0);
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -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<Kind, number> = {twofofc: 1.0, fofc: 3.0, anom: 4.0};
|
||||
const REPR: ReprMode = 'cartoon';
|
||||
const [show, setShow] = useState<Record<Kind, boolean>>({twofofc: true, fofc: true, anom: anomalousSignal});
|
||||
const [sigma, setSigma] = useState<Record<Kind, number>>({twofofc: 1.0, fofc: 3.5, anom: 4.0});
|
||||
const [reprMode, setReprMode] = useState<ReprMode>('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<string[]>([]);
|
||||
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<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={{position: 'relative', bgcolor: '#000', borderRadius: 1, overflow: 'hidden',
|
||||
height: fill ? '100%' : {xs: '55vh', md: 'clamp(520px, 64vh, 820px)'}, minHeight: fill ? 0 : undefined}}>
|
||||
@@ -261,54 +156,18 @@ function RugnuxStructureViewer({resultKey, anomalous, anomalousSignal, blobs, fi
|
||||
{modelLoading ? 'loading model…' : 'computing density maps…'}</Typography>
|
||||
</Box>}
|
||||
{error && <Alert severity="warning" sx={{position: 'absolute', top: 8, left: 8}}>{error}</Alert>}
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
{/* the one control: the difference map on or off */}
|
||||
{hasMap.fofc && (
|
||||
<Tooltip title={`mFo−DFc at ±${SIGMA.fofc.toFixed(0)} σ (green positive, red negative); 2mFo−DFc at ${SIGMA.twofofc.toFixed(0)} σ in blue`}>
|
||||
<Box component="button" type="button" onClick={() => 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'}}>
|
||||
<Box sx={{width: 8, height: 8, borderRadius: '2px', bgcolor: hex(MAP_COLORS.fofcPos)}}/>
|
||||
Fo−Fc
|
||||
</Box>
|
||||
</Popover>
|
||||
</Box>
|
||||
{mapsLoading && <Typography sx={{position: 'absolute', bottom: 8, right: 12, fontSize: '0.8rem', color: '#8aa0b5'}}>
|
||||
computing density maps…</Typography>}
|
||||
</Tooltip>)}
|
||||
{!modelLoading && !mapsLoading && !hasMap.twofofc && !error &&
|
||||
<Typography sx={{position: 'absolute', bottom: 8, right: 12, fontSize: '0.8rem', color: '#8aa0b5'}}>No maps</Typography>}
|
||||
</Box>
|
||||
|
||||
@@ -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<string, number> = {serious: 0, attention: 1};
|
||||
|
||||
@@ -16,18 +16,6 @@ const ICON: Record<string, React.ReactNode> = {
|
||||
ok: <CheckCircleIcon color="success" sx={{fontSize: 18}}/>,
|
||||
};
|
||||
|
||||
function TriageLine({item}: { item: rugnux_triage_item }) {
|
||||
return (
|
||||
<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: 700, whiteSpace: "nowrap"}}>{item.label}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" noWrap sx={{minWidth: 0}}>{item.text}</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function tip(item: rugnux_triage_item) {
|
||||
return (
|
||||
<Box sx={{whiteSpace: "pre-line"}}>
|
||||
@@ -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 (
|
||||
<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}
|
||||
<Tooltip title={tip(item)} arrow placement="bottom-start">
|
||||
<Box sx={{...CHIP_SX, borderColor: item.severity === "serious" ? "error.main" : "warning.main"}}>
|
||||
{ICON[item.severity]}{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.
|
||||
// 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 = (
|
||||
<Box>
|
||||
{items.map(i => <Box key={i.label}><b>{i.label}</b>{i.text && `: ${i.text}`}</Box>)}
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Tooltip title={list} arrow placement="bottom-start">
|
||||
<Box sx={{...CHIP_SX, borderColor: colour, color: colour, fontWeight: 500}}>
|
||||
{icon}{items.length} {label}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Box>
|
||||
{problems.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>
|
||||
<Box sx={{display: "flex", flexWrap: "wrap", gap: 1}}>
|
||||
{problems.map(item => <Problem key={item.label} item={item}/>)}
|
||||
<Count items={items.filter(i => i.severity === "ok")} label="OK" colour="success.main" icon={ICON.ok}/>
|
||||
<Count items={items.filter(i => i.severity === "not_measured")} label="not measured" colour="text.disabled"/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user