From 7ae9f90ba7a3e68b01c5fffa5c08791bd5a4b127 Mon Sep 17 00:00:00 2001 From: arnevogt Date: Mon, 18 Nov 2024 15:15:07 +0100 Subject: [PATCH] Implement support for GroupLayers (#222, #371) Co-authored-by: Michael Beckemeyer --- .changeset/happy-tables-walk.md | 8 + .changeset/selfish-squids-travel.md | 47 ++++ .changeset/twelve-moles-scream.md | 6 + .changeset/wet-buses-itch.md | 12 + src/packages/map/api/layers/GroupLayer.ts | 62 +++++ src/packages/map/api/layers/SimpleLayer.ts | 2 + src/packages/map/api/layers/WMSLayer.ts | 1 + src/packages/map/api/layers/WMTSLayer.ts | 3 + src/packages/map/api/layers/base.ts | 47 +++- src/packages/map/api/layers/index.ts | 1 + src/packages/map/model/AbstractLayer.test.ts | 14 +- src/packages/map/model/AbstractLayer.ts | 4 +- .../map/model/AbstractLayerBase.test.ts | 5 + src/packages/map/model/AbstractLayerBase.ts | 51 +++- .../map/model/LayerCollectionImpl.test.ts | 124 ++++++++- src/packages/map/model/LayerCollectionImpl.ts | 27 +- .../map/model/SublayersCollectionImpl.ts | 9 + .../map/model/layers/GroupLayerImpl.test.ts | 97 +++++++ .../map/model/layers/GroupLayerImpl.ts | 108 ++++++++ .../map/model/layers/SimpleLayerImpl.ts | 8 +- .../map/model/layers/WMSLayerImpl.test.ts | 3 +- src/packages/map/model/layers/WMSLayerImpl.ts | 13 +- .../map/model/layers/WMTSLayerImpl.test.ts | 2 +- .../map/model/layers/WMTSLayerImpl.ts | 20 +- src/packages/toc/Context.ts | 26 ++ src/packages/toc/LayerList.test.tsx | 245 +++++++++++++----- src/packages/toc/LayerList.tsx | 51 ++-- src/packages/toc/README.md | 11 + src/packages/toc/Toc.tsx | 15 +- src/packages/toc/Tools.tsx | 27 +- .../test-toc/toc-app/MapConfigProviderImpl.ts | 47 ++-- 31 files changed, 934 insertions(+), 162 deletions(-) create mode 100644 .changeset/happy-tables-walk.md create mode 100644 .changeset/selfish-squids-travel.md create mode 100644 .changeset/twelve-moles-scream.md create mode 100644 .changeset/wet-buses-itch.md create mode 100644 src/packages/map/api/layers/GroupLayer.ts create mode 100644 src/packages/map/model/layers/GroupLayerImpl.test.ts create mode 100644 src/packages/map/model/layers/GroupLayerImpl.ts create mode 100644 src/packages/toc/Context.ts diff --git a/.changeset/happy-tables-walk.md b/.changeset/happy-tables-walk.md new file mode 100644 index 000000000..c6362c23c --- /dev/null +++ b/.changeset/happy-tables-walk.md @@ -0,0 +1,8 @@ +--- +"@open-pioneer/map": minor +--- + +Add new `children` property to all layers. +This property makes it possible to handle any layer children in a generic fashion, regardless of the layer's actual type. + +`layer.children` is either an alias of `layer.sublayers` (if the layer has sublayers), `layer.layers` (if it's a `GroupLayer`) or undefined, if the layer does not have any children. diff --git a/.changeset/selfish-squids-travel.md b/.changeset/selfish-squids-travel.md new file mode 100644 index 000000000..518f04a53 --- /dev/null +++ b/.changeset/selfish-squids-travel.md @@ -0,0 +1,47 @@ +--- +"@open-pioneer/map": minor +--- + +Add new layer type `GroupLayer` to to the Map API. + +A `GroupLayer` contains a list of `Layer` (e.g. `SimpleLayer` or `WMSLayer`). Because `GroupLayer` is a `Layer` as well nested groups are supported. +The child layers of a `GroupLayer` can be accessed with the `layers` property - `layers` is `undefined` if it is not a group. +The parent `GroupLayer` of a child layer can be accessed with the `parent` property - `parent` is `undefined` if this layer is not part of a group (or not a sublayer). + +```js +const olLayer1 = new TileLayer({ + source: new OSM() +}); +const olLayer2 = new TileLayer({ + source: new BkgTopPlusOpen() +}); + +// Create group layer with nested sub group +const group = new GroupLayer({ + id: "group", + title: "a group layer", + layers: [ + new SimpleLayer({ + id: "member", + title: "group member", + olLayer: olLayer1 + }), + new GroupLayer({ + id: "subgroup", + title: "a nested group layer", + layers: [ + new SimpleLayer({ + id: "submember", + title: "subgroup member", + olLayer: olLayer2 + }) + ] + }) + ] +}); + +const childLayers: GroupLayerCollection = group.layers; // Access child layers +``` + +Layers can only be added to a single group or map. +Sublayers (e.g. `WMSSublayer`) cannot be added to a group directly. diff --git a/.changeset/twelve-moles-scream.md b/.changeset/twelve-moles-scream.md new file mode 100644 index 000000000..6b2090273 --- /dev/null +++ b/.changeset/twelve-moles-scream.md @@ -0,0 +1,6 @@ +--- +"@open-pioneer/toc": patch +--- + +Implement support for `GroupLayer`. +The hierarchy of (possibly nested) groups is visualized by rendering them as a tree. diff --git a/.changeset/wet-buses-itch.md b/.changeset/wet-buses-itch.md new file mode 100644 index 000000000..81ec06900 --- /dev/null +++ b/.changeset/wet-buses-itch.md @@ -0,0 +1,12 @@ +--- +"@open-pioneer/toc": minor +--- + +When showing a layer via the toc, all parent layers of that layer will also be made visible. + +This can be disabled by configuring `autoShowParents={false}` on the `TOC` component. + +```jsx +// Default: true + +``` diff --git a/src/packages/map/api/layers/GroupLayer.ts b/src/packages/map/api/layers/GroupLayer.ts new file mode 100644 index 000000000..abc9ccbd5 --- /dev/null +++ b/src/packages/map/api/layers/GroupLayer.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) +// SPDX-License-Identifier: Apache-2.0 +import type { Group } from "ol/layer"; +import { GroupLayerImpl } from "../../model/layers/GroupLayerImpl"; +import type { LayerRetrievalOptions } from "../shared"; +import type { ChildrenCollection, Layer, LayerBaseType, LayerConfig } from "./base"; + +/** + * Configuration options to construct a {@link GroupLayer}. + */ +export interface GroupLayerConfig extends LayerConfig { + /** + * List of layers that belong to the new group layer. + * + * The group layer takes ownership of the given layers: they will be destroyed when the parent is destroyed. + * A layer must have a unique parent: it can only be added to the map or a single group layer. + */ + layers: Layer[]; +} + +/** + * Represents a group of layers. + * + * A group layer contains a collection of {@link Layer} children. + * Groups can be nested to form a hierarchy. + */ +export interface GroupLayer extends LayerBaseType { + readonly type: "group"; + + /** + * Layers contained in this group. + */ + readonly layers: GroupLayerCollection; + + /** + * Raw OpenLayers group instance. + * + * **Warning:** Do not manipulate the collection of layers in this group directly, changes are not synchronized! + */ + readonly olLayer: Group; + + readonly sublayers: undefined; +} + +/** + * Contains {@link Layer} instances that belong to a {@link GroupLayer} + */ +export interface GroupLayerCollection extends ChildrenCollection { + /** + * Returns all layers in this collection + */ + getLayers(options?: LayerRetrievalOptions): Layer[]; +} + +export interface GroupLayerConstructor { + prototype: GroupLayer; + + /** Creates a new {@link GroupLayer}. */ + new (config: GroupLayerConfig): GroupLayer; +} + +export const GroupLayer: GroupLayerConstructor = GroupLayerImpl; diff --git a/src/packages/map/api/layers/SimpleLayer.ts b/src/packages/map/api/layers/SimpleLayer.ts index f55d61276..a914722c7 100644 --- a/src/packages/map/api/layers/SimpleLayer.ts +++ b/src/packages/map/api/layers/SimpleLayer.ts @@ -29,6 +29,8 @@ export interface SimpleLayerConstructor { */ export interface SimpleLayer extends LayerBaseType { readonly type: "simple"; + + readonly layers: undefined; } export const SimpleLayer: SimpleLayerConstructor = SimpleLayerImpl; diff --git a/src/packages/map/api/layers/WMSLayer.ts b/src/packages/map/api/layers/WMSLayer.ts index 214a8a64c..fef45ffbc 100644 --- a/src/packages/map/api/layers/WMSLayer.ts +++ b/src/packages/map/api/layers/WMSLayer.ts @@ -48,6 +48,7 @@ export interface WMSLayer extends LayerBaseType { readonly type: "wms"; readonly sublayers: SublayersCollection; + readonly layers: undefined; /** The URL of the WMS service that was used during layer construction. */ readonly url: string; diff --git a/src/packages/map/api/layers/WMTSLayer.ts b/src/packages/map/api/layers/WMTSLayer.ts index eb0f9534f..bd90dd5ad 100644 --- a/src/packages/map/api/layers/WMTSLayer.ts +++ b/src/packages/map/api/layers/WMTSLayer.ts @@ -22,6 +22,7 @@ export interface WMTSLayerConfig extends LayerConfig { */ sourceOptions?: Partial; } + export interface WMTSLayer extends LayerBaseType { readonly type: "wmts"; @@ -33,6 +34,8 @@ export interface WMTSLayer extends LayerBaseType { /** The name of the tile matrix set in the service's capabilities. */ readonly matrixSet: string; + + readonly layers: undefined; } export interface WMTSLayerConstructor { diff --git a/src/packages/map/api/layers/base.ts b/src/packages/map/api/layers/base.ts index 9aaa76439..b97bce168 100644 --- a/src/packages/map/api/layers/base.ts +++ b/src/packages/map/api/layers/base.ts @@ -6,7 +6,8 @@ import type { MapModel } from "../MapModel"; import type { LayerRetrievalOptions } from "../shared"; import type { SimpleLayer } from "./SimpleLayer"; import type { WMSLayer, WMSSublayer } from "./WMSLayer"; -import { WMTSLayer } from "./WMTSLayer"; +import type { WMTSLayer } from "./WMTSLayer"; +import type { GroupLayer, GroupLayerCollection } from "./GroupLayer"; /** Events emitted by the {@link Layer} and other layer types. */ export interface LayerBaseEvents { @@ -69,6 +70,13 @@ export interface AnyLayerBaseType /** The map this layer belongs to. */ readonly map: MapModel; + /** + * The direct parent of this layer instance, used for sublayers or for layers in a group layer. + * + * The property shall be undefined if the layer is not a sublayer or member of a group layer. + */ + readonly parent: AnyLayer | undefined; + /** * The unique id of this layer within its map model. * @@ -97,9 +105,29 @@ export interface AnyLayerBaseType readonly legend: string | undefined; /** - * The collection of child sublayers for this layer. + * The direct children of this layer. + * + * The children may either be a set of operational layers (e.g. for a group layer) or a set of sublayers, or `undefined`. + * + * See also {@link layers} and {@link sublayers}. + */ + readonly children: ChildrenCollection | undefined; + + /** + * If this layer is a group layer this property contains a collection of all layers that a members to the group. + * + * The property shall be `undefined` if it is not a group layer. + * + * The properties `layers` and `sublayers` are mutually exclusive. + */ + readonly layers: GroupLayerCollection | undefined; + + /** + * The collection of child sublayers for this layer. Sublayers are layers that cannot exist without an appropriate parent layer. * * Layers that can never have any sublayers may not have a `sublayers` collection. + * + * The properties `layers` and `sublayers` are mutually exclusive. */ readonly sublayers: SublayersCollection | undefined; @@ -209,10 +237,21 @@ export interface SublayerBaseType extends AnyLayerBaseType { readonly parentLayer: Layer; } +/** + * Contains the children of a layer. + */ +export interface ChildrenCollection { + /** + * Returns the items in this collection. + */ + getItems(options?: LayerRetrievalOptions): LayerType[]; +} + /** * Contains the sublayers that belong to a {@link Layer} or {@link Sublayer}. */ -export interface SublayersCollection { +export interface SublayersCollection + extends ChildrenCollection { /** * Returns the child sublayers in this collection. */ @@ -222,7 +261,7 @@ export interface SublayersCollection { /** * Union type for all layers (extending {@link LayerBaseType}) */ -export type Layer = SimpleLayer | WMSLayer | WMTSLayer; +export type Layer = SimpleLayer | WMSLayer | WMTSLayer | GroupLayer; export type LayerTypes = Layer["type"]; /** diff --git a/src/packages/map/api/layers/index.ts b/src/packages/map/api/layers/index.ts index cbf17024c..d0ee95bb7 100644 --- a/src/packages/map/api/layers/index.ts +++ b/src/packages/map/api/layers/index.ts @@ -4,3 +4,4 @@ export * from "./base"; export * from "./SimpleLayer"; export * from "./WMSLayer"; export * from "./WMTSLayer"; +export * from "./GroupLayer"; diff --git a/src/packages/map/model/AbstractLayer.test.ts b/src/packages/map/model/AbstractLayer.test.ts index 7d95542e3..97528f41d 100644 --- a/src/packages/map/model/AbstractLayer.test.ts +++ b/src/packages/map/model/AbstractLayer.test.ts @@ -3,15 +3,16 @@ /** * @vitest-environment node */ +import { syncWatch } from "@conterra/reactivity-core"; +import { HttpService } from "@open-pioneer/http"; import Layer from "ol/layer/Layer"; import TileLayer from "ol/layer/Tile"; -import { HttpService } from "@open-pioneer/http"; +import Source, { State } from "ol/source/Source"; import { Mock, MockInstance, afterEach, describe, expect, it, vi } from "vitest"; import { HealthCheckFunction, LayerConfig, SimpleLayerConfig } from "../api"; import { AbstractLayer } from "./AbstractLayer"; -import Source, { State } from "ol/source/Source"; +import { GroupLayerCollectionImpl } from "./layers/GroupLayerImpl"; import { MapModelImpl } from "./MapModelImpl"; -import { syncWatch } from "@conterra/reactivity-core"; afterEach(() => { vi.restoreAllMocks(); @@ -339,12 +340,17 @@ describe("performs a health check", () => { // Basic impl for tests class LayerImpl extends AbstractLayer { type = "simple" as const; + get legend(): string | undefined { return undefined; } get sublayers(): undefined { return undefined; } + + get layers(): GroupLayerCollectionImpl | undefined { + return undefined; + } } function createLayer(layerConfig: SimpleLayerConfig, options?: { fetch?: Mock }) { @@ -359,7 +365,7 @@ function createLayer(layerConfig: SimpleLayerConfig, options?: { fetch?: Mock }) } as unknown as MapModelImpl; const layer = new LayerImpl(layerConfig); - layer.__attach(mapModel); + layer.__attachToMap(mapModel); return { layer, mapModel, httpService }; } diff --git a/src/packages/map/model/AbstractLayer.ts b/src/packages/map/model/AbstractLayer.ts index 17b2e024c..feaaa2be0 100644 --- a/src/packages/map/model/AbstractLayer.ts +++ b/src/packages/map/model/AbstractLayer.ts @@ -83,7 +83,7 @@ export abstract class AbstractLayer /** * Called by the map model when the layer is added to the map. */ - __attach(map: MapModelImpl): void { + __attachToMap(map: MapModelImpl): void { super.__attachToMap(map); const { initial: initialState, resource: stateWatchResource } = watchLoadState( @@ -114,7 +114,7 @@ export abstract class AbstractLayer } } - abstract readonly type: "simple" | "wms" | "wmts"; + abstract readonly type: "simple" | "wms" | "wmts" | "group"; } function watchLoadState( diff --git a/src/packages/map/model/AbstractLayerBase.test.ts b/src/packages/map/model/AbstractLayerBase.test.ts index a94911f08..0f7125bd4 100644 --- a/src/packages/map/model/AbstractLayerBase.test.ts +++ b/src/packages/map/model/AbstractLayerBase.test.ts @@ -8,6 +8,7 @@ import { AbstractLayerBase, AbstractLayerBaseOptions } from "./AbstractLayerBase import { MapModelImpl } from "./MapModelImpl"; import { SublayersCollectionImpl } from "./SublayersCollectionImpl"; import { syncWatch } from "@conterra/reactivity-core"; +import { GroupLayerCollectionImpl } from "./layers/GroupLayerImpl"; afterEach(() => { vi.restoreAllMocks(); @@ -255,6 +256,10 @@ abstract class SharedParent extends AbstractLayerBase { return undefined; } + get layers(): GroupLayerCollectionImpl | undefined { + return undefined; + } + get sublayers() { return this._sublayers; } diff --git a/src/packages/map/model/AbstractLayerBase.ts b/src/packages/map/model/AbstractLayerBase.ts index e73e4b330..dcb15b74c 100644 --- a/src/packages/map/model/AbstractLayerBase.ts +++ b/src/packages/map/model/AbstractLayerBase.ts @@ -1,7 +1,5 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 -import { EventEmitter, createLogger } from "@open-pioneer/core"; -import { v4 as uuid4v } from "uuid"; import { batch, computed, @@ -10,7 +8,18 @@ import { reactiveMap, ReadonlyReactive } from "@conterra/reactivity-core"; -import { AnyLayerBaseType, AnyLayerTypes, LayerBaseEvents, Sublayer } from "../api"; +import { createLogger, EventEmitter } from "@open-pioneer/core"; +import { v4 as uuid4v } from "uuid"; +import { + AnyLayer, + AnyLayerBaseType, + AnyLayerTypes, + ChildrenCollection, + LayerBaseEvents, + Sublayer +} from "../api"; +import { GroupLayer } from "../api/layers/GroupLayer"; +import { GroupLayerCollectionImpl } from "./layers/GroupLayerImpl"; import { MapModelImpl } from "./MapModelImpl"; import { SublayersCollectionImpl } from "./SublayersCollectionImpl"; @@ -32,6 +41,7 @@ export abstract class AbstractLayerBase implements AnyLayerBaseType { #map: MapModelImpl | undefined; + #parent: AnyLayer | undefined; #id: string; #title: Reactive; @@ -82,10 +92,20 @@ export abstract class AbstractLayerBase return this.#attributes.value; } + get parent(): AnyLayer | undefined { + return this.#parent; + } + + get children(): ChildrenCollection | undefined { + return this.layers ?? this.sublayers ?? undefined; + } + abstract get type(): AnyLayerTypes; abstract get visible(): boolean; + abstract get layers(): GroupLayerCollectionImpl | undefined; + abstract get sublayers(): SublayersCollectionImpl | undefined; abstract get legend(): string | undefined; @@ -97,6 +117,7 @@ export abstract class AbstractLayerBase this.#destroyed = true; this.sublayers?.destroy(); + this.layers?.destroy(); try { this.emit("destroy"); } catch (e) { @@ -107,7 +128,7 @@ export abstract class AbstractLayerBase /** * Attaches the layer to its owning map. */ - protected __attachToMap(map: MapModelImpl): void { + __attachToMap(map: MapModelImpl): void { if (this.#map) { throw new Error( `Layer '${this.id}' has already been attached to the map '${this.map.id}'` @@ -116,6 +137,28 @@ export abstract class AbstractLayerBase this.#map = map; } + /** + * Attach group layers to its parent group layer. + * Called by the parent layer. + */ + __attachToGroup(parent: GroupLayer): void { + if (this.#parent) { + throw new Error( + `Layer '${this.id}' has already been attached to the group layer '${this.#parent.id}'` + ); + } + this.#parent = parent; + } + + /** + * Detach layer from parent group layer. + * + * Called by the parent group layer when destroyed or the layer gets removed. + */ + __detachFromGroup(): void { + this.#parent = undefined; + } + setTitle(newTitle: string): void { this.#title.value = newTitle; } diff --git a/src/packages/map/model/LayerCollectionImpl.test.ts b/src/packages/map/model/LayerCollectionImpl.test.ts index 1cc4cc46b..c9589fc1f 100644 --- a/src/packages/map/model/LayerCollectionImpl.test.ts +++ b/src/packages/map/model/LayerCollectionImpl.test.ts @@ -18,6 +18,8 @@ import { createMapModel } from "./createMapModel"; import { SimpleLayerImpl } from "./layers/SimpleLayerImpl"; import { WMSLayerImpl } from "./layers/WMSLayerImpl"; import { syncWatch } from "@conterra/reactivity-core"; +import { GroupLayer } from "../api/layers/GroupLayer"; +import { Group } from "ol/layer"; const THIS_DIR = dirname(fileURLToPath(import.meta.url)); const WMTS_CAPAS = readFileSync( @@ -237,6 +239,32 @@ it("supports lookup by layer id", async () => { expect(l3).toBeUndefined(); }); +it("supports lookup by layer id for members of a group layer", async () => { + const olLayer = new TileLayer({ + source: new OSM() + }); + + const child = new SimpleLayerImpl({ + id: "member", + title: "group member", + olLayer: olLayer + }); + const group = new GroupLayer({ + id: "group", + title: "group test", + layers: [child] + }); + + model = await create("foo", { + layers: [group] + }); + + const memberLayer = model.layers.getLayerById("member"); + expect(memberLayer).toBe(child); + const groupLayer = model.layers.getLayerById("group"); + expect(groupLayer).toBe(group); +}); + it("results in an error, if using the same layer id twice", async () => { await expect(async () => { model = await create("foo", { @@ -287,6 +315,100 @@ it("supports reverse lookup from OpenLayers layer", async () => { expect(l2).toBeUndefined(); }); +it("supports reverse lookup from OpenLayers layer for members of a group layer", async () => { + const olLayer = new TileLayer({ + source: new OSM() + }); + + model = await create("foo", { + layers: [ + new GroupLayer({ + id: "group", + title: "group test", + layers: [ + new SimpleLayerImpl({ + id: "member", + title: "group member", + olLayer: olLayer + }) + ] + }) + ] + }); + + const memberLayer = model.layers.getLayerByRawInstance(olLayer); + expect(memberLayer).toBeDefined(); + const olGroup = model.olMap.getLayers().getArray()[1]; //get raw ol group la + const groupLayer = model.layers.getLayerByRawInstance(olGroup!); + expect(olGroup instanceof Group).toBeTruthy(); + expect(groupLayer).toBeDefined(); +}); + +it("should unindex layers that are member of group layer", async () => { + const olLayer = new TileLayer({ + source: new OSM() + }); + + model = await create("foo", { + layers: [ + new GroupLayer({ + id: "group", + title: "group test", + layers: [ + new SimpleLayerImpl({ + id: "member", + title: "group member", + olLayer: olLayer + }) + ] + }) + ] + }); + + let memberLayer = model.layers.getLayerByRawInstance(olLayer); + expect(memberLayer).toBeDefined(); + memberLayer = model.layers.getLayerById("member") as Layer; + expect(memberLayer).toBeDefined(); + + //remove group layer and check if group members are not indexed anymore + model.layers.removeLayerById("group"); + memberLayer = model.layers.getLayerByRawInstance(olLayer); + expect(memberLayer).toBeUndefined(); + memberLayer = model.layers.getLayerById("member") as Layer; + expect(memberLayer).toBeUndefined(); +}); + +it("destroys child layers when parent group layer is removed", async () => { + const olLayer = new TileLayer({ + source: new OSM() + }); + + const groupMember = new SimpleLayerImpl({ + id: "member", + title: "group member", + olLayer: olLayer + }); + const groupLayer = new GroupLayer({ + id: "group", + title: "group test", + layers: [groupMember] + }); + //register dummy event handlers + const groupFn = vi.fn(); + const memberFn = vi.fn(); + groupMember.on("destroy", () => memberFn()); + groupLayer.on("destroy", () => groupFn()); + + model = await create("foo", { + layers: [groupLayer] + }); + + model.layers.removeLayerById("group"); //remove group layer + //destroy event should be emitted for each (child) layer + expect(memberFn).toHaveBeenCalledOnce(); + expect(groupFn).toHaveBeenCalledOnce(); +}); + it("registering the same OpenLayers layer twice throws an error", async () => { const rawL1 = new TileLayer({ source: new OSM() @@ -308,7 +430,7 @@ it("registering the same OpenLayers layer twice throws an error", async () => { ] }); }).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: OlLayer has already been used in this or another layer.]` + `[Error: OlLayer used by layer 'l-2' has already been used in map.]` ); }); diff --git a/src/packages/map/model/LayerCollectionImpl.ts b/src/packages/map/model/LayerCollectionImpl.ts index 89a07dfb7..8985e7c19 100644 --- a/src/packages/map/model/LayerCollectionImpl.ts +++ b/src/packages/map/model/LayerCollectionImpl.ts @@ -24,6 +24,9 @@ type LayerBaseType = (AbstractLayerBase & Layer) | (AbstractLayerBase & Sublayer */ export const TOPMOST_LAYER_Z = 9999999; +/** + * Manages the (top-level) content of the map. + */ export class LayerCollectionImpl implements LayerCollection { #map: MapModelImpl; @@ -59,7 +62,7 @@ export class LayerCollectionImpl implements LayerCollection { addLayer(layer: Layer): void { checkLayerInstance(layer); - layer.__attach(this.#map); + layer.__attachToMap(this.#map); this.#addLayer(layer); } @@ -216,7 +219,7 @@ export class LayerCollectionImpl implements LayerCollection { ); } if (olLayer && this.#layersByOlLayer.has(olLayer)) { - throw new Error(`OlLayer has already been used in this or another layer.`); + throw new Error(`OlLayer used by layer '${id}' has already been used in map.`); } // Register this layer with the maps. @@ -226,7 +229,10 @@ export class LayerCollectionImpl implements LayerCollection { } registrations.push([id, olLayer]); - // Recurse into nested sublayers. + // Recurse into nested children. + for (const layer of model.layers?.__getRawLayers() ?? []) { + visit(layer); + } for (const sublayer of model.sublayers?.__getRawSublayers() ?? []) { visit(sublayer); } @@ -235,6 +241,8 @@ export class LayerCollectionImpl implements LayerCollection { try { visit(model); } catch (e) { + // If any error happens, undo the indexing. + // This way we don't leave a partially indexed layer tree behind. for (const [id, olLayer] of registrations) { this.#layersById.delete(id); if (olLayer) { @@ -246,7 +254,7 @@ export class LayerCollectionImpl implements LayerCollection { } /** - * Removes index entries for the given layer and all its sublayers. + * Removes index entries for the given layer and all its children. */ #unIndexLayer(model: AbstractLayer) { const visit = (model: AbstractLayer | AbstractLayerBase) => { @@ -254,6 +262,11 @@ export class LayerCollectionImpl implements LayerCollection { this.#layersByOlLayer.delete(model.olLayer); } this.#layersById.delete(model.id); + + for (const layer of model.layers?.__getRawLayers() ?? []) { + visit(layer); + } + for (const sublayer of model.sublayers?.__getRawSublayers() ?? []) { visit(sublayer); } @@ -266,13 +279,9 @@ function sortLayersByDisplayOrder(layers: Layer[]) { layers.sort((left, right) => { // currently layers are added with increasing z-index (base layers: 0), so // ordering by z-index is automatically the correct display order. - // we use the id as the tie breaker for equal z-indices. const leftZ = left.olLayer.getZIndex() ?? 1; const rightZ = right.olLayer.getZIndex() ?? 1; - if (leftZ !== rightZ) { - return leftZ - rightZ; - } - return left.id.localeCompare(right.id, "en"); + return leftZ - rightZ; }); } diff --git a/src/packages/map/model/SublayersCollectionImpl.ts b/src/packages/map/model/SublayersCollectionImpl.ts index 8b6f1230a..bd503e69f 100644 --- a/src/packages/map/model/SublayersCollectionImpl.ts +++ b/src/packages/map/model/SublayersCollectionImpl.ts @@ -3,11 +3,15 @@ import { LayerRetrievalOptions, SublayerBaseType, SublayersCollection } from "../api"; import { AbstractLayerBase } from "./AbstractLayerBase"; +/** + * Manages the sublayers of a layer. + */ // NOTE: adding / removing sublayers currently not supported /* eslint-disable indent */ export class SublayersCollectionImpl implements SublayersCollection { + /* eslint-enable indent */ #sublayers: Sublayer[]; constructor(sublayers: Sublayer[]) { @@ -21,6 +25,11 @@ export class SublayersCollectionImpl { + const olLayer = new TileLayer({}); + const grouplayer = new GroupLayerImpl({ + id: "group", + title: "group test", + layers: [ + new SimpleLayerImpl({ + id: "member", + title: "group member", + olLayer: olLayer + }) + ] + }); + expect(grouplayer.sublayers).toBeUndefined(); +}); + +it("should create OL group that contains all group members", () => { + const olLayer1 = new TileLayer({}); + const olLayer2 = new TileLayer({}); + const grouplayer = new GroupLayerImpl({ + id: "group", + title: "group test", + layers: [ + new SimpleLayerImpl({ + id: "member", + title: "group member", + olLayer: olLayer1 + }), + new GroupLayerImpl({ + id: "subgroup", + title: "subgroup test", + layers: [ + new SimpleLayerImpl({ + id: "subgroupmember", + title: "subgroup member", + olLayer: olLayer2 + }) + ] + }) + ] + }); + + expect(grouplayer.olLayer instanceof Group).toBeTruthy(); + expect(grouplayer.olLayer.getLayers().getArray()).toContain(olLayer1); + expect(grouplayer.layers.getLayers()[1]?.olLayer instanceof Group).toBeTruthy(); //subgroup +}); + +it("should set parent of group members to this group layer", () => { + const olLayer = new TileLayer({}); + const child = new SimpleLayerImpl({ + id: "member", + title: "group member", + olLayer: olLayer + }); + expect(child.parent).toBeUndefined(); + + const grouplayer = new GroupLayerImpl({ + id: "group", + title: "group test", + layers: [child] + }); + expect(grouplayer.layers.getLayers()[0]).toBe(child); + expect(grouplayer.layers).toBe(grouplayer.children); // alias + expect(child.parent).toBe(grouplayer); + + grouplayer.destroy(); + expect(grouplayer.layers.getLayers().length).toBe(0); + expect(child.parent).toBeUndefined(); +}); + +it("throws when adding the same child twice", () => { + const olLayer = new TileLayer({}); + const child = new SimpleLayerImpl({ + id: "member", + title: "group member", + olLayer: olLayer + }); + expect( + () => + new GroupLayerImpl({ + id: "group", + title: "group test", + layers: [child, child] + }) + ).toThrowErrorMatchingInlineSnapshot(`[Error: Duplicate item added to a unique collection]`); +}); diff --git a/src/packages/map/model/layers/GroupLayerImpl.ts b/src/packages/map/model/layers/GroupLayerImpl.ts new file mode 100644 index 000000000..c8701a021 --- /dev/null +++ b/src/packages/map/model/layers/GroupLayerImpl.ts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) +// SPDX-License-Identifier: Apache-2.0 +import { Group } from "ol/layer"; +import { GroupLayerCollection, Layer, LayerRetrievalOptions } from "../../api"; +import { GroupLayer, GroupLayerConfig } from "../../api/layers/GroupLayer"; +import { AbstractLayer } from "../AbstractLayer"; +import { AbstractLayerBase } from "../AbstractLayerBase"; +import { MapModelImpl } from "../MapModelImpl"; + +export class GroupLayerImpl extends AbstractLayer implements GroupLayer { + #children: GroupLayerCollectionImpl; + + constructor(config: GroupLayerConfig) { + const groupLayers = config.layers; + const olGroup = new Group({ layers: groupLayers.map((sublayer) => sublayer.olLayer) }); + super({ ...config, olLayer: olGroup }); + this.#children = new GroupLayerCollectionImpl(groupLayers, this); + } + + get type() { + return "group" as const; + } + + get legend() { + return undefined; + } + + get layers(): GroupLayerCollectionImpl { + return this.#children; + } + + get sublayers(): undefined { + return undefined; + } + + /** + * return raw OL LayerGroup + * Warning: Do not manipulate the collection of layers in this group, changes are not synchronized! + */ + get olLayer(): Group { + return super.olLayer as Group; + } + + __attachToMap(map: MapModelImpl): void { + super.__attachToMap(map); + this.layers.__getRawLayers().forEach((layer) => layer.__attachToMap(map)); + } +} + +// NOTE: adding / removing currently not supported. +// When adding support for dynamic content, make sure to also updating the layer indexing logic in the map (LayerCollectionImpl). +// Nested children of a group layer must also be found in id-lookups. +export class GroupLayerCollectionImpl implements GroupLayerCollection { + #layers: (AbstractLayer & Layer)[]; + #parent: GroupLayer; + + constructor(layers: Layer[], parent: GroupLayer) { + layers = layers.slice(); // Don't modify the input + for (const layer of layers) { + if (layer instanceof AbstractLayer) { + layer.__attachToGroup(parent); //attach every layer to the parent group layer + } else { + throw new Error( + `Layer '${layer.id}' of group '${parent.id}' does not implement abstract class '${AbstractLayerBase.name}` + ); + } + } + this.#layers = layers as (Layer & AbstractLayer)[]; + this.#parent = parent; + } + + /** + * Destroys this collection, all contained layers are detached from their parent group layer + */ + destroy() { + for (const layer of this.#layers) { + layer.__detachFromGroup(); + layer.destroy(); + } + this.#layers = []; + } + + // Generic method name for consistent interface + getItems(options?: LayerRetrievalOptions): (AbstractLayer & Layer)[] { + return this.getLayers(options); + } + + getLayers(_options?: LayerRetrievalOptions | undefined): (AbstractLayer & Layer)[] { + // NOTE: options are ignored because layers are always ordered at this time. + return this.#layers.slice(); + } + + /** + * Returns a reference to the internal group layer array. + * + * NOTE: Do not modify directly! + */ + __getRawLayers(): (AbstractLayer & Layer)[] { + return this.#layers; + } + + /** + * Returns the parent group layer that owns this collection. + */ + __getParent(): GroupLayer { + return this.#parent; + } +} diff --git a/src/packages/map/model/layers/SimpleLayerImpl.ts b/src/packages/map/model/layers/SimpleLayerImpl.ts index fe7962e98..8baf36475 100644 --- a/src/packages/map/model/layers/SimpleLayerImpl.ts +++ b/src/packages/map/model/layers/SimpleLayerImpl.ts @@ -12,9 +12,15 @@ export class SimpleLayerImpl extends AbstractLayer implements SimpleLayer { get type() { return "simple" as const; } - get legend() { + + get legend(): undefined { return undefined; } + + get layers(): undefined { + return undefined; + } + get sublayers(): undefined { return undefined; } diff --git a/src/packages/map/model/layers/WMSLayerImpl.test.ts b/src/packages/map/model/layers/WMSLayerImpl.test.ts index 5dbe43fc0..601da4b27 100644 --- a/src/packages/map/model/layers/WMSLayerImpl.test.ts +++ b/src/packages/map/model/layers/WMSLayerImpl.test.ts @@ -233,6 +233,7 @@ it("provides access to sublayers", () => { ], attach: true }); + expect(layer.children).toBe(layer.sublayers); const sublayers = layer.sublayers.getSublayers(); expect(sublayers.length).toBe(1); @@ -338,7 +339,7 @@ function createLayer(options: WMSLayerConfig & { fetch?: Mock; attach?: boolean } as MapModelImpl; if (options?.attach) { - layer.__attach(mapModel); + layer.__attachToMap(mapModel); } return { diff --git a/src/packages/map/model/layers/WMSLayerImpl.ts b/src/packages/map/model/layers/WMSLayerImpl.ts index f13d57d0a..6c860d493 100644 --- a/src/packages/map/model/layers/WMSLayerImpl.ts +++ b/src/packages/map/model/layers/WMSLayerImpl.ts @@ -93,8 +93,9 @@ export class WMSLayerImpl extends AbstractLayer implements WMSLayer { get url(): string { return this.#url; } - get __source() { - return this.#source; + + get layers(): undefined { + return undefined; } get sublayers(): SublayersCollectionImpl { @@ -105,8 +106,8 @@ export class WMSLayerImpl extends AbstractLayer implements WMSLayer { return this.#capabilities; } - __attach(map: MapModelImpl): void { - super.__attach(map); + __attachToMap(map: MapModelImpl): void { + super.__attachToMap(map); for (const sublayer of this.#sublayers.getSublayers()) { sublayer.__attach(map, this, this); } @@ -249,6 +250,10 @@ class WMSSublayerImpl extends AbstractLayerBase implements WMSSublayer { return this.#name; } + get layers(): undefined { + return undefined; + } + get sublayers(): SublayersCollectionImpl { return this.#sublayers; } diff --git a/src/packages/map/model/layers/WMTSLayerImpl.test.ts b/src/packages/map/model/layers/WMTSLayerImpl.test.ts index b8f416a5d..335a159b6 100644 --- a/src/packages/map/model/layers/WMTSLayerImpl.test.ts +++ b/src/packages/map/model/layers/WMTSLayerImpl.test.ts @@ -82,7 +82,7 @@ function createLayer( } as MapModelImpl; if (options?.attach) { - layer.__attach(mapModel); + layer.__attachToMap(mapModel); } return { diff --git a/src/packages/map/model/layers/WMTSLayerImpl.ts b/src/packages/map/model/layers/WMTSLayerImpl.ts index 609af6b60..39cafa481 100644 --- a/src/packages/map/model/layers/WMTSLayerImpl.ts +++ b/src/packages/map/model/layers/WMTSLayerImpl.ts @@ -53,8 +53,16 @@ export class WMTSLayerImpl extends AbstractLayer implements WMTSLayer { return this.#legend.value; } - __attach(map: MapModelImpl): void { - super.__attach(map); + get sublayers(): undefined { + return undefined; + } + + get layers(): undefined { + return undefined; + } + + __attachToMap(map: MapModelImpl): void { + super.__attachToMap(map); this.#fetchWMTSCapabilities() .then((result: string) => { const parser = new WMTSCapabilities(); @@ -88,10 +96,6 @@ export class WMTSLayerImpl extends AbstractLayer implements WMTSLayer { }); } - get layer() { - return this.#layer; - } - get url() { return this.#url; } @@ -104,10 +108,6 @@ export class WMTSLayerImpl extends AbstractLayer implements WMTSLayer { return this.#matrixSet; } - get sublayers(): undefined { - return undefined; - } - async #fetchWMTSCapabilities(): Promise { const httpService = this.map.__sharedDependencies.httpService; return fetchCapabilities(this.#url, httpService, this.#abortController.signal); diff --git a/src/packages/toc/Context.ts b/src/packages/toc/Context.ts new file mode 100644 index 000000000..d4540226e --- /dev/null +++ b/src/packages/toc/Context.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) +// SPDX-License-Identifier: Apache-2.0 +import { createContext, useContext } from "react"; + +/** + * Toc context to pass global widget options around (to avoid props drilling). + * + * @internal + */ +export interface TocWidgetOptions { + /** True: When showing a child, show all parents as well (`setVisible(true)`). */ + autoShowParents: boolean; +} + +const TocWidgetOptionsContext = createContext(undefined); + +export const TocWidgetOptionsProvider = + TocWidgetOptionsContext.Provider as React.Provider; + +export function useTocWidgetOptions(): TocWidgetOptions { + const context = useContext(TocWidgetOptionsContext); + if (!context) { + throw new Error("useTocWidgetOptions must be used within a TocWidgetOptionsProvider"); + } + return context; +} diff --git a/src/packages/toc/LayerList.test.tsx b/src/packages/toc/LayerList.test.tsx index 39399f815..942e0dc6c 100644 --- a/src/packages/toc/LayerList.test.tsx +++ b/src/packages/toc/LayerList.test.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 import { nextTick } from "@conterra/reactivity-core"; -import { SimpleLayer } from "@open-pioneer/map"; +import { GroupLayer, SimpleLayer } from "@open-pioneer/map"; import { setupMap } from "@open-pioneer/map-test-utils"; import { PackageContextProvider } from "@open-pioneer/test-utils/react"; import { @@ -16,8 +16,9 @@ import userEvent from "@testing-library/user-event"; import LayerGroup from "ol/layer/Group"; import TileLayer from "ol/layer/Tile"; import OSM from "ol/source/OSM"; -import { act } from "react"; +import { act, ReactNode } from "react"; import { expect, it } from "vitest"; +import { TocWidgetOptions, TocWidgetOptionsProvider } from "./Context"; import { LayerList } from "./LayerList"; it("should show layers in the correct order", async () => { @@ -39,11 +40,9 @@ it("should show layers in the correct order", async () => { }); const map = await registry.expectMapModel(mapId); - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); /* Layers are configured from bottom to top, but the TOC lists @@ -75,11 +74,9 @@ it("does not display base layers", async function () { }); const map = await registry.expectMapModel(mapId); - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); const labels = getCurrentLabels(container); expect(labels).toEqual(["Layer 1"]); @@ -100,11 +97,9 @@ it("shows a single entry for layer groups inside a SimpleLayer", async function }); const map = await registry.expectMapModel(mapId); - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); const labels = getCurrentLabels(container); expect(labels).toEqual(["Layer 2", "Layer 1"]); @@ -116,11 +111,9 @@ it("shows a fallback message if there are no layers", async function () { }); const map = await registry.expectMapModel(mapId); - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); expect(container.textContent).toBe("missingLayers"); }); @@ -136,11 +129,9 @@ it("reacts to changes in the layer composition", async function () { }); const map = await registry.expectMapModel(mapId); - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); const initialItems = getCurrentItems(container); expect(initialItems).toHaveLength(1); @@ -179,11 +170,9 @@ it("displays the layer's current title", async () => { throw new Error("test layer not found!"); } - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); expect(getCurrentLabels(container)).toEqual(["Layer 1"]); await act(async () => { @@ -211,11 +200,9 @@ it("displays the layer's current visibility", async () => { } expect(layer.visible).toBe(true); - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); const checkbox = queryByRole(container, "checkbox"); expect(checkbox).toBeTruthy(); @@ -246,11 +233,9 @@ it("changes the layer's visibility when toggling the checkbox", async () => { throw new Error("test layer not found!"); } - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); // Initial state reflects layer state (visible) const checkbox = queryByRole(container, "checkbox")!; @@ -281,11 +266,9 @@ it("includes the layer id in the item's class list", async () => { }); const map = await registry.expectMapModel(mapId); - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); const item = container.querySelector(".layer-some-layer-id"); expect(item).toBeTruthy(); @@ -308,11 +291,10 @@ it("renders buttons for all layer's with description property", async () => { }); const map = await registry.expectMapModel(mapId); - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); + const initialItems = queryAllByRole(container, "button"); expect(initialItems).toHaveLength(1); }); @@ -338,11 +320,9 @@ it("changes the description popover's visibility when toggling the button", asyn throw new Error("test layer not found!"); } - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); const button = queryByRole(container, "button"); if (!button) { @@ -389,11 +369,10 @@ it("reacts to changes in the layer description", async () => { throw new Error("test layer not found!"); } - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); + const initialItems = queryAllByRole(container, "button"); expect(initialItems).toHaveLength(1); screen.getByText("Description"); @@ -421,11 +400,9 @@ it("reacts to changes of the layer load state", async () => { }); const map = await registry.expectMapModel(mapId); - const { container } = render( - - - - ); + const { container } = render(, { + wrapper: createWrapper() + }); const checkbox = queryByRole(container, "checkbox")!; const button = queryByRole(container, "button"); @@ -458,6 +435,91 @@ it("reacts to changes of the layer load state", async () => { expect(icons).toHaveLength(0); }); +it("supports a hierarchy of layers", async () => { + const user = userEvent.setup(); + + const { group, subgroup, submember } = createGroupHierarchy(); + const { mapId, registry } = await setupMap({ + layers: [group] + }); + + const map = await registry.expectMapModel(mapId); + const { container } = render( + + + , + { + wrapper: (props) => + } + ); + + // Check hierarchy of dom elements + const groupItem = findLayerItem(container, "group")!; + expect(groupItem).toBeDefined(); + + const memberItem = findLayerItem(groupItem!, "member")!; + expect(memberItem?.tagName).toBeDefined(); + expect(groupItem!.contains(memberItem)).toBe(true); + + const subgroupItem = findLayerItem(groupItem!, "subgroup")!; + expect(subgroupItem).toBeDefined(); + expect(groupItem!.contains(subgroupItem)).toBe(true); + + const submemberItem = findLayerItem(subgroupItem, "submember")!; + expect(submemberItem).toBeDefined(); + expect(subgroupItem!.contains(submemberItem)).toBe(true); + expect(subgroupItem.contains(memberItem)).toBe(false); + + // Make the leaf layer visible, this should show all parents as well. + const checkbox = queryByRole(submemberItem, "checkbox")!; + expect(group.visible).toBe(false); + expect(subgroup.visible).toBe(false); + expect(submember.visible).toBe(false); + await user.click(checkbox); + await act(async () => { + await nextTick(); + }); + expect(group.visible).toBe(true); + expect(subgroup.visible).toBe(true); + expect(submember.visible).toBe(true); +}); + +it("supports disabling autoShowParents", async () => { + const user = userEvent.setup(); + + const { group, subgroup, submember } = createGroupHierarchy(); + const { mapId, registry } = await setupMap({ + layers: [group] + }); + + const map = await registry.expectMapModel(mapId); + const { container } = render( + + + , + { + wrapper: (props) => + } + ); + + const submemberItem = findLayerItem(container, "submember")!; + expect(submemberItem).toBeDefined(); + + const checkbox = queryByRole(submemberItem, "checkbox")!; + expect(group.visible).toBe(false); + expect(subgroup.visible).toBe(false); + expect(submember.visible).toBe(false); + await user.click(checkbox); + await act(async () => { + await nextTick(); + }); + + // only the clicked layer should be visible + expect(group.visible).toBe(false); + expect(subgroup.visible).toBe(false); + expect(submember.visible).toBe(true); +}); + /** Returns the layer list's current list items. */ function getCurrentItems(container: HTMLElement) { return queryAllByRole(container, "listitem"); @@ -467,3 +529,52 @@ function getCurrentItems(container: HTMLElement) { function getCurrentLabels(container: HTMLElement) { return getCurrentItems(container).map((item) => item.textContent); } + +function findLayerItem(container: HTMLElement, id: string) { + return container.querySelector(`li.toc-layer-item.layer-${id}`) as HTMLElement | null; +} + +function createGroupHierarchy() { + const o1 = new TileLayer({}); + const o2 = new TileLayer({}); + const submember = new SimpleLayer({ + id: "submember", + title: "subgroup member", + olLayer: o2, + visible: false + }); + const subgroup = new GroupLayer({ + id: "subgroup", + title: "a nested group layer", + visible: false, + layers: [submember] + }); + const group = new GroupLayer({ + id: "group", + title: "a group layer", + visible: false, + layers: [ + new SimpleLayer({ + id: "member", + title: "group member", + olLayer: o1, + visible: false + }), + subgroup + ] + }); + return { group, subgroup, submember }; +} + +function createWrapper(autoShowParents = true) { + const options: TocWidgetOptions = { autoShowParents }; + return function Wrapper(props: { children: ReactNode }) { + return ( + + + {props.children} + + + ); + }; +} diff --git a/src/packages/toc/LayerList.tsx b/src/packages/toc/LayerList.tsx index f20f2f223..b0597f78a 100644 --- a/src/packages/toc/LayerList.tsx +++ b/src/packages/toc/LayerList.tsx @@ -19,12 +19,13 @@ import { Text, Tooltip } from "@open-pioneer/chakra-integration"; -import { Layer, AnyLayer, MapModel, Sublayer } from "@open-pioneer/map"; +import { AnyLayer, isSublayer, Layer, MapModel } from "@open-pioneer/map"; import { useReactiveSnapshot } from "@open-pioneer/reactivity"; import { PackageIntl } from "@open-pioneer/runtime"; import classNames from "classnames"; import { useIntl } from "open-pioneer:react-hooks"; import { FiAlertTriangle, FiMoreVertical } from "react-icons/fi"; +import { useTocWidgetOptions } from "./Context"; /** * Lists the (top level) operational layers in the map. @@ -43,13 +44,13 @@ export function LayerList(props: { map: MapModel; "aria-label"?: string }): JSX. ); } - return createList(layers, intl, { + return createList(layers, { "aria-label": ariaLabel }); } -function createList(layers: AnyLayer[], intl: PackageIntl, listProps: ListProps) { - const items = layers.map((layer) => ); +function createList(layers: AnyLayer[], listProps: ListProps) { + const items = layers.map((layer) => ); return ( { return { title: layer.title, @@ -78,18 +81,17 @@ function LayerItem(props: { layer: AnyLayer; intl: PackageIntl }): JSX.Element { isVisible: layer.visible }; }, [layer]); - const sublayers = useSublayers(layer); + const childLayers = useChildLayers(layer); const isAvailable = useLoadState(layer) !== "error"; const notAvailableLabel = intl.formatMessage({ id: "layerNotAvailable" }); let nestedChildren; - if (sublayers?.length) { - nestedChildren = createList(sublayers, intl, { + if (childLayers?.length) { + nestedChildren = createList(childLayers, { ml: 4, "aria-label": intl.formatMessage({ id: "childgroupLabel" }, { title: title }) }); } - return ( layer.setVisible(event.target.checked)} + onChange={(event) => + updateLayerVisibility(layer, event.target.checked, options.autoShowParents) + } > {title} @@ -143,6 +147,13 @@ function LayerItem(props: { layer: AnyLayer; intl: PackageIntl }): JSX.Element { ); } +function updateLayerVisibility(layer: AnyLayer, visible: boolean, autoShowParents: boolean) { + layer.setVisible(visible); + if (visible && autoShowParents && layer.parent) { + updateLayerVisibility(layer.parent, true, true); + } +} + function LayerItemDescriptor(props: { layer: AnyLayer; title: string; @@ -189,18 +200,14 @@ function useLayers(map: MapModel): Layer[] { } /** - * Returns the sublayers of the given layer (or undefined, if the sublayer cannot have any). - * Sublayers are returned in render order (topmost sublayer first). + * Returns the child layers (sublayers or layers contained in a group layer) of a layer. + * Layers are returned in render order (topmost sublayer first). */ -function useSublayers(layer: AnyLayer): Sublayer[] | undefined { +function useChildLayers(layer: AnyLayer): AnyLayer[] | undefined { return useReactiveSnapshot(() => { - const sublayers = layer.sublayers?.getSublayers({ sortByDisplayOrder: true }); - if (!sublayers) { - return undefined; - } - - sublayers.reverse(); // render topmost layer first - return sublayers; + const children = layer.children?.getItems({ sortByDisplayOrder: true }); + children?.reverse(); // render topmost layer first + return children; }, [layer]); } @@ -208,7 +215,7 @@ function useSublayers(layer: AnyLayer): Sublayer[] | undefined { function useLoadState(layer: AnyLayer): string { return useReactiveSnapshot(() => { // for sublayers, use the state of the parent - const target = "parentLayer" in layer ? layer.parentLayer : layer; + const target = isSublayer(layer) ? layer.parentLayer : layer; return target.loadState; }, [layer]); } diff --git a/src/packages/toc/README.md b/src/packages/toc/README.md index f375c3aed..9abf34b6a 100644 --- a/src/packages/toc/README.md +++ b/src/packages/toc/README.md @@ -59,6 +59,17 @@ For example, given a layer with the ID `test-geojson`, the TOC's list item for t > NOTE: List items are not guaranteed to be rendered as `li`. Only the CSS class name is guaranteed. +### Automatic parent layer visibility + +When showing a layer via the TOC component (e.g. by clicking the checkbox next to its name), all parent layers of that layer will also be made visible by default. + +This can be disabled by configuring `autoShowParents={false}` on the `TOC` component. + +```jsx +// Default: true + +``` + ## License Apache-2.0 (see `LICENSE` file) diff --git a/src/packages/toc/Toc.tsx b/src/packages/toc/Toc.tsx index 6cdcecfa7..52be3ccae 100644 --- a/src/packages/toc/Toc.tsx +++ b/src/packages/toc/Toc.tsx @@ -10,7 +10,8 @@ import { useCommonComponentProps } from "@open-pioneer/react-utils"; import { useIntl } from "open-pioneer:react-hooks"; -import { FC, useId } from "react"; +import { FC, useId, useMemo } from "react"; +import { TocWidgetOptions, TocWidgetOptionsProvider } from "./Context"; import { LayerList } from "./LayerList"; import { Tools } from "./Tools"; @@ -40,6 +41,12 @@ export interface TocProps extends CommonComponentProps, MapModelProps { * Property "mapId" is not applied. */ basemapSwitcherProps?: Omit; + + /** + * Show the parent layers when a child layer is made visible. + * Defaults to `true`. + */ + autoShowParents?: boolean; } /** @@ -64,10 +71,12 @@ export const Toc: FC = (props: TocProps) => { showTools = false, toolsConfig, showBasemapSwitcher = true, - basemapSwitcherProps + basemapSwitcherProps, + autoShowParents = true } = props; const { containerProps } = useCommonComponentProps("toc", props); const basemapsHeadingId = useId(); + const options = useMemo((): TocWidgetOptions => ({ autoShowParents }), [autoShowParents]); const state = useMapModel(props); let content: JSX.Element | null; @@ -134,7 +143,7 @@ export const Toc: FC = (props: TocProps) => { return ( - {content} + {content} ); }; diff --git a/src/packages/toc/Tools.tsx b/src/packages/toc/Tools.tsx index 1e0aeba57..df7b0a682 100644 --- a/src/packages/toc/Tools.tsx +++ b/src/packages/toc/Tools.tsx @@ -1,18 +1,18 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 import { + Box, + Button, Menu, MenuButton, - MenuList, MenuItem, - Button, - Box, + MenuList, Portal } from "@open-pioneer/chakra-integration"; +import { AnyLayer, MapModel } from "@open-pioneer/map"; +import { useIntl } from "open-pioneer:react-hooks"; import { FC } from "react"; import { FiMoreVertical } from "react-icons/fi"; -import { useIntl } from "open-pioneer:react-hooks"; -import { MapModel, SublayersCollection } from "@open-pioneer/map"; import { ToolsConfig } from "./Toc"; export const Tools: FC<{ map: MapModel } & ToolsConfig> = (props) => { @@ -54,17 +54,18 @@ export const Tools: FC<{ map: MapModel } & ToolsConfig> = (props) => { }; function hideAllLayers(map: MapModel | undefined) { - const hideSublayer = (sublayers: SublayersCollection | undefined) => { - sublayers?.getSublayers().forEach((layer) => { - layer.setVisible(false); + const hide = (layer: AnyLayer) => { + layer.setVisible(false); - hideSublayer(layer?.sublayers); - }); + const children = layer.children?.getItems(); + if (children) { + for (const child of children) { + hide(child); + } + } }; map?.layers.getOperationalLayers().forEach((layer) => { - layer.setVisible(false); - - hideSublayer(layer?.sublayers); + hide(layer); }); } diff --git a/src/samples/test-toc/toc-app/MapConfigProviderImpl.ts b/src/samples/test-toc/toc-app/MapConfigProviderImpl.ts index 1990a6ee5..2ddbefd4f 100644 --- a/src/samples/test-toc/toc-app/MapConfigProviderImpl.ts +++ b/src/samples/test-toc/toc-app/MapConfigProviderImpl.ts @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 -import { MapConfig, MapConfigProvider, SimpleLayer, WMSLayer } from "@open-pioneer/map"; +import { MapConfig, MapConfigProvider, SimpleLayer, WMSLayer, GroupLayer } from "@open-pioneer/map"; import GeoJSON from "ol/format/GeoJSON"; import TileLayer from "ol/layer/Tile"; import VectorLayer from "ol/layer/Vector"; @@ -67,22 +67,36 @@ export class MapConfigProviderImpl implements MapConfigProvider { source: new OSM() }) }), - new SimpleLayer({ - title: "Haltestellen Stadt Rostock", - visible: true, - description: - "Haltestellen des öffentlichen Personenverkehrs in der Hanse- und Universitätsstadt Rostock.", - olLayer: createHaltestellenLayer() - }), - new SimpleLayer({ - title: "Kindertagesstätten", - visible: true, - healthCheck: - "https://sgx.geodatenzentrum.de/wmts_topplus_open/1.0.0/WMTSCapabilities.xml", - olLayer: createKitasLayer() + new GroupLayer({ + id: "group_edu", + title: "Bildung", + layers: [ + new SimpleLayer({ + title: "Kindertagesstätten", + id: "kitas", + visible: true, + healthCheck: + "https://sgx.geodatenzentrum.de/wmts_topplus_open/1.0.0/WMTSCapabilities.xml", + olLayer: createKitasLayer() + }), + createSchulenLayer() + ] }), - createSchulenLayer(), - createStrassenLayer() + new GroupLayer({ + title: "Verkehr", + id: "group_transport", + layers: [ + new SimpleLayer({ + title: "Haltestellen Stadt Rostock", + id: "bustops", + visible: true, + description: + "Haltestellen des öffentlichen Personenverkehrs in der Hanse- und Universitätsstadt Rostock.", + olLayer: createHaltestellenLayer() + }), + createStrassenLayer() + ] + }) ] }; } @@ -175,6 +189,7 @@ function createKitasLayer() { function createSchulenLayer() { return new WMSLayer({ title: "Schulstandorte", + id: "schools", description: `Der vorliegende Datenbestand / Dienst zu den Schulstandorten in NRW stammt aus der Schuldatenbank. Die Informationen werden von den Schulträgern bzw. Schulen selbst eingetragen und aktuell gehalten. Die Daten werden tagesaktuell bereitgestellt und enthalten alle grundlegenden Informationen zu Schulen wie Schulnummer, Schulbezeichnung und Adresse.Der vorliegende Datenbestand / Dienst zu den Schulstandorten in NRW stammt aus der Schuldatenbank. Die Informationen werden von den Schulträgern bzw. Schulen selbst eingetragen und aktuell gehalten. Die Daten werden tagesaktuell bereitgestellt und enthalten alle grundlegenden Informationen zu Schulen wie Schulnummer, Schulbezeichnung und Adresse.Der vorliegende Datenbestand / Dienst zu den Schulstandorten in NRW stammt aus der Schuldatenbank. Die Informationen werden von den Schulträgern bzw. Schulen selbst eingetragen und aktuell gehalten. Die Daten werden tagesaktuell bereitgestellt und enthalten alle grundlegenden Informationen zu Schulen wie Schulnummer, Schulbezeichnung und Adresse.Der vorliegende Datenbestand / Dienst zu den Schulstandorten in NRW stammt aus der Schuldatenbank. Die Informationen werden von den Schulträgern bzw. Schulen selbst eingetragen und aktuell gehalten. Die Daten werden tagesaktuell bereitgestellt und enthalten alle grundlegenden Informationen zu Schulen wie Schulnummer, Schulbezeichnung und Adresse.`, visible: true, // example for a custom health check running async