Files
Jungfraujoch/frontend/src/components/PreviewImage.tsx
T
leonarski_fandClaude Fable 5 a4559e576d Preview: geometry-following arcs, true beam center, unified colours, predictions
Draw the resolution ring and azimuthal-ROI outlines by sweeping ResPhiToPxl
through the geometry (a shared DrawArc/DrawThickLine helper), so they trace the
true conic on a tilted detector instead of PONI-centred circles. Draw the beam
crosshair at GetDirectBeam_pxl() (the real beam/detector intersection) rather
than the stored PONI.

Unify the spot overlay colours with the viewer: green = not indexed, magenta =
indexed (primary lattice), cyan = ice ring, coral = secondary/further lattice.
Add Bragg-prediction overlay (dark-red circles, skipping systematic absences)
gated on a new show_predictions preview setting, wired through the OpenAPI spec,
the image.jpeg HTTP handler, and the frontend toggle (+regenerated TS client).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:04:00 +02:00

436 lines
17 KiB
TypeScript

import {ChangeEvent, memo, useEffect, useRef, useState} from 'react';
import Paper from "@mui/material/Paper";
import {
Checkbox,
FormControlLabel,
Grid,
Input,
Radio,
RadioGroup,
Select,
Slider,
Stack
} from "@mui/material";
import {TransformComponent, TransformWrapper} from "react-zoom-pan-pinch";
import {color_scale} from "../client";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
type preview_settings = {
saturation: number;
show_spots: boolean;
show_predictions: boolean;
show_roi: boolean;
jpeg_quality: number;
show_user_mask: boolean;
show_beam_center: boolean;
resolution_ring: number;
scale: color_scale;
image_id: number;
auto_contrast: boolean;
res_estimate: boolean;
};
type MyProps = {
measuring: boolean,
min_image_number: number | undefined,
max_image_number: number | undefined
}
function handleErrors(response: Response): Response {
if (!response.ok) {
throw Error(response.statusText);
}
return response;
}
function stringToEnum(value: string): color_scale {
const enumValue = Object.values(color_scale).find(
(v) => v === value
) as color_scale;
// If no match is found, default to the indigo color scale
return enumValue || color_scale.INDIGO;
}
const default_preview_settings: preview_settings = {
saturation: 10,
jpeg_quality: 90,
show_spots: true,
show_predictions: false,
show_roi: false,
show_user_mask: false,
show_beam_center: true,
resolution_ring: 0.5,
scale: color_scale.INDIGO,
image_id: -1,
auto_contrast: true,
res_estimate: true
};
function PreviewImage({measuring, min_image_number, max_image_number}: MyProps) {
const [settings, setSettings] = useState<preview_settings>(default_preview_settings);
const [sUrl, setSUrl] = useState<string | null>(null);
const [update, setUpdate] = useState(true);
const [connectionError, setConnectionError] = useState(true);
const [imageIdMode, setImageIdMode] = useState('last');
const [imageId, setImageId] = useState(0);
// Refs let the polling interval read the latest settings without being
// re-created on every settings change, and let cleanup revoke the live URL.
const sUrlRef = useRef<string | null>(null);
const settingsRef = useRef(settings);
settingsRef.current = settings;
const getImage = (s: preview_settings) => {
let url = `/image_buffer/image.jpeg`;
url += `?id=${s.image_id}`;
if (!s.auto_contrast)
url += `&saturation=${s.saturation}`;
url += `&jpeg_quality=${s.jpeg_quality}`
url += `&show_spots=${s.show_spots}`
url += `&show_predictions=${s.show_predictions}`
url += `&show_roi=${s.show_roi}`
url += `&show_user_mask=${s.show_user_mask}`;
if (s.res_estimate)
url += '&show_res_est=true';
else
url += `&show_res_ring=${s.resolution_ring}`;
url += `&show_beam_center=${s.show_beam_center}`;
url += `&color=${s.scale}`;
fetch(url, {method: "GET"})
.then(handleErrors)
.then(data => data.blob())
.then(data => {
const objectUrl = URL.createObjectURL(data);
const tmp = sUrlRef.current;
sUrlRef.current = objectUrl;
setSUrl(objectUrl);
setConnectionError(false);
if (tmp !== null)
URL.revokeObjectURL(tmp);
}).catch(error => {
setConnectionError(true);
})
};
const getValues = (s: preview_settings = settings) => {
getImage(s);
};
// Initial fetch + revoke the live object URL on unmount.
useEffect(() => {
getImage(settingsRef.current);
return () => {
if (sUrlRef.current !== null)
URL.revokeObjectURL(sUrlRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-update poll; reads the latest settings via ref.
useEffect(() => {
const id = setInterval(() => {
if (update && measuring)
getImage(settingsRef.current);
}, 2000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [update, measuring]);
const update_image_id_mode = (event: ChangeEvent<HTMLInputElement>) => {
let image_id : number;
switch (event.target.value) {
case "last":
image_id = -1;
break;
case "last_indexed":
image_id = -2;
break;
case "select":
image_id = imageId;
break;
default:
return;
}
let s : preview_settings = {...settings, image_id};
setSettings(s);
setImageIdMode(event.target.value);
getValues(s);
};
const updateToggle = (event: ChangeEvent<HTMLInputElement>) => {
setUpdate(event.target.checked);
if (event.target.checked)
getValues();
};
const setSaturation = (event: Event, newValue: number | number[]) => {
let s : preview_settings = {...settings, saturation: newValue as number};
setSettings(s);
getValues(s);
};
const setSaturationText = (event: ChangeEvent<HTMLInputElement>) => {
let newValue = 0;
if (event.target.value)
newValue = Number(event.target.value);
if (newValue < 0)
newValue = 0;
let s : preview_settings = {...settings, saturation: newValue as number};
setSettings(s);
getValues(s);
};
const showSpotsToggle = (event: ChangeEvent<HTMLInputElement>) => {
let s : preview_settings = {...settings, show_spots: event.target.checked};
setSettings(s);
getValues(s);
};
const showPredictionsToggle = (event: ChangeEvent<HTMLInputElement>) => {
let s : preview_settings = {...settings, show_predictions: event.target.checked};
setSettings(s);
getValues(s);
};
const resEstToggle = (event: ChangeEvent<HTMLInputElement>) => {
let s: preview_settings = {...settings, res_estimate: event.target.checked};
setSettings(s);
getValues(s);
};
const autoContrastToggle = (event: ChangeEvent<HTMLInputElement>) => {
let s: preview_settings = {...settings, auto_contrast: event.target.checked};
setSettings(s);
getValues(s);
};
const showROIToggle = (event: ChangeEvent<HTMLInputElement>) => {
let s : preview_settings = {...settings, show_roi: event.target.checked};
setSettings(s);
getValues(s);
};
const showBeamCenterToggle = (event: ChangeEvent<HTMLInputElement>) => {
let s : preview_settings = {...settings, show_beam_center: event.target.checked};
setSettings(s);
getValues(s);
};
const setResolutionRing = (event: Event, newValue: number | number[]) => {
let s : preview_settings = {...settings, resolution_ring: newValue as number};
setSettings(s);
getValues(s);
};
const setResolutionRingText = (event: ChangeEvent<HTMLInputElement>) => {
let newValue = 0.5;
if (event.target.value)
newValue = Number(event.target.value);
if (newValue < 0.5)
newValue = 0.5;
if (newValue > 50.0)
newValue = 50.0;
let s : preview_settings = {...settings, resolution_ring: newValue as number};
setSettings(s);
getValues(s);
};
const setImageIDSlider = (event: Event, newValue: number | number[]) => {
if (imageIdMode == "select") {
let s : preview_settings = {...settings, image_id: newValue as number};
setSettings(s);
setImageId(newValue as number);
getValues(s);
} else {
setImageId(newValue as number);
}
};
const setImageIdText = (event: ChangeEvent<HTMLInputElement>) => {
let newValue = 0;
if (event.target.value)
newValue = Number(event.target.value);
if (newValue < 0)
newValue = 0;
if (imageIdMode == "select") {
let s : preview_settings = {...settings, image_id: newValue as number};
setSettings(s);
setImageId(newValue as number);
getValues(s);
} else {
setImageId(newValue as number);
}
};
const showUserMaskToggle = (event: ChangeEvent<HTMLInputElement>) => {
let s : preview_settings = {...settings, show_user_mask: event.target.checked};
setSettings(s);
getValues(s);
};
return <div>
<Grid container spacing={3}>
<Grid item xs={8}>
<Paper sx={{width: "100%", height: "100%", p: 1,
display: "flex", justifyContent: "center", alignItems: "center"}}>
{(!connectionError && (sUrl !== null)) ?
<Stack
direction="row"
justifyContent="center"
alignItems="center"
sx={{width: "100%"}}
>
<TransformWrapper maxScale={32}>
<TransformComponent>
<img src={sUrl} alt="Live preview"
style={{maxWidth: "100%", maxHeight: 900}}/>
</TransformComponent>
</TransformWrapper>
</Stack> : <div>Preview image not available</div>
}
</Paper>
</Grid>
<Grid item xs={4}>
<Paper sx={{width: "100%", height: "100%"}}>
<br/>
<Stack spacing={2} direction="column" sx={{
justifyContent: "center",
alignItems: "center"}} alignItems="center">
<div><strong>Preview image selection </strong></div>
<FormControl sx={{width:"83%"}}>
<RadioGroup value={imageIdMode} onChange={update_image_id_mode}>
<FormControlLabel value="last" control={<Radio />} label="Last image" />
<FormControlLabel value="last_indexed" control={<Radio />} label="Last indexed image" />
<FormControlLabel value="select" control={<Radio />} label="Choose image" />
</RadioGroup>
</FormControl>
<Stack spacing={3} direction="row" sx={{width:"83%"}}>
<Slider
value={imageId}
min={min_image_number ?? 0}
max={max_image_number ?? 0}
step={1}
onChange={setImageIDSlider}
disabled={imageIdMode !== "select"}
valueLabelDisplay="auto"/>
<Input
value={imageId}
onChange={setImageIdText}
disabled={imageIdMode !== "select"}
inputProps={{
step: 1,
min: min_image_number ?? 0,
max: max_image_number ?? 0,
type: 'number',
}}
/>
</Stack>
<div><strong>Preview image settings </strong></div>
<FormControl sx = {{width: "83%"}}>
<FormControlLabel control={
<Checkbox checked={update} onChange={updateToggle} name="Update"/>
} label="Auto-update image"/>
<FormControlLabel control={
<Checkbox checked={settings.auto_contrast} onChange={autoContrastToggle} name="Auto-contrast"/>
} label="Auto-contrast"/>
<FormControlLabel control={
<Checkbox checked={settings.show_spots} onChange={showSpotsToggle} name="Show spots"/>
} label="Show spots"/>
<FormControlLabel control={
<Checkbox checked={settings.show_predictions} onChange={showPredictionsToggle} name="Show predictions"/>
} label="Show predictions"/>
<FormControlLabel control={
<Checkbox checked={settings.show_roi} onChange={showROIToggle} name="Show ROI"/>
} label="Show ROI"/>
<FormControlLabel control={
<Checkbox checked={settings.res_estimate} onChange={resEstToggle} name="Show resolution estimation (auto ring)"/>
} label="Show resolution estimate (auto ring)"/>
<FormControlLabel control={
<Checkbox checked={settings.show_beam_center} onChange={showBeamCenterToggle} name="Show beam center"/>
} label="Show beam center"/>
<FormControlLabel control={
<Checkbox checked={settings.show_user_mask} onChange={showUserMaskToggle} name="Show user mask"/>
} label="Show user mask"/>
</FormControl>
<FormControl sx = {{width: "83%"}}>
<InputLabel id="color-map">Color map</InputLabel>
<Select
value={settings.scale}
variant="outlined"
label="Color map"
onChange={(event) => {
let s : preview_settings = {...settings, scale: stringToEnum(event.target.value)};
setSettings(s);
getValues(s);
}}
fullWidth
>
<MenuItem value={color_scale.INDIGO}>Indigo/white</MenuItem>
<MenuItem value={color_scale.HEAT}>Heat</MenuItem>
<MenuItem value={color_scale.VIRIDIS}>Viridis</MenuItem>
<MenuItem value={color_scale.MAGMA}>Magma</MenuItem>
<MenuItem value={color_scale.INFERNO}>Inferno</MenuItem>
<MenuItem value={color_scale.BW}>Black/white</MenuItem>
<MenuItem value={color_scale.WB}>White/black</MenuItem>
<MenuItem value={color_scale.GREEN}>Green</MenuItem>
</Select>
</FormControl>
<Stack spacing={3} direction="row" sx={{width:"83%"}}>
<Slider value={Number(settings.saturation)} min={1} max={200}
onChange={setSaturation}
disabled={settings.auto_contrast}
valueLabelDisplay="auto"/>
<Input
value={settings.saturation}
onChange={setSaturationText}
disabled={settings.auto_contrast}
inputProps={{
step: 1,
min: 0,
max: 200,
type: 'number',
}}
/>
</Stack><br/>Saturation level
<Stack spacing={3} direction="row" sx={{width:"83%"}}>
<Slider
value={(settings.resolution_ring === undefined) ? 0.5 : Number(settings.resolution_ring)}
min={0.5} max={10.0} step={0.1}
onChange={setResolutionRing} valueLabelDisplay="auto"
disabled={settings.res_estimate}/>
<Input
value={settings.resolution_ring}
disabled={settings.res_estimate}
onChange={setResolutionRingText}
endAdornment="&nbsp;&nbsp;Å"
inputProps={{
step: 0.1,
min: 0.5,
max: 50.0,
type: 'number',
}}
/>
</Stack>
<br/>Resolution Ring
</Stack>
</Paper>
</Grid>
</Grid>
</div>
}
export default memo(PreviewImage);