diff --git a/.changeset/core-visible-when-layer-ids.md b/.changeset/core-visible-when-layer-ids.md new file mode 100644 index 00000000..239f7304 --- /dev/null +++ b/.changeset/core-visible-when-layer-ids.md @@ -0,0 +1,5 @@ +--- +"@mapsight/core": minor +--- + +Derive layer visibility from `visibleWhenLayerIds` after each map reduce diff --git a/packages/core/src/js/lib/map/__tests__/schema.test.ts b/packages/core/src/js/lib/map/__tests__/schema.test.ts index 6df3433f..544f80d2 100644 --- a/packages/core/src/js/lib/map/__tests__/schema.test.ts +++ b/packages/core/src/js/lib/map/__tests__/schema.test.ts @@ -63,6 +63,23 @@ describe("map config schema", () => { expect(result.success).toBe(false); }); + it("parses visibleWhenLayerIds on layer metadata", () => { + const result = layerConfigSchema.safeParse({ + type: "TileLayer", + metaData: { + visibleWhenLayerIds: ["base", "theme"], + }, + options: {visible: false}, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.metaData?.visibleWhenLayerIds).toEqual([ + "base", + "theme", + ]); + } + }); + it("parses stadtplan-style map config with cluster options", () => { const result = mapConfigSchema.safeParse({ layers: { diff --git a/packages/core/src/js/lib/map/lib/WithLayerOverlays.ts b/packages/core/src/js/lib/map/lib/WithLayerOverlays.ts index 4c1c44c8..253dacab 100644 --- a/packages/core/src/js/lib/map/lib/WithLayerOverlays.ts +++ b/packages/core/src/js/lib/map/lib/WithLayerOverlays.ts @@ -5,12 +5,13 @@ import forEach from "lodash/forEach"; import type {LayerDefinition} from "@/lib/map/lib/WithLayers"; import type {VectorFeatureSourceLayer} from "@/lib/map/types"; +import {Z_INDEX_OVERLAY} from "@/lib/map/z-index"; import {di, updateProxyObject} from "@/ol-proxy"; import WithMap from "./WithMap"; import {getIdForLayer, tagLayer} from "./tagLayer"; -export const Z_INDEX_OVERLAY = 2; +export {Z_INDEX_OVERLAY}; export const LAYER_GROUP_DEFAULT = "default"; export const LAYER_TYPE = "VectorOverlayLayer"; @@ -37,8 +38,8 @@ export default class WithLayerOverlays extends WithMap { ) => { const oldDefinition = oldDefinitions[id]; const overlayDefinition = newDefinition && { - zIndex: Z_INDEX_OVERLAY, ...newDefinition, + zIndex: Z_INDEX_OVERLAY, }; // update overlay diff --git a/packages/core/src/js/lib/map/lib/WithLayers.test.ts b/packages/core/src/js/lib/map/lib/WithLayers.test.ts new file mode 100644 index 00000000..2c391691 --- /dev/null +++ b/packages/core/src/js/lib/map/lib/WithLayers.test.ts @@ -0,0 +1,65 @@ +import {describe, expect, it} from "vitest"; + +import {ACTION_NOOP, ACTION_SET} from "@/lib/base/reducer"; +import {MapController} from "@/lib/map/controller"; +import type {LayerState, MapState} from "@/lib/map/types"; + +import WithLayers from "./WithLayers"; + +function tileLayer( + visible: boolean, + metaData: LayerState["metaData"] = {}, +): LayerState { + return { + type: "TileLayer", + metaData, + options: {visible}, + }; +} + +function mapState(layers: Record): MapState { + return { + layers, + size: [0, 0], + }; +} + +describe("WithLayers dependent visibility", () => { + const controller = new WithLayers("map"); + + it("derives visibleWhenLayerIds after the action is applied", () => { + const before = mapState({ + base: tileLayer(true, {isBaseLayer: true}), + theme: tileLayer(true), + companion: tileLayer(false, { + visibleWhenLayerIds: ["base", "theme"], + }), + }); + + const afterShow = controller.reduce(before, {type: ACTION_NOOP}); + expect(afterShow.layers.companion?.options?.visible).toBe(true); + + const afterHideTheme = controller.reduce(afterShow, { + type: ACTION_SET, + path: ["layers", "theme", "options", "visible"], + value: false, + }); + expect(afterHideTheme.layers.theme?.options?.visible).toBe(false); + expect(afterHideTheme.layers.companion?.options?.visible).toBe(false); + }); + + it("is applied on MapController so store reduces derive visibility", () => { + const controller = new MapController("map"); + const next = controller.reduce( + mapState({ + base: tileLayer(true), + theme: tileLayer(true), + companion: tileLayer(false, { + visibleWhenLayerIds: ["base", "theme"], + }), + }), + {type: ACTION_NOOP}, + ); + expect(next.layers.companion?.options?.visible).toBe(true); + }); +}); diff --git a/packages/core/src/js/lib/map/lib/WithLayers.ts b/packages/core/src/js/lib/map/lib/WithLayers.ts index 74ff3f7c..0cd696fd 100644 --- a/packages/core/src/js/lib/map/lib/WithLayers.ts +++ b/packages/core/src/js/lib/map/lib/WithLayers.ts @@ -8,17 +8,19 @@ import {ensureNonNullable} from "@mapsight/lib-js/nonNullable"; import matchesPath from "@mapsight/lib-redux/matchesPath"; import reducers from "@mapsight/lib-redux/reducers/immutable-path"; +import {ACTION_SET} from "@/lib/base/reducer"; import type {MapController} from "@/lib/map/controller"; -import type {LayerState} from "@/lib/map/types"; +import type {LayerState, MapState} from "@/lib/map/types"; import {di, updateProxyObject} from "@/ol-proxy"; +import type {Action, State} from "@/types"; -import {ACTION_SET} from "../../base/reducer"; import { FIT_MAP_VIEW_TO_LAYER_FEATURE, FIT_MAP_VIEW_TO_LAYER_SOURCE_EXTENT, } from "../actions"; import WithAnimations from "./WithAnimations"; import proxyPassOpenLayersEventsToMapController from "./proxyPassOpenLayersEventsToMapController"; +import {syncDependentLayerVisibility} from "./syncDependentLayerVisibility"; import {getGroupForLayer, tagLayer} from "./tagLayer"; export type LayerDefinition = LayerState; @@ -186,4 +188,17 @@ export default class WithLayers extends WithAnimations { return state; }); } + + override reduce( + state: MapState = {} as MapState, + action: Action, + _globalState?: State, + ): MapState { + const next = super.reduce(state, action, _globalState); + if (!next.layers) { + return next; + } + const layers = syncDependentLayerVisibility(next.layers); + return layers === next.layers ? next : {...next, layers}; + } } diff --git a/packages/core/src/js/lib/map/lib/syncDependentLayerVisibility.test.ts b/packages/core/src/js/lib/map/lib/syncDependentLayerVisibility.test.ts new file mode 100644 index 00000000..4dfbf6e9 --- /dev/null +++ b/packages/core/src/js/lib/map/lib/syncDependentLayerVisibility.test.ts @@ -0,0 +1,74 @@ +import {describe, expect, it} from "vitest"; + +import type {LayerState} from "@/lib/map/types"; + +import {syncDependentLayerVisibility} from "./syncDependentLayerVisibility"; + +function tileLayer( + visible: boolean, + visibleWhenLayerIds?: string[], +): LayerState { + return { + type: "TileLayer", + metaData: visibleWhenLayerIds ? {visibleWhenLayerIds} : {}, + options: {visible}, + }; +} + +describe("syncDependentLayerVisibility", () => { + it("shows a dependent layer only when every required layer is visible", () => { + const layers = { + base: tileLayer(true), + theme: tileLayer(true), + companion: tileLayer(false, ["base", "theme"]), + }; + + const next = syncDependentLayerVisibility(layers); + + expect(next.companion?.options?.visible).toBe(true); + expect(next).not.toBe(layers); + }); + + it("hides a dependent layer when any required layer is off", () => { + const layers = { + base: tileLayer(true), + theme: tileLayer(false), + companion: tileLayer(true, ["base", "theme"]), + }; + + expect( + syncDependentLayerVisibility(layers).companion?.options?.visible, + ).toBe(false); + }); + + it("treats a missing required layer as not visible", () => { + const layers = { + base: tileLayer(true), + companion: tileLayer(true, ["base", "theme"]), + }; + + expect( + syncDependentLayerVisibility(layers).companion?.options?.visible, + ).toBe(false); + }); + + it("returns the same object when nothing changes", () => { + const layers = { + base: tileLayer(true), + theme: tileLayer(true), + companion: tileLayer(true, ["base", "theme"]), + markers: tileLayer(true), + }; + + expect(syncDependentLayerVisibility(layers)).toBe(layers); + }); + + it("leaves layers without visibleWhenLayerIds unchanged", () => { + const layers = { + base: tileLayer(true), + other: tileLayer(false), + }; + + expect(syncDependentLayerVisibility(layers)).toBe(layers); + }); +}); diff --git a/packages/core/src/js/lib/map/lib/syncDependentLayerVisibility.ts b/packages/core/src/js/lib/map/lib/syncDependentLayerVisibility.ts new file mode 100644 index 00000000..ef0c29a8 --- /dev/null +++ b/packages/core/src/js/lib/map/lib/syncDependentLayerVisibility.ts @@ -0,0 +1,41 @@ +import type {LayerState} from "@/lib/map/types"; + +/** + * Derive visibility for layers that declare `metaData.visibleWhenLayerIds`. + * Every listed layer must currently be visible (AND). Missing ids count as off. + */ +export function syncDependentLayerVisibility( + layers: Record, +): Record { + let result = layers; + let copied = false; + + for (const [id, layer] of Object.entries(layers)) { + const requiredIds = layer.metaData?.visibleWhenLayerIds; + if (!requiredIds?.length) { + continue; + } + + const desired = requiredIds.every( + (requiredId) => layers[requiredId]?.options?.visible === true, + ); + const current = layer.options?.visible === true; + if (desired === current) { + continue; + } + + if (!copied) { + result = {...layers}; + copied = true; + } + result[id] = { + ...layer, + options: { + ...layer.options, + visible: desired, + } as LayerState["options"], + }; + } + + return result; +} diff --git a/packages/core/src/js/lib/map/schema.ts b/packages/core/src/js/lib/map/schema.ts index b30cd3de..7dc70135 100644 --- a/packages/core/src/js/lib/map/schema.ts +++ b/packages/core/src/js/lib/map/schema.ts @@ -20,6 +20,11 @@ export const layerMetaDataSchema = z.looseObject({ lockedInLayerSwitcher: z.boolean().optional(), visibleInLayerSwitcher: z.boolean().optional(), visibleInExternalLayerSwitcher: z.boolean().optional(), + /** + * Derived visibility: this layer is shown only while every listed layer + * is visible (AND). + */ + visibleWhenLayerIds: z.array(z.string().min(1)).optional(), }); export const vectorFeatureSourceOptionsSchema = z.looseObject({ diff --git a/packages/core/src/js/lib/map/z-index.ts b/packages/core/src/js/lib/map/z-index.ts new file mode 100644 index 00000000..4a2ef0b8 --- /dev/null +++ b/packages/core/src/js/lib/map/z-index.ts @@ -0,0 +1,2 @@ +/** Selection and highlight overlays stay above catalog layers. */ +export const Z_INDEX_OVERLAY = 1000; diff --git a/packages/core/src/js/ol-proxy/definitions/layer/VectorOverlayLayer.ts b/packages/core/src/js/ol-proxy/definitions/layer/VectorOverlayLayer.ts index 3a1664f9..bbd7f358 100644 --- a/packages/core/src/js/ol-proxy/definitions/layer/VectorOverlayLayer.ts +++ b/packages/core/src/js/ol-proxy/definitions/layer/VectorOverlayLayer.ts @@ -2,6 +2,7 @@ import Collection from "ol/Collection"; import VectorLayer from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; +import {Z_INDEX_OVERLAY} from "@/lib/map/z-index"; import type {Definition} from "@/ol-proxy"; import {OPTION_SKIP} from "@/ol-proxy"; @@ -21,7 +22,7 @@ export default { layer.set("updateWhileAnimating", true); layer.set("updateWhileInteracting", true); layer.setSource(source); - layer.setZIndex(1000); // TODO + layer.setZIndex(Z_INDEX_OVERLAY); }, }, optionMap: {