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:
leonarski_fandClaude Opus 5.5 committed 2026-11-11 17:43:28 +01:00
1 parent 9122bda2e6
commit 39dcc8bd6f
4 files changed
+147 -318

No files matched your search

+58 -61
View File
@@ -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>
);
+24 -52
View File
@@ -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"}}}>
+31 -172
View File
@@ -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>
+34 -33
View File
@@ -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>
);
}