Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion apps/visr/src/components/ControlsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@ interface ControlsDrawerProps {
open: boolean;
onToggle: () => void;
controls: ReactNode | ReactNode[];
/** Shown in the header, so it stays visible when the drawer is closed */
status?: ReactNode;
}

function ControlsDrawer({ open, onToggle, controls }: ControlsDrawerProps) {
function ControlsDrawer({
open,
onToggle,
controls,
status,
}: ControlsDrawerProps) {
const items = Children.toArray(controls);

return (
Expand All @@ -27,12 +34,23 @@ function ControlsDrawer({ open, onToggle, controls }: ControlsDrawerProps) {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
px: 2,
height: 80,
flexShrink: 0,
}}
>
<Typography variant="overline">Controls</Typography>
<Box
sx={{
flex: 1,
minWidth: 0,
display: "flex",
justifyContent: "flex-end",
}}
>
{status}
</Box>
<IconButton
onClick={onToggle}
aria-label={open ? "Collapse" : "Expand"}
Expand Down
181 changes: 181 additions & 0 deletions apps/visr/src/components/ScanStatusLine.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {
Box,
CircularProgress,
Fade,
Tooltip,
Typography,
} from "@mui/material";
import { useEffect, useState } from "react";
import type { ScanStatus } from "../hooks/useScanStatus";

/**
* Where the plots' last points are after a scan ends: the server isn't ready
* with them yet, or they couldn't be fetched
*/
export type LastPointsStatus = "loading" | "missing";

/** Plans with no scan type, by the name the status line gives them */
const PLAN_LABELS: Record<string, string> = {
darks_flats: "Darks/flats",
};

/** "Fly scan", "Darks/flats", or plain "Scan" */
function scanLabel({ scanType, planName }: ScanStatus): string {
if (scanType) {
return `${scanType.charAt(0).toUpperCase()}${scanType.slice(1)} scan`;
}
if (planName) return PLAN_LABELS[planName] ?? planName;
return "Scan";
}

/** 0:07, 1:42, 1:02:03 */
function formatDuration(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = String(total % 60).padStart(2, "0");
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
: `${minutes}:${seconds}`;
}

/** The current time, ticking each second while `active` */
function useNow(active: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
setNow(Date.now());
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [active]);
return now;
}

type Line = {
text: string;
colour: string;
/** Will change on its own: shown with a spinner */
busy?: boolean;
/** Fades out after a while: nothing more to say */
fades?: boolean;
/** Shown in full on hover, for text cut short */
detail?: string;
};

function describe(
status: ScanStatus,
lastPoints: LastPointsStatus | null,
now: number,
): Line {
const label = scanLabel(status);
const { startedAt, endedAt } = status;
switch (status.phase) {
case "running":
return {
text:
startedAt !== undefined
? `${label} running · ${formatDuration(now - startedAt)}`
: `${label} running`,
colour: "text.secondary",
busy: true,
};
case "complete": {
if (lastPoints === "loading") {
return {
text: `${label} complete, loading the last points…`,
colour: "text.secondary",
busy: true,
};
}
if (lastPoints === "missing") {
return {
text: `${label} complete. Some of the last points could not be loaded`,
colour: "warning.main",
};
}
const took =
startedAt !== undefined && endedAt !== undefined
? ` · ${formatDuration(endedAt - startedAt)}`
: "";
return {
text: `${label} complete${took}`,
colour: "text.secondary",
fades: true,
};
}
case "aborted":
return { text: `${label} aborted`, colour: "text.secondary" };
case "failed":
return {
text: status.error
? `${label} failed: ${status.error}`
: `${label} failed`,
colour: "error.main",
detail: status.error,
};
}
}

/** "Complete" fades out after this long; problems stay */
const COMPLETE_SHOWN_MS = 3000;
const FADE_MS = 1000;

/** One line of status; remounted when it changes, so a fade starts afresh */
function StatusText({ line }: { line: Line }) {
const [shown, setShown] = useState(true);
useEffect(() => {
if (!line.fades) return;
const timer = setTimeout(() => setShown(false), COMPLETE_SHOWN_MS);
return () => clearTimeout(timer);
}, [line.fades]);
return (
<Fade in={shown} timeout={FADE_MS} appear={false}>
<Box
sx={{ display: "flex", alignItems: "center", gap: 0.75, minWidth: 0 }}
>
{line.busy && (
<CircularProgress
size={10}
thickness={5}
color="inherit"
sx={{ flexShrink: 0 }}
/>
)}
<Tooltip title={line.detail ?? ""} disableHoverListener={!line.detail}>
<Typography variant="caption" color={line.colour} noWrap>
{line.text}
</Typography>
</Tooltip>
</Box>
</Fade>
);
}

interface ScanStatusLineProps {
status: ScanStatus | null;
/** The plots' last points, for a view that polls for them after the scan */
lastPoints?: LastPointsStatus | null;
}

/** The latest scan's status in one quiet line */
export function ScanStatusLine({
status,
lastPoints = null,
}: ScanStatusLineProps) {
const now = useNow(status?.phase === "running");
if (!status) return null;
const line = describe(status, lastPoints, now);
// the key changes with the state, not the ticking time, so only a new state
// restarts the fade
const key = `${status.uuid}-${status.phase}-${lastPoints}-${line.busy}`;
return (
<Box
role="status"
sx={{ display: "flex", alignItems: "center", minWidth: 0 }}
>
<StatusText key={key} line={line} />
</Box>
);
}

export default ScanStatusLine;
123 changes: 123 additions & 0 deletions apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
Box,
FormControlLabel,
Radio,
RadioGroup,
Tooltip,
Typography,
} from "@mui/material";
import { DataSource } from "./dataSource";
import {
POSITIONS,
READBACK_ONLY_HELP,
SHAPES,
type Positions,
type Shape,
} from "./plotKinds";

interface Props {
onSetDataSource: (event: React.ChangeEvent<HTMLInputElement>) => void;
dataSource: DataSource;
positions: Positions;
onSetPositions: (positions: Positions) => void;
/** The shape for readbacks, kept while the setpoint grid is shown */
shape: Shape;
onSetShape: (shape: Shape) => void;
}

export default function Controls({
onSetDataSource,
dataSource,
positions,
onSetPositions,
shape,
onSetShape,
}: Props) {
const onGrid = positions === "setpoints";
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
p: 3,
}}
>
<Typography variant="overline" color="primary">
Plots
</Typography>

<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="body2" color="primary">
Data Source
</Typography>
<RadioGroup row value={dataSource} onChange={onSetDataSource}>
<FormControlLabel
value={DataSource.Camera}
control={<Radio />}
label="Camera"
/>
<FormControlLabel
value={DataSource.Diodes}
control={<Radio />}
label="Diodes"
/>
</RadioGroup>
</Box>

<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="body2" color="primary">
Positions
</Typography>
<RadioGroup
row
value={positions}
onChange={event => onSetPositions(event.target.value as Positions)}
>
{POSITIONS.map(({ value, label, help }) => (
<Tooltip key={value} title={help} describeChild>
<FormControlLabel
value={value}
control={<Radio />}
label={label}
/>
</Tooltip>
))}
</RadioGroup>
</Box>

<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="body2" color="primary">
Plot Type
</Typography>
<RadioGroup
row
// the setpoint grid's cells are rectangles
value={onGrid ? "rectangles" : shape}
onChange={event => onSetShape(event.target.value as Shape)}
>
{SHAPES.map(({ value, label, help }) => {
const disabled = onGrid && value !== "rectangles";
return (
<Tooltip
key={value}
title={disabled ? READBACK_ONLY_HELP : help}
describeChild
>
{/* a span, so the tooltip still shows on a disabled option */}
<span>
<FormControlLabel
value={value}
control={<Radio />}
label={label}
disabled={disabled}
/>
</span>
</Tooltip>
);
})}
</RadioGroup>
</Box>
</Box>
);
}
Loading
Loading