diff --git a/.changeset/layer-control-initial-state.md b/.changeset/layer-control-initial-state.md new file mode 100644 index 0000000..96bca75 --- /dev/null +++ b/.changeset/layer-control-initial-state.md @@ -0,0 +1,6 @@ +--- +"@xtramaps/layer-control-maplibre": minor +"@xtramaps/layer-control-maplibre-react": minor +--- + +LayerControl: groups and radio-groups accept `opened: false` to start collapsed, and the initial selection now follows the `visibility` layout property of the style's layers instead of activating every layer. Groups start checked if all of their entries are checked, merge-groups as soon as one of their layers is visible, and radio-groups select their first entry that is not hidden in the style. diff --git a/package-lock.json b/package-lock.json index af9c408..4837bff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@vitest/browser": "4.1.10", "@vitest/browser-playwright": "4.1.10", "@vitest/coverage-v8": "4.1.10", + "bootstrap": "5.3.8", "lefthook": "2.1.4", "npm-check-updates": "19.6.3", "playwright": "1.62.1", @@ -4399,6 +4400,26 @@ "dev": true, "license": "MIT" }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/brace-expansion": { "version": "5.0.4", "dev": true, @@ -8646,7 +8667,7 @@ }, "packages/core/layer-control-maplibre": { "name": "@xtramaps/layer-control-maplibre", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "@maplibre/maplibre-gl-style-spec": "^22.0.0", @@ -8663,7 +8684,7 @@ }, "packages/core/web-map-cesium": { "name": "@xtramaps/web-map-cesium", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@cesium/engine": "^26.2.0", @@ -8676,7 +8697,7 @@ }, "packages/core/web-map-maplibre": { "name": "@xtramaps/web-map-maplibre", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "@maplibre/maplibre-gl-style-spec": "^26.0.0", @@ -8712,7 +8733,7 @@ }, "packages/core/web-map-openlayers": { "name": "@xtramaps/web-map-openlayers", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "ol-mapbox-style": "^13.4.2", @@ -8727,10 +8748,10 @@ }, "packages/react/layer-control-maplibre": { "name": "@xtramaps/layer-control-maplibre-react", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { - "@xtramaps/layer-control-maplibre": "1.0.0", + "@xtramaps/layer-control-maplibre": "1.1.0", "@xtramaps/legend-symbols-maplibre-react": "1.0.0", "reactstrap": "^9.2.3" }, @@ -8740,7 +8761,7 @@ "@types/react-dom": "^19.2.3", "@vis.gl/react-maplibre": "^8.1.2", "@vitejs/plugin-react": "^6.0.0", - "@xtramaps/web-map-maplibre-react": "1.0.0", + "@xtramaps/web-map-maplibre-react": "1.1.0", "maplibre-gl": "^6.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -8777,10 +8798,10 @@ }, "packages/react/web-map-cesium": { "name": "@xtramaps/web-map-cesium-react", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { - "@xtramaps/web-map-cesium": "1.0.0" + "@xtramaps/web-map-cesium": "1.1.0" }, "devDependencies": { "@cesium/engine": "^26.2.0", @@ -8802,12 +8823,12 @@ }, "packages/react/web-map-maplibre": { "name": "@xtramaps/web-map-maplibre-react", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "@mapbox/mapbox-gl-draw": "^1.5.0", "@turf/combine": "^7.2.0", - "@xtramaps/web-map-maplibre": "1.0.0" + "@xtramaps/web-map-maplibre": "1.1.0" }, "devDependencies": { "@storybook/react-vite": "10.5.7", @@ -8830,10 +8851,10 @@ }, "packages/react/web-map-openlayers": { "name": "@xtramaps/web-map-openlayers-react", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { - "@xtramaps/web-map-openlayers": "1.0.0" + "@xtramaps/web-map-openlayers": "1.1.0" }, "devDependencies": { "@storybook/react-vite": "10.5.7", diff --git a/package.json b/package.json index 5bf288e..200aeba 100644 --- a/package.json +++ b/package.json @@ -34,13 +34,14 @@ "devDependencies": { "@biomejs/biome": "2.4.6", "@changesets/cli": "^2.30.0", + "@commitlint/cli": "20.5.0", + "@commitlint/config-conventional": "20.5.0", "@storybook/addon-vitest": "10.5.7", "@storybook/react-vite": "10.5.7", "@vitest/browser": "4.1.10", "@vitest/browser-playwright": "4.1.10", "@vitest/coverage-v8": "4.1.10", - "@commitlint/cli": "20.5.0", - "@commitlint/config-conventional": "20.5.0", + "bootstrap": "5.3.8", "lefthook": "2.1.4", "npm-check-updates": "19.6.3", "playwright": "1.62.1", diff --git a/packages/core/layer-control-maplibre/README.md b/packages/core/layer-control-maplibre/README.md index adc6780..b88b5ff 100644 --- a/packages/core/layer-control-maplibre/README.md +++ b/packages/core/layer-control-maplibre/README.md @@ -17,9 +17,13 @@ import { parse, initialCfg } from "@xtramaps/layer-control-maplibre"; const config = await parse(style, entries, /* preferStyle */ true); // config.entries -> hydrated tree ready to render +// config.selectedIds / radioIds -> initial selection, derived from the layers' `visibility` +// config.openedIds -> initially expanded groups (all except those with `opened: false`) // config.deps / depsParent / depsChild -> selection-cascade dependency maps ``` +Layers with `layout.visibility: "none"` in the style start deselected, groups start selected only if all their entries are selected, merge-groups (shown as a single entry) as soon as one of their layers is visible, and radio-groups start with their first entry that is not hidden. Groups and radio-groups are expanded unless configured with `opened: false`. + ### Exports - `parse()` - the main entry point: hydrates `entries` against a style's layers and loads sprites diff --git a/packages/core/layer-control-maplibre/src/config.ts b/packages/core/layer-control-maplibre/src/config.ts index ec46dea..eb2aac0 100644 --- a/packages/core/layer-control-maplibre/src/config.ts +++ b/packages/core/layer-control-maplibre/src/config.ts @@ -57,6 +57,7 @@ export const getRadioGroups = ( const getRadioGroupLayers = ( groups: EntryLike[], selectedOnly?: boolean, + hidden: Set = new Set(), ): Record => { const radioGroups: Record = {}; @@ -69,8 +70,10 @@ const getRadioGroupLayers = ( const entries = g.entries as EntryLike[] | undefined; const id = g.id as string; if (selectedOnly) { - radioGroups[id] = - entries && entries.length > 0 ? getId(entries[0]) : null; + // first entry that is not hidden in the style, falling back to the first entry + const selected = + entries?.find((e) => !hidden.has(getId(e))) ?? entries?.[0]; + radioGroups[id] = selected ? getId(selected) : null; } else { radioGroups[id] = entries ? entries.map((e) => getId(e)) : []; } @@ -183,6 +186,88 @@ export function getChildDeps( return tmp ? deps : deps.clean; } +/** Ids of all style layers with `layout.visibility: "none"`. */ +const getHiddenLayerIds = (style: StyleWithSpriteLoaded | null): Set => + new Set( + (style?.layers ?? []) + .filter( + (layer) => + (layer as { layout?: { visibility?: string } }).layout?.visibility === + "none", + ) + .map((layer) => layer.id), + ); + +/** + * Layers are selected unless hidden in the style. A merge-group is shown as a single entry, + * so it is selected as soon as one of its layers is visible; groups (whose entries are + * listed individually) are selected only if all of their entries are selected. + */ +const getSelectedIds = ( + entries: EntryLike[], + hidden: Set, +): string[] => { + const selected: string[] = []; + + const visit = (entry: EntryLike): boolean => { + if (typeof entry === "string") { + return true; + } + const id = entry.id as string; + if (entry.type === "layer") { + const isSelected = !hidden.has(id); + if (isSelected) selected.push(id); + return isSelected; + } + const states = ((entry.entries as EntryLike[] | undefined) ?? []).map( + visit, + ); + const isSelected = + entry.type === "merge-group" && states.length > 0 + ? states.some(Boolean) + : states.every(Boolean); + if (isSelected) selected.push(id); + return isSelected; + }; + + entries.forEach(visit); + + return selected; +}; + +/** Ids of all groups and radio-groups, except those configured with `opened: false`. */ +const getOpenedIds = (entries: EntryLike[]): string[] => { + const ids: string[] = []; + + entries.forEach((e) => { + if (typeof e === "string") { + return; + } + if ( + (e.type === "group" || e.type === "radio-group") && + e.opened !== false + ) { + ids.push(e.id as string); + } + if (e.entries) { + ids.push(...getOpenedIds(e.entries as EntryLike[])); + } + }); + + return ids; +}; + +const getMergeGroupLayerIds = (entries: EntryLike[]): string[] => + entries.flatMap((e) => { + if (typeof e === "string" || !e.entries) { + return []; + } + const children = e.entries as EntryLike[]; + return e.type === "merge-group" + ? children.map(getId) + : getMergeGroupLayerIds(children); + }); + const getLayers = ( style: StyleWithSpriteLoaded | null, ): Record> => @@ -319,6 +404,7 @@ export const parse = async ( const { opened, onlyLegend, entries = entriesCfg } = styleMetadataCfg; const layers = getLayers(style); + const hidden = getHiddenLayerIds(style); const hydrated = hydrate(entries as EntryLike[], layers); const config: LayerControlConfig = { @@ -328,10 +414,14 @@ export const parse = async ( allIds: getIds(hydrated as EntryLike[]), layerIds: getIds(hydrated as EntryLike[], ["layer"]), groupIds: getIds(hydrated as EntryLike[], ["group", "radio-group"]), - radioIds: getRadioGroupLayers(hydrated as EntryLike[], true) as Record< - string, - string | null - >, + selectedIds: getSelectedIds(hydrated as EntryLike[], hidden), + openedIds: getOpenedIds(hydrated as EntryLike[]), + mergeGroupLayerIds: getMergeGroupLayerIds(hydrated as EntryLike[]), + radioIds: getRadioGroupLayers( + hydrated as EntryLike[], + true, + hidden, + ) as Record, radioGroups: getRadioGroupLayers(hydrated as EntryLike[]) as Record< string, string[] @@ -355,6 +445,9 @@ export const initialCfg: LayerControlConfig = { allIds: [], layerIds: [], groupIds: [], + selectedIds: [], + openedIds: [], + mergeGroupLayerIds: [], radioIds: {}, radioGroups: {}, deps: {}, diff --git a/packages/core/layer-control-maplibre/src/types.ts b/packages/core/layer-control-maplibre/src/types.ts index 36a38b3..62ff925 100644 --- a/packages/core/layer-control-maplibre/src/types.ts +++ b/packages/core/layer-control-maplibre/src/types.ts @@ -14,6 +14,7 @@ export interface RadioGroupEntry { id: string; label?: string; type: "radio-group"; + opened?: boolean; entries?: LayerEntry[]; } @@ -29,6 +30,7 @@ export interface GroupEntry { id: string; label?: string; type: "group"; + opened?: boolean; onlyLegend?: boolean; entries: LayerControlEntry[]; } @@ -55,6 +57,7 @@ export interface HydratedRadioGroup { id: string; label?: string; type: "radio-group"; + opened?: boolean; entries: HydratedLayer[]; onlyLegend?: boolean; } @@ -72,6 +75,7 @@ export interface HydratedGroup { id: string; label?: string; type: "group"; + opened?: boolean; onlyLegend?: boolean; entries: HydratedEntry[]; } @@ -93,6 +97,12 @@ export interface LayerControlConfig { allIds: string[]; layerIds: string[]; groupIds: string[]; + /** Ids initially selected, derived from the `visibility` layout property of the style's layers. */ + selectedIds: string[]; + /** Ids of groups and radio-groups that are initially expanded (all except `opened: false`). */ + openedIds: string[]; + /** Ids of layers inside merge-groups - not shown as entries of their own. */ + mergeGroupLayerIds: string[]; radioIds: Record; radioGroups: Record; deps: Record; diff --git a/packages/react/layer-control-maplibre/src/Control.tsx b/packages/react/layer-control-maplibre/src/Control.tsx index bac17e6..698a515 100644 --- a/packages/react/layer-control-maplibre/src/Control.tsx +++ b/packages/react/layer-control-maplibre/src/Control.tsx @@ -61,10 +61,16 @@ function Control({ } }); + // a parent is selected once all of its entries are - layers inside merge-groups are + // not entries of their own (a merge-group may be selected with some of them hidden) cfg.depsParent[id].forEach((parent) => { if ( !newSelected.includes(parent) && - cfg.depsChild[parent].every((child) => newSelected.includes(child)) + cfg.depsChild[parent].every( + (child) => + cfg.mergeGroupLayerIds.includes(child) || + newSelected.includes(child), + ) ) { newSelected.push(parent); } @@ -103,8 +109,8 @@ function Control({ if (config.opened === true) setIsVisible(true); if (config.onlyLegend === true) setIsControlable(false); setSelectedRadioGroups(config.radioIds); - setSelected(config.allIds); - setOpen(config.groupIds); + setSelected(config.selectedIds); + setOpen(config.openedIds); }); }; diff --git a/packages/react/layer-control-maplibre/src/LayerControl.stories.tsx b/packages/react/layer-control-maplibre/src/LayerControl.stories.tsx index a6a1e1c..eea0fb6 100644 --- a/packages/react/layer-control-maplibre/src/LayerControl.stories.tsx +++ b/packages/react/layer-control-maplibre/src/LayerControl.stories.tsx @@ -1,6 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import MapLibre from "@xtramaps/web-map-maplibre-react"; import "@xtramaps/web-map-maplibre-react/dist/index.css"; +// LayerControl is built on reactstrap and expects the host app to bring Bootstrap CSS +// (as ogcapi-html does) - without it never actually hides anything. +import "bootstrap/dist/css/bootstrap.min.css"; import type { Map as MaplibreMap } from "maplibre-gl"; import { expect, userEvent, waitFor, within } from "storybook/test"; import LayerControl from "./LayerControl"; @@ -250,3 +253,209 @@ export const StyleMetadata: Story = { ); }, }; + +// Layers hidden via `visibility: "none"` in the style itself - LayerControl must take +// them over as its initial selection instead of forcing every layer visible. +const HIDDEN_IN_STYLE = [ + "militarysrf", + "hydrographycrv", + "utilityinfrastructurepnt", + // only one of the two layers of the "Railway" merge-group + "transportationgroundcrv.0b", +]; + +const initialStateEntries = entries.map((entry) => + typeof entry !== "string" && entry.type === "group" + ? { + ...entry, + entries: entry.entries.map((child) => + typeof child !== "string" && child.id === "Hydro" + ? { ...child, opened: false } + : child, + ), + } + : entry, +); + +const loadStyleWithHiddenLayers = async () => { + const style = await (await fetch(STYLE_URL)).json(); + for (const layer of style.layers) { + if (HIDDEN_IN_STYLE.includes(layer.id)) { + layer.layout = { ...layer.layout, visibility: "none" }; + } + } + const styleUrl = URL.createObjectURL( + new Blob([JSON.stringify(style)], { type: "application/json" }), + ); + return { styleUrl }; +}; + +const renderInitialState: Story["render"] = (_args, { loaded }) => ( + { + capturedMap = map; + }} + > + + +); + +// Hydro starts collapsed (opened: false); the layers in HIDDEN_IN_STYLE start deselected. +// This story only asserts the initial state - no clicks - so what you see in Storybook +// is exactly the state after loading. Interactions live in InitialStateInteractions. +export const InitialState: Story = { + loaders: [loadStyleWithHiddenLayers], + render: renderInitialState, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step( + "layers hidden in the style start unchecked and stay hidden", + async () => { + const checkbox = await findInputByText(canvas, "militarysrf"); + await expect(checkbox).not.toBeChecked(); + await waitFor(() => expect(capturedMap).not.toBeNull()); + await expect( + capturedMap?.getLayoutProperty("militarysrf", "visibility"), + ).toBe("none"); + }, + ); + + await step( + "a group with a hidden layer starts unchecked, its visible layers checked", + async () => { + await expect( + await findInputByText(canvas, "hydrographycrv"), + ).not.toBeChecked(); + await expect( + await findInputByText(canvas, "hydrographysrf"), + ).toBeChecked(); + await expect( + await findInputByText(canvas, "Hydrography"), + ).not.toBeChecked(); + await expect( + await findInputByText(canvas, "Overview"), + ).not.toBeChecked(); + await expect(await findInputByText(canvas, "Railway")).toBeChecked(); + }, + ); + + await step( + "a partially hidden merge-group starts checked, its hidden layer stays hidden", + async () => { + const railway = await findInputByText(canvas, "Railway"); + await expect(railway).toBeChecked(); + await expect( + capturedMap?.getLayoutProperty( + "transportationgroundcrv.0a", + "visibility", + ), + ).not.toBe("none"); + await expect( + capturedMap?.getLayoutProperty( + "transportationgroundcrv.0b", + "visibility", + ), + ).toBe("none"); + }, + ); + + await step( + "a radio-group selects its first entry that is not hidden in the style", + async () => { + await expect( + await findInputByText(canvas, "utilityinfrastructurepnt"), + ).not.toBeChecked(); + await expect( + await findInputByText(canvas, "agriculturesrf"), + ).toBeChecked(); + await waitFor(() => + expect( + capturedMap?.getLayoutProperty("agriculturesrf", "visibility"), + ).not.toBe("none"), + ); + }, + ); + + await step( + "a group with opened: false starts collapsed, others expanded", + async () => { + await waitFor(() => + expect(canvas.getByText("hydrographysrf")).not.toBeVisible(), + ); + await expect(canvas.getByText("Railway")).toBeVisible(); + }, + ); + }, +}; + +export const InitialStateInteractions: Story = { + loaders: [loadStyleWithHiddenLayers], + render: renderInitialState, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + await canvas.findByText("militarysrf", {}, { timeout: 15000 }); + + await step( + "a group gets checked once all of its entries are, even with a partially hidden merge-group", + async () => { + const toggleHydro = async () => { + const header = (await canvas.findByText("Hydrography")).closest( + ".row", + ); + const button = header?.querySelector("button"); + await expect(button).not.toBeNull(); + await userEvent.click(button as HTMLButtonElement); + }; + + await toggleHydro(); + await userEvent.click(await findInputByText(canvas, "hydrographycrv")); + + await expect( + await findInputByText(canvas, "Hydrography"), + ).toBeChecked(); + // "Railway" is checked although one of its layers is still hidden + await expect(await findInputByText(canvas, "Overview")).toBeChecked(); + await expect( + capturedMap?.getLayoutProperty( + "transportationgroundcrv.0b", + "visibility", + ), + ).toBe("none"); + }, + ); + + await step( + "switching a partially hidden merge-group off and on shows all its layers", + async () => { + const railway = await findInputByText(canvas, "Railway"); + await userEvent.click(railway); + await expect(railway).not.toBeChecked(); + await waitFor(() => + expect( + capturedMap?.getLayoutProperty( + "transportationgroundcrv.0a", + "visibility", + ), + ).toBe("none"), + ); + + await userEvent.click(railway); + await expect(railway).toBeChecked(); + for (const layerId of [ + "transportationgroundcrv.0a", + "transportationgroundcrv.0b", + ]) { + await waitFor(() => + expect( + capturedMap?.getLayoutProperty(layerId, "visibility"), + ).not.toBe("none"), + ); + } + }, + ); + }, +};