diff --git a/.gitignore b/.gitignore
index 01770a54f..825d5a73c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,4 @@ node_modules
*.sln
*.sw?
*.local
+*.iml
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 35d2ad989..0bd632c28 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -284,12 +284,18 @@ importers:
'@open-pioneer/runtime':
specifier: ^1.1.0
version: 1.1.0(@formatjs/intl@2.9.9)(@open-pioneer/base-theme@0.1.0)(@open-pioneer/chakra-integration@1.1.0)(@open-pioneer/core@1.1.0)(@open-pioneer/runtime-react-support@1.0.0)(react-dom@18.2.0)(react@18.2.0)
+ chakra-react-select:
+ specifier: ^4.7.6
+ version: 4.7.6(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/layout@2.3.1)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@emotion/react@11.11.1)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0)
ol:
specifier: ^8.2.0
version: 8.2.0
react:
specifier: ^18.2.0
version: 18.2.0
+ react-icons:
+ specifier: ^4.11.0
+ version: 4.11.0(react@18.2.0)
devDependencies:
'@open-pioneer/map-test-utils':
specifier: workspace:^
@@ -1143,6 +1149,48 @@ importers:
specifier: ^18.2.0
version: 18.2.0
+ src/samples/test-toc/toc-app:
+ dependencies:
+ '@chakra-ui/icons':
+ specifier: ^2.1.0
+ version: 2.1.0(@chakra-ui/system@2.6.2)(react@18.2.0)
+ '@chakra-ui/system':
+ specifier: ^2.6.0
+ version: 2.6.2(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(react@18.2.0)
+ '@emotion/react':
+ specifier: ^11.11.1
+ version: 11.11.1(@types/react@18.2.37)(react@18.2.0)
+ '@emotion/styled':
+ specifier: ^11.11.0
+ version: 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.37)(react@18.2.0)
+ '@open-pioneer/chakra-integration':
+ specifier: ^1.1.0
+ version: 1.1.0(@chakra-ui/react@2.8.2)(@emotion/cache@11.11.0)(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(framer-motion@10.16.5)(react-dom@18.2.0)(react@18.2.0)
+ '@open-pioneer/map':
+ specifier: workspace:^
+ version: link:../../../packages/map
+ '@open-pioneer/react-utils':
+ specifier: workspace:^
+ version: link:../../../packages/react-utils
+ '@open-pioneer/runtime':
+ specifier: ^1.1.0
+ version: 1.1.0(@formatjs/intl@2.9.9)(@open-pioneer/base-theme@0.1.0)(@open-pioneer/chakra-integration@1.1.0)(@open-pioneer/core@1.1.0)(@open-pioneer/runtime-react-support@1.0.0)(react-dom@18.2.0)(react@18.2.0)
+ '@open-pioneer/toc':
+ specifier: workspace:^
+ version: link:../../../packages/toc
+ ol:
+ specifier: ^8.2.0
+ version: 8.2.0
+ react:
+ specifier: ^18.2.0
+ version: 18.2.0
+ react-dom:
+ specifier: ^18.2.0
+ version: 18.2.0(react@18.2.0)
+ react-icons:
+ specifier: ^4.11.0
+ version: 4.11.0(react@18.2.0)
+
src/samples/theming-sample/theming-app:
dependencies:
'@open-pioneer/chakra-integration':
diff --git a/src/index.html b/src/index.html
index 1ab162840..9146c6927 100644
--- a/src/index.html
+++ b/src/index.html
@@ -69,6 +69,11 @@
Test applications
Test app for the basemap switcher.
+
+ TOC + Health Check
+
+ Demonstrates the TOC and health check to detect unavailable services.
+
Theming
diff --git a/src/packages/basemap-switcher/BasemapSwitcher.test.tsx b/src/packages/basemap-switcher/BasemapSwitcher.test.tsx
index 562649b15..5b88ac26e 100644
--- a/src/packages/basemap-switcher/BasemapSwitcher.test.tsx
+++ b/src/packages/basemap-switcher/BasemapSwitcher.test.tsx
@@ -4,10 +4,11 @@ import { BkgTopPlusOpen, SimpleLayer } from "@open-pioneer/map";
import { createServiceOptions, setupMap } from "@open-pioneer/map-test-utils";
import { PackageContextProvider } from "@open-pioneer/test-utils/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import TileLayer from "ol/layer/Tile";
import OSM from "ol/source/OSM";
import { describe, expect, it } from "vitest";
-import { BasemapSwitcher, NO_BASEMAP_ID } from "./BasemapSwitcher";
+import { BasemapSwitcher } from "./BasemapSwitcher";
const defaultBasemapConfig = [
{
@@ -39,11 +40,13 @@ it("should successfully create a basemap switcher component", async () => {
// basemap switcher is mounted
const { switcherDiv, switcherSelect } = await waitForBasemapSwitcher();
+ showDropdown(switcherSelect);
expect(switcherDiv).toMatchSnapshot();
// check basemap switcher box and select is available
expect(switcherDiv.tagName).toBe("DIV");
- expect(switcherSelect.tagName).toBe("SELECT");
+ expect(switcherSelect.tagName).toBe("DIV");
+ expect(switcherSelect.getElementsByTagName("input").length).toBe(1);
});
it("should successfully create a basemap switcher component with additional css classes and box properties", async () => {
@@ -65,6 +68,7 @@ it("should successfully create a basemap switcher component with additional css
});
it("should successfully select a basemap from basemap switcher", async () => {
+ const user = userEvent.setup();
const { mapId, registry } = await setupMap({
layers: defaultBasemapConfig
});
@@ -79,21 +83,32 @@ it("should successfully select a basemap from basemap switcher", async () => {
// basemap switcher is mounted
const { switcherSelect } = await waitForBasemapSwitcher();
+ showDropdown(switcherSelect);
+
+ let options = getCurrentOptions(switcherSelect);
+ const osmOption = options.find((option) => option.textContent === "OSM");
+ if (!osmOption) {
+ throw new Error("Layer OSM missing in basemap options");
+ }
+ await user.click(osmOption);
- act(() => {
- fireEvent.change(switcherSelect, { target: { value: "osm" } });
- });
const firstActiveBaseLayer = map.layers.getActiveBaseLayer();
expect(firstActiveBaseLayer?.id).toBe("osm");
- act(() => {
- fireEvent.change(switcherSelect, { target: { value: "topplus-open" } });
- });
+ showDropdown(switcherSelect);
+ options = getCurrentOptions(switcherSelect);
+ const topPlusOption = options.find((option) => option.textContent === "TopPlus Open");
+ if (!topPlusOption) {
+ throw new Error("Layer topplus-open missing in basemap options");
+ }
+ await user.click(topPlusOption);
+
const nextActiveBaseLayer = map.layers.getActiveBaseLayer();
expect(nextActiveBaseLayer?.id).toBe("topplus-open");
});
it("should allow selecting 'no basemap' when enabled", async () => {
+ const user = userEvent.setup();
const { mapId, registry } = await setupMap({
layers: defaultBasemapConfig
});
@@ -108,64 +123,28 @@ it("should allow selecting 'no basemap' when enabled", async () => {
// basemap switcher is mounted
const { switcherSelect } = await waitForBasemapSwitcher();
- expect(switcherSelect).toMatchInlineSnapshot(`
-
- `);
- expect(switcherSelect.value).toBe("osm");
+
+ expect(switcherSelect.textContent).toBe("OSM");
expect(map.layers.getActiveBaseLayer()?.id).toBe("osm");
- act(() => {
- fireEvent.change(switcherSelect, { target: { value: NO_BASEMAP_ID } });
- });
+ showDropdown(switcherSelect);
+ const options = getCurrentOptions(switcherSelect);
+ const optionsBasemap = options.find((option) => option.textContent === "emptyBasemapLabel");
+ if (!optionsBasemap) {
+ throw new Error("Layer Basemap missing in basemap options");
+ }
+ await user.click(optionsBasemap);
- expect(switcherSelect.value).toBe(NO_BASEMAP_ID);
+ expect(switcherSelect.textContent).toBe("emptyBasemapLabel");
expect(map.layers.getActiveBaseLayer()).toBe(undefined);
});
-it("should successfully select emptyBasemap, if all configured basemaps are configured as not visible", async () => {
+it("should not allow selecting 'no basemap' by default", async () => {
const { mapId, registry } = await setupMap({
- layers: [
- {
- id: "b-1",
- title: "OSM",
- isBaseLayer: true,
- visible: false,
- olLayer: new TileLayer({
- source: new OSM()
- })
- },
- {
- id: "b-2",
- title: "topplus-open",
- isBaseLayer: true,
- visible: false,
- olLayer: new TileLayer({
- source: new BkgTopPlusOpen()
- })
- }
- ]
+ layers: defaultBasemapConfig
});
- const map = await registry.expectMapModel(mapId);
+ const map = await registry.expectMapModel(mapId);
const injectedServices = createServiceOptions({ registry });
render(
@@ -175,32 +154,16 @@ it("should successfully select emptyBasemap, if all configured basemaps are conf
// basemap switcher is mounted
const { switcherSelect } = await waitForBasemapSwitcher();
- expect(switcherSelect).toMatchInlineSnapshot(`
-
- `);
- expect(switcherSelect.value).toBe(NO_BASEMAP_ID);
- const activeBaseLayer = map.layers.getActiveBaseLayer();
- expect(activeBaseLayer).toBeUndefined();
+ expect(switcherSelect.textContent).toBe("OSM");
+ expect(map.layers.getActiveBaseLayer()?.id).toBe("osm");
+
+ showDropdown(switcherSelect);
+ const options = getCurrentOptions(switcherSelect);
+ const optionsEmptyBasemap = options.filter(
+ (option) => option.textContent === "emptyBasemapLabel"
+ );
+ expect(optionsEmptyBasemap).toHaveLength(0);
});
it("should update when a new basemap is registered", async () => {
@@ -218,7 +181,10 @@ it("should update when a new basemap is registered", async () => {
// basemap switcher is mounted
const { switcherSelect } = await waitForBasemapSwitcher();
- expect(switcherSelect.options.length).toBe(2);
+ showDropdown(switcherSelect);
+
+ let options = getCurrentOptions(switcherSelect);
+ expect(options.length).toBe(2);
act(() => {
const layer = new SimpleLayer({
@@ -230,29 +196,16 @@ it("should update when a new basemap is registered", async () => {
map.layers.addLayer(layer);
});
- expect(switcherSelect.options.length).toBe(3);
- expect(switcherSelect).toMatchInlineSnapshot(`
-
- `);
+ options = getCurrentOptions(switcherSelect);
+ expect(options.length).toBe(3);
+ const optionLabels = Array.from(options).map((opt) => opt.textContent);
+ expect(optionLabels, "new basemap was added").toMatchInlineSnapshot(`
+ [
+ "OSM",
+ "TopPlus Open",
+ "Foo",
+ ]
+ `);
});
it("should update when a different basemap is activated from somewhere else", async () => {
@@ -270,13 +223,13 @@ it("should update when a different basemap is activated from somewhere else", as
// basemap switcher is mounted
const { switcherSelect } = await waitForBasemapSwitcher();
- expect(switcherSelect.value).toBe("osm");
+ expect(switcherSelect.textContent).toBe("OSM");
expect(map.layers.getActiveBaseLayer()?.id).toBe("osm");
act(() => {
map.layers.activateBaseLayer("topplus-open");
});
- expect(switcherSelect.value).toBe("topplus-open");
+ expect(switcherSelect.textContent).toBe("TopPlus Open");
});
describe("should successfully select the correct basemap from basemap switcher", () => {
@@ -314,8 +267,8 @@ describe("should successfully select the correct basemap from basemap switcher",
// basemap switcher is mounted
const { switcherSelect } = await waitForBasemapSwitcher();
- expect(switcherSelect.value).toBe("osm");
- expect(switcherSelect.value).not.toBe("topplus-open");
+ expect(switcherSelect.textContent).toBe("OSM");
+ expect(switcherSelect.textContent).not.toBe("TopPlus Open");
const activeBaseLayer = map.layers.getActiveBaseLayer();
expect(activeBaseLayer?.id).toBe("osm");
@@ -355,14 +308,148 @@ describe("should successfully select the correct basemap from basemap switcher",
// basemap switcher is mounted
const { switcherSelect } = await waitForBasemapSwitcher();
- expect(switcherSelect.value).toBe("topplus-open");
- expect(switcherSelect.value).not.toBe("osm");
+ expect(switcherSelect.textContent).toBe("TopPlus Open");
+ expect(switcherSelect.textContent).not.toBe("OSM");
const activeBaseLayer = map.layers.getActiveBaseLayer();
expect(activeBaseLayer?.id).toBe("topplus-open");
});
});
+it("should deactivate unavailable layers for selection", async () => {
+ const osmSource = new OSM();
+ const topPlusSource = new BkgTopPlusOpen();
+
+ const { mapId, registry } = await setupMap({
+ layers: [
+ {
+ id: "osm",
+ title: "OSM",
+ isBaseLayer: true,
+ visible: true,
+ olLayer: new TileLayer({
+ source: osmSource
+ })
+ },
+ {
+ id: "topplus-open",
+ title: "TopPlus Open",
+ isBaseLayer: true,
+ visible: true,
+ olLayer: new TileLayer({
+ source: topPlusSource
+ })
+ }
+ ]
+ });
+ const map = await registry.expectMapModel(mapId);
+
+ const injectedServices = createServiceOptions({ registry });
+ render(
+
+
+
+ );
+
+ // basemap switcher is mounted
+ const { switcherSelect } = await waitForBasemapSwitcher();
+
+ let activeBaseLayer = map.layers.getActiveBaseLayer();
+ expect(activeBaseLayer?.id).toBe("osm");
+
+ showDropdown(switcherSelect);
+ expect(switcherSelect).toMatchSnapshot();
+
+ act(() => {
+ osmSource.setState("error");
+ });
+
+ // switch active layer
+ activeBaseLayer = map.layers.getActiveBaseLayer();
+ expect(activeBaseLayer?.id).toBe("osm");
+
+ // option disabled, warning icon shown and selected option changed?
+ expect(switcherSelect).toMatchSnapshot();
+});
+
+it("should update the ui when a layer title changes", async () => {
+ const { mapId, registry } = await setupMap({
+ layers: [
+ {
+ id: "osm",
+ title: "OSM",
+ isBaseLayer: true,
+ visible: true,
+ olLayer: new TileLayer({
+ source: new OSM()
+ })
+ },
+ {
+ id: "topplus-open",
+ title: "TopPlus Open",
+ isBaseLayer: true,
+ visible: true,
+ olLayer: new TileLayer({
+ source: new BkgTopPlusOpen()
+ })
+ }
+ ]
+ });
+ const map = await registry.expectMapModel(mapId);
+
+ const injectedServices = createServiceOptions({ registry });
+ render(
+
+
+
+ );
+
+ // basemap switcher is mounted
+ const { switcherSelect } = await waitForBasemapSwitcher();
+
+ const activeBaseLayer = map.layers.getActiveBaseLayer();
+ expect(activeBaseLayer?.id).toBe("osm");
+
+ showDropdown(switcherSelect);
+
+ let options = getCurrentOptions(switcherSelect);
+ let optionLabels = Array.from(options).map((opt) => opt.textContent);
+ expect(optionLabels, "basemap options are not equal to their expected values")
+ .toMatchInlineSnapshot(`
+ [
+ "OSM",
+ "TopPlus Open",
+ ]
+ `);
+
+ // change layer title
+ act(() => {
+ activeBaseLayer?.setTitle("New Layer Title");
+ });
+
+ options = getCurrentOptions(switcherSelect);
+ optionLabels = Array.from(options).map((opt) => opt.textContent);
+ expect(optionLabels, "basemap layer was not renamed").toMatchInlineSnapshot(`
+ [
+ "New Layer Title",
+ "TopPlus Open",
+ ]
+ `);
+});
+
+function showDropdown(switcherSelect: HTMLElement) {
+ // open dropdown to include options in snapshot; react-select creates list of options in dom after opening selection
+ act(() => {
+ fireEvent.keyDown(switcherSelect, { key: "ArrowDown" });
+ });
+}
+
+function getCurrentOptions(switcherSelect: HTMLElement) {
+ return Array.from(
+ switcherSelect.getElementsByClassName("basemap-switcher-option")
+ ) as HTMLElement[];
+}
+
async function waitForBasemapSwitcher() {
const { switcherDiv, switcherSelect } = await waitFor(async () => {
const switcherDiv: HTMLDivElement | null =
@@ -371,7 +458,7 @@ async function waitForBasemapSwitcher() {
throw new Error("basemap switcher not rendered");
}
- const switcherSelect: HTMLSelectElement | null = switcherDiv.querySelector(
+ const switcherSelect: HTMLElement | null = switcherDiv.querySelector(
".basemap-switcher-select"
);
if (!switcherSelect) {
diff --git a/src/packages/basemap-switcher/BasemapSwitcher.tsx b/src/packages/basemap-switcher/BasemapSwitcher.tsx
index 32a11de01..cd9145c19 100644
--- a/src/packages/basemap-switcher/BasemapSwitcher.tsx
+++ b/src/packages/basemap-switcher/BasemapSwitcher.tsx
@@ -1,10 +1,12 @@
// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
// SPDX-License-Identifier: Apache-2.0
-import { Box, Select } from "@open-pioneer/chakra-integration";
+import { Box, Flex, Tooltip } from "@open-pioneer/chakra-integration";
import { Layer, MapModel, useMapModel } from "@open-pioneer/map";
import { useIntl } from "open-pioneer:react-hooks";
import { FC, useCallback, useMemo, useRef, useSyncExternalStore } from "react";
+import { Select, OptionProps, SingleValueProps, chakraComponents } from "chakra-react-select";
import { CommonComponentProps, useCommonComponentProps } from "@open-pioneer/react-utils";
+import { FiAlertTriangle } from "react-icons/fi";
/*
Exported for tests. Feels a bit hacky but should be fine for now.
@@ -13,17 +15,18 @@ import { CommonComponentProps, useCommonComponentProps } from "@open-pioneer/rea
export const NO_BASEMAP_ID = "___NO_BASEMAP___";
/**
- * These are special properties for the `Select`.
+ * Properties for single select options.
*/
-interface SelectOption {
+export interface SelectOption {
/**
* The id of the basemap for the select option.
*/
- id: string;
+ value: string;
+
/**
- * The label of the basemap for the select option.
+ * The layer object for the select option.
*/
- label: string;
+ layer: Layer | undefined;
}
/**
@@ -44,7 +47,7 @@ export interface BasemapSwitcherProps extends CommonComponentProps {
* Specifies whether an option to deactivate all basemap layers is available in the BasemapSwitcher.
* Defaults to `false`.
*/
- allowSelectingEmptyBasemap?: boolean;
+ allowSelectingEmptyBasemap?: boolean | undefined;
/**
* Optional aria-labelledby property.
@@ -66,7 +69,7 @@ export const BasemapSwitcher: FC = (props) => {
const intl = useIntl();
const {
mapId,
- allowSelectingEmptyBasemap,
+ allowSelectingEmptyBasemap = false,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy
} = props;
@@ -75,32 +78,59 @@ export const BasemapSwitcher: FC = (props) => {
const { map } = useMapModel(mapId);
const baseLayers = useBaseLayers(map);
- const { selectOptions, selectedId } = useMemo(() => {
- return createOptions({ baseLayers, allowSelectingEmptyBasemap, emptyBasemapLabel });
- }, [baseLayers, allowSelectingEmptyBasemap, emptyBasemapLabel]);
+
const activateLayer = (layerId: string) => {
map?.layers.activateBaseLayer(layerId === NO_BASEMAP_ID ? undefined : layerId);
};
+ const { options, selectedLayer } = useMemo(() => {
+ const options: SelectOption[] = baseLayers.map((layer) => {
+ return { value: layer.id, layer: layer };
+ });
+
+ const activeBaseLayer = map?.layers.getActiveBaseLayer();
+ if (allowSelectingEmptyBasemap || activeBaseLayer == undefined) {
+ const emptyOption: SelectOption = { value: NO_BASEMAP_ID, layer: undefined };
+ options.push(emptyOption);
+ }
+
+ const selectedLayer = options.find((l) => l.layer === activeBaseLayer);
+ return { options, selectedLayer };
+ }, [allowSelectingEmptyBasemap, baseLayers, map?.layers]);
+
+ const components = useMemo(() => {
+ return {
+ Option: BasemapSelectOption,
+ SingleValue: BasemapSelectValue
+ };
+ }, []);
+
return (
{map ? (
-
- ) : (
- ""
- )}
+ classNamePrefix="react-select"
+ options={options}
+ value={selectedLayer}
+ onChange={(option) => option && activateLayer(option.value)}
+ isClearable={false}
+ isSearchable={false}
+ // optionLabel is used by screenreaders
+ getOptionLabel={(option) =>
+ option.layer !== undefined
+ ? option.layer.title +
+ (option.layer.loadState === "error"
+ ? " " + intl.formatMessage({ id: "layerNotAvailable" })
+ : "")
+ : emptyBasemapLabel
+ }
+ isOptionDisabled={(option) => option?.layer?.loadState === "error"}
+ components={components}
+ />
+ ) : null}
);
};
@@ -136,30 +166,96 @@ function useBaseLayers(mapModel: MapModel | undefined): Layer[] {
return useSyncExternalStore(subscribe, getSnapshot);
}
-function createOptions(params: {
- baseLayers: Layer[];
- allowSelectingEmptyBasemap: boolean | undefined;
- emptyBasemapLabel: string;
-}): { selectOptions: SelectOption[]; selectedId: string } {
- const { baseLayers = [], allowSelectingEmptyBasemap = false, emptyBasemapLabel } = params;
- const selectOptions: SelectOption[] = baseLayers.map((item) => ({
- id: item.id,
- label: item.title
- }));
-
- let selectedId = baseLayers.find((layer) => layer.visible)?.id;
- if (allowSelectingEmptyBasemap || selectedId == null) {
- selectOptions.push(getNonBaseMapConfig(emptyBasemapLabel));
- }
- if (selectedId == null) {
- selectedId = NO_BASEMAP_ID;
- }
- return { selectOptions, selectedId };
+function BasemapSelectOption(props: OptionProps): JSX.Element {
+ const { layer } = props.data;
+ const { isAvailable, content } = useBasemapItem(layer);
+
+ return (
+
+ {content}
+
+ );
+}
+
+function BasemapSelectValue(props: SingleValueProps): JSX.Element {
+ const { layer } = props.data;
+ const { isAvailable, content } = useBasemapItem(layer);
+
+ return (
+
+ {content}
+
+ );
}
-function getNonBaseMapConfig(label: string) {
+function useBasemapItem(layer: Layer | undefined) {
+ const intl = useIntl();
+ const notAvailableLabel = intl.formatMessage({ id: "layerNotAvailable" });
+ const label = useTitle(layer);
+ const isAvailable = useLoadState(layer) !== "error";
+
return {
- id: NO_BASEMAP_ID,
- label
+ isAvailable,
+ content: (
+
+ {label}
+ {!isAvailable && (
+
+
+
+
+
+
+
+ )}
+
+ )
};
}
+
+function useTitle(layer: Layer | undefined): string {
+ const intl = useIntl();
+ const emptyBasemapLabel = intl.formatMessage({ id: "emptyBasemapLabel" });
+
+ const getSnapshot = useCallback(() => {
+ return layer === undefined ? emptyBasemapLabel : layer.title; // undefined == empty basemap
+ }, [layer, emptyBasemapLabel]);
+ const subscribe = useCallback(
+ (cb: () => void) => {
+ if (layer !== undefined) {
+ const resource = layer.on("changed:title", cb);
+ return () => resource.destroy();
+ }
+ return () => {};
+ },
+ [layer]
+ );
+
+ return useSyncExternalStore(subscribe, getSnapshot);
+}
+
+function useLoadState(layer: Layer | undefined): string {
+ const getSnapshot = useCallback(() => {
+ return layer === undefined ? "loaded" : layer.loadState; // undefined == empty basemap
+ }, [layer]);
+ const subscribe = useCallback(
+ (cb: () => void) => {
+ if (layer !== undefined) {
+ const resource = layer.on("changed:loadState", cb);
+ return () => resource.destroy();
+ }
+ return () => {};
+ },
+ [layer]
+ );
+
+ return useSyncExternalStore(subscribe, getSnapshot);
+}
diff --git a/src/packages/basemap-switcher/README.md b/src/packages/basemap-switcher/README.md
index d6af95a27..7e7b79dff 100644
--- a/src/packages/basemap-switcher/README.md
+++ b/src/packages/basemap-switcher/README.md
@@ -2,6 +2,8 @@
This package provides a UI component that allows a user to switch between different base maps.
+Unavailable basemaps are marked with an icon and will be deactivated for selection. If a basemap was configured as initially selected, it remains selected and there will not be any automatic fallback to another basemap.
+
## Usage
To add the component to your app, insert the following snippet with a reference to a map ID:
@@ -10,7 +12,7 @@ To add the component to your app, insert the following snippet with a reference
```
-To provide an option to deactivate all basemap layers, add the optional property `allowSelectingEmptyBasemap`.
+To provide an option to deactivate all basemap layers, add the optional property `allowSelectingEmptyBasemap` (default: `false`).
```jsx
diff --git a/src/packages/basemap-switcher/__snapshots__/BasemapSwitcher.test.tsx.snap b/src/packages/basemap-switcher/__snapshots__/BasemapSwitcher.test.tsx.snap
index c837a648d..42e0c534f 100644
--- a/src/packages/basemap-switcher/__snapshots__/BasemapSwitcher.test.tsx.snap
+++ b/src/packages/basemap-switcher/__snapshots__/BasemapSwitcher.test.tsx.snap
@@ -1,5 +1,330 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+exports[`should deactivate unavailable layers for selection 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`should deactivate unavailable layers for selection 2`] = `
+
+
+
+
+
+
+`;
+
exports[`should successfully create a basemap switcher component 1`] = `
-
+
+
+
- emptyBasemapLabel
-
-
+
+
+
+
diff --git a/src/packages/basemap-switcher/i18n/de.yaml b/src/packages/basemap-switcher/i18n/de.yaml
index 2def2a6a9..5297bb20a 100644
--- a/src/packages/basemap-switcher/i18n/de.yaml
+++ b/src/packages/basemap-switcher/i18n/de.yaml
@@ -1,2 +1,3 @@
messages:
emptyBasemapLabel: Ohne Hintergrundkarte
+ layerNotAvailable: Layer nicht verfügbar
diff --git a/src/packages/basemap-switcher/i18n/en.yaml b/src/packages/basemap-switcher/i18n/en.yaml
index 77d1eee9b..6dad90f3d 100644
--- a/src/packages/basemap-switcher/i18n/en.yaml
+++ b/src/packages/basemap-switcher/i18n/en.yaml
@@ -1,2 +1,3 @@
messages:
emptyBasemapLabel: Without basemap
+ layerNotAvailable: Layer not available
diff --git a/src/packages/basemap-switcher/package.json b/src/packages/basemap-switcher/package.json
index de5952edb..57926502f 100644
--- a/src/packages/basemap-switcher/package.json
+++ b/src/packages/basemap-switcher/package.json
@@ -11,8 +11,10 @@
"@open-pioneer/map": "workspace:^",
"@open-pioneer/runtime": "^1.1.0",
"@open-pioneer/react-utils": "workspace:^",
+ "chakra-react-select": "^4.7.6",
"ol": "^8.2.0",
- "react": "^18.2.0"
+ "react": "^18.2.0",
+ "react-icons": "^4.11.0"
},
"devDependencies": {
"@open-pioneer/test-utils": "^1.0.1",
diff --git a/src/packages/map/README.md b/src/packages/map/README.md
index be0c5375c..f2a741fa1 100644
--- a/src/packages/map/README.md
+++ b/src/packages/map/README.md
@@ -258,6 +258,65 @@ layer.updateAttributes({
layer.deleteAttribute("foo");
```
+An optional property `healthCheck` allows to determine the availability status of a layer (e.g. map service down). The health check is performed asynchronous.
+
+It is possible to provide
+
+- either a URL to perform a test request check the returned HTTP status
+- or a `HealthCheckFunction` performing a custom check and returning the state
+
+**Important**: The availability of a layer is only checked once during initialization to reduce the load on server side. If a service becomes available again later, the application will need to be reloaded in order to update the availability status.
+
+The availability status of a layer can be accessed with the property `loadState`. Its value depends on the result of the health check and the OpenLayers `Source` of the layer. If at least one of both checks returns the state `error`, the `loadState` will be set to `error`.
+
+Example: Check of layer availability ("health check")
+
+```ts
+// YOUR-APP/MapConfigProviderImpl.ts
+import { MapConfig, MapConfigProvider, SimpleLayer } from "@open-pioneer/map";
+import TileLayer from "ol/layer/Tile";
+import OSM from "ol/source/OSM";
+
+export class MapConfigProviderImpl implements MapConfigProvider {
+ async getMapConfig(): Promise {
+ return {
+ layers: [
+ new SimpleLayer({
+ id: "1",
+ title: "Layer 1",
+ olLayer: new TileLayer({
+ source: new OSM()
+ }),
+ // check layer availability by requesting the provided URL
+ healthCheck:
+ "https://sgx.geodatenzentrum.de/wmts_topplus_open/1.0.0/WMTSCapabilities.xml",
+ isBaseLayer: false,
+ visible: true
+ }),
+ new SimpleLayer({
+ id: "2",
+ title: "Layer 2",
+ olLayer: new TileLayer({
+ source: new OSM()
+ }),
+ // check layer availability by providing a custom health check function
+ healthCheck: async () => {
+ function wait(milliseconds: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
+ }
+
+ await wait(3000);
+ return "error";
+ },
+ isBaseLayer: false,
+ visible: false
+ })
+ ]
+ };
+ }
+}
+```
+
> NOTE: The visibility of base layers cannot be changed through the method `setVisible`.
> Call `activateBaseLayer` instead.
diff --git a/src/packages/map/api/layers/WMSLayer.ts b/src/packages/map/api/layers/WMSLayer.ts
index 9837b4d3b..3ca8233db 100644
--- a/src/packages/map/api/layers/WMSLayer.ts
+++ b/src/packages/map/api/layers/WMSLayer.ts
@@ -2,12 +2,12 @@
// SPDX-License-Identifier: Apache-2.0
import type { Options as WMSSourceOptions } from "ol/source/ImageWMS";
import { WMSLayerImpl } from "../../model/layers/WMSLayerImpl";
-import type { LayerBaseConfig, Layer, SublayersCollection } from "./base";
+import type { LayerBaseConfig, Layer, SublayersCollection, LayerConfig } from "./base";
/**
* Configuration options to construct a WMS layer.
*/
-export interface WMSLayerConfig extends LayerBaseConfig {
+export interface WMSLayerConfig extends LayerConfig {
/** URL of the WMS service. */
url: string;
diff --git a/src/packages/map/api/layers/base.ts b/src/packages/map/api/layers/base.ts
index f899340b4..e3304be11 100644
--- a/src/packages/map/api/layers/base.ts
+++ b/src/packages/map/api/layers/base.ts
@@ -19,6 +19,9 @@ export interface LayerBaseEvents {
/** The load state of a layer. */
export type LayerLoadState = "not-loaded" | "loading" | "loaded" | "error";
+/** Custom function to check the state of a layer and returning a "loaded" or "error". */
+export type HealthCheckFunction = (layer: LayerConfig) => Promise<"loaded" | "error">;
+
/**
* Configuration options supported by all layer types (layers and sublayers).
*/
@@ -139,6 +142,13 @@ export interface LayerConfig extends LayerBaseConfig {
* Defaults to `false`.
*/
isBaseLayer?: boolean;
+
+ /**
+ * Optional property to check the availability of the layer.
+ * It is possible to provide either a URL which indicates the state of the service (2xx response meaning "ok")
+ * or a {@link HealthCheckFunction} performing a custom check and returning the state.
+ */
+ healthCheck?: string | HealthCheckFunction;
}
/**
diff --git a/src/packages/map/model/AbstractLayer.test.ts b/src/packages/map/model/AbstractLayer.test.ts
index 94bdd3b38..6ea71474a 100644
--- a/src/packages/map/model/AbstractLayer.test.ts
+++ b/src/packages/map/model/AbstractLayer.test.ts
@@ -5,10 +5,10 @@
*/
import Layer from "ol/layer/Layer";
import TileLayer from "ol/layer/Tile";
-import Source from "ol/source/Source";
-import { afterEach, expect, it, vi } from "vitest";
-import { SimpleLayerConfig } from "../api";
+import { SpyInstance, afterEach, describe, expect, it, vi } from "vitest";
+import { HealthCheckFunction, LayerConfig, SimpleLayerConfig } from "../api";
import { AbstractLayer } from "./AbstractLayer";
+import Source, { State } from "ol/source/Source";
afterEach(() => {
vi.restoreAllMocks();
@@ -123,6 +123,190 @@ it("tracks the layer source's state", async () => {
}
});
+describe("performs a health check", () => {
+ it("when specified as URL, success", async () => {
+ const mockedFetch: SpyInstance = vi.spyOn(global, "fetch");
+ mockedFetch.mockResolvedValue(
+ new Response("", {
+ status: 200
+ })
+ );
+
+ const testUrl = "http://example.org/health";
+ const { layer } = createLayerWithHealthCheck({
+ healthCheck: testUrl,
+ sourceState: "ready"
+ });
+
+ let eventEmitted = 0;
+ layer.on("changed:loadState", () => eventEmitted++);
+
+ expect(layer.olLayer.getSourceState()).toBe("ready");
+ expect(mockedFetch).toHaveBeenCalledWith(testUrl);
+ expect(eventEmitted).toBe(0);
+ expect(layer.loadState).toBe("loaded");
+
+ await sleep(25);
+ expect(eventEmitted).toBe(0); // no change of state
+ expect(layer.loadState).toBe("loaded");
+ // ol layer state remains ready and is overwritten by internal health check
+ expect(layer.olLayer.getSourceState()).toBe("ready");
+ });
+
+ it("when specified as URL, fail", async () => {
+ const mockedWarn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const mockedFetch: SpyInstance = vi.spyOn(global, "fetch");
+ mockedFetch.mockResolvedValue(
+ new Response("", {
+ status: 404
+ })
+ );
+ const testUrl = "http://example.org/health";
+
+ const { layer } = createLayerWithHealthCheck({
+ healthCheck: testUrl,
+ sourceState: "ready"
+ });
+
+ let eventEmitted = 0;
+ layer.on("changed:loadState", () => eventEmitted++);
+
+ expect(layer.olLayer.getSourceState()).toBe("ready");
+ expect(mockedFetch).toHaveBeenCalledWith(testUrl);
+ expect(layer.loadState).toBe("loaded");
+ expect(eventEmitted).toBe(0);
+
+ await sleep(25);
+ // ol layer state remains ready and is overwritten by internal health check
+ expect(layer.olLayer.getSourceState()).toBe("ready");
+ expect(layer.loadState).toBe("error");
+ expect(eventEmitted).toBe(1);
+
+ expect(mockedWarn.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "[WARN] map:AbstractLayer: Health check failed for layer 'a' (http status 404)",
+ ],
+ ]
+ `);
+ });
+
+ it("when specified as function", async () => {
+ let didResolve = false;
+ const mockedFetch: SpyInstance = vi.spyOn(global, "fetch");
+ const customHealthCheck: HealthCheckFunction = async () => {
+ function wait(milliseconds: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
+ }
+
+ await wait(5);
+
+ didResolve = true;
+ return "error";
+ };
+ const mockedCustomHealthCheck = vi.fn(customHealthCheck);
+
+ const { layer, config } = createLayerWithHealthCheck({
+ sourceState: "ready",
+ healthCheck: mockedCustomHealthCheck
+ });
+
+ let eventEmitted = 0;
+ layer.on("changed:loadState", () => eventEmitted++);
+
+ expect(mockedFetch).toHaveBeenCalledTimes(0);
+ expect(mockedCustomHealthCheck).toHaveBeenCalledOnce();
+ expect(mockedCustomHealthCheck).toHaveBeenCalledWith(config);
+ expect(layer.olLayer.getSourceState()).toBe("ready");
+ expect(eventEmitted).toBe(0);
+ expect(layer.loadState).toBe("loaded");
+ expect(didResolve).toBe(false);
+
+ await sleep(25);
+ expect(didResolve).toBe(true);
+ // ol layer state remains ready and is overwritten by internal health check
+ expect(layer.olLayer.getSourceState()).toBe("ready");
+ expect(eventEmitted).toBe(1);
+ expect(layer.loadState).toBe("error");
+ });
+
+ it("when specified as function returning 'loaded'", async () => {
+ const _mockedFetch: SpyInstance = vi.spyOn(global, "fetch");
+ const customHealthCheck: HealthCheckFunction = async () => {
+ return "loaded";
+ };
+ const mockedCustomHealthCheck = vi.fn(customHealthCheck);
+
+ const { layer } = createLayerWithHealthCheck({
+ sourceState: "ready",
+ healthCheck: mockedCustomHealthCheck
+ });
+
+ await sleep(25);
+ expect(mockedCustomHealthCheck).toHaveBeenCalledOnce();
+ expect(layer.loadState).toBe("loaded");
+ });
+
+ it("when specified as function throwing an error", async () => {
+ const mockedWarn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+ const _mockedFetch: SpyInstance = vi.spyOn(global, "fetch");
+ const customHealthCheck: HealthCheckFunction = async () => {
+ throw new Error("broken!");
+ };
+ const mockedCustomHealthCheck = vi.fn(customHealthCheck);
+
+ const { layer } = createLayerWithHealthCheck({
+ sourceState: "ready",
+ healthCheck: mockedCustomHealthCheck
+ });
+
+ await sleep(25);
+ expect(mockedCustomHealthCheck).toHaveBeenCalledOnce();
+ expect(layer.loadState).toBe("error");
+
+ expect(mockedWarn.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "[WARN] map:AbstractLayer: Health check failed for layer 'a'",
+ [Error: broken!],
+ ],
+ ]
+ `);
+ });
+
+ it("not when no health check specified in layer config", async () => {
+ const mockedFetch: SpyInstance = vi.spyOn(global, "fetch");
+ const { layer } = createLayerWithHealthCheck({
+ healthCheck: undefined,
+ sourceState: "ready"
+ });
+
+ let eventEmitted = 0;
+ layer.on("changed:loadState", () => eventEmitted++);
+
+ expect(mockedFetch).toHaveBeenCalledTimes(0);
+ expect(eventEmitted).toBe(0); // no change of state
+ expect(layer.loadState).toBe("loaded");
+ expect(layer.olLayer.getSourceState()).toBe("ready");
+ });
+
+ it("not when ol layer state already is error", async () => {
+ const mockedFetch: SpyInstance = vi.spyOn(global, "fetch");
+ const { layer } = createLayerWithHealthCheck({
+ healthCheck: "http://example.org/health",
+ sourceState: "error"
+ });
+
+ let eventEmitted = 0;
+ layer.on("changed:loadState", () => eventEmitted++);
+
+ expect(layer.loadState).toBe("error");
+ expect(layer.olLayer.getSourceState()).toBe("error");
+ expect(mockedFetch).toHaveBeenCalledTimes(0);
+ expect(eventEmitted).toBe(0); // no event emitted because state was initially error
+ });
+});
+
// Basic impl for tests
class LayerImpl extends AbstractLayer {
get sublayers(): undefined {
@@ -134,3 +318,32 @@ class LayerImpl extends AbstractLayer {
function createLayer(layerConfig: SimpleLayerConfig): AbstractLayer {
return new LayerImpl(layerConfig);
}
+
+function createLayerWithHealthCheck(options?: {
+ healthCheck?: LayerConfig["healthCheck"];
+ sourceState?: State;
+}) {
+ const source = new Source({
+ state: options?.sourceState ?? "ready"
+ });
+ const olLayer = new Layer({
+ source
+ });
+ const config: SimpleLayerConfig = {
+ id: "a",
+ title: "A",
+ visible: true,
+ olLayer: olLayer,
+ healthCheck: options?.healthCheck
+ };
+
+ const layer = createLayer(config);
+
+ return { layer, source, config };
+}
+
+function sleep(ms: number) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
diff --git a/src/packages/map/model/AbstractLayer.ts b/src/packages/map/model/AbstractLayer.ts
index b97fe7a90..9cffc0e9d 100644
--- a/src/packages/map/model/AbstractLayer.ts
+++ b/src/packages/map/model/AbstractLayer.ts
@@ -6,7 +6,7 @@ import { EventsKey } from "ol/events";
import OlBaseLayer from "ol/layer/Base";
import OlLayer from "ol/layer/Layer";
import Source, { State as OlSourceState } from "ol/source/Source";
-import { Layer, LayerLoadState, SimpleLayerConfig } from "../api";
+import { HealthCheckFunction, Layer, LayerLoadState, SimpleLayerConfig } from "../api";
import { AbstractLayerBase } from "./AbstractLayerBase";
import { MapModelImpl } from "./MapModelImpl";
@@ -23,6 +23,7 @@ export abstract class AbstractLayer
{
#olLayer: OlBaseLayer;
#isBaseLayer: boolean;
+ #healthCheck?: string | HealthCheckFunction;
#visible: boolean;
#loadState: LayerLoadState;
@@ -32,15 +33,17 @@ export abstract class AbstractLayer
super(config);
this.#olLayer = config.olLayer;
this.#isBaseLayer = config.isBaseLayer ?? false;
- this.#visible = config.visible ?? true;
+ this.#healthCheck = config.healthCheck;
const { initial: initialState, resource: stateWatchResource } = watchLoadState(
- this.#olLayer,
+ this.id,
+ config,
(state) => {
this.#loadState = state;
this.__emitChangeEvent("changed:loadState");
}
);
+ this.#visible = config.visible ?? true;
this.#loadState = initialState;
this.#stateWatchResource = stateWatchResource;
}
@@ -105,9 +108,12 @@ export abstract class AbstractLayer
}
function watchLoadState(
- olLayer: OlBaseLayer,
+ layerId: string,
+ config: SimpleLayerConfig,
onChange: (newState: LayerLoadState) => void
): { initial: LayerLoadState; resource: Resource } {
+ const olLayer = config.olLayer;
+
if (!(olLayer instanceof OlLayer)) {
// Some layers don't have a source (such as group)
return {
@@ -121,9 +127,25 @@ function watchLoadState(
}
let currentSource = olLayer?.getSource() as Source | null;
- let currentLoadState = mapState(currentSource?.getState());
+ const currentOlLayerState = mapState(currentSource?.getState());
+
+ let currentLoadState: LayerLoadState = currentOlLayerState;
+ let currentHealthState = "loading"; // initial state loading until health check finished
+
+ // custom health check not needed when OL already returning an error state
+ if (currentOlLayerState !== "error") {
+ // health check only once during initialization
+ healthCheck(layerId, config).then((state: LayerLoadState) => {
+ currentHealthState = state;
+ updateState();
+ });
+ }
+
const updateState = () => {
- const nextLoadState = mapState(currentSource?.getState());
+ const olLayerState = mapState(currentSource?.getState());
+ const nextLoadState: LayerLoadState =
+ currentHealthState === "error" ? "error" : olLayerState;
+
if (currentLoadState !== nextLoadState) {
currentLoadState = nextLoadState;
onChange(currentLoadState);
@@ -158,6 +180,41 @@ function watchLoadState(
};
}
+async function healthCheck(layerId: string, config: SimpleLayerConfig): Promise {
+ const healthCheck = config.healthCheck;
+ if (healthCheck == null) {
+ return "loaded";
+ }
+
+ let healthCheckFn: HealthCheckFunction;
+ if (typeof healthCheck === "function") {
+ healthCheckFn = healthCheck;
+ } else if (typeof healthCheck === "string") {
+ healthCheckFn = async () => {
+ // TODO replace by fetch from HttpService
+ const response = await fetch(healthCheck);
+ if (response.ok) {
+ return "loaded";
+ }
+ LOG.warn(`Health check failed for layer '${layerId}' (http status ${response.status})`);
+ return "error";
+ };
+ } else {
+ LOG.error(
+ `Unexpected object for 'healthCheck' parameter of layer '${layerId}'`,
+ healthCheck
+ );
+ return "error";
+ }
+
+ try {
+ return await healthCheckFn(config);
+ } catch (e) {
+ LOG.warn(`Health check failed for layer '${layerId}'`, e);
+ return "error";
+ }
+}
+
function mapState(state: OlSourceState | undefined): LayerLoadState {
switch (state) {
case undefined:
diff --git a/src/packages/toc/LayerList.test.tsx b/src/packages/toc/LayerList.test.tsx
index 9a234e3a3..d1386dd80 100644
--- a/src/packages/toc/LayerList.test.tsx
+++ b/src/packages/toc/LayerList.test.tsx
@@ -14,6 +14,7 @@ import {
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 { expect, it } from "vitest";
import { LayerList } from "./LayerList";
import { SimpleLayer } from "@open-pioneer/map";
@@ -402,6 +403,58 @@ it("reacts to changes in the layer description", async () => {
screen.getByText("New description");
});
+it("reacts to changes of the layer load state", async () => {
+ const source = new OSM();
+
+ const { mapId, registry } = await setupMap({
+ layers: [
+ {
+ id: "layer1",
+ title: "Layer 1",
+ description: "Description 1",
+ olLayer: new TileLayer({
+ source: source
+ })
+ }
+ ]
+ });
+ const map = await registry.expectMapModel(mapId);
+
+ const { container } = render(
+
+
+
+ );
+
+ const checkbox = queryByRole(container, "checkbox")!;
+ const button = queryByRole(container, "button");
+ let icons = container.querySelectorAll(".toc-layer-item-content-icon");
+
+ expect(checkbox).toBeTruthy();
+ expect(checkbox.disabled).toBe(false);
+ expect(button?.disabled).toBe(false);
+ expect(icons).toHaveLength(0);
+
+ act(() => {
+ source.setState("error");
+ });
+
+ icons = container.querySelectorAll(".toc-layer-item-content-icon");
+ expect(checkbox.disabled).toBe(true);
+ expect(button?.disabled).toBe(true);
+ expect(icons).toHaveLength(1);
+
+ // and back
+ act(() => {
+ source.setState("ready");
+ });
+
+ icons = container.querySelectorAll(".toc-layer-item-content-icon");
+ expect(checkbox.disabled).toBe(false);
+ expect(button?.disabled).toBe(false);
+ expect(icons).toHaveLength(0);
+});
+
/** Returns the layer list's current list items. */
function getCurrentItems(container: HTMLElement) {
return queryAllByRole(container, "listitem");
diff --git a/src/packages/toc/LayerList.tsx b/src/packages/toc/LayerList.tsx
index a865773f9..112ae9a0f 100644
--- a/src/packages/toc/LayerList.tsx
+++ b/src/packages/toc/LayerList.tsx
@@ -15,14 +15,18 @@ import {
PopoverHeader,
PopoverTrigger,
Portal,
- Text
+ Spacer,
+ Text,
+ Tooltip
} from "@open-pioneer/chakra-integration";
-import { LayerBase, MapModel, Sublayer } from "@open-pioneer/map";
+import { Layer, LayerBase, MapModel, Sublayer } from "@open-pioneer/map";
import { PackageIntl } from "@open-pioneer/runtime";
import classNames from "classnames";
import { useIntl } from "open-pioneer:react-hooks";
import { useCallback, useRef, useSyncExternalStore } from "react";
-import { FiMoreVertical } from "react-icons/fi";
+import { FiAlertTriangle, FiMoreVertical } from "react-icons/fi";
+
+type TocLayer = Layer | Sublayer;
/**
* Lists the (top level) operational layers in the map.
@@ -46,7 +50,7 @@ export function LayerList(props: { map: MapModel; "aria-labelledby"?: string }):
});
}
-function createList(layers: LayerBase[], intl: PackageIntl, listProps: ListProps) {
+function createList(layers: TocLayer[], intl: PackageIntl, listProps: ListProps) {
const items = layers.map((layer) => );
return (
setVisible(event.target.checked)}
>
{title}
+ {!isAvailable && (
+
+
+
+
+
+ )}
+
{layer.description && (
)}
@@ -106,18 +132,20 @@ function LayerItem(props: { layer: LayerBase; intl: PackageIntl }): JSX.Element
}
function LayerItemDescriptor(props: {
- layer: LayerBase;
+ layer: TocLayer;
title: string;
intl: PackageIntl;
}): JSX.Element {
const { layer, title, intl } = props;
const buttonLabel = intl.formatMessage({ id: "descriptionLabel" });
const description = useLayerDescription(layer);
+ const isAvailable = useLoadState(layer) !== "error";
return (
+
diff --git a/src/packages/toc/i18n/de.yaml b/src/packages/toc/i18n/de.yaml
index c6eb5c0a8..0f59b6ea8 100644
--- a/src/packages/toc/i18n/de.yaml
+++ b/src/packages/toc/i18n/de.yaml
@@ -4,6 +4,7 @@ messages:
operationalLayerLabel: "Layer"
missingLayers: "Es sind keine Layer vorhanden."
error: "Beim Erstellen des Karteninhalts ist ein Fehler aufgetreten."
+ layerNotAvailable: "Layer nicht verfügbar"
toolsLabel: "Kartenwerkzeuge"
tools:
hideAllLayers: "Alle Karteninhalte ausblenden"
diff --git a/src/packages/toc/i18n/en.yaml b/src/packages/toc/i18n/en.yaml
index 466dc1dc9..91e0e6956 100644
--- a/src/packages/toc/i18n/en.yaml
+++ b/src/packages/toc/i18n/en.yaml
@@ -4,6 +4,7 @@ messages:
operationalLayerLabel: "Operational layers"
missingLayers: "There are no layers to display."
error: "Error while creating map content."
+ layerNotAvailable: "Layer not available"
toolsLabel: "Map tools"
tools:
hideAllLayers: "Hide all layers"
diff --git a/src/samples/map-sample/ol-app/MapConfigProviderImpl.ts b/src/samples/map-sample/ol-app/MapConfigProviderImpl.ts
index a0ac9132d..e2c664d9e 100644
--- a/src/samples/map-sample/ol-app/MapConfigProviderImpl.ts
+++ b/src/samples/map-sample/ol-app/MapConfigProviderImpl.ts
@@ -28,6 +28,8 @@ export class MapConfigProviderImpl implements MapConfigProvider {
title: "TopPlus Open",
isBaseLayer: true,
visible: true,
+ healthCheck:
+ "https://sgx.geodatenzentrum.de/wmts_topplus_open/1.0.0/WMTSCapabilities.xml",
olLayer: createTopPlusOpenLayer("web")
}),
new SimpleLayer({
diff --git a/src/samples/test-basemap-switcher/basemap-switcher-app/AppUI.tsx b/src/samples/test-basemap-switcher/basemap-switcher-app/AppUI.tsx
index 7a8721b74..f668de5ef 100644
--- a/src/samples/test-basemap-switcher/basemap-switcher-app/AppUI.tsx
+++ b/src/samples/test-basemap-switcher/basemap-switcher-app/AppUI.tsx
@@ -68,39 +68,38 @@ export function AppUI() {
-
-
- Description
-
- This application can be used to test the basemap switcher.
- The basemap switcher synchronizes with the state of the
- shared map model. If the map model is changed (for example,
- by changing the current basemap), the basemap switcher must
- update itself accordingly.
-
-
-
- Adding a new basemap updates the dropdown menu (new
- option)
-
-
- Changing the current basemap to another basemap updates
- the selected option
-
-
- Setting the current basemap to {"'undefined'"} also
- updates the selection
-
-
-
-
+
+
+
+ Description
+
+ This application can be used to test the basemap switcher. The
+ basemap switcher synchronizes with the state of the shared map
+ model. If the map model is changed (for example, by changing the
+ current basemap), the basemap switcher must update itself
+ accordingly.
+
+
+
+ Adding a new basemap updates the dropdown menu (new option)
+
+
+ Changing the current basemap to another basemap updates the
+ selected option
+
+
+ Setting the current basemap to {"'undefined'"} also updates
+ the selection
+
+
+
diff --git a/src/samples/test-toc/index.html b/src/samples/test-toc/index.html
new file mode 100644
index 000000000..7643259fc
--- /dev/null
+++ b/src/samples/test-toc/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+ [Demo] TOC + Health Check
+
+
+
+
+
+
+
+
diff --git a/src/samples/test-toc/toc-app/AppUI.tsx b/src/samples/test-toc/toc-app/AppUI.tsx
new file mode 100644
index 000000000..34370ec31
--- /dev/null
+++ b/src/samples/test-toc/toc-app/AppUI.tsx
@@ -0,0 +1,119 @@
+// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
+// SPDX-License-Identifier: Apache-2.0
+import { Box, Flex, VStack, Text } from "@open-pioneer/chakra-integration";
+import { MapAnchor, MapContainer } from "@open-pioneer/map";
+import { SectionHeading, TitledSection, ToolButton } from "@open-pioneer/react-utils";
+import { Toc } from "@open-pioneer/toc";
+import { useIntl } from "open-pioneer:react-hooks";
+import { useId, useState } from "react";
+import { PiListLight } from "react-icons/pi";
+import { MAP_ID } from "./MapConfigProviderImpl";
+
+export function AppUI() {
+ const intl = useIntl();
+ const tocTitleId = useId();
+ const [showToc, setShowToc] = useState(true);
+
+ function toggleToc() {
+ setShowToc(!showToc);
+ }
+
+ return (
+
+
+
+ OpenLayers Base Packages - TOC and Health Check Sample
+
+
+ }
+ >
+
+
+
+ {showToc && (
+
+ {showToc && (
+
+
+ {intl.formatMessage({ id: "tocTitle" })}
+
+ }
+ >
+
+
+
+ )}
+
+ )}
+
+
+
+ Description
+
+ This application can be used to test the TOC, including health
+ checks for configured layers. Two base layers ({'"'}TopPlus Open
+ {'"'} and {'"'}TopPlus Open (Grau){'"'}) and one operational
+ layer ({'"'}Schulstandorte{'"'}) will be unavailable and should
+ be marked as such by the UI.
+
+
+
+
+
+ }
+ isActive={showToc}
+ onClick={toggleToc}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/src/samples/test-toc/toc-app/CHANGELOG.md b/src/samples/test-toc/toc-app/CHANGELOG.md
new file mode 100644
index 000000000..2d05b63fb
--- /dev/null
+++ b/src/samples/test-toc/toc-app/CHANGELOG.md
@@ -0,0 +1 @@
+# toc-app
diff --git a/src/samples/test-toc/toc-app/MapConfigProviderImpl.ts b/src/samples/test-toc/toc-app/MapConfigProviderImpl.ts
new file mode 100644
index 000000000..1990a6ee5
--- /dev/null
+++ b/src/samples/test-toc/toc-app/MapConfigProviderImpl.ts
@@ -0,0 +1,221 @@
+// 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 GeoJSON from "ol/format/GeoJSON";
+import TileLayer from "ol/layer/Tile";
+import VectorLayer from "ol/layer/Vector";
+import OSM from "ol/source/OSM";
+import VectorSource from "ol/source/Vector";
+import WMTS from "ol/source/WMTS";
+import WMTSTileGrid from "ol/tilegrid/WMTS";
+
+export const MAP_ID = "main";
+
+export class MapConfigProviderImpl implements MapConfigProvider {
+ mapId = MAP_ID;
+
+ async getMapConfig(): Promise {
+ return {
+ initialView: {
+ kind: "position",
+ center: { x: 404747, y: 5757920 },
+ zoom: 14
+ },
+ projection: "EPSG:25832",
+ layers: [
+ new SimpleLayer({
+ id: "topplus_open",
+ title: "TopPlus Open",
+ isBaseLayer: true,
+ visible: true,
+ // broken URL
+ healthCheck:
+ "https://sgx.geodatenzentrum.de/wmts_topplus_openERROR/1.0.0/WMTSCapabilities.xml",
+ olLayer: createTopPlusOpenLayer("web")
+ }),
+ new SimpleLayer({
+ id: "topplus_open_grau",
+ title: "TopPlus Open (Grau)",
+ isBaseLayer: true,
+ visible: false,
+ // example for a custom health check running async
+ healthCheck: async () => {
+ function wait(milliseconds: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
+ }
+
+ await wait(2000);
+ return "error";
+ },
+ olLayer: createTopPlusOpenLayer("web_grau")
+ }),
+ new SimpleLayer({
+ id: "topplus_open_light",
+ title: "TopPlus Open (Light)",
+ isBaseLayer: true,
+ visible: false,
+ // valid URL
+ healthCheck:
+ "https://sgx.geodatenzentrum.de/wmts_topplus_open/1.0.0/WMTSCapabilities.xml",
+ olLayer: createTopPlusOpenLayer("web_light")
+ }),
+ new SimpleLayer({
+ title: "OSM",
+ visible: false,
+ isBaseLayer: true,
+ olLayer: new TileLayer({
+ 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()
+ }),
+ createSchulenLayer(),
+ createStrassenLayer()
+ ]
+ };
+ }
+}
+
+/**
+ * This method demonstrates how to integrate a WMTS-Service using
+ * advanced predefined configuration.
+ *
+ * For more details, see the documentation of the map package.
+ */
+function createTopPlusOpenLayer(layer: "web" | "web_grau" | "web_light") {
+ const topLeftCorner = [-3803165.98427299, 8805908.08284866];
+
+ /**
+ * Resolutions taken from AdV WMTS-Profil
+ * @see https://www.adv-online.de/AdV-Produkte/Standards-und-Produktblaetter/AdV-Profile/
+ */
+ const resolutions = [
+ 4891.96981025128, // AdV-Level 0 (1:17471320.7508974)
+ 2445.98490512564, // AdV-Level 1 (1:8735660.37544872)
+ 1222.99245256282, // AdV-Level 2 (1:4367830.18772436)
+ 611.49622628141, // AdV-Level 3 (1:2183915.09386218)
+ 305.748113140705, // AdV-Level 4 (1:1091957.54693109)
+ 152.874056570353, // AdV-Level 5 (1:545978.773465545)
+ 76.4370282851763, // AdV-Level 6 (1:272989,386732772)
+ 38.2185141425881, // AdV-Level 7 (1:136494,693366386)
+ 19.1092570712941, // AdV-Level 8 (1:68247,3466831931)
+ 9.55462853564703, // AdV-Level 9 (1:34123,6733415966)
+ 4.77731426782352, // AdV-Level 10 (1:17061,8366707983)
+ 2.38865713391176, // AdV-Level 11 (1:8530,91833539914)
+ 1.19432856695588, // AdV-Level 12 (1:4265,45916769957)
+ 0.59716428347794 // AdV-Level 13 (1:2132,72958384978)
+ ];
+
+ /**
+ * The length of matrixIds needs to match the length of the resolutions array
+ * @see https://openlayers.org/en/latest/apidoc/module-ol_tilegrid_WMTS-WMTSTileGrid.html
+ */
+ const matrixIds = new Array(resolutions.length);
+ for (let i = 0; i < resolutions.length; i++) {
+ matrixIds[i] = i;
+ }
+
+ const wmts = new WMTS({
+ url: `https://sgx.geodatenzentrum.de/wmts_topplus_open/tile/1.0.0/${layer}/{Style}/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}.png`,
+ layer: "web_grau",
+ matrixSet: "EU_EPSG_25832_TOPPLUS",
+ format: "image/png",
+ projection: "EPSG:25832",
+ requestEncoding: "REST",
+ tileGrid: new WMTSTileGrid({
+ origin: topLeftCorner,
+ resolutions: resolutions,
+ matrixIds: matrixIds
+ }),
+ style: "default",
+ attributions: `Kartendarstellung und Präsentationsgraphiken: © Bundesamt für Kartographie und Geodäsie ${new Date().getFullYear()}, Datenquellen`
+ });
+ return new TileLayer({
+ source: wmts
+ });
+}
+
+function createHaltestellenLayer() {
+ const geojsonSource = new VectorSource({
+ url: "https://geo.sv.rostock.de/download/opendata/haltestellen/haltestellen.json",
+ format: new GeoJSON(), //assign GeoJson parser
+ attributions: "Haltestellen Stadt Rostock, Creative Commons CC Zero License (cc-zero)"
+ });
+
+ return new VectorLayer({
+ source: geojsonSource
+ });
+}
+
+function createKitasLayer() {
+ const geojsonSource = new VectorSource({
+ url: "https://ogc-api.nrw.de/inspire-us-kindergarten/v1/collections/governmentalservice/items?f=json&limit=10000",
+ format: new GeoJSON(), //assign GeoJson parser
+ attributions:
+ '© Bundesamt für Kartographie und Geodäsie 2017, Datenquellen'
+ });
+
+ return new VectorLayer({
+ source: geojsonSource
+ });
+}
+
+function createSchulenLayer() {
+ return new WMSLayer({
+ title: "Schulstandorte",
+ 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
+ healthCheck: async () => {
+ function wait(milliseconds: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
+ }
+
+ await wait(3000);
+ return "error";
+ },
+ url: "https://www.wms.nrw.de/wms/wms_nw_inspire-schulen",
+ sublayers: [
+ {
+ name: "US.education",
+ title: "INSPIRE - WMS Schulstandorte NRW"
+ }
+ ],
+ sourceOptions: {
+ ratio: 1
+ }
+ });
+}
+
+function createStrassenLayer() {
+ return new WMSLayer({
+ title: "Straßennetz Landesbetrieb Straßenbau NRW",
+ url: "https://www.wms.nrw.de/wms/strassen_nrw_wms",
+ sublayers: [
+ {
+ name: "1",
+ title: "Verwaltungen"
+ },
+ {
+ name: "4",
+ title: "Abschnitte und Äste"
+ },
+ {
+ name: "6",
+ title: "Unfälle"
+ }
+ ]
+ });
+}
diff --git a/src/samples/test-toc/toc-app/app.css b/src/samples/test-toc/toc-app/app.css
new file mode 100644
index 000000000..4accb86f9
--- /dev/null
+++ b/src/samples/test-toc/toc-app/app.css
@@ -0,0 +1 @@
+/* suppress empty file warning */
diff --git a/src/samples/test-toc/toc-app/app.ts b/src/samples/test-toc/toc-app/app.ts
new file mode 100644
index 000000000..36ffddff1
--- /dev/null
+++ b/src/samples/test-toc/toc-app/app.ts
@@ -0,0 +1,20 @@
+// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
+// SPDX-License-Identifier: Apache-2.0
+import { createCustomElement } from "@open-pioneer/runtime";
+import * as appMetadata from "open-pioneer:app";
+import { AppUI } from "./AppUI";
+
+const element = createCustomElement({
+ component: AppUI,
+ appMetadata,
+ openShadowRoot: true,
+ async resolveConfig(ctx) {
+ const locale = ctx.getAttribute("forced-locale");
+ if (!locale) {
+ return undefined;
+ }
+ return { locale };
+ }
+});
+
+customElements.define("toc-map-app", element);
diff --git a/src/samples/test-toc/toc-app/build.config.mjs b/src/samples/test-toc/toc-app/build.config.mjs
new file mode 100644
index 000000000..84863bc0d
--- /dev/null
+++ b/src/samples/test-toc/toc-app/build.config.mjs
@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
+// SPDX-License-Identifier: Apache-2.0
+import { defineBuildConfig } from "@open-pioneer/build-support";
+
+export default defineBuildConfig({
+ styles: "./app.css",
+ i18n: ["en", "de"],
+ services: {
+ MapConfigProviderImpl: {
+ provides: ["map.MapConfigProvider"]
+ }
+ },
+ ui: {
+ references: ["map.MapRegistry"]
+ }
+});
diff --git a/src/samples/test-toc/toc-app/i18n/de.yaml b/src/samples/test-toc/toc-app/i18n/de.yaml
new file mode 100644
index 000000000..ccd9bae18
--- /dev/null
+++ b/src/samples/test-toc/toc-app/i18n/de.yaml
@@ -0,0 +1,8 @@
+messages:
+ basemapLabel: "Hintergrundkarte auswählen: "
+ tocTitle: "Karteninhalt"
+ ariaLabel:
+ header: "Kopfleiste"
+ footer: "Fussleiste mit Maßstabsangabe, Raumbezugssystem und Koordinatenanzeige"
+ map: "Karte. Mit den Pfeiltasten kannst du die Karte bewegen. Mit der Plus Taste hineinzoomen und mit der Minus Taste herauszoomen."
+ toolbar: "Kartenwerkzeuge"
diff --git a/src/samples/test-toc/toc-app/i18n/en.yaml b/src/samples/test-toc/toc-app/i18n/en.yaml
new file mode 100644
index 000000000..0cfe44d25
--- /dev/null
+++ b/src/samples/test-toc/toc-app/i18n/en.yaml
@@ -0,0 +1,8 @@
+messages:
+ basemapLabel: "Select basemap:"
+ tocTitle: "Table of contents"
+ ariaLabel:
+ header: "Header bar"
+ footer: "Base bar with scale information, spatial reference system and coordinate display"
+ map: "Map. Use the arrow keys to move the map. Zoom in with the plus button and zoom out with the minus button."
+ toolbar: "Maptools"
diff --git a/src/samples/test-toc/toc-app/package.json b/src/samples/test-toc/toc-app/package.json
new file mode 100644
index 000000000..f1315a4f3
--- /dev/null
+++ b/src/samples/test-toc/toc-app/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "toc-map",
+ "private": true,
+ "dependencies": {
+ "@chakra-ui/icons": "^2.1.0",
+ "@chakra-ui/system": "^2.6.0",
+ "@emotion/react": "^11.11.1",
+ "@emotion/styled": "^11.11.0",
+ "@open-pioneer/chakra-integration": "^1.1.0",
+ "@open-pioneer/toc": "workspace:^",
+ "@open-pioneer/map": "workspace:^",
+ "@open-pioneer/runtime": "^1.1.0",
+ "@open-pioneer/react-utils": "workspace:^",
+ "ol": "^8.2.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-icons": "^4.11.0"
+ },
+ "version": "0.0.1"
+}
diff --git a/src/samples/test-toc/toc-app/services.ts b/src/samples/test-toc/toc-app/services.ts
new file mode 100644
index 000000000..60f6dc663
--- /dev/null
+++ b/src/samples/test-toc/toc-app/services.ts
@@ -0,0 +1,3 @@
+// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
+// SPDX-License-Identifier: Apache-2.0
+export { MapConfigProviderImpl } from "./MapConfigProviderImpl";
diff --git a/vite.config.ts b/vite.config.ts
index 47b999d83..c9387b39a 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -27,6 +27,7 @@ const sampleSites = [
"samples/theming-sample",
"samples/test-basemap-switcher",
+ "samples/test-toc",
"samples/test-highlight-and-zoom",
"samples/test-menu-fix",