feat: along-track profile for ICESat-2 / GEDI footprint layers (#3089)

* feat: along-track profile for ICESat-2 / GEDI footprint layers

Add Layer actions → Along-track profile for footprint layers: a
movable, resizable window that charts distance along one beam against
ground and canopy top (ICESat-2 ATL08: h_te_best_fit and + h_canopy;
GEDI L2A / L2B: elev_lowestmode and + rh98 / rh100), the surface height
(ATL06), or up to three numeric fields.

- Linked to the map both ways: hovering drops a marker on the
  footprint, clicking selects it (standard map highlight), and a
  footprint selected on the map or in the attribute table is marked on
  the chart, switching beams when needed.
- Wheel zooms along the track around the cursor, drag pans,
  double-click resets; lines break at missing values and real gaps
  (over five times the typical spacing); PNG and CSV export.
- New footprint layers are tagged metadata.sourceKind
  "spaceborne-lidar" with the product and beams, and features get a
  stable id so map selection matches them exactly (the Python helpers
  in #3087 do the same). Untagged layers whose points carry beam and
  distance_km are recognized too.

Part of #3078 (Phase 2: along-track profile view).

* Address review feedback

- AlongTrackProfileWindow: a click or tap selects the footprint nearest
  the pointer from the event itself, so touch and pen taps (which never
  set a hover) select too; one helper serves hover and click.
- AlongTrackProfileWindow: reset the zoom only when the beam or the
  series keys change, so a layer edit such as a restyle keeps the zoom.
- AddSpaceborneLidarDialog: keep any metadata the store set when tagging
  the layer.

* Address review feedback

- AlongTrackProfileWindow: follow a selected footprint's beam only when
  the selection changes, so the beam dropdown works while a footprint
  stays selected (the effect no longer depends on the beam).
- AlongTrackProfileWindow: hover and click pick a footprint only within
  16 px of the pointer, so a click inside a plotted gap selects nothing
  instead of a distant footprint.
This commit is contained in:
Qiusheng Wu authored and GitHub committed 2026-10-10 01:29:13 -04:00
1 parent e2956c93c1
commit 35facfacbf
11 files changed
+1454 -1

No files matched your search

@@ -19,6 +19,7 @@ import {
import { FileUp, Satellite } from "lucide-react";
import { useDialogResize } from "../../hooks/useDialogResize";
import { buildSymbologyStyle } from "../../lib/assistant/symbology";
import { SPACEBORNE_LIDAR_SOURCE_KIND } from "../../lib/along-track-profile";
import { pointBounds } from "../../lib/point-bounds";
import { openLocalDataFileWithFallback } from "../../lib/tauri-io";
import {
@@ -277,6 +278,17 @@ export function AddSpaceborneLidarDialog({
const store = useAppStore.getState();
const name = `${file.product.id} ${baseName(fileName)}`;
const id = store.addGeoJsonLayer(name, result.geojson, fileName);
// Tag the layer so the Layers panel offers its along-track profile.
const added = useAppStore.getState().layers.find((entry) => entry.id === id);
store.updateLayer(id, {
metadata: {
// Keep whatever the store put there; updateLayer replaces the object.
...added?.metadata,
sourceKind: SPACEBORNE_LIDAR_SOURCE_KIND,
product: file.product.id,
beams: result.perBeam.filter((entry) => entry.kept > 0).map((entry) => entry.beam),
},
});
const layer = useAppStore.getState().layers.find((entry) => entry.id === id);
let style: Parameters<typeof store.setLayerStyle>[1] = { circleRadius: FOOTPRINT_RADIUS };
if (layer && colorBy) {
@@ -0,0 +1,688 @@
import { useAppStore, type GeoLibreLayer } from "@geolibre/core";
import type { MapEngine } from "@geolibre/map";
import { Button } from "@geolibre/ui";
import { Download, GripVertical, Image as ImageIcon, LineChart, RotateCcw, X } from "lucide-react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type PointerEvent as ReactPointerEvent,
type RefObject,
type WheelEvent as ReactWheelEvent,
} from "react";
import { useTranslation } from "react-i18next";
import { useFloatingPanelRect } from "../../hooks/useFloatingPanelRect";
import {
buildProfilePoints,
fieldSeries,
nearestProfileIndex,
profileBeams,
profileCsv,
profileFields,
profileGapThreshold,
profilePath,
profilePresets,
spaceborneLidarProduct,
type ProfileSeriesDef,
} from "../../lib/along-track-profile";
import {
closeAlongTrackProfile,
getAlongTrackProfileLayerId,
subscribeAlongTrackProfile,
} from "../../lib/along-track-profile-store";
import { downloadChartPng, triggerDownload } from "../../lib/chart-export";
import { niceTickValues } from "../../lib/netcdf-profile-series";
import { sanitizeExportFileName } from "../../lib/vector-export";
import { paletteColor } from "../panels/charts/chart-colors";
const PANEL_MIN_W = 420;
const PANEL_MIN_H = 300;
const PANEL_MARGIN = 12;
// Below the NetCDF profile window's top, so both headers stay grabbable.
const FALLBACK_RECT = { x: PANEL_MARGIN, y: 96, w: 640, h: 380 };
const MARGIN = { top: 12, right: 16, bottom: 34, left: 58 };
const AXIS = "hsl(var(--border))";
const TICK = "hsl(var(--muted-foreground))";
/** Ground in an earth tone and canopy in green, readable in light and dark. */
const PRESET_COLORS = ["#b45309", "#16a34a"];
/** How many fields "Fields" mode plots at once. */
const MAX_FIELDS = 3;
const MIN_SPAN_KM = 0.05;
/** How far, in pixels, a hover or click may be from a footprint to pick it. */
const PICK_RADIUS_PX = 16;
function formatNumber(value: number): string {
if (!Number.isFinite(value)) return "";
const abs = Math.abs(value);
if (abs !== 0 && (abs >= 1e6 || abs < 1e-3)) return value.toExponential(2);
return new Intl.NumberFormat(undefined, { maximumFractionDigits: abs >= 100 ? 1 : 3 }).format(
value,
);
}
/**
* The along-track profile of an ICESat-2 / GEDI footprint layer: distance along
* one beam against ground and canopy height (or any numeric field), in a
* movable window over the map.
*
* Linked to the map both ways: hovering the chart drops a marker on that
* footprint, clicking selects it (the map highlights it), and selecting a
* footprint on the map marks it on the chart, switching beams if needed.
* Wheel to zoom along the track, drag to pan, double-click to reset.
*
* @param props.mapControllerRef The primary map engine, for the hover marker.
* @returns The window, or null while no footprint layer is charted.
*/
export function AlongTrackProfileWindow({
mapControllerRef,
}: {
mapControllerRef: RefObject<MapEngine | null>;
}) {
const layerId = useSyncExternalStore(
subscribeAlongTrackProfile,
getAlongTrackProfileLayerId,
getAlongTrackProfileLayerId,
);
const layer = useAppStore((state) =>
layerId ? state.layers.find((candidate) => candidate.id === layerId) : undefined,
);
// The layer was removed (or a project replaced it): close instead of
// charting a stale id.
useEffect(() => {
if (layerId && !layer) closeAlongTrackProfile();
}, [layerId, layer]);
if (!layerId || !layer) return null;
return <ProfileWindow key={layer.id} layer={layer} mapControllerRef={mapControllerRef} />;
}
function ProfileWindow({
layer,
mapControllerRef,
}: {
layer: GeoLibreLayer;
mapControllerRef: RefObject<MapEngine | null>;
}) {
const { t } = useTranslation();
const { panelRef, rect, handleDragStart, handleResizeStart } = useFloatingPanelRect({
minWidth: PANEL_MIN_W,
minHeight: PANEL_MIN_H,
margin: PANEL_MARGIN,
fallback: FALLBACK_RECT,
});
const selectLayer = useAppStore((state) => state.selectLayer);
const selectFeature = useAppStore((state) => state.selectFeature);
const selectedFeatureId = useAppStore((state) =>
state.selectedLayerId === layer.id ? state.selectedFeatureId : null,
);
const product = spaceborneLidarProduct(layer);
const beams = useMemo(() => profileBeams(layer), [layer]);
const fields = useMemo(() => profileFields(layer), [layer]);
const presets = useMemo(() => profilePresets(product, fields), [product, fields]);
// Open on a strong / power beam when there is one: they hold the most
// footprints and the cleanest returns.
const [beam, setBeam] = useState(
() => (beams.find((b) => b.type === "strong" || b.type === "power") ?? beams[0])?.name ?? "",
);
const [mode, setMode] = useState<string>(presets[0]?.id ?? "fields");
const [chosenFields, setChosenFields] = useState<string[]>(() => fields.slice(0, 1));
const series: ProfileSeriesDef[] = useMemo(() => {
const preset = presets.find((candidate) => candidate.id === mode);
if (preset) return preset.series;
return chosenFields.filter((name) => fields.includes(name)).map(fieldSeries);
}, [presets, mode, chosenFields, fields]);
const colors = series.map((_, index) =>
presets.some((candidate) => candidate.id === mode)
? (PRESET_COLORS[index] ?? paletteColor(index))
: paletteColor(index),
);
const seriesLabel = (def: ProfileSeriesDef) =>
def.labelKey ? t(`alongTrackProfile.series.${def.labelKey}`) : def.key;
const points = useMemo(() => buildProfilePoints(layer, beam, series), [layer, beam, series]);
const gap = useMemo(() => profileGapThreshold(points), [points]);
const fullDomain = useMemo<[number, number]>(() => {
if (points.length === 0) return [0, 1];
const first = points[0].distance;
const last = points[points.length - 1].distance;
return last > first ? [first, last] : [first - 0.5, last + 0.5];
}, [points]);
const [xDomain, setXDomain] = useState<[number, number] | null>(null);
// A new beam or field set starts zoomed out.
// Keyed on the series keys, not the array: a layer edit (a restyle) rebuilds
// the definitions with the same content and must not undo the user's zoom.
const seriesKey = series.map((def) => def.key).join("|");
useEffect(() => setXDomain(null), [beam, seriesKey]);
const domain = xDomain ?? fullDomain;
// Map selection → chart: follow a footprint selected on another beam. Only
// when the selection itself changes, so the user can still pick another
// beam from the dropdown while a footprint stays selected.
useEffect(() => {
if (selectedFeatureId === null) return;
const feature = useAppStore
.getState()
.layers.find((candidate) => candidate.id === layer.id)
?.geojson?.features.find(
(candidate, index) => String(candidate.id ?? index) === selectedFeatureId,
);
const selectedBeam = (feature?.properties as Record<string, unknown> | null)?.beam;
if (typeof selectedBeam === "string") setBeam(selectedBeam);
}, [selectedFeatureId, layer.id]);
// Chart size follows the window.
const plotRef = useRef<HTMLDivElement | null>(null);
const [size, setSize] = useState({ width: 600, height: 240 });
useLayoutEffect(() => {
const element = plotRef.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) setSize({ width, height });
});
observer.observe(element);
return () => observer.disconnect();
}, []);
const innerW = Math.max(10, size.width - MARGIN.left - MARGIN.right);
const innerH = Math.max(10, size.height - MARGIN.top - MARGIN.bottom);
// The points inside the x domain (sorted, so two binary searches).
const visible = useMemo(() => {
if (points.length === 0) return points;
const start = Math.max(0, nearestProfileIndex(points, domain[0]) - 1);
const end = Math.min(points.length, nearestProfileIndex(points, domain[1]) + 2);
return points.slice(start, end);
}, [points, domain]);
const yDomain = useMemo<[number, number]>(() => {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
// Only footprints inside the window: the one beyond each edge is kept for
// drawing the line into view, but must not stretch the y range.
const inside = visible.filter((p) => p.distance >= domain[0] && p.distance <= domain[1]);
for (const point of inside.length > 0 ? inside : visible) {
for (const value of point.values) {
if (value === null) continue;
if (value < min) min = value;
if (value > max) max = value;
}
}
if (!Number.isFinite(min)) return [0, 1];
const pad = (max - min) * 0.05 || 1;
return [min - pad, max + pad];
}, [visible, domain]);
const x = useCallback(
(distance: number) => MARGIN.left + ((distance - domain[0]) / (domain[1] - domain[0])) * innerW,
[domain, innerW],
);
const y = useCallback(
(value: number) =>
MARGIN.top + innerH - ((value - yDomain[0]) / (yDomain[1] - yDomain[0])) * innerH,
[yDomain, innerH],
);
const distanceAt = (px: number) =>
domain[0] + ((px - MARGIN.left) / innerW) * (domain[1] - domain[0]);
const paths = useMemo(
() => series.map((_, index) => profilePath(visible, index, x, y, gap)),
[series, visible, x, y, gap],
);
const xTicks = niceTickValues(domain[0], domain[1], Math.max(2, Math.floor(innerW / 90)));
const yTicks = niceTickValues(yDomain[0], yDomain[1], Math.max(2, Math.floor(innerH / 45)));
// Hover: a crosshair on the chart and a marker on the map.
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const hoverMarker = useRef<(() => void) | null>(null);
const hoverFrame = useRef<number | null>(null);
const clearHoverMarker = useCallback(() => {
if (hoverFrame.current !== null) cancelAnimationFrame(hoverFrame.current);
hoverFrame.current = null;
hoverMarker.current?.();
hoverMarker.current = null;
}, []);
useEffect(() => clearHoverMarker, [clearHoverMarker]);
const hovered = hoverIndex !== null ? points[hoverIndex] : undefined;
useEffect(() => {
if (!hovered) {
clearHoverMarker();
return;
}
if (hoverFrame.current !== null) cancelAnimationFrame(hoverFrame.current);
hoverFrame.current = requestAnimationFrame(() => {
hoverFrame.current = null;
hoverMarker.current?.();
hoverMarker.current =
mapControllerRef.current?.showSearchResult({
type: "Point",
coordinates: hovered.coordinates,
}) ?? null;
});
}, [hovered, mapControllerRef, clearHoverMarker]);
const selectedIndex = useMemo(
() =>
selectedFeatureId === null
? -1
: points.findIndex((point) => point.featureId === selectedFeatureId),
[points, selectedFeatureId],
);
// Wheel zoom around the cursor; drag to pan.
const svgRef = useRef<SVGSVGElement | null>(null);
const drag = useRef<{ startX: number; domain: [number, number]; moved: boolean } | null>(null);
const localX = (clientX: number) => clientX - (svgRef.current?.getBoundingClientRect().left ?? 0);
const clampDomain = (lo: number, hi: number): [number, number] => {
const [fullLo, fullHi] = fullDomain;
const span = Math.min(Math.max(hi - lo, MIN_SPAN_KM), fullHi - fullLo);
let start = Math.max(fullLo, Math.min(lo, fullHi - span));
if (start + span > fullHi) start = fullHi - span;
return [start, start + span];
};
const onWheel = (event: ReactWheelEvent<SVGSVGElement>) => {
if (points.length === 0) return;
const px = localX(event.clientX);
const factor = event.deltaY > 0 ? 1.25 : 0.8;
// From the latest domain, not this render's: wheel events can arrive
// faster than React re-renders, and each must zoom from the last.
setXDomain((previous) => {
const [lo, hi] = previous ?? fullDomain;
const center = lo + ((px - MARGIN.left) / innerW) * (hi - lo);
return clampDomain(center - (center - lo) * factor, center + (hi - center) * factor);
});
};
const onPointerDown = (event: ReactPointerEvent<SVGSVGElement>) => {
drag.current = { startX: event.clientX, domain, moved: false };
event.currentTarget.setPointerCapture(event.pointerId);
};
/** The footprint nearest a pointer x, or null outside the plot or in a gap. */
const nearestInView = (clientX: number): number | null => {
const px = localX(clientX);
if (px < MARGIN.left || px > MARGIN.left + innerW || points.length === 0) return null;
const nearest = nearestProfileIndex(points, distanceAt(px));
// In a gap, the nearest footprint may sit outside the view or far from
// the pointer; take none rather than a distant one.
const inView =
nearest >= 0 &&
points[nearest].distance >= domain[0] &&
points[nearest].distance <= domain[1] &&
Math.abs(x(points[nearest].distance) - px) <= PICK_RADIUS_PX;
return inView ? nearest : null;
};
const onPointerMove = (event: ReactPointerEvent<SVGSVGElement>) => {
const active = drag.current;
if (active && Math.abs(event.clientX - active.startX) > 3) {
active.moved = true;
const shift =
((event.clientX - active.startX) / innerW) * (active.domain[1] - active.domain[0]);
setXDomain(clampDomain(active.domain[0] - shift, active.domain[1] - shift));
setHoverIndex(null);
return;
}
setHoverIndex(nearestInView(event.clientX));
};
const onPointerUp = (event: ReactPointerEvent<SVGSVGElement>) => {
const active = drag.current;
drag.current = null;
// Click or tap: select the footprint under the pointer (from the event,
// not the hover state, which a touch tap never sets).
const picked = active && !active.moved ? nearestInView(event.clientX) : null;
if (picked !== null) {
selectLayer(layer.id);
selectFeature(points[picked].featureId);
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const labels = series.map(seriesLabel);
const exportBase = sanitizeExportFileName(`${layer.name}-${beam}-profile`);
const [exportError, setExportError] = useState<string | null>(null);
const downloadPng = () => {
const svg = svgRef.current;
if (!svg) return;
setExportError(null);
downloadChartPng(svg, size.width, size.height, `${exportBase}.png`).catch((error: unknown) =>
setExportError(error instanceof Error ? error.message : t("alongTrackProfile.exportError")),
);
};
const downloadCsv = () => {
triggerDownload(
new Blob([profileCsv(points, labels)], { type: "text/csv;charset=utf-8" }),
`${exportBase}.csv`,
);
};
const toggleField = (name: string) =>
setChosenFields((current) =>
current.includes(name)
? current.filter((existing) => existing !== name)
: [...current, name].slice(-MAX_FIELDS),
);
const tooltipLeft = hovered
? Math.min(Math.max(x(hovered.distance), MARGIN.left), MARGIN.left + innerW)
: 0;
const tooltipOnLeft = tooltipLeft > size.width / 2;
return (
<div
ref={panelRef}
className={
rect
? "pointer-events-auto absolute z-20 flex flex-col overflow-hidden rounded-lg border bg-background shadow-xl"
: "pointer-events-auto absolute start-3 top-24 z-20 flex h-[24rem] max-h-[calc(100%-9rem)] w-[min(40rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-lg border bg-background shadow-xl"
}
style={rect ? { left: rect.x, top: rect.y, width: rect.w, height: rect.h } : undefined}
role="region"
aria-label={t("alongTrackProfile.heading")}
data-testid="along-track-profile-window"
>
<div
className="flex cursor-move touch-none select-none items-center justify-between gap-2 border-b px-3 py-2"
onPointerDown={handleDragStart}
>
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<LineChart className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
<span className="truncate">
{t("alongTrackProfile.heading")} · {layer.name}
</span>
</div>
<button
type="button"
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring"
onClick={closeAlongTrackProfile}
aria-label={t("alongTrackProfile.close")}
title={t("alongTrackProfile.close")}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2 text-xs">
<label className="flex items-center gap-1">
{t("alongTrackProfile.beam")}
<select
className="rounded border bg-background px-1 py-0.5"
value={beam}
onChange={(event) => setBeam(event.target.value)}
>
{beams.map((entry) => (
<option key={entry.name} value={entry.name}>
{entry.type ? `${entry.name} (${entry.type})` : entry.name} · {entry.count}
</option>
))}
</select>
</label>
<label className="flex items-center gap-1">
{t("alongTrackProfile.show")}
<select
className="rounded border bg-background px-1 py-0.5"
value={mode}
onChange={(event) => setMode(event.target.value)}
>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{t(`alongTrackProfile.preset.${preset.labelKey}`)}
</option>
))}
<option value="fields">{t("alongTrackProfile.preset.fields")}</option>
</select>
</label>
{mode === "fields" && (
<div
className="flex flex-wrap items-center gap-1"
role="group"
aria-label={t("alongTrackProfile.fields")}
>
{fields.map((name) => (
<button
key={name}
type="button"
aria-pressed={chosenFields.includes(name)}
className={
chosenFields.includes(name)
? "rounded border border-primary bg-primary px-1.5 py-0.5 text-primary-foreground"
: "rounded border px-1.5 py-0.5"
}
onClick={() => toggleField(name)}
>
{name}
</button>
))}
</div>
)}
<div className="ms-auto flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setXDomain(null)}
disabled={xDomain === null}
title={t("alongTrackProfile.resetZoom")}
aria-label={t("alongTrackProfile.resetZoom")}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={downloadPng}
title={t("alongTrackProfile.exportPng")}
aria-label={t("alongTrackProfile.exportPng")}
>
<ImageIcon className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={downloadCsv}
disabled={points.length === 0}
title={t("alongTrackProfile.exportCsv")}
aria-label={t("alongTrackProfile.exportCsv")}
>
<Download className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="relative min-h-0 flex-1 p-2" dir="ltr">
<div ref={plotRef} className="relative h-full w-full">
{points.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t("alongTrackProfile.empty")}</p>
) : (
<svg
ref={svgRef}
width={size.width}
height={size.height}
className="touch-none select-none"
role="img"
aria-label={t("alongTrackProfile.chartLabel", { beam })}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={() => {
if (!drag.current) setHoverIndex(null);
}}
onDoubleClick={() => setXDomain(null)}
>
<defs>
<clipPath id={`atp-clip-${layer.id}`}>
<rect x={MARGIN.left} y={MARGIN.top} width={innerW} height={innerH} />
</clipPath>
</defs>
{yTicks.map((tick) => (
<g key={`y${tick}`}>
<line
x1={MARGIN.left}
x2={MARGIN.left + innerW}
y1={y(tick)}
y2={y(tick)}
stroke={AXIS}
strokeOpacity={0.5}
/>
<text
x={MARGIN.left - 6}
y={y(tick)}
textAnchor="end"
dominantBaseline="middle"
fontSize={10}
fill={TICK}
>
{formatNumber(tick)}
</text>
</g>
))}
{xTicks.map((tick) => (
<text
key={`x${tick}`}
x={x(tick)}
y={MARGIN.top + innerH + 14}
textAnchor="middle"
fontSize={10}
fill={TICK}
>
{formatNumber(tick)}
</text>
))}
<line
x1={MARGIN.left}
x2={MARGIN.left + innerW}
y1={MARGIN.top + innerH}
y2={MARGIN.top + innerH}
stroke={AXIS}
/>
<line
x1={MARGIN.left}
x2={MARGIN.left}
y1={MARGIN.top}
y2={MARGIN.top + innerH}
stroke={AXIS}
/>
<text
x={MARGIN.left + innerW / 2}
y={size.height - 4}
textAnchor="middle"
fontSize={11}
fill={TICK}
>
{t("alongTrackProfile.distanceAxis")}
</text>
<text
transform={`translate(12 ${MARGIN.top + innerH / 2}) rotate(-90)`}
textAnchor="middle"
fontSize={11}
fill={TICK}
>
{mode === "fields" ? labels.join(", ") : t("alongTrackProfile.elevationAxis")}
</text>
<g clipPath={`url(#atp-clip-${layer.id})`}>
{paths.map((d, index) => (
<path
key={series[index].key}
d={d}
fill="none"
stroke={colors[index]}
strokeWidth={1.5}
strokeLinejoin="round"
/>
))}
{selectedIndex >= 0 &&
points[selectedIndex].values.map((value, index) =>
value === null ? null : (
<circle
key={`sel${series[index]?.key}`}
cx={x(points[selectedIndex].distance)}
cy={y(value)}
r={5}
fill="none"
stroke="hsl(var(--foreground))"
strokeWidth={2}
/>
),
)}
{hovered && (
<>
<line
x1={x(hovered.distance)}
x2={x(hovered.distance)}
y1={MARGIN.top}
y2={MARGIN.top + innerH}
stroke={TICK}
strokeDasharray="3 3"
/>
{hovered.values.map((value, index) =>
value === null ? null : (
<circle
key={`hov${series[index]?.key}`}
cx={x(hovered.distance)}
cy={y(value)}
r={3.5}
fill={colors[index]}
/>
),
)}
</>
)}
</g>
</svg>
)}
{hovered && (
<div
className="pointer-events-none absolute top-2 z-10 rounded border bg-popover px-2 py-1 text-xs text-popover-foreground shadow"
style={
tooltipOnLeft ? { right: size.width - tooltipLeft + 8 } : { left: tooltipLeft + 8 }
}
>
<div>
{t("alongTrackProfile.tooltipDistance", { value: formatNumber(hovered.distance) })}
</div>
{hovered.values.map((value, index) => (
<div key={series[index]?.key} style={{ color: colors[index] }}>
{labels[index]}: {value === null ? "—" : formatNumber(value)}
</div>
))}
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-3 border-t px-3 py-1.5 text-xs text-muted-foreground">
{labels.map((label, index) => (
<span key={series[index].key} className="flex items-center gap-1">
<span className="inline-block h-0.5 w-4" style={{ backgroundColor: colors[index] }} />
{label}
</span>
))}
<span className="ms-auto">{t("alongTrackProfile.hint")}</span>
{exportError && <span className="text-destructive">{exportError}</span>}
</div>
{/* Physically bottom-right in every locale: useFloatingPanelRect grows
the width with a rightward drag, so the grip must not mirror. */}
<div
className="absolute bottom-0 h-4 w-4 cursor-se-resize touch-none"
style={{ right: 0 }}
onPointerDown={handleResizeStart}
role="presentation"
>
<svg viewBox="0 0 10 10" className="h-full w-full text-muted-foreground" aria-hidden="true">
<path d="M9 1 L1 9 M9 5 L5 9" stroke="currentColor" strokeWidth={1} fill="none" />
</svg>
</div>
</div>
);
}
@@ -70,6 +70,7 @@ import { NetcdfSampleMarkers } from "./NetcdfSampleMarkers";
import { NetcdfCubeSetupDialog } from "./NetcdfCubeSetupDialog";
import { NetcdfCubeWindow } from "./NetcdfCubeWindow";
import { NetcdfProfileWindow } from "./NetcdfProfileWindow";
import { AlongTrackProfileWindow } from "./AlongTrackProfileWindow";
import { hasElevationConsent } from "../../lib/elevation-consent";
import { MapLegendPanel } from "../legend/MapLegendPanel";
import { RasterSubsetPanel } from "./RasterSubsetPanel";
@@ -888,6 +889,7 @@ export function DesktopShell({
<BoundsRestrictionIndicator />
<QuickAnalysisBanner />
<NetcdfProfileWindow />
<AlongTrackProfileWindow mapControllerRef={mapControllerRef} />
<MountWhenOpened isOpen={(ui) => ui.styleManagerOpen}>
<Suspense fallback={null}>
<StyleManagerPanel />
@@ -55,6 +55,7 @@ import {
FolderPlus,
LassoSelect,
Library,
LineChart,
Locate,
Lock,
MousePointerClick,
@@ -76,6 +77,8 @@ import {
Upload,
X,
} from "lucide-react";
import { isSpaceborneLidarLayer } from "../../../lib/along-track-profile";
import { openAlongTrackProfile } from "../../../lib/along-track-profile-store";
import { canOpenLayerAttributeTable } from "../../../lib/attribute-table-source";
import type { CollaborationApi } from "../../../hooks/useCollaboration";
import {
@@ -268,6 +271,7 @@ export function LayerActionsMenuItems({
const canMaterializeDuckDB =
isDuckDBQueryLayer(layer) && typeof layer.metadata.query === "string";
const canOpenAttributeTable = canOpenLayerAttributeTable(layer);
const canOpenAlongTrackProfile = layer.type === "geojson" && isSpaceborneLidarLayer(layer);
// The interactive selection dialogs (#1314) resolve selection ids
// against in-store features, like the highlight overlay does, and
// inspecting which features match is a read of the layer's data.
@@ -574,6 +578,12 @@ export function LayerActionsMenuItems({
{t("layers.openAttributeTable")}
</DropdownMenuItem>
)}
{canOpenAlongTrackProfile && (
<DropdownMenuItem onSelect={() => openAlongTrackProfile(layer.id)}>
<LineChart className="me-2 h-3.5 w-3.5" />
{t("alongTrackProfile.menuItem")}
</DropdownMenuItem>
)}
{canSelectFeatures && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
@@ -10462,5 +10462,33 @@
"unrecognizedProduct": "Not a recognized ICESat-2 or GEDI product. Supported: ATL06, ATL08, GEDI L2A, GEDI L2B, GEDI L4A.",
"noBeams": "The {{product}} file has no beams with footprints."
}
},
"alongTrackProfile": {
"menuItem": "Along-track profile",
"heading": "Along-track profile",
"close": "Close the profile",
"beam": "Beam",
"show": "Show",
"fields": "Fields to plot",
"preset": {
"groundCanopy": "Ground and canopy top",
"surface": "Surface height",
"fields": "Fields"
},
"series": {
"ground": "Ground",
"canopyTop": "Canopy top",
"surfaceHeight": "Surface height"
},
"resetZoom": "Reset zoom",
"exportPng": "Download the chart as PNG",
"exportCsv": "Download the profile as CSV",
"exportError": "Could not export the chart.",
"empty": "No footprints with values on this beam.",
"chartLabel": "Along-track profile of beam {{beam}}",
"distanceAxis": "Along-track distance (km)",
"elevationAxis": "Elevation (m)",
"tooltipDistance": "{{value}} km",
"hint": "Wheel to zoom, drag to pan, click to select a footprint."
}
}
@@ -0,0 +1,51 @@
/**
* Which footprint layer the along-track profile window charts (null when it is
* closed). Opened from a layer's actions menu; the window reads the layer from
* the app store, so it follows edits and closes itself if the layer goes away.
*/
let layerId: string | null = null;
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of [...listeners]) listener();
}
/**
* Open the profile for a layer (replacing any open one).
*
* @param id The footprint layer's id.
*/
export function openAlongTrackProfile(id: string): void {
layerId = id;
emit();
}
/** Close the profile window. */
export function closeAlongTrackProfile(): void {
if (layerId === null) return;
layerId = null;
emit();
}
/**
* The charted layer's id, or null when the window is closed.
*
* @returns The layer id.
*/
export function getAlongTrackProfileLayerId(): string | null {
return layerId;
}
/**
* Subscribe to open / close changes (for `useSyncExternalStore`).
*
* @param listener Called after each change.
* @returns Unsubscribe.
*/
export function subscribeAlongTrackProfile(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
@@ -0,0 +1,337 @@
/**
* Data for the along-track profile of an ICESat-2 / GEDI footprint layer:
* distance along the track (x) against elevation or any numeric field (y), one
* beam at a time, so ground and canopy read as two lines along the orbit.
*
* Footprint layers come from Add Data → ICESat-2 / GEDI or the Python
* `Map.add_icesat2` / `add_gedi`; both tag the layer with
* `metadata.sourceKind = "spaceborne-lidar"` and give every point `beam`,
* `beam_type`, `time` and `distance_km`.
*/
import type { GeoLibreLayer } from "@geolibre/core";
import type { Feature, Geometry } from "geojson";
/** `metadata.sourceKind` of a footprint layer. */
export const SPACEBORNE_LIDAR_SOURCE_KIND = "spaceborne-lidar";
/** Properties every footprint carries; never offered as a plotted field. */
const BASE_PROPERTIES = new Set(["beam", "beam_type", "time", "distance_km"]);
const PRODUCT_PREFIX = /^(ATL06|ATL08|GEDI_L2A|GEDI_L2B|GEDI_L4A)\b/;
/** Label keys under `alongTrackProfile.series`. */
export type ProfileSeriesLabelKey = "ground" | "canopyTop" | "surfaceHeight";
/** Label keys under `alongTrackProfile.preset`. */
export type ProfilePresetLabelKey = "groundCanopy" | "surface";
/** One line on the chart: a label and how to read its value from a footprint. */
export interface ProfileSeriesDef {
key: string;
/** i18n key under `alongTrackProfile.series`, or null to show `key` as is. */
labelKey: ProfileSeriesLabelKey | null;
value: (properties: Record<string, unknown>) => number | null;
}
/** A named set of series, e.g. ground and canopy top. */
export interface ProfilePreset {
id: string;
/** i18n key under `alongTrackProfile.preset`. */
labelKey: ProfilePresetLabelKey;
series: ProfileSeriesDef[];
}
/** One plotted footprint. */
export interface ProfilePoint {
distance: number;
/** One value per series, null where the footprint has none. */
values: (number | null)[];
/** The store feature id, for selecting the footprint on the map. */
featureId: string;
coordinates: [number, number];
}
/** A beam present in the layer. */
export interface ProfileBeam {
name: string;
type: string | null;
count: number;
}
function numberOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function features(layer: GeoLibreLayer): Feature<Geometry | null>[] {
return (layer.geojson?.features ?? []) as Feature<Geometry | null>[];
}
/**
* Whether a layer holds ICESat-2 / GEDI footprints the profile can chart.
*
* @param layer A store layer.
* @returns True for a tagged footprint layer, or an untagged GeoJSON layer
* whose points carry `beam` and `distance_km`.
*/
export function isSpaceborneLidarLayer(layer: GeoLibreLayer): boolean {
if (
(layer.metadata as { sourceKind?: unknown } | undefined)?.sourceKind ===
SPACEBORNE_LIDAR_SOURCE_KIND
) {
return true;
}
const first = features(layer)[0];
const props = first?.properties as Record<string, unknown> | null | undefined;
return (
first?.geometry?.type === "Point" &&
typeof props?.beam === "string" &&
typeof props?.distance_km === "number"
);
}
/**
* The product a footprint layer came from.
*
* @param layer A footprint layer.
* @returns The product id from metadata or the default layer name, or null.
*/
export function spaceborneLidarProduct(layer: GeoLibreLayer): string | null {
const product = (layer.metadata as { product?: unknown } | undefined)?.product;
if (typeof product === "string") return product;
return PRODUCT_PREFIX.exec(layer.name)?.[1] ?? null;
}
/**
* The beams in a footprint layer, in name order.
*
* @param layer A footprint layer.
* @returns Each beam with its type and footprint count.
*/
export function profileBeams(layer: GeoLibreLayer): ProfileBeam[] {
const beams = new Map<string, ProfileBeam>();
for (const feature of features(layer)) {
const props = (feature.properties ?? {}) as Record<string, unknown>;
if (typeof props.beam !== "string") continue;
const entry = beams.get(props.beam);
if (entry) entry.count += 1;
else {
beams.set(props.beam, {
name: props.beam,
type: typeof props.beam_type === "string" ? props.beam_type : null,
count: 1,
});
}
}
return [...beams.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/**
* The numeric fields a user can plot, in first-seen order.
*
* @param layer A footprint layer.
* @returns Property names with at least one finite number.
*/
export function profileFields(layer: GeoLibreLayer): string[] {
const seen = new Set<string>();
const numeric: string[] = [];
for (const feature of features(layer)) {
for (const [key, value] of Object.entries(feature.properties ?? {})) {
if (BASE_PROPERTIES.has(key) || seen.has(key)) continue;
if (numberOrNull(value) !== null) {
seen.add(key);
numeric.push(key);
}
}
}
return numeric;
}
const field =
(name: string) =>
(properties: Record<string, unknown>): number | null =>
numberOrNull(properties[name]);
const sum =
(base: string, height: string) =>
(properties: Record<string, unknown>): number | null => {
const ground = numberOrNull(properties[base]);
const above = numberOrNull(properties[height]);
return ground === null || above === null ? null : ground + above;
};
/**
* The preset views for a product, given the fields the layer actually has:
* ground with canopy top (ICESat-2 ATL08, GEDI L2A / L2B) or the surface
* height (ATL06), so the chart opens on the profile people expect.
*
* @param product The product id, or null.
* @param fields The layer's numeric fields.
* @returns Presets whose fields are all present (possibly none).
*/
export function profilePresets(product: string | null, fields: string[]): ProfilePreset[] {
const has = (...names: string[]) => names.every((name) => fields.includes(name));
const presets: ProfilePreset[] = [];
const groundAndCanopy = (ground: string, canopy: string): ProfilePreset => ({
id: "ground-canopy",
labelKey: "groundCanopy",
series: [
{ key: ground, labelKey: "ground", value: field(ground) },
{ key: `${ground}+${canopy}`, labelKey: "canopyTop", value: sum(ground, canopy) },
],
});
if (product === "ATL08" && has("h_te_best_fit", "h_canopy")) {
presets.push(groundAndCanopy("h_te_best_fit", "h_canopy"));
} else if (product === "GEDI_L2A" && has("elev_lowestmode", "rh98")) {
presets.push(groundAndCanopy("elev_lowestmode", "rh98"));
} else if (product === "GEDI_L2B" && has("elev_lowestmode", "rh100")) {
presets.push(groundAndCanopy("elev_lowestmode", "rh100"));
} else if (product === "ATL06" && has("h_li")) {
presets.push({
id: "surface",
labelKey: "surface",
series: [{ key: "h_li", labelKey: "surfaceHeight", value: field("h_li") }],
});
}
return presets;
}
/**
* A series definition that plots one property as is.
*
* @param name The property name.
* @returns The series definition.
*/
export function fieldSeries(name: string): ProfileSeriesDef {
return { key: name, labelKey: null, value: field(name) };
}
/**
* The plotted points of one beam, sorted by along-track distance.
*
* Store feature ids follow the GeoJSON layer convention: the feature's own
* `id`, else its index in the collection.
*
* @param layer A footprint layer.
* @param beam The beam to plot.
* @param series The lines to plot.
* @returns Points with at least one value.
*/
export function buildProfilePoints(
layer: GeoLibreLayer,
beam: string,
series: ProfileSeriesDef[],
): ProfilePoint[] {
const points: ProfilePoint[] = [];
features(layer).forEach((feature, index) => {
const props = (feature.properties ?? {}) as Record<string, unknown>;
if (props.beam !== beam || feature.geometry?.type !== "Point") return;
const distance = numberOrNull(props.distance_km);
if (distance === null) return;
const values = series.map((def) => def.value(props));
if (values.every((value) => value === null)) return;
const [lng, lat] = feature.geometry.coordinates;
points.push({
distance,
values,
featureId: String(feature.id ?? index),
coordinates: [lng, lat],
});
});
points.sort((a, b) => a.distance - b.distance);
return points;
}
/**
* The point nearest an along-track distance (binary search; points sorted).
*
* @param points Points sorted by distance.
* @param distance The distance to look up, in km.
* @returns The index of the nearest point, or -1 when there are none.
*/
export function nearestProfileIndex(points: ProfilePoint[], distance: number): number {
if (points.length === 0) return -1;
let lo = 0;
let hi = points.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (points[mid].distance < distance) lo = mid + 1;
else hi = mid;
}
if (lo > 0 && distance - points[lo - 1].distance < points[lo].distance - distance) return lo - 1;
return lo;
}
/**
* An SVG path through a series, broken where a value is missing or where
* footprints are further apart than `maxGapKm` (a cloud gap, a dropped orbit
* segment), so the line never bridges data that is not there.
*
* @param points Points sorted by distance.
* @param seriesIndex Which value to draw.
* @param x Maps a distance to an x pixel.
* @param y Maps a value to a y pixel.
* @param maxGapKm The largest step drawn as a connected line.
* @returns The `d` attribute.
*/
export function profilePath(
points: ProfilePoint[],
seriesIndex: number,
x: (distance: number) => number,
y: (value: number) => number,
maxGapKm: number,
): string {
let path = "";
let previous: ProfilePoint | null = null;
for (const point of points) {
const value = point.values[seriesIndex];
if (value === null) {
previous = null;
continue;
}
const connect = previous !== null && point.distance - previous.distance <= maxGapKm;
path += `${connect ? "L" : "M"}${x(point.distance).toFixed(1)} ${y(value).toFixed(1)}`;
previous = point;
}
return path;
}
/**
* The largest along-track step still drawn as a line: a few times the typical
* spacing (ATL08 segments are 100 m, GEDI shots 60 m), so real gaps break it.
*
* @param points Points sorted by distance.
* @returns The gap threshold in km.
*/
export function profileGapThreshold(points: ProfilePoint[]): number {
if (points.length < 3) return Number.POSITIVE_INFINITY;
const steps: number[] = [];
for (let i = 1; i < points.length; i += 1) {
const step = points[i].distance - points[i - 1].distance;
if (step > 0) steps.push(step);
}
if (steps.length === 0) return Number.POSITIVE_INFINITY;
steps.sort((a, b) => a - b);
return steps[Math.floor(steps.length / 2)] * 5;
}
/**
* The plotted profile as CSV: distance, then one column per series.
*
* @param points The plotted points.
* @param labels One column header per series.
* @returns CSV text.
*/
export function profileCsv(points: ProfilePoint[], labels: string[]): string {
const escape = (text: string) => (/[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text);
const rows = [["distance_km", "longitude", "latitude", ...labels].map(escape).join(",")];
for (const point of points) {
rows.push(
[
point.distance,
point.coordinates[0],
point.coordinates[1],
...point.values.map((value) => (value === null ? "" : value)),
].join(","),
);
}
return `${rows.join("\n")}\n`;
}
+1 -1
View File
@@ -23,7 +23,7 @@ To collect supported dataset links from a catalog or other webpage and open seve
| **Encoded Polyline** | Loads Google (precision 5) or Valhalla/Mapbox (precision 6) encoded polyline strings from pasted text or uploaded text files. |
| **MBTiles Layer** | Loads a local MBTiles tile archive (desktop app). |
| **OSM PBF Layer** | Reads an OpenStreetMap `.osm.pbf` extract and adds the features you select from it. |
| **ICESat-2 / GEDI** | Spaceborne LiDAR footprints from an ICESat-2 (ATL06, ATL08) or GEDI (L2A, L2B, L4A) HDF5 granule, from a local file or the sample granules on Source Cooperative (or found and downloaded with [NASA Earthaccess](web-services.md#nasa-earthaccess)), added as a point layer colored by the product's main field. Plot a field against `distance_km` in the attribute table's Charts for an along-track profile. |
| **ICESat-2 / GEDI** | Spaceborne LiDAR footprints from an ICESat-2 (ATL06, ATL08) or GEDI (L2A, L2B, L4A) HDF5 granule, from a local file or the sample granules on Source Cooperative (or found and downloaded with [NASA Earthaccess](web-services.md#nasa-earthaccess)), added as a point layer colored by the product's main field. Open **Layer actions → Along-track profile** for distance along a beam against ground and canopy top (ICESat-2 ATL08, GEDI L2A/L2B) or any field; it is linked to the map, so hovering marks the footprint and clicking selects it. |
A layer the Add Vector panel loads in GeoJSON mode, with up to 100,000 features, is handed over to GeoLibre once it has loaded, so it gets the same Style panel as a file dropped on the map: joins, virtual fields, the attribute form, popup design, editor tracking, diagrams, and geometry generators included. It then leaves the panel's own layer list and is managed from the Layers panel. The panel keeps the layers it draws as tiles, streamed GeoParquet, larger layers, and KML/KMZ layers with placemark icons; for those, the Style panel shows only the settings the panel can draw. A saved project still reopens a handed-over layer from its URL or file path, and a URL-backed one refreshes the same way.
@@ -545,6 +545,9 @@ class SpaceborneLidarGranule implements SpaceborneLidarFile {
}
features.push({
type: "Feature",
// A stable id lets the map select a footprint unambiguously (and the
// along-track profile link to it); property matching cannot.
id: features.length,
geometry: { type: "Point", coordinates: [round(lon[i], 7), round(lat[i], 7)] },
properties,
});
+169
View File
@@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { GeoLibreLayer } from "@geolibre/core";
import {
buildProfilePoints,
fieldSeries,
isSpaceborneLidarLayer,
nearestProfileIndex,
profileBeams,
profileCsv,
profileFields,
profileGapThreshold,
profilePath,
profilePresets,
spaceborneLidarProduct,
} from "../apps/geolibre-desktop/src/lib/along-track-profile";
function footprint(id: number, beam: string, distance: number, extra: Record<string, unknown>) {
return {
type: "Feature" as const,
id,
geometry: { type: "Point" as const, coordinates: [-84 + distance / 100, 35] },
properties: {
beam,
beam_type: beam.endsWith("l") ? "strong" : "weak",
time: "2023-06-29T23:02:40.000Z",
distance_km: distance,
...extra,
},
};
}
function layer(features: ReturnType<typeof footprint>[], overrides: Partial<GeoLibreLayer> = {}) {
return {
id: "L1",
name: "ATL08 ATL08_20230629",
type: "geojson",
visible: true,
opacity: 1,
source: { type: "geojson" },
style: {},
metadata: {},
geojson: { type: "FeatureCollection", features },
...overrides,
} as unknown as GeoLibreLayer;
}
const ATL08 = layer([
footprint(0, "gt1l", 0.2, { h_te_best_fit: 300, h_canopy: 12 }),
footprint(1, "gt1l", 0.1, { h_te_best_fit: 299, h_canopy: null }),
footprint(2, "gt1r", 0.1, { h_te_best_fit: 310, h_canopy: 5 }),
footprint(3, "gt1l", 0.3, { h_te_best_fit: null, h_canopy: null }),
footprint(4, "gt1l", 5.0, { h_te_best_fit: 305, h_canopy: 20 }),
]);
describe("along-track profile layers", () => {
it("recognizes tagged and untagged footprint layers", () => {
assert.equal(
isSpaceborneLidarLayer(
layer([], { metadata: { sourceKind: "spaceborne-lidar" } } as Partial<GeoLibreLayer>),
),
true,
);
assert.equal(isSpaceborneLidarLayer(ATL08), true);
const plain = layer([]);
(plain.geojson as { features: unknown[] }).features = [
{
type: "Feature",
geometry: { type: "Point", coordinates: [0, 0] },
properties: { name: "x" },
},
];
assert.equal(isSpaceborneLidarLayer(plain), false);
});
it("reads the product from metadata or the default name", () => {
assert.equal(spaceborneLidarProduct(ATL08), "ATL08");
assert.equal(
spaceborneLidarProduct(
layer([], { name: "x", metadata: { product: "GEDI_L4A" } } as Partial<GeoLibreLayer>),
),
"GEDI_L4A",
);
assert.equal(spaceborneLidarProduct(layer([], { name: "My shots" })), null);
});
it("lists beams with types and counts, and plottable fields", () => {
assert.deepEqual(profileBeams(ATL08), [
{ name: "gt1l", type: "strong", count: 4 },
{ name: "gt1r", type: "weak", count: 1 },
]);
assert.deepEqual(profileFields(ATL08), ["h_te_best_fit", "h_canopy"]);
});
});
describe("along-track profile series", () => {
it("offers ground and canopy top for ATL08, GEDI L2A and L2B", () => {
const [preset] = profilePresets("ATL08", ["h_te_best_fit", "h_canopy"]);
assert.equal(preset.id, "ground-canopy");
assert.equal(preset.series[1].value({ h_te_best_fit: 300, h_canopy: 12 }), 312);
assert.equal(preset.series[1].value({ h_te_best_fit: 300, h_canopy: null }), null);
assert.equal(profilePresets("GEDI_L2A", ["elev_lowestmode", "rh98"])[0].id, "ground-canopy");
assert.equal(profilePresets("GEDI_L2B", ["elev_lowestmode", "rh100"])[0].id, "ground-canopy");
assert.equal(profilePresets("ATL06", ["h_li"])[0].id, "surface");
// Missing fields or another product: no preset, Fields mode only.
assert.deepEqual(profilePresets("ATL08", ["h_te_best_fit"]), []);
assert.deepEqual(profilePresets("GEDI_L4A", ["agbd"]), []);
});
it("builds one beam's points sorted by distance with store feature ids", () => {
const [preset] = profilePresets("ATL08", profileFields(ATL08));
const points = buildProfilePoints(ATL08, "gt1l", preset.series);
// The footprint with no values at all is dropped.
assert.deepEqual(
points.map((p) => [p.distance, p.featureId, ...p.values]),
[
[0.1, "1", 299, null],
[0.2, "0", 300, 312],
[5.0, "4", 305, 325],
],
);
assert.deepEqual(points[0].coordinates, [-84 + 0.001, 35]);
});
it("falls back to the feature index when a feature has no id", () => {
const noIds = layer([footprint(9, "gt1l", 1, { h_li: 5 })]);
delete (noIds.geojson!.features[0] as { id?: unknown }).id;
assert.equal(buildProfilePoints(noIds, "gt1l", [fieldSeries("h_li")])[0].featureId, "0");
});
it("finds the nearest point by distance", () => {
const points = buildProfilePoints(ATL08, "gt1l", [fieldSeries("h_te_best_fit")]);
assert.equal(nearestProfileIndex(points, -1), 0);
assert.equal(nearestProfileIndex(points, 0.16), 1);
assert.equal(nearestProfileIndex(points, 3), 2);
assert.equal(nearestProfileIndex(points, 99), 2);
assert.equal(nearestProfileIndex([], 1), -1);
});
it("breaks the line at missing values and real gaps", () => {
const [preset] = profilePresets("ATL08", profileFields(ATL08));
const points = buildProfilePoints(ATL08, "gt1l", preset.series);
const x = (d: number) => d * 10;
const y = (v: number) => v;
// Ground: 0.1 -> 0.2 connected, then a 4.8 km jump starts a new run.
assert.equal(profilePath(points, 0, x, y, 1), "M1.0 299.0L2.0 300.0M50.0 305.0");
// Canopy top: the first footprint has none, so the line starts at 0.2.
assert.equal(profilePath(points, 1, x, y, 10), "M2.0 312.0L50.0 325.0");
});
it("sets the gap threshold from the typical spacing", () => {
const evenly = layer(
[0, 0.1, 0.2, 0.3, 2.0].map((d, i) => footprint(i, "gt1l", d, { h_li: 1 })),
);
const points = buildProfilePoints(evenly, "gt1l", [fieldSeries("h_li")]);
assert.ok(Math.abs(profileGapThreshold(points) - 0.5) < 1e-9);
assert.equal(profileGapThreshold(points.slice(0, 2)), Number.POSITIVE_INFINITY);
});
it("exports the plotted profile as CSV", () => {
const [preset] = profilePresets("ATL08", profileFields(ATL08));
const points = buildProfilePoints(ATL08, "gt1l", preset.series);
const csv = profileCsv(points, ["Ground", "Canopy top"]);
const lines = csv.trim().split("\n");
assert.equal(lines[0], "distance_km,longitude,latitude,Ground,Canopy top");
assert.equal(lines[1], "0.1,-83.999,35,299,");
assert.equal(lines.length, 4);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { act, fireEvent, render, screen, useAppStore } from "./helpers/dom";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createElement } from "react";
import type { GeoLibreLayer } from "@geolibre/core";
// Loaded after the harness so its CSS imports and Vite globals are handled.
const { AlongTrackProfileWindow } =
await import("../apps/geolibre-desktop/src/components/layout/AlongTrackProfileWindow");
const { openAlongTrackProfile, closeAlongTrackProfile, getAlongTrackProfileLayerId } =
await import("../apps/geolibre-desktop/src/lib/along-track-profile-store");
function footprint(id: number, beam: string, distance: number, ground: number, canopy: number) {
return {
type: "Feature",
id,
geometry: { type: "Point", coordinates: [-84 + distance / 100, 35] },
properties: {
beam,
beam_type: beam === "gt1l" ? "strong" : "weak",
time: "2023-06-29T23:02:40.000Z",
distance_km: distance,
h_te_best_fit: ground,
h_canopy: canopy,
},
};
}
const LAYER = {
id: "atl08",
name: "ATL08 ATL08_test",
type: "geojson",
visible: true,
opacity: 1,
source: { type: "geojson" },
style: {},
metadata: { sourceKind: "spaceborne-lidar", product: "ATL08", beams: ["gt1l", "gt1r"] },
geojson: {
type: "FeatureCollection",
features: [
footprint(0, "gt1l", 0, 300, 10),
footprint(1, "gt1l", 0.1, 302, 12),
footprint(2, "gt1l", 0.2, 305, 15),
footprint(3, "gt1r", 0, 310, 5),
footprint(4, "gt1r", 0.1, 311, 6),
],
},
} as unknown as GeoLibreLayer;
/** Render the window over a store holding the footprint layer. */
function renderWindow() {
const markers: unknown[] = [];
const engine = {
showSearchResult: (geometry: unknown) => {
markers.push(geometry);
return () => undefined;
},
};
act(() => {
useAppStore.setState({ layers: [LAYER] });
openAlongTrackProfile(LAYER.id);
});
render(
createElement(AlongTrackProfileWindow, { mapControllerRef: { current: engine as never } }),
);
return { markers };
}
function beamSelect(): HTMLSelectElement {
return screen.getByRole("combobox", { name: "Beam" }) as HTMLSelectElement;
}
describe("AlongTrackProfileWindow", () => {
it("stays closed until a layer's profile is opened", () => {
act(() => closeAlongTrackProfile());
render(createElement(AlongTrackProfileWindow, { mapControllerRef: { current: null } }));
assert.equal(screen.queryByTestId("along-track-profile-window"), null);
});
it("opens on the strong beam with ground and canopy top", () => {
renderWindow();
assert.ok(screen.getByTestId("along-track-profile-window"));
assert.equal(beamSelect().value, "gt1l");
const options = [...beamSelect().options].map((option) => option.textContent);
assert.deepEqual(options, ["gt1l (strong) · 3", "gt1r (weak) · 2"]);
assert.ok(screen.getByText("Ground"));
assert.ok(screen.getByText("Canopy top"));
assert.ok(screen.getByRole("img", { name: "Along-track profile of beam gt1l" }));
act(() => closeAlongTrackProfile());
});
it("follows a footprint selected on another beam", () => {
renderWindow();
act(() => {
useAppStore.getState().selectLayer(LAYER.id);
useAppStore.getState().selectFeature("4");
});
assert.equal(beamSelect().value, "gt1r");
act(() => closeAlongTrackProfile());
});
it("marks the hovered footprint on the map and selects it on click", async () => {
const { markers } = renderWindow();
const svg = screen.getByRole("img", { name: "Along-track profile of beam gt1l" });
// The plot spans x = 58..584 of the default 600 px; the middle is 0.1 km.
act(() => {
fireEvent.pointerMove(svg, { clientX: 321, clientY: 100 });
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
assert.deepEqual(markers.at(-1), { type: "Point", coordinates: [-84 + 0.001, 35] });
act(() => {
fireEvent.pointerDown(svg, { clientX: 321, clientY: 100, pointerId: 1 });
fireEvent.pointerUp(svg, { clientX: 321, clientY: 100, pointerId: 1 });
});
const state = useAppStore.getState();
assert.equal(state.selectedLayerId, LAYER.id);
assert.equal(state.selectedFeatureId, "1");
act(() => closeAlongTrackProfile());
});
it("keeps the beam the user picks while a footprint stays selected", () => {
renderWindow();
act(() => {
useAppStore.getState().selectLayer(LAYER.id);
useAppStore.getState().selectFeature("1");
});
act(() => {
fireEvent.change(beamSelect(), { target: { value: "gt1r" } });
});
assert.equal(beamSelect().value, "gt1r");
act(() => closeAlongTrackProfile());
});
it("selects on a tap without a prior hover", () => {
renderWindow();
const svg = screen.getByRole("img", { name: "Along-track profile of beam gt1l" });
act(() => {
fireEvent.pointerDown(svg, { clientX: 584, clientY: 100, pointerId: 2 });
fireEvent.pointerUp(svg, { clientX: 584, clientY: 100, pointerId: 2 });
});
assert.equal(useAppStore.getState().selectedFeatureId, "2");
act(() => closeAlongTrackProfile());
});
it("closes itself when the layer is removed", () => {
renderWindow();
act(() => useAppStore.setState({ layers: [] }));
assert.equal(screen.queryByTestId("along-track-profile-window"), null);
assert.equal(getAlongTrackProfileLayerId(), null);
});
});