Skip to content

Commit dc0f89a

Browse files
authored
fix(layers): hide control-rendered rasters with their group (#1720)
* fix(layers): hide control-rendered rasters with their group Turning a group off left its COG layers painted on the map. The raster control's store subscriber only reacted to `state.layers` and diffed each layer's own `visible`/`opacity`, but group state lives in `layerGroups` and never mutates a child layer, so the subscriber bailed out before it ran. A deck.gl-rendered raster has no MapLibre style layer for layer-sync to toggle, so that control push is its only channel and the raster stayed visible. The subscriber now wakes on `layerGroups` changes and diffs the group-folded values, so hiding or fading a group reaches the control. Project restore replays the folded values too, so a project saved with a hidden group no longer reopens with its rasters on the map. Because the control now holds folded values, the control -> store mirror would burn a hidden group's `false` into the layer's own `visible` on the next control event, leaving it hidden after the group is shown again. It now ignores a reported value that matches the fold and only records one that differs, which is a genuine control-side edit. Adds `effectiveLayerRenderState`, the single-layer form of `applyGroupEffects`, so the fold (including the nested-group walk and its cycle guard) has one definition. Fixes #1717 * Address Claude review feedback; grey the eye of a group-hidden layer - raster-layer-sync: the echo guard keyed on a re-derived group fold, so it decided "this is an echo" from a value coincidence rather than from provenance. It now records the folded value this module actually pushed (subscriber and project-restore replay both register) and suppresses only that, so any other control-reported value is treated as a user edit. Records are dropped when the control loses the raster and cleared on unwire. - LayerPanel: a layer hidden by its group kept a full-strength eye icon while its name was already greyed, so the row contradicted itself. The eye is now muted with the name and its tooltip says why. It deliberately stays an Eye rather than flipping to EyeOff: the button is the layer's *own* switch, and an EyeOff would offer a "Show layer" that turns that switch off, making the layer take two clicks to reveal once the group is shown. - LayerPanel: that cue read only the immediate parent, so a hidden grandparent left the row looking fully visible. It now folds through effectiveLayerRenderState and covers nested groups. * Address Claude review feedback (round 2) - raster-layer-sync: the echo guard remembered only values this module pushed, never ones it accepted, so a control-side toggle away from the pushed value and back landed on that value again and was misread as an echo and dropped — the raster would then reappear when the group was shown. The map now tracks the last value the control is known to hold, updated on accepted edits as well as pushes, and is renamed controlRenderState to say so. Adds the toggle-away-and-back regression test, which the round-1 tests missed. - effectiveLayerRenderState now also takes a prebuilt id -> group map, and LayerPanel passes its memoized groupById, so folding every row no longer rebuilds that map once per layer on each render. * Address Claude review feedback (round 3) - maplibre-raster: build the restore-path group lookup as a Map once instead of handing effectiveLayerRenderState the array inside the loop, which rebuilt it per grouped raster — the pattern that helper's own JSDoc warns against. - LayerPanel: the visibility button's tooltip explained why the eye was muted but its aria-label did not, so the group-hidden context reached a hover and not a screen reader. Both now render the same string.
1 parent f35c7a2 commit dc0f89a

6 files changed

Lines changed: 366 additions & 31 deletions

File tree

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

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { ParseKeys, TFunction } from "i18next";
1616
import {
1717
NETCDF_IMAGE_SOURCE_KIND,
1818
DEFAULT_BASEMAP,
19+
effectiveLayerRenderState,
1920
getPlanetaryBasemapById,
2021
getPlanetaryBasemapByStyleUrl,
2122
isDuckDBQueryLayer,
@@ -2950,12 +2951,22 @@ export function LayerPanel({
29502951
const isFirstOfGroup = group ? firstMemberIdByGroup.get(group.id) === layer.id : false;
29512952
const groupCollapsed = group?.collapsed ?? false;
29522953
const groupAncestorCollapsed = group ? hasCollapsedAncestor(group) : false;
2953-
// When the parent group is hidden, a layer whose own visibility
2954+
// When an ancestor group is hidden, a layer whose own visibility
29542955
// toggle is still on is not rendered — a surprising state. Grey its
2955-
// name out as a cue that the group-level setting is what's hiding
2956-
// it (issue #430). If the layer's own toggle is also off, the
2957-
// EyeOff icon already explains it, so skip the group cue then.
2958-
const groupHidden = group ? !group.visible && layer.visible : false;
2956+
// name and eye out as a cue that the group-level setting is what's
2957+
// hiding it (issue #430). If the layer's own toggle is also off,
2958+
// the EyeOff icon already explains it, so skip the group cue then.
2959+
// Folded through effectiveLayerRenderState rather than read off the
2960+
// immediate parent, so a hidden grandparent gets the cue too. Given
2961+
// the memoized `groupById` rather than the array, so folding every
2962+
// row does not rebuild that map once per layer.
2963+
const groupHidden =
2964+
layer.visible && !effectiveLayerRenderState(layer, groupById).visible;
2965+
const visibilityToggleLabel = groupHidden
2966+
? `${t("layers.hiddenByGroup")}${t("layers.hideLayer")}`
2967+
: layer.visible
2968+
? t("layers.hideLayer")
2969+
: t("layers.showLayer");
29592970
const canIdentify =
29602971
layer.type === "geojson" ||
29612972
isDuckDBQueryLayer(layer) ||
@@ -3172,15 +3183,25 @@ export function LayerPanel({
31723183
<button
31733184
type="button"
31743185
className="rounded p-0.5 hover:bg-muted"
3175-
title={layer.visible ? t("layers.hideLayer") : t("layers.showLayer")}
3176-
aria-label={layer.visible ? t("layers.hideLayer") : t("layers.showLayer")}
3186+
// The eye stays the layer's *own* switch even while a
3187+
// group hides it — showing EyeOff here would offer a
3188+
// "Show layer" that turns the layer's own toggle off,
3189+
// so revealing it later would take two clicks. The
3190+
// muted icon plus the tooltip say why it is not drawn.
3191+
// Same string for the tooltip and the accessible name,
3192+
// so the group-hidden context reaches a screen reader
3193+
// and not only a sighted hover.
3194+
title={visibilityToggleLabel}
3195+
aria-label={visibilityToggleLabel}
31773196
onClick={(e) => {
31783197
e.stopPropagation();
31793198
setLayerVisibility(layer.id, !layer.visible);
31803199
}}
31813200
>
31823201
{layer.visible ? (
3183-
<Eye className="h-3.5 w-3.5" />
3202+
<Eye
3203+
className={`h-3.5 w-3.5 ${groupHidden ? "text-muted-foreground" : ""}`}
3204+
/>
31843205
) : (
31853206
<EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
31863207
)}

packages/core/src/layer-groups.ts

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,23 +92,67 @@ export function applyGroupEffects(layers: GeoLibreLayer[], groups: LayerGroup[])
9292
if (groups.length === 0) return layers;
9393
const groupById = new Map(groups.map((g) => [g.id, g]));
9494
return layers.map((layer) => {
95-
if (!layer.groupId) return layer;
96-
let group = groupById.get(layer.groupId);
97-
if (!group) return layer;
98-
let visible = layer.visible;
99-
let opacity = layer.opacity;
100-
const visited = new Set<string>();
101-
while (group && !visited.has(group.id)) {
102-
visited.add(group.id);
103-
visible = visible && group.visible;
104-
opacity *= group.opacity;
105-
group = group.parentId ? groupById.get(group.parentId) : undefined;
106-
}
95+
if (!layer.groupId || !groupById.has(layer.groupId)) return layer;
96+
const { visible, opacity } = foldGroupChain(layer, groupById);
10797
if (visible === layer.visible && opacity === layer.opacity) return layer;
10898
return { ...layer, visible, opacity };
10999
});
110100
}
111101

102+
/**
103+
* Walk a layer's group chain upward, ANDing each group's visibility into the
104+
* layer's own and multiplying each group's opacity into it. The `visited` set
105+
* makes a corrupted `parentId` cycle terminate instead of hanging.
106+
*/
107+
function foldGroupChain(
108+
layer: GeoLibreLayer,
109+
groupById: ReadonlyMap<string, LayerGroup>,
110+
): { visible: boolean; opacity: number } {
111+
let visible = layer.visible;
112+
let opacity = layer.opacity;
113+
let group = layer.groupId ? groupById.get(layer.groupId) : undefined;
114+
const visited = new Set<string>();
115+
while (group && !visited.has(group.id)) {
116+
visited.add(group.id);
117+
visible = visible && group.visible;
118+
opacity *= group.opacity;
119+
group = group.parentId ? groupById.get(group.parentId) : undefined;
120+
}
121+
return { visible, opacity };
122+
}
123+
124+
/**
125+
* The single layer form of {@link applyGroupEffects}: the visibility and
126+
* opacity a layer should actually render at once its (possibly nested) parent
127+
* groups are folded in.
128+
*
129+
* Group state never mutates a child's own `visible`/`opacity` fields, so any
130+
* renderer that cannot read the folded array — a plugin control that owns its
131+
* own paint and is driven layer by layer — asks for a single layer here
132+
* instead.
133+
*
134+
* @param layer The layer to fold.
135+
* @param groups Group definitions, or an already-built id → group map. Callers
136+
* in a render path that fold many layers against the same groups (the layer
137+
* panel) should pass a memoized map, since the array form rebuilds one per
138+
* call.
139+
* @returns The effective render state (the layer's own values when it is
140+
* ungrouped or its `groupId` is dangling).
141+
*/
142+
export function effectiveLayerRenderState(
143+
layer: GeoLibreLayer,
144+
groups: LayerGroup[] | ReadonlyMap<string, LayerGroup>,
145+
): { visible: boolean; opacity: number } {
146+
const size = Array.isArray(groups) ? groups.length : groups.size;
147+
if (size === 0 || !layer.groupId) {
148+
return { visible: layer.visible, opacity: layer.opacity };
149+
}
150+
return foldGroupChain(
151+
layer,
152+
Array.isArray(groups) ? new Map(groups.map((g) => [g.id, g])) : groups,
153+
);
154+
}
155+
112156
/**
113157
* Indices, in store order, of every layer that belongs to `groupId`.
114158
*

packages/plugins/src/plugins/maplibre-raster.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { styleValue, useAppStore } from "@geolibre/core";
1+
import { effectiveLayerRenderState, styleValue, useAppStore } from "@geolibre/core";
22
import type { Layer } from "@deck.gl/core";
33
import type {
44
RasterControl,
@@ -17,6 +17,7 @@ import {
1717
import {
1818
isRasterControlStoreLayer,
1919
rememberLocalRasterPath,
20+
rememberControlRasterRenderState,
2021
rendersNativeMapLibreLayer,
2122
resetRasterStoreSyncSuspension,
2223
runWithRasterStoreSyncSuspended,
@@ -565,6 +566,11 @@ export function restoreRasterLayers(app: GeoLibreAppAPI): void {
565566
if (!storeLayerIds.has(info.id)) control.removeRaster(info.id);
566567
}
567568

569+
// Built once here rather than handed to effectiveLayerRenderState as an
570+
// array, which would rebuild it for every grouped raster in the loop.
571+
const restoredGroups = new Map(
572+
useAppStore.getState().layerGroups.map((group) => [group.id, group] as const),
573+
);
568574
for (const layer of useAppStore.getState().layers) {
569575
if (!isRasterControlStoreLayer(layer)) continue;
570576
if (control.getRaster(layer.id)) continue;
@@ -587,15 +593,23 @@ export function restoreRasterLayers(app: GeoLibreAppAPI): void {
587593
continue;
588594
}
589595

596+
// A parent group's visibility/opacity never touch the child layer's
597+
// own fields, so replay the folded values — otherwise a project saved
598+
// with a hidden group reopens with its rasters painted on the map.
599+
// Recorded so the first control event after the restore reads them as
600+
// this replay's echo rather than as a control-side edit.
601+
const effective = effectiveLayerRenderState(layer, restoredGroups);
602+
rememberControlRasterRenderState(layer.id, effective);
603+
590604
pending.push(
591605
control
592606
.addRaster(source, {
593607
id: layer.id,
594608
name: layer.name,
595609
state: {
596610
...savedRasterState(layer),
597-
opacity: layer.opacity,
598-
visible: layer.visible,
611+
opacity: effective.opacity,
612+
visible: effective.visible,
599613
// The zoom range lives on layer.style (the shared Style-panel
600614
// control), not in metadata.rasterState, so it is replayed here
601615
// to survive a project reload / map reinitialisation.

packages/plugins/src/plugins/raster-layer-sync.ts

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { DEFAULT_LAYER_STYLE, type GeoLibreLayer, styleValue, useAppStore } from "@geolibre/core";
1+
import {
2+
applyGroupEffects,
3+
DEFAULT_LAYER_STYLE,
4+
type GeoLibreLayer,
5+
styleValue,
6+
useAppStore,
7+
} from "@geolibre/core";
28
import type { RasterLayerInfo, RasterLayerState, RenderEngine } from "maplibre-gl-raster";
39

410
export const RASTER_SOURCE_KIND = "maplibre-gl-raster";
@@ -82,6 +88,35 @@ let syncingLayersToStore = false;
8288
// control emits raster* events synchronously from those calls, and syncing
8389
// mid-mutation would observe a partially updated layer list.
8490
let storeSyncSuspended = 0;
91+
// The last visibility/opacity this module knows each raster to hold in the
92+
// control, by layer id -- whether it got there by being pushed (a group fold,
93+
// which never lives on the child layer and so reaches a deck.gl raster only
94+
// through the control) or by a control-side edit this module accepted. The
95+
// control reports its state back on every later event, and comparing against
96+
// this is what lets syncRasterLayersToStoreWithOptions tell that echo from a
97+
// real edit. It must track *accepted* values too, not just pushed ones: with
98+
// only the pushed value recorded, a user toggling a control's checkbox away
99+
// and back would land on the pushed value again and have that second edit
100+
// misread as an echo and dropped.
101+
const controlRenderState = new Map<string, { visible?: boolean; opacity?: number }>();
102+
103+
/**
104+
* Record what the control now holds for a raster, so the next control->store
105+
* mirror can recognize it as an echo rather than a user edit. Called wherever
106+
* this module forces a group-folded value on the control -- its own store
107+
* subscriber and the project-restore replay in maplibre-raster -- and again
108+
* whenever the mirror accepts a control-side edit.
109+
*
110+
* @param id - The raster/store layer id.
111+
* @param patch - The fields the control now holds.
112+
*/
113+
export function rememberControlRasterRenderState(
114+
id: string,
115+
patch: { visible?: boolean; opacity?: number },
116+
): void {
117+
const existing = controlRenderState.get(id);
118+
controlRenderState.set(id, existing ? { ...existing, ...patch } : { ...patch });
119+
}
85120

86121
/**
87122
* Detects a layer panel entry owned by the maplibre-gl-raster control.
@@ -241,6 +276,12 @@ export function syncRasterLayersToStoreWithOptions(
241276
const infoIds = new Set(infos.map((info) => info.id));
242277
const panelCollapsed = rasterPanelCollapsedFromControl(control);
243278

279+
// A raster the control no longer holds can never echo again, and its id
280+
// could otherwise mis-suppress a future raster added under the same id.
281+
for (const id of controlRenderState.keys()) {
282+
if (!infoIds.has(id)) controlRenderState.delete(id);
283+
}
284+
244285
syncingLayersToStore = true;
245286
try {
246287
for (const storeLayer of useAppStore.getState().layers) {
@@ -272,9 +313,28 @@ export function syncRasterLayersToStoreWithOptions(
272313
const metadata =
273314
Object.keys(preserved).length > 0 ? { ...layer.metadata, ...preserved } : layer.metadata;
274315

316+
// A control still reporting the value this module last knows it to hold
317+
// is echoing that value, not recording a user edit. Mirroring the echo
318+
// would burn a hidden group's `false` into the layer's own `visible`,
319+
// leaving it hidden after the group is shown again. Anything else is a
320+
// genuine control-side change: take it, and remember it so the control's
321+
// next report is compared against the value it actually holds now.
322+
const known = controlRenderState.get(layer.id);
323+
const visibleIsEcho = known?.visible !== undefined && layer.visible === known.visible;
324+
const opacityIsEcho =
325+
known?.opacity !== undefined && numbersEqual(layer.opacity, known.opacity);
326+
const visible = visibleIsEcho ? existing.visible : layer.visible;
327+
const opacity = opacityIsEcho ? existing.opacity : layer.opacity;
328+
if (!visibleIsEcho || !opacityIsEcho) {
329+
rememberControlRasterRenderState(layer.id, {
330+
...(visibleIsEcho ? {} : { visible: layer.visible }),
331+
...(opacityIsEcho ? {} : { opacity: layer.opacity }),
332+
});
333+
}
334+
275335
if (
276-
existing.visible !== layer.visible ||
277-
existing.opacity !== layer.opacity ||
336+
existing.visible !== visible ||
337+
existing.opacity !== opacity ||
278338
existing.sourcePath !== layer.sourcePath ||
279339
!recordsEqual(existing.source, layer.source) ||
280340
!recordsEqual(existing.metadata, metadata)
@@ -283,10 +343,10 @@ export function syncRasterLayersToStoreWithOptions(
283343
// Replace metadata wholesale so stale keys (error, bounds) cannot
284344
// survive a raster being swapped out under the same id.
285345
metadata,
286-
opacity: layer.opacity,
346+
opacity,
287347
source: layer.source,
288348
sourcePath: layer.sourcePath,
289-
visible: layer.visible,
349+
visible,
290350
});
291351
}
292352
}
@@ -314,7 +374,7 @@ export function wireRasterStoreSync(control: RasterSyncableControl): void {
314374
!activeControl ||
315375
syncingLayersToStore ||
316376
isRasterStoreSyncSuspended() ||
317-
state.layers === previous.layers
377+
(state.layers === previous.layers && state.layerGroups === previous.layerGroups)
318378
) {
319379
return;
320380
}
@@ -323,22 +383,33 @@ export function wireRasterStoreSync(control: RasterSyncableControl): void {
323383
// when the previous snapshot held no control-managed rasters at all.
324384
if (!previous.layers.some(isRasterControlStoreLayer)) return;
325385

326-
const currentById = new Map(state.layers.map((layer) => [layer.id, layer]));
386+
// Diff the *group-folded* visibility and opacity, not the layers' own
387+
// fields: hiding or fading a parent group never touches a child layer, so
388+
// watching `layer.visible` alone leaves a grouped raster on the map
389+
// (GeoLibre#1717). A deck.gl-rendered raster has no MapLibre style layer
390+
// for layer-sync to toggle, so this control push is its only channel.
391+
const currentById = new Map(
392+
applyGroupEffects(state.layers, state.layerGroups).map((layer) => [layer.id, layer]),
393+
);
394+
const previousLayers = applyGroupEffects(previous.layers, previous.layerGroups);
327395
runWithRasterStoreSyncSuspended(() => {
328-
for (const layer of previous.layers) {
396+
for (const layer of previousLayers) {
329397
if (!isRasterControlStoreLayer(layer)) continue;
330398

331399
const current = currentById.get(layer.id);
332400
if (!current) {
333401
activeControl.removeRaster(layer.id);
402+
controlRenderState.delete(layer.id);
334403
continue;
335404
}
336405

337406
if (current.visible !== layer.visible) {
338407
activeControl.setVisible(layer.id, current.visible);
408+
rememberControlRasterRenderState(layer.id, { visible: current.visible });
339409
}
340410
if (current.opacity !== layer.opacity) {
341411
activeControl.setRasterState(layer.id, { opacity: current.opacity });
412+
rememberControlRasterRenderState(layer.id, { opacity: current.opacity });
342413
}
343414
const patch = rasterStatePatch(layer, current);
344415
if (patch) activeControl.setRasterState(layer.id, patch);
@@ -435,6 +506,9 @@ export function unwireRasterStoreSync(): void {
435506
storeUnsubscribe?.();
436507
storeUnsubscribe = null;
437508
syncedControl = null;
509+
// The successor control has been told nothing, so no echo of this one's
510+
// pushes can arrive; a stale record would only mis-suppress its first sync.
511+
controlRenderState.clear();
438512
}
439513

440514
/**
@@ -616,6 +690,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
616690
return typeof value === "object" && value !== null && !Array.isArray(value);
617691
}
618692

693+
// Opacity round-trips through the control as a float that a group fold has
694+
// multiplied, so compare the echo with a tolerance rather than by identity —
695+
// a last-bit difference would read as a user edit and overwrite the layer's
696+
// own opacity with the folded one.
697+
function numbersEqual(left: number, right: number): boolean {
698+
return Math.abs(left - right) < 1e-9;
699+
}
700+
619701
function serializableRasterState(state: RasterLayerState): Record<string, unknown> {
620702
// visible and opacity live on the top-level layer fields (the panel edits
621703
// them there); persisting copies here would leave two competing values in

0 commit comments

Comments
 (0)