Skip to content

Commit 3025abc

Browse files
authored
fix(netcdf): let an RGB composite be sampled like a single band (#1723)
* fix(netcdf): let an RGB composite be sampled like a single band Adding a NetCDF cube as an RGB composite added the layer but retained nothing, so Identify stayed disabled and the spectral profile never appeared. Register the composite's three channel grids and its cube reader, report all three readings in the popup, and give the layer a band summary in place of the colormap controls, which would otherwise re-bake the composite as a single band. * Address CodeRabbit review feedback - Bounds-check row/column in gridValueAt: the helper is exported, and a column past a row's width would fold into the next row and report a real value from the wrong cell rather than the documented miss. Covers the out-of-range, negative, and non-integer cases with regression tests.
1 parent b93ad29 commit 3025abc

13 files changed

Lines changed: 576 additions & 177 deletions

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ export function AddNetcdfDialog({ open, appApi, onOpenChange }: AddNetcdfDialogP
475475
scaleFactor: channels[0].scaleFactor,
476476
addOffset: channels[0].addOffset,
477477
});
478-
addImageOverlayLayer(
478+
const layerId = addImageOverlayLayer(
479479
`${baseName} - ${variable} (RGB)`,
480480
{
481481
url: encodeImageOverlay(image),
@@ -486,9 +486,25 @@ export function AddNetcdfDialog({ open, appApi, onOpenChange }: AddNetcdfDialogP
486486
// Without this the store defaults it to "kml-ground-overlay",
487487
// which every consumer of that marker would then act on.
488488
sourceKind: NETCDF_IMAGE_SOURCE_KIND,
489-
metadata: { variable },
489+
// Identify reads cell values, not features, exactly as the
490+
// single-band path does; a composite reports all three channels.
491+
metadata: { variable, pixelIdentify: true },
490492
},
491493
);
494+
// A composite is by definition built on a band axis, so the file
495+
// always stays open: it is what a click's spectral signature and the
496+
// 3-D cube view are read from.
497+
retainedFileRef.current = dataset.file;
498+
registerNetcdfLayer(layerId, {
499+
grid: channels[0],
500+
variable,
501+
...(selectedVar?.units ? { units: selectedVar.units } : {}),
502+
cube: cubeReader(dataset, variable, rgbAxis, selector),
503+
rgb: {
504+
bands: rgbBands,
505+
channels: channels as [LocalNetcdfGrid, LocalNetcdfGrid, LocalNetcdfGrid],
506+
},
507+
});
492508
appApi.fitBounds?.(image.bounds);
493509
} else if (useImagePath) {
494510
// The grid itself, not just its pixels, so the Style panel can

apps/geolibre-desktop/src/components/panels/LayerPanel.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ import {
221221
} from "../../lib/postgis-connections";
222222
import { IS_MAS_BUILD } from "../../lib/build-flags";
223223
import { isTauri } from "../../lib/is-tauri";
224-
import { getNetcdfImageSource } from "../../lib/netcdf-image-symbology";
224+
import { getNetcdfLayerState } from "../../lib/netcdf-image-symbology";
225225
import { BasemapPickerDialog } from "./BasemapPickerDialog";
226226
import { LayerPanelPlaceSearch } from "./LayerPanelPlaceSearch";
227227
import { LayerSwatchIcon } from "./LayerSwatchIcon";
@@ -584,11 +584,13 @@ function hasNativeIdentifyLayers(layer: GeoLibreLayer): boolean {
584584
// registered by a plugin, but its values are held in memory and read directly
585585
// by useNetcdfIdentify. Named here rather than given a synthetic
586586
// `nativeLayerIds`, which would make layer-sync treat it as plugin-owned and
587-
// stop drawing it. Gated on the grid actually being retained: an RGB
588-
// composite shares the source kind but registers none, and a reload drops it,
589-
// and offering Identify that answers nothing is worse than not offering it.
587+
// stop drawing it. Gated on the grids actually being retained — a project
588+
// reload drops them — since offering Identify that answers nothing is worse
589+
// than not offering it. Deliberately the layer state rather than
590+
// `getNetcdfImageSource`, which is null for an RGB composite: that has no
591+
// colormap to re-apply but does have three channels a click can read.
590592
if (layer.metadata.sourceKind === NETCDF_IMAGE_SOURCE_KIND) {
591-
return getNetcdfImageSource(layer.id) !== null;
593+
return getNetcdfLayerState(layer.id) !== null;
592594
}
593595

594596
return Array.isArray(layer.metadata.nativeLayerIds) && layer.metadata.nativeLayerIds.length > 0;

apps/geolibre-desktop/src/components/panels/NetcdfSymbologySection.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Boxes } from "lucide-react";
44
import { useState } from "react";
55
import { useTranslation } from "react-i18next";
66
import { useColormapRamps } from "../../hooks/useColormapRamps";
7+
import { bandMeasure } from "../../lib/netcdf-band-axis";
78
import { openNetcdfCubeSetup } from "../../lib/netcdf-cube-store";
89
import {
910
bakeNetcdfImage,
@@ -13,8 +14,65 @@ import {
1314
netcdfImageSymbology,
1415
warmNetcdfColormap,
1516
type NetcdfImageSymbology,
17+
type NetcdfLayerState,
1618
} from "../../lib/netcdf-image-symbology";
1719

20+
/** The three band pickers' names, red first, reused for the RGB summary rows. */
21+
const CHANNEL_LABEL_KEYS = [
22+
"addData.netcdf.channel.red",
23+
"addData.netcdf.channel.green",
24+
"addData.netcdf.channel.blue",
25+
] as const;
26+
27+
/**
28+
* What an RGB composite gets in place of the colormap controls: the three bands
29+
* it was composed from, and the way into the 3-D cube.
30+
*
31+
* There is nothing to re-apply here — the pixels came from three channels, each
32+
* stretched to its own range, and re-baking any one of them with a colormap
33+
* would replace the composite with a single-band image. So the bands are shown
34+
* rather than edited; changing them means adding the layer again.
35+
*
36+
* @param props.layerId - The composite's layer id, for the cube setup.
37+
* @param props.state - Its retained grids, which must carry `rgb`.
38+
* @returns The summary section.
39+
*/
40+
function NetcdfRgbSection({ layerId, state }: { layerId: string; state: NetcdfLayerState }) {
41+
const { t } = useTranslation();
42+
const axis = state.cube?.axis;
43+
return (
44+
<>
45+
<Separator />
46+
<div className="space-y-3">
47+
<p className="text-xs font-semibold">{t("addData.netcdf.bandCombinationLabel")}</p>
48+
<dl className="space-y-1 text-xs">
49+
{(state.rgb?.bands ?? []).map((band, channel) => (
50+
<div key={CHANNEL_LABEL_KEYS[channel]} className="flex items-baseline gap-2">
51+
<dt className="text-muted-foreground">{t(CHANNEL_LABEL_KEYS[channel])}</dt>
52+
<dd className="truncate font-medium">
53+
{axis ? bandMeasure(axis, band) : String(band)}
54+
</dd>
55+
</div>
56+
))}
57+
</dl>
58+
{/* Same condition as the single-band section's button: only a layer
59+
whose source file is still open on a band axis has a cube to read. */}
60+
{state.cube ? (
61+
<Button
62+
type="button"
63+
variant="outline"
64+
size="sm"
65+
onClick={() => openNetcdfCubeSetup(layerId)}
66+
>
67+
<Boxes className="me-1.5 h-4 w-4" aria-hidden="true" />
68+
{t("netcdfSymbology.openCube")}
69+
</Button>
70+
) : null}
71+
</div>
72+
</>
73+
);
74+
}
75+
1876
/**
1977
* Symbology for a NetCDF grid baked into an `image` overlay: the same colormap
2078
* catalogue the raster panel offers, a reverse toggle, and the color limits.
@@ -28,8 +86,12 @@ import {
2886
* case after a project reload: the baked pixels are in the project file but the
2987
* values behind them are not, so there is nothing to re-colormap.
3088
*
89+
* An RGB composite retains its grids but has no single ramp to edit, so it gets
90+
* {@link NetcdfRgbSection} — its bands, and the cube — instead.
91+
*
3192
* @param props.layer - The image layer to style.
32-
* @returns The symbology controls, or null when the grid is unavailable.
93+
* @returns The symbology controls, the RGB summary, or null when nothing about
94+
* the layer is retained.
3395
*/
3496
export function NetcdfSymbologySection({ layer }: { layer: GeoLibreLayer }) {
3597
const { t } = useTranslation();
@@ -60,6 +122,11 @@ export function NetcdfSymbologySection({ layer }: { layer: GeoLibreLayer }) {
60122
setPendingSymbology(null);
61123
}
62124

125+
// After the hooks above, not before: an early return that skipped them would
126+
// change the hook order between a single-band layer and a composite.
127+
const rgbState = getNetcdfLayerState(layer.id);
128+
if (rgbState?.rgb) return <NetcdfRgbSection layerId={layer.id} state={rgbState} />;
129+
63130
if (!source) return null;
64131

65132
// Re-baking walks every cell and then PNG-encodes the result, which is

apps/geolibre-desktop/src/components/panels/StylePanel.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import { useTranslation } from "react-i18next";
5656
import { AttributeFormSection } from "./AttributeFormSection";
5757
import { LayerJoinsSection } from "./LayerJoinsSection";
5858
import { VirtualFieldsSection } from "./VirtualFieldsSection";
59-
import { getNetcdfImageSource, NETCDF_IMAGE_SOURCE_KIND } from "../../lib/netcdf-image-symbology";
59+
import { getNetcdfLayerState, NETCDF_IMAGE_SOURCE_KIND } from "../../lib/netcdf-image-symbology";
6060
import { NetcdfProfilePanel } from "./NetcdfProfilePanel";
6161
import { NetcdfSymbologySection } from "./NetcdfSymbologySection";
6262
import { RasterSymbologySection } from "./RasterSymbologySection";
@@ -4631,11 +4631,14 @@ export function StylePanel({
46314631
}
46324632

46334633
if (!hasVectorPaintControls) {
4634-
// The section renders nothing without a retained grid, so ask here too, or
4634+
// The section renders nothing without retained grids, so ask here too, or
46354635
// the panel would suppress the fallback message and show an empty body.
4636+
// The layer state rather than `getNetcdfImageSource`, which is null for an
4637+
// RGB composite: that one has no colormap to re-apply, but it does have a
4638+
// band summary to show and pixels to sample.
46364639
const hasNetcdfSymbology =
46374640
layer.metadata.sourceKind === NETCDF_IMAGE_SOURCE_KIND &&
4638-
getNetcdfImageSource(layer.id) !== null;
4641+
getNetcdfLayerState(layer.id) !== null;
46394642
return (
46404643
<aside aria-label={t("style.panelLabel")} className={STYLE_PANEL_ASIDE_CLASS}>
46414644
{resizeHandle}
@@ -4660,8 +4663,8 @@ export function StylePanel({
46604663
{/* A NetCDF grid baked to pixels has no MapLibre paint properties,
46614664
so it lands in this branch; its colormap/limits are re-applied
46624665
by re-baking the image rather than by a style property. The
4663-
grid is dropped on a project reload, and an RGB composite never
4664-
had one, so the generic message still has to appear for those. */}
4666+
grids are dropped on a project reload, so the generic message
4667+
still has to appear for a layer restored from one. */}
46654668
{hasNetcdfSymbology ? (
46664669
<NetcdfSymbologySection layer={layer} />
46674670
) : (

apps/geolibre-desktop/src/hooks/useNetcdfIdentify.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { useAppStore } from "@geolibre/core";
2+
import type { TFunction } from "i18next";
23
import maplibregl from "maplibre-gl";
34
import { useEffect } from "react";
45
import { useTranslation } from "react-i18next";
56
import type { MapController } from "@geolibre/map";
7+
import { bandMeasure } from "../lib/netcdf-band-axis";
68
import {
79
displayUnits,
810
getNetcdfLayerState,
911
gridPixelAt,
12+
gridValueAt,
1013
NETCDF_IMAGE_SOURCE_KIND,
1114
readNetcdfProfile,
15+
type GridPixel,
16+
type NetcdfLayerState,
1217
} from "../lib/netcdf-image-symbology";
1318
import {
1419
addNetcdfProfileSample,
@@ -35,6 +40,55 @@ function formatReading(value: number, units: string | undefined): string {
3540
return unit ? `${formatValue(value)} ${unit}` : formatValue(value);
3641
}
3742

43+
/**
44+
* The channel names an RGB composite's rows are labelled with, red first — the
45+
* same three the Add dialog's band pickers carry, so a reading is named exactly
46+
* as the field that chose it.
47+
*/
48+
const CHANNEL_LABEL_KEYS = [
49+
"addData.netcdf.channel.red",
50+
"addData.netcdf.channel.green",
51+
"addData.netcdf.channel.blue",
52+
] as const;
53+
54+
/**
55+
* The value rows for one clicked cell.
56+
*
57+
* A single-band layer reports the variable it was built from. A composite
58+
* reports all three channels instead: they are the same variable at three
59+
* bands, so naming it three times would say nothing, where the channel and the
60+
* band it was drawn from say everything. The three share a geometry, so the
61+
* cell `gridPixelAt` found on the red channel addresses the other two directly.
62+
*
63+
* @param state - The layer's retained grids.
64+
* @param pixel - The cell under the click.
65+
* @param t - The translator, for the channel names and the no-data marker.
66+
* @returns Label/value pairs, in display order.
67+
*/
68+
function valueRows(
69+
state: NetcdfLayerState,
70+
pixel: GridPixel,
71+
t: TFunction,
72+
): Array<[string, string]> {
73+
const reading = (value: number | null): string =>
74+
value === null ? t("netcdfIdentify.noData") : formatReading(value, state.units);
75+
76+
const rgb = state.rgb;
77+
if (!rgb) return [[state.variable, reading(pixel.value)]];
78+
79+
const axis = state.cube?.axis;
80+
return rgb.bands.map((band, channel) => {
81+
const name = t(CHANNEL_LABEL_KEYS[channel]);
82+
return [
83+
// The axis is gone once the file behind a second cube closed it, and with
84+
// it any way to say which wavelength this was; the channel name alone
85+
// still reads correctly.
86+
axis ? `${name} (${bandMeasure(axis, band)})` : name,
87+
reading(gridValueAt(rgb.channels[channel], pixel.row, pixel.column)),
88+
] as [string, string];
89+
});
90+
}
91+
3892
/**
3993
* Bridges the store's `identifyLayerId` to a NetCDF image layer's retained grid.
4094
*
@@ -107,12 +161,7 @@ export function useNetcdfIdentify(
107161
const container = document.createElement("div");
108162
container.className = "space-y-0.5 text-xs";
109163
const rows: Array<[string, string]> = [
110-
[
111-
state.variable,
112-
pixel.value === null
113-
? t("netcdfIdentify.noData")
114-
: formatReading(pixel.value, state.units),
115-
],
164+
...valueRows(state, pixel, t),
116165
[t("netcdfIdentify.coordinates"), `${pixel.lng.toFixed(5)}, ${pixel.lat.toFixed(5)}`],
117166
[t("netcdfIdentify.cell"), `${pixel.row}, ${pixel.column}`],
118167
];

apps/geolibre-desktop/src/lib/netcdf-band-axis.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,28 @@ export function axisOptionLabel(axis: LocalNetcdfAxis, coordinate: number, index
4747
return `${measure} (${axis.name} ${index})`;
4848
}
4949

50+
/**
51+
* `"650.41 nm"` — just the band's measure, for places where the axis and the
52+
* channel it feeds are already named around it (the identify popup's red/green/
53+
* blue rows, the Style panel's band summary). Shorter than {@link bandLabel} on
54+
* purpose: those sit inside a row that is already a label.
55+
*
56+
* An axis with no coordinate values, or none at this index, has no measure to
57+
* show, so it falls back to naming the position — the only thing known about it.
58+
*
59+
* @param axis - The band axis.
60+
* @param index - The band's position along it.
61+
* @returns The coordinate with its units, or `"bands 47"`.
62+
*/
63+
export function bandMeasure(axis: LocalNetcdfAxis, index: number): string {
64+
const coordinate = axis.values?.[index];
65+
if (coordinate === undefined) return `${axis.name} ${index}`;
66+
const rounded = Number.isInteger(coordinate) ? String(coordinate) : coordinate.toFixed(2);
67+
// Without units the bare number reads as nothing in particular, so keep the
68+
// axis name in front of it rather than showing a naked "47".
69+
return axis.units ? `${rounded} ${axis.units}` : `${axis.name} ${rounded}`;
70+
}
71+
5072
/** How one band index should read in a picker, whatever the axis carries. */
5173
export function bandLabel(axis: LocalNetcdfAxis, index: number): string {
5274
const coordinate = axis.values?.[index];

0 commit comments

Comments
 (0)