Skip to content

Commit f35c7a2

Browse files
authored
feat(netcdf): mark sampled pixels and float the spectral profile (#1713)
* feat(netcdf): mark sampled pixels and float the spectral profile A click now records the pixel as a sampled point, drawn on the map as a numbered dot in that point's chart color. Nothing previously showed where a spectrum had been read from, so several lines on one chart could not be told apart by location. This turns the profile store from a list of readings into a list of points: the point goes in on the click so its marker lands with the popup, and the band-axis read attaches to it by id when it resolves. A result addressed to a point that has since been cleared, or aged off the six-point cap, lands nowhere, so a slow read no longer needs cancelling. Points keep their number and color when an older one falls off the cap, and Clear drops the markers along with the chart since both derive from the same list. The chart can also be popped out of the Style panel into a window over the map, which is draggable and resizable -- the panel is narrow and scrolls, a poor place to read a 285-band spectrum closely. It opens top-left because its only resize grip is the bottom-right one, which on the right would sit against the Style panel with nowhere to grow. The drag/resize gesture is extracted from the Time Slider's pixel chart, which is the same kind of window over the same container, and both now share it. Profiles export as PNG (through the existing chart export, which resolves the theme variables the SVG references) or CSV, one column per sampled pixel with fill readings left empty so they read as gaps. The x-axis labels round multiples rather than just its two ends and the midpoint, which on a 381-2493 nm axis told you nothing about where a feature sat. The step is chosen by which candidate's tick *count* lands nearest the target: rounding the interval up to the next round number overshoots badly here, turning eight labels into four. A pixel from a 2-D grid is now kept as a point too, rather than clearing the list. It has no band axis to profile, so it charts nothing, but it still gets a marker and a list entry -- the readout is worth a mark on the map even when there is no spectrum behind it. * Address review feedback - Give `id` its own counter that is never reset, so only `order` restarts after a clear. Sharing one counter meant a clear recycled ids, and a band-axis read still in flight could resolve onto an unrelated later sample that reused its number -- silently violating the invariant that a stale read lands nowhere. - Put the profile CSV's axis header through `displayUnits`, as the chart's own axis label already does, so a coordinate declaring `unitless`/`1` no longer exports as "wavelength (1)" while the chart it came from says "wavelength". - Guard that header with `spreadsheetSafeText`: the axis name and units are free text out of the file's own metadata, and `csvCell` escapes quotes and commas but not a leading `=`, which a spreadsheet would execute. The point columns are built from numbers and stay verbatim. - Move `displayUnits` into a dependency-free `lib/cf-units` (re-exported from `netcdf-image-symbology`, so callers are unchanged). Importing it from `netcdf-image-symbology` reaches the plugin barrel and, through it, `maplibre-gl-earth-engine`, which throws "window is not defined" and would have taken the CSV builder's `node --test` run down with it. - Repoint the stale `{@link PixelTimeSeriesControl.measureRect}` in PixelTimeSeriesControl's geometry doc, since `measureRect` moved into `useFloatingPanelRect`. * Address review feedback - Thread `mapReadyGeneration` into NetcdfSampleMarkers, the way `useNetcdfIdentify` already takes it. The marker effect keyed only on `samples`, so a map teardown/recreate left the markers on the discarded map and they would not reappear until the next click changed the list. - Give the chart's export error `role="alert"`, matching the Time Slider chart's export error. The message lands after the click that caused it, so without a live region a screen-reader user was never told the export failed. - Drop `aria-hidden` from the sample list's swatch. The color is decorative but the digit inside it is the number tying that row to its marker, and the marker itself is hidden from assistive tech, so this list is the only place the number can be read. - Reuse `chart-export`'s `triggerDownload` (now exported) for the CSV instead of a second local anchor-and-revoke helper. - Correct the marker effect's comment: it also re-runs when a band-axis read resolves and replaces the `samples` array, not only on a click. Left as a full rebuild -- at MAX_PROFILE_SAMPLES markers that beats reconciling them. - Match `FALLBACK_RECT` to the CSS default it stands in for (512x416, not 520x420), so the doc comment's claim that the two agree is true of the size as well as the corner. - Replace the tests' `as never` casts with a `charted()` narrowing helper, so a change to `netcdfAxisPositions`' parameter type still fails the test file. * Address review feedback - Drop the `disposed` guard on a resolved band-axis read. The effect tears down whenever the identify target changes, so switching away from a NetCDF layer and back during a read that takes tens of seconds left that point permanently profile-less even though the fetch completed. The store's own guard is what makes the read safe to let land: a result for a point that has since been cleared or aged off the cap attaches to nothing. Pending timeouts are still cleared on teardown, so no *new* read starts after the session ends. * Address review feedback - Cascade the profile window's default placement 64px down. The Time Slider's pixel chart opens at the same corner and z-index, and the two are gated independently, so a user with a time-slider stack and a popped-out NetCDF profile got two windows exactly on top of each other. The offset leaves that panel's drag header exposed, and keeps this window top-left, which is what its bottom-right resize grip needs. - Drop the now-unused `ReactPointerEvent` type import from PixelTimeSeriesControl, left behind when the gesture handlers moved into `useFloatingPanelRect`.
1 parent 80a80ef commit f35c7a2

16 files changed

Lines changed: 1402 additions & 475 deletions

apps/geolibre-desktop/src/components/layout/DesktopShell.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ import { useCollaboration } from "../../hooks/useCollaboration";
126126
import { MapModeBanner } from "./MapModeBanner";
127127
import { QuickAnalysisBanner } from "./QuickAnalysisBanner";
128128
import { PixelTimeSeriesControl } from "./PixelTimeSeriesControl";
129+
import { NetcdfSampleMarkers } from "./NetcdfSampleMarkers";
130+
import { NetcdfProfileWindow } from "./NetcdfProfileWindow";
129131
import { MapLegendPanel } from "../legend/MapLegendPanel";
130132
import { RasterSubsetPanel } from "./RasterSubsetPanel";
131133
import { BasemapExtractPanel } from "./BasemapExtractPanel";
@@ -2282,6 +2284,11 @@ export function DesktopShell({
22822284
<MapModeBanner mapControllerRef={mapControllerRef} />
22832285
<QuickAnalysisBanner />
22842286
<PixelTimeSeriesControl mapControllerRef={mapControllerRef} />
2287+
<NetcdfSampleMarkers
2288+
mapControllerRef={mapControllerRef}
2289+
mapReadyGeneration={mapReadyGeneration}
2290+
/>
2291+
<NetcdfProfileWindow />
22852292
<MapLegendPanel
22862293
mapControllerRef={mapControllerRef}
22872294
mapReadyGeneration={mapReadyGeneration}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { Button } from "@geolibre/ui";
2+
import { GripVertical, LineChart, X } from "lucide-react";
3+
import { useSyncExternalStore } from "react";
4+
import { useTranslation } from "react-i18next";
5+
import { useFloatingPanelRect } from "../../hooks/useFloatingPanelRect";
6+
import {
7+
clearNetcdfProfileSamples,
8+
getNetcdfProfileSamples,
9+
isNetcdfProfilePoppedOut,
10+
setNetcdfProfilePoppedOut,
11+
subscribeNetcdfProfile,
12+
} from "../../lib/netcdf-profile-store";
13+
import { NetcdfProfileChart } from "../panels/NetcdfProfileChart";
14+
15+
/** Panel geometry (px). The window opens near the top-left corner and can then
16+
* be dragged and resized anywhere on the map. Top-left because the only resize
17+
* grip is the bottom-right one: opening on the right would put that grip against
18+
* the Style panel with nowhere to grow. The CSS default below and
19+
* {@link FALLBACK_RECT} describe the same spot, so a drag that starts from the
20+
* untouched default does not jump. */
21+
const PANEL_MIN_W = 360;
22+
const PANEL_MIN_H = 260;
23+
const PANEL_MARGIN = 12;
24+
/**
25+
* Vertical offset from the top inset. The Time Slider's pixel chart opens at the
26+
* same corner and z-index, and the two are gated independently, so a user with a
27+
* time-slider stack *and* a popped-out NetCDF profile would otherwise get two
28+
* windows stacked exactly on top of each other. Cascading this one down leaves
29+
* that panel's drag header exposed and grabbable underneath.
30+
*/
31+
const PANEL_TOP = 64;
32+
// 512x416 is the CSS default below (32rem x 26rem at a 16px root) in px, so the
33+
// fallback matches the size as well as the corner.
34+
const FALLBACK_RECT = { x: PANEL_MARGIN, y: PANEL_TOP, w: 512, h: 416 };
35+
36+
/**
37+
* The spectral profile detached from the Style panel into a movable, resizable
38+
* window over the map.
39+
*
40+
* The Style panel is narrow and scrolls, which is a poor home for a chart the
41+
* user wants to read closely; floating it gives the chart room and lets it sit
42+
* beside the pixels it was sampled from. The store holds one layer's samples at
43+
* a time, so the window charts "the current samples" without tracking a layer.
44+
*
45+
* Mounted in the map area so it is positioned against the map, not the shell.
46+
*
47+
* @returns The window, or null while the chart is docked or has nothing to show.
48+
*/
49+
export function NetcdfProfileWindow() {
50+
const { t } = useTranslation();
51+
const samples = useSyncExternalStore(
52+
subscribeNetcdfProfile,
53+
getNetcdfProfileSamples,
54+
getNetcdfProfileSamples,
55+
);
56+
const poppedOut = useSyncExternalStore(
57+
subscribeNetcdfProfile,
58+
isNetcdfProfilePoppedOut,
59+
isNetcdfProfilePoppedOut,
60+
);
61+
const { panelRef, rect, handleDragStart, handleResizeStart } = useFloatingPanelRect({
62+
minWidth: PANEL_MIN_W,
63+
minHeight: PANEL_MIN_H,
64+
margin: PANEL_MARGIN,
65+
fallback: FALLBACK_RECT,
66+
});
67+
68+
if (!poppedOut || samples.length === 0) return null;
69+
70+
return (
71+
<div
72+
ref={panelRef}
73+
className={
74+
rect
75+
? "pointer-events-auto absolute z-20 flex flex-col overflow-hidden rounded-lg border bg-background shadow-xl"
76+
: "pointer-events-auto absolute start-3 top-16 z-20 flex h-[26rem] max-h-[calc(100%-9rem)] w-[min(32rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-lg border bg-background shadow-xl"
77+
}
78+
style={rect ? { left: rect.x, top: rect.y, width: rect.w, height: rect.h } : undefined}
79+
role="region"
80+
aria-label={t("netcdfProfile.heading")}
81+
data-testid="netcdf-profile-window"
82+
>
83+
<div
84+
className="flex cursor-move touch-none select-none items-center justify-between gap-2 border-b px-3 py-2"
85+
onPointerDown={handleDragStart}
86+
>
87+
<div className="flex items-center gap-2 text-sm font-semibold">
88+
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
89+
<LineChart className="h-4 w-4 text-primary" aria-hidden="true" />
90+
{t("netcdfProfile.heading")}
91+
</div>
92+
<div className="flex items-center gap-1">
93+
<Button type="button" variant="ghost" size="sm" onClick={clearNetcdfProfileSamples}>
94+
{t("netcdfProfile.clear")}
95+
</Button>
96+
<button
97+
type="button"
98+
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring"
99+
onClick={() => setNetcdfProfilePoppedOut(false)}
100+
aria-label={t("netcdfProfile.dock")}
101+
title={t("netcdfProfile.dock")}
102+
>
103+
<X className="h-4 w-4" />
104+
</button>
105+
</div>
106+
</div>
107+
108+
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-3">
109+
{/* `flex-1` rather than a fixed height: the chart scales with the
110+
window, which is the point of resizing it. */}
111+
<NetcdfProfileChart samples={samples} chartClassName="flex-1" />
112+
</div>
113+
114+
{/* Resize grip (bottom-right). The diagonal lines hint the affordance.
115+
Mouse/touch-only, so it is presentational — there is no keyboard
116+
resize to expose to assistive tech. */}
117+
<div
118+
className="absolute bottom-0 right-0 h-4 w-4 cursor-se-resize touch-none"
119+
onPointerDown={handleResizeStart}
120+
role="presentation"
121+
>
122+
<svg viewBox="0 0 10 10" className="h-full w-full text-muted-foreground" aria-hidden="true">
123+
<path d="M9 1 L1 9 M9 5 L5 9" stroke="currentColor" strokeWidth={1} fill="none" />
124+
</svg>
125+
</div>
126+
</div>
127+
);
128+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import maplibregl from "maplibre-gl";
2+
import { type RefObject, useEffect, useSyncExternalStore } from "react";
3+
import type { MapController } from "@geolibre/map";
4+
import { netcdfSeriesColor } from "../../lib/netcdf-profile-series";
5+
import {
6+
getNetcdfProfileSamples,
7+
type NetcdfProfileSample,
8+
subscribeNetcdfProfile,
9+
} from "../../lib/netcdf-profile-store";
10+
11+
/**
12+
* Builds a sampled point's marker element: a numbered dot in the point's series
13+
* color, so a line in the spectral profile and the pixel it was read from are
14+
* matched by both color and number.
15+
*
16+
* A custom element rather than MapLibre's default pin because the series colors
17+
* include `hsl(var(--primary))`, and a CSS variable does not resolve in the
18+
* `fill` *attribute* the built-in pin sets — only in a CSS property, which is
19+
* what `style.backgroundColor` writes here.
20+
*
21+
* @param sample - The sampled pixel the marker represents.
22+
* @returns The marker element.
23+
*/
24+
function buildMarkerElement(sample: NetcdfProfileSample): HTMLElement {
25+
const element = document.createElement("div");
26+
element.className =
27+
"flex h-5 w-5 items-center justify-center rounded-full border-2 border-white text-[10px] font-semibold leading-none text-white shadow-md";
28+
element.style.backgroundColor = netcdfSeriesColor(sample);
29+
element.textContent = String(sample.order);
30+
// The profile panel already lists every point as text, and a marker that
31+
// swallowed clicks would block sampling the pixel underneath it.
32+
element.style.pointerEvents = "none";
33+
element.setAttribute("aria-hidden", "true");
34+
return element;
35+
}
36+
37+
/**
38+
* Shows where each NetCDF pixel sampled with Identify was read from.
39+
*
40+
* Renders no React output: the markers are imperative map objects reconciled
41+
* against the sample store, so "Clear", the oldest point aging off the cap, and
42+
* a switch to another layer all clear the map without bookkeeping of their own.
43+
*
44+
* Mounted in the map area so its re-renders (one per sample change) stay off
45+
* the shell.
46+
*
47+
* @param props.mapControllerRef - The live map controller.
48+
* @param props.mapReadyGeneration - Bumped by the shell each time the map
49+
* (re)initialises. A ref's `.current` pointing at a new map does not re-run an
50+
* effect, so without this the markers would stay on the discarded map and not
51+
* reappear on the new one until the next click changed `samples`. The same
52+
* signal `useNetcdfIdentify` takes, for the same reason.
53+
* @returns Nothing.
54+
*/
55+
export function NetcdfSampleMarkers({
56+
mapControllerRef,
57+
mapReadyGeneration,
58+
}: {
59+
mapControllerRef: RefObject<MapController | null>;
60+
mapReadyGeneration: number;
61+
}) {
62+
const samples = useSyncExternalStore(
63+
subscribeNetcdfProfile,
64+
getNetcdfProfileSamples,
65+
getNetcdfProfileSamples,
66+
);
67+
68+
useEffect(() => {
69+
const map = mapControllerRef.current?.getMap();
70+
if (!map) return;
71+
const live = new Map<number, maplibregl.Marker>();
72+
for (const sample of samples) {
73+
live.set(
74+
sample.id,
75+
new maplibregl.Marker({ element: buildMarkerElement(sample), anchor: "center" })
76+
.setLngLat([sample.lng, sample.lat])
77+
.addTo(map),
78+
);
79+
}
80+
// Rebuilding the whole set each time keeps this to one short effect. It runs
81+
// more often than the markers actually change — every resolved band-axis
82+
// read replaces the `samples` array too, without moving or recoloring
83+
// anything — but at MAX_PROFILE_SAMPLES markers that is cheaper than the
84+
// bookkeeping to reconcile them.
85+
return () => {
86+
for (const marker of live.values()) marker.remove();
87+
};
88+
}, [samples, mapControllerRef, mapReadyGeneration]);
89+
90+
return null;
91+
}

0 commit comments

Comments
 (0)