Skip to content

Commit 4049fb7

Browse files
committed
feat(imageplot): add profile extraction feature with freeze state and UI enhancements
1 parent 86a9b31 commit 4049fb7

4 files changed

Lines changed: 175 additions & 9 deletions

File tree

‎src/App.tsx‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,6 +1669,32 @@ export default function App() {
16691669
[pendingProfile, runFeature],
16701670
);
16711671

1672+
// Extract a frozen cross-section profile from the image viewer as a new
1673+
// signal, reusing the Sigima ``line_profile`` feature (mirrors DataLab
1674+
// desktop's "Process signal" button next to the cross-section panels).
1675+
const handleExtractProfile = useCallback(
1676+
async (params: {
1677+
direction: "horizontal" | "vertical";
1678+
row: number;
1679+
col: number;
1680+
}) => {
1681+
if (!runtime || !currentId) return;
1682+
const feature = features.find((f) => f.id === "image:line_profile");
1683+
if (!feature) return;
1684+
// Start from the feature's default parameter values so any item we do
1685+
// not set keeps its default, then override direction + position.
1686+
const schema = await runtime.getFeatureSchema(feature.id, currentId);
1687+
const values: Record<string, unknown> = {
1688+
...(schema?.values ?? {}),
1689+
direction: params.direction,
1690+
row: params.row,
1691+
col: params.col,
1692+
};
1693+
await runFeature(feature, [currentId], null, values, effectiveGroupIds());
1694+
},
1695+
[runtime, currentId, features, runFeature, effectiveGroupIds],
1696+
);
1697+
16721698
const refreshResults = useCallback(
16731699
async (oid: string) => {
16741700
if (!runtime) return;
@@ -4307,6 +4333,7 @@ export default function App() {
43074333
);
43084334
}
43094335
}}
4336+
onExtractProfile={handleExtractProfile}
43104337
/>
43114338
)}
43124339
</main>

‎src/components/ImagePlot.tsx‎

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ interface ImagePlotProps {
9191
* toolbar. Persisted on the image object so it survives panel
9292
* switches. Only affects the downsampled display bitmap. */
9393
onResampleChange?: (method: ResampleMethod) => void;
94+
/** Called when the user clicks one of the "extract profile" buttons next
95+
* to a frozen cross-section preview. ``direction`` selects the horizontal
96+
* (top preview, fixed ``row``) or vertical (right preview, fixed ``col``)
97+
* slice. The host wires this to the Sigima ``line_profile`` feature so a
98+
* new signal is created, mirroring DataLab desktop's "Process signal". */
99+
onExtractProfile?: (params: {
100+
direction: "horizontal" | "vertical";
101+
row: number;
102+
col: number;
103+
}) => void;
94104
}
95105

96106
/**
@@ -118,6 +128,7 @@ export function ImagePlot({
118128
onLutRangeChange,
119129
onColormapChange,
120130
onResampleChange,
131+
onExtractProfile,
121132
}: ImagePlotProps) {
122133
const plotlyTheme = usePlotlyTheme();
123134
// ------------------------------------------------------------------
@@ -130,6 +141,16 @@ export function ImagePlot({
130141

131142
// Reset transient tool state when the underlying image changes.
132143
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
144+
// When ``frozen`` is true the crosshair / profiles stop following the mouse
145+
// and stay pinned at the last cursor position (toggled by clicking the
146+
// image in profiles mode), mirroring DataLab desktop's placed cross-section
147+
// marker. Only meaningful while the profiles tool is active.
148+
const [frozen, setFrozen] = useState(false);
149+
// Leaving the profiles tool always unfreezes so the next activation starts
150+
// in the live-tracking state.
151+
useEffect(() => {
152+
if (tool !== "profiles" && frozen) setFrozen(false);
153+
}, [tool, frozen]);
133154
const [statsRect, setStatsRect] = useState<{
134155
x0: number;
135156
y0: number;
@@ -165,6 +186,7 @@ export function ImagePlot({
165186
setCursor(null);
166187
setStatsRect(null);
167188
setDraftLut(null);
189+
setFrozen(false);
168190
}, [data.id]);
169191

170192
const effectiveLut: [number, number] = useMemo(() => {
@@ -544,6 +566,11 @@ export function ImagePlot({
544566
const xMax = extent.xMax;
545567
const yMin = extent.yMin;
546568
const yMax = extent.yMax;
569+
// Pinned crosshair is drawn solid (and slightly thicker) so the user
570+
// can tell at a glance that the profiles no longer follow the mouse.
571+
const crossLine = frozen
572+
? { color: CROSSHAIR_COLOR, width: 1.5, dash: "solid" }
573+
: { color: CROSSHAIR_COLOR, width: 1, dash: "dot" };
547574
shapes.push({
548575
type: "line",
549576
xref: "x",
@@ -552,7 +579,7 @@ export function ImagePlot({
552579
x1: cursor.x,
553580
y0: yMin,
554581
y1: yMax,
555-
line: { color: CROSSHAIR_COLOR, width: 1, dash: "dot" },
582+
line: crossLine,
556583
layer: "above",
557584
editable: false,
558585
});
@@ -564,7 +591,7 @@ export function ImagePlot({
564591
x1: xMax,
565592
y0: cursor.y,
566593
y1: cursor.y,
567-
line: { color: CROSSHAIR_COLOR, width: 1, dash: "dot" },
594+
line: crossLine,
568595
layer: "above",
569596
editable: false,
570597
});
@@ -585,7 +612,7 @@ export function ImagePlot({
585612
});
586613
}
587614
return shapes;
588-
}, [tool, cursor, statsRect, extent]);
615+
}, [tool, cursor, statsRect, extent, frozen]);
589616

590617
const allTraces = useMemo(
591618
() => [...traces, ...resultTraces],
@@ -694,7 +721,7 @@ export function ImagePlot({
694721

695722
const handleHover = useCallback(
696723
(event: Record<string, unknown>) => {
697-
if (tool !== "profiles") return;
724+
if (tool !== "profiles" || frozen) return;
698725
const points = (event as { points?: Array<Record<string, unknown>> })
699726
.points;
700727
if (!points || points.length === 0) return;
@@ -703,7 +730,7 @@ export function ImagePlot({
703730
const y = Number(p.y);
704731
if (Number.isFinite(x) && Number.isFinite(y)) setCursor({ x, y });
705732
},
706-
[tool],
733+
[tool, frozen],
707734
);
708735

709736
// ------------------------------------------------------------------
@@ -751,15 +778,27 @@ export function ImagePlot({
751778
const row = data.data[j] as Float32Array | number[];
752779
const z = row ? row[i] : NaN;
753780
setHoverInfo({ x: xData, y: yData, z, px, py });
754-
if (tool === "profiles") setCursor({ x: xData, y: yData });
781+
if (tool === "profiles" && !frozen) setCursor({ x: xData, y: yData });
755782
},
756-
[data, tool, hoverInfo, xEdges, yEdges],
783+
[data, tool, hoverInfo, xEdges, yEdges, frozen],
757784
);
758785

759786
const handleWrapperMouseLeave = useCallback(() => {
760787
setHoverInfo(null);
761788
}, []);
762789

790+
// Click toggles the frozen crosshair while the profiles tool is active.
791+
// Freezing requires a current cursor position (the click point) so the
792+
// crosshair has somewhere to pin; unfreezing always works.
793+
const handleHostClick = useCallback(() => {
794+
if (tool !== "profiles") return;
795+
if (frozen) {
796+
setFrozen(false);
797+
} else if (cursor) {
798+
setFrozen(true);
799+
}
800+
}, [tool, frozen, cursor]);
801+
763802
const handleRelayout = useCallback(
764803
(event: Record<string, unknown>) => {
765804
// Capture pan/zoom first (in any mode) so the LOD raster follows the
@@ -1065,10 +1104,17 @@ export function ImagePlot({
10651104

10661105
const imagePlotEl = (
10671106
<div
1068-
className="image-plot-host"
1107+
className={`image-plot-host${
1108+
tool === "profiles"
1109+
? frozen
1110+
? " profiles-frozen"
1111+
: " profiles-live"
1112+
: ""
1113+
}`}
10691114
style={{ position: "relative", width: "100%", height: "100%" }}
10701115
onMouseMove={handleWrapperMouseMove}
10711116
onMouseLeave={handleWrapperMouseLeave}
1117+
onClick={handleHostClick}
10721118
>
10731119
{heatmapPlot}
10741120
{hoverTooltip}
@@ -1141,8 +1187,32 @@ export function ImagePlot({
11411187
{t("Hover the image to update profiles")}
11421188
</div>
11431189
)}
1190+
{frozen && profileData && onExtractProfile ? (
1191+
<button
1192+
type="button"
1193+
className="image-profile-extract"
1194+
title={t("Extract this horizontal profile as a new signal")}
1195+
onClick={() =>
1196+
onExtractProfile({
1197+
direction: "horizontal",
1198+
row: profileData.row,
1199+
col: profileData.col,
1200+
})
1201+
}
1202+
>
1203+
{t("Extract")}
1204+
</button>
1205+
) : null}
1206+
</div>
1207+
<div className="image-plot-cell image-plot-cell-corner">
1208+
{cursor ? (
1209+
<div className="image-profile-freeze-hint">
1210+
{frozen
1211+
? t("Frozen — click the image to unfreeze")
1212+
: t("Click the image to freeze")}
1213+
</div>
1214+
) : null}
11441215
</div>
1145-
<div className="image-plot-cell image-plot-cell-corner" />
11461216
<div className="image-plot-cell image-plot-cell-heatmap">
11471217
{imagePlotEl}
11481218
</div>
@@ -1182,6 +1252,22 @@ export function ImagePlot({
11821252
useResizeHandler
11831253
/>
11841254
) : null}
1255+
{frozen && profileData && onExtractProfile ? (
1256+
<button
1257+
type="button"
1258+
className="image-profile-extract"
1259+
title={t("Extract this vertical profile as a new signal")}
1260+
onClick={() =>
1261+
onExtractProfile({
1262+
direction: "vertical",
1263+
row: profileData.row,
1264+
col: profileData.col,
1265+
})
1266+
}
1267+
>
1268+
{t("Extract")}
1269+
</button>
1270+
) : null}
11851271
</div>
11861272
</div>
11871273
) : (

‎src/locales/fr.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@
166166
"Clear all overrides and fall back to the auto-cycling palette": "Effacer toutes les surcharges et revenir à la palette à cycle automatique",
167167
"Clear stats area": "Effacer la zone de statistiques",
168168
"Click": "Cliquez sur",
169+
"Click the image to freeze": "Cliquer sur l'image pour figer",
169170
"Click to manage memory": "Cliquer pour gérer la mémoire",
170171
"Clipboard access was denied.": "L'accès au presse-papier a été refusé.",
171172
"Clipping": "Écrêtage",
@@ -356,11 +357,14 @@
356357
"Export…": "Exporter…",
357358
"Exposure": "Exposition",
358359
"Extension": "Extension",
360+
"Extract": "Extraire",
359361
"Extract (merged into one image)": "Extraire (fusionné en une seule image)",
360362
"Extract (merged into one signal)": "Extraire (fusionné en un seul signal)",
361363
"Extract (one image per ROI)": "Extraire (une image par ROI)",
362364
"Extract (one signal per ROI)": "Extraire (un signal par ROI)",
363365
"Extract pulse features": "Extraire les caractéristiques d'une impulsion",
366+
"Extract this horizontal profile as a new signal": "Extraire ce profil horizontal en tant que nouveau signal",
367+
"Extract this vertical profile as a new signal": "Extraire ce profil vertical en tant que nouveau signal",
364368
"Failed to initialise Sigima": "Échec de l'initialisation de Sigima",
365369
"Failed to open {name}: {error}": "Échec de l'ouverture de {name} : {error}",
366370
"Failed to read {name}: {error}": "Échec de la lecture de {name} : {error}",
@@ -387,6 +391,7 @@
387391
"Freed {collected} unused objects. The engine still reserves {after}; reload the page to return it to the browser.": "{collected} objets inutilisés libérés. Le moteur réserve toujours {after} ; rechargez la page pour la rendre au navigateur.",
388392
"Frequency filters": "Filtres fréquentiels",
389393
"From template: {label}": "Depuis un modèle : {label}",
394+
"Frozen — click the image to unfreeze": "Figé — cliquer sur l'image pour libérer",
390395
"Full width at 1/e²": "Largeur à 1/e²",
391396
"Full width at half-maximum": "Largeur à mi-hauteur",
392397
"Full width at y=...": "Largeur à y=...",

‎src/styles.css‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3631,12 +3631,60 @@ input {
36313631
.image-plot-cell {
36323632
min-width: 0;
36333633
min-height: 0;
3634+
position: relative;
36343635
}
36353636

36363637
.image-plot-cell-corner {
36373638
background: transparent;
36383639
}
36393640

3641+
/* Cursor feedback for the cross-profiles tool: a crosshair while live,
3642+
a pointer once frozen (click to toggle). */
3643+
.image-plot-host.profiles-live {
3644+
cursor: crosshair;
3645+
}
3646+
3647+
.image-plot-host.profiles-frozen {
3648+
cursor: pointer;
3649+
}
3650+
3651+
/* Small "Extract" button overlaid on each frozen profile preview; mirrors
3652+
DataLab desktop's "Process signal" button next to the cross-section
3653+
panels. */
3654+
.image-profile-extract {
3655+
position: absolute;
3656+
top: 4px;
3657+
right: 4px;
3658+
z-index: 6;
3659+
padding: 1px 6px;
3660+
font-size: 10px;
3661+
line-height: 1.4;
3662+
color: var(--text, #eee);
3663+
background: var(--panel, #2a2a2a);
3664+
border: 1px solid var(--border, #444);
3665+
border-radius: 3px;
3666+
cursor: pointer;
3667+
}
3668+
3669+
.image-profile-extract:hover {
3670+
background: var(--accent, #3da4ff);
3671+
border-color: var(--accent, #3da4ff);
3672+
color: #fff;
3673+
}
3674+
3675+
/* Freeze-state hint shown in the otherwise-empty top-right corner cell. */
3676+
.image-profile-freeze-hint {
3677+
display: flex;
3678+
align-items: center;
3679+
justify-content: center;
3680+
height: 100%;
3681+
padding: 2px 4px;
3682+
text-align: center;
3683+
color: var(--text-dim, #888);
3684+
font-size: 10px;
3685+
font-style: italic;
3686+
}
3687+
36403688
.image-plot-hint {
36413689
display: flex;
36423690
align-items: center;

0 commit comments

Comments
 (0)