diff --git a/demo/DemoMapBasic.js b/demo/DemoMapBasic.js
index ed12a76b0..613888a05 100644
--- a/demo/DemoMapBasic.js
+++ b/demo/DemoMapBasic.js
@@ -30,7 +30,7 @@ function MapInner () {
mapStyle: MAP_STYLE,
center: [-2.9631008,54.432306],
zoom: 15,
- containerHeight: '500px'
+ containerHeight: '516px'
})
})
}, [])
diff --git a/demo/DemoMapDraw.js b/demo/DemoMapDraw.js
new file mode 100644
index 000000000..b2545e8e2
--- /dev/null
+++ b/demo/DemoMapDraw.js
@@ -0,0 +1,171 @@
+import { useEffect, useRef } from 'react'
+import BrowserOnly from '@docusaurus/BrowserOnly'
+
+const MAP_STYLE = {
+ id: 'outdoor',
+ url: 'https://labs.os.uk/tiles/styles/open-zoomstack-outdoor/style.json',
+ attribution: `Contains OS data © Crown copyright and database rights ${new Date().getFullYear()}`,
+ backgroundColor: '#f5f5f0'
+}
+
+const DRAW_LAYERS = ['fill-inactive.cold', 'stroke-inactive.cold']
+const ICON_POLYGON = ''
+const ICON_LINE = ''
+const ICON_EDIT = ''
+const ICON_DELETE = ''
+
+function MapInner () {
+ const initialised = useRef(false)
+
+ useEffect(() => {
+ if (initialised.current) {
+ return
+ }
+ initialised.current = true
+
+ Promise.all([
+ import('../src/index.js'),
+ import('../providers/maplibre/src/index.js'),
+ import('../plugins/interact/src/index.js'),
+ import('../plugins/beta/draw-ml/src/index.js')
+ ]).then(([
+ { default: InteractiveMap },
+ { default: maplibreProvider },
+ { default: createInteractPlugin },
+ { default: createDrawPlugin }
+ ]) => {
+ const interactPlugin = createInteractPlugin({
+ layers: [
+ { layerId: 'fill-inactive.cold', idProperty: 'id' },
+ { layerId: 'stroke-inactive.cold', idProperty: 'id' }
+ ],
+ interactionModes: ['selectFeature'],
+ multiSelect: true,
+ deselectOnClickOutside: true
+ })
+
+ const drawPlugin = createDrawPlugin({
+ snapLayers: ['buildings 3D']
+ })
+
+ const interactiveMap = new InteractiveMap('demo-map-draw', {
+ behaviour: 'hybrid',
+ mapProvider: maplibreProvider(),
+ mapStyle: MAP_STYLE,
+ center: [-0.1276, 51.5074],
+ zoom: 12,
+ containerHeight: '516px',
+ plugins: [interactPlugin, drawPlugin],
+ hasExitButton: true
+ })
+
+ let selectedFeatureIds = []
+
+ interactiveMap.on('map:ready', () => {
+ interactPlugin.enable()
+
+ interactiveMap.addButton('drawTools', {
+ label: 'Draw tools',
+ mobile: { slot: 'bottom-right' },
+ tablet: { slot: 'top-middle' },
+ desktop: { slot: 'top-middle' },
+ menuItems: [
+ {
+ id: 'drawPolygon',
+ label: 'Draw polygon',
+ iconSvgContent: ICON_POLYGON,
+ onClick: () => {
+ interactiveMap.toggleButtonState('drawTools', 'hidden', true)
+ drawPlugin.newPolygon(crypto.randomUUID(), {
+ stroke: '#e6c700',
+ fill: 'rgba(255, 221, 0, 0.1)'
+ })
+ }
+ },
+ {
+ id: 'drawLine',
+ label: 'Draw line',
+ iconSvgContent: ICON_LINE,
+ onClick: () => {
+ interactiveMap.toggleButtonState('drawTools', 'hidden', true)
+ drawPlugin.newLine(crypto.randomUUID(), {
+ stroke: '#d4351c',
+ strokeWidth: 4
+ })
+ }
+ },
+ {
+ id: 'editFeature',
+ label: 'Edit feature',
+ iconSvgContent: ICON_EDIT,
+ isDisabled: true,
+ onClick: () => {
+ if (!drawPlugin.editFeature(selectedFeatureIds[0])) return
+ interactiveMap.toggleButtonState('drawTools', 'hidden', true)
+ interactPlugin.disable()
+ }
+ },
+ {
+ id: 'deleteFeature',
+ label: 'Delete feature',
+ iconSvgContent: ICON_DELETE,
+ isDisabled: true,
+ onClick: () => {
+ drawPlugin.deleteFeature(selectedFeatureIds)
+ interactPlugin.clear()
+ interactiveMap.toggleButtonState('drawTools', 'hidden', false)
+ interactiveMap.toggleButtonState('drawPolygon', 'disabled', false)
+ interactiveMap.toggleButtonState('drawLine', 'disabled', false)
+ interactiveMap.toggleButtonState('editFeature', 'disabled', true)
+ interactiveMap.toggleButtonState('deleteFeature', 'disabled', true)
+ }
+ }
+ ]
+ })
+ })
+
+ interactiveMap.on('draw:started', () => {
+ interactPlugin.disable()
+ })
+
+ interactiveMap.on('draw:created', () => {
+ interactiveMap.toggleButtonState('drawTools', 'hidden', false)
+ interactPlugin.enable()
+ })
+
+ interactiveMap.on('draw:edited', () => {
+ interactiveMap.toggleButtonState('drawTools', 'hidden', false)
+ interactPlugin.enable()
+ })
+
+ interactiveMap.on('draw:cancelled', () => {
+ interactiveMap.toggleButtonState('drawTools', 'hidden', false)
+ interactPlugin.enable()
+ })
+
+ interactiveMap.on('interact:selectionchange', (e) => {
+ const singleFeature = e.selectedFeatures.length === 1
+ const anyFeature = e.selectedFeatures.length > 0
+ const isDrawFeature = singleFeature && DRAW_LAYERS.includes(e.selectedFeatures[0].layerId)
+ const allDrawFeatures = anyFeature && e.selectedFeatures.every(f => DRAW_LAYERS.includes(f.layerId))
+ selectedFeatureIds = e.selectedFeatures.map(f => f.featureId)
+ interactiveMap.toggleButtonState('drawPolygon', 'disabled', singleFeature)
+ interactiveMap.toggleButtonState('drawLine', 'disabled', singleFeature)
+ interactiveMap.toggleButtonState('editFeature', 'disabled', !isDrawFeature)
+ interactiveMap.toggleButtonState('deleteFeature', 'disabled', !allDrawFeatures)
+ })
+ })
+ }, [])
+
+ return
+}
+
+export default function DemoMapDraw () {
+ return (
+ The map requires JavaScript to be enabled.}
+ >
+ {() => }
+
+ )
+}
diff --git a/demo/DemoMapMarkerPanel.js b/demo/DemoMapMarkerPanel.js
index aaf7d6b4f..48bf6ce1b 100644
--- a/demo/DemoMapMarkerPanel.js
+++ b/demo/DemoMapMarkerPanel.js
@@ -39,7 +39,7 @@ function MapInner () {
mapStyle: MAP_STYLE,
center: MARKER_COORDS,
zoom: 15,
- containerHeight: '500px',
+ containerHeight: '516px',
plugins: [interactPlugin]
})
diff --git a/demo/DemoMapSearchNominatim.js b/demo/DemoMapSearchNominatim.js
new file mode 100644
index 000000000..b0bc01f4a
--- /dev/null
+++ b/demo/DemoMapSearchNominatim.js
@@ -0,0 +1,94 @@
+import { useEffect, useRef } from 'react'
+import BrowserOnly from '@docusaurus/BrowserOnly'
+
+const MAP_STYLE = {
+ url: 'https://labs.os.uk/tiles/styles/open-zoomstack-outdoor/style.json',
+ attribution: `Contains OS data © Crown copyright and database rights ${new Date().getFullYear()}`,
+ backgroundColor: '#f5f5f0'
+}
+
+const markQuery = (text, query) => {
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ return text.replace(new RegExp(`(${escaped})`, 'i'), '$1')
+}
+
+const SETTLEMENT_TYPES = new Set(['city', 'town', 'village', 'hamlet', 'suburb', 'quarter', 'neighbourhood', 'municipality', 'borough', 'district', 'county'])
+
+const nominatimDataset = {
+ name: 'nominatim',
+ buildRequest: (query) => {
+ const params = new URLSearchParams({
+ q: query,
+ format: 'json',
+ limit: '8',
+ countrycodes: 'gb'
+ })
+ return new Request(`https://nominatim.openstreetmap.org/search?${params}`, {
+ headers: { 'Accept-Language': 'en' }
+ })
+ },
+ parseResults: (json, query) => {
+ if (!Array.isArray(json)) {
+ return []
+ }
+ const results = json.filter(item => SETTLEMENT_TYPES.has(item.addresstype)).map(item => {
+ // Nominatim boundingbox: [min_lat, max_lat, min_lon, max_lon]
+ const [minLat, maxLat, minLon, maxLon] = item.boundingbox.map(Number)
+ return {
+ id: String(item.place_id),
+ text: item.display_name,
+ marked: markQuery(item.display_name, query),
+ point: [Number(item.lon), Number(item.lat)],
+ bounds: [minLon, minLat, maxLon, maxLat]
+ }
+ })
+ return Array.from(new Map(results.map(r => [r.text, r])).values())
+ }
+}
+
+function MapInner () {
+ const initialised = useRef(false)
+
+ useEffect(() => {
+ if (initialised.current) {
+ return
+ }
+ initialised.current = true
+
+ Promise.all([
+ import('../src/index.js'),
+ import('../providers/maplibre/src/index.js'),
+ import('../plugins/search/src/index.js')
+ ]).then(([
+ { default: InteractiveMap },
+ { default: maplibreProvider },
+ { default: createSearchPlugin }
+ ]) => {
+ const searchPlugin = createSearchPlugin({
+ customDatasets: [nominatimDataset]
+ })
+
+ new InteractiveMap('demo-map-search-control', {
+ behaviour: 'inline',
+ mapProvider: maplibreProvider(),
+ mapStyle: MAP_STYLE,
+ center: [-1.6, 53.1],
+ zoom: 6,
+ containerHeight: '516px',
+ plugins: [searchPlugin]
+ })
+ })
+ }, [])
+
+ return
+}
+
+export default function DemoMapSearchNominatim () {
+ return (
+ The map requires JavaScript to be enabled.}
+ >
+ {() => }
+
+ )
+}
diff --git a/demo/DemoMapSelectBuilding.js b/demo/DemoMapSelectBuilding.js
new file mode 100644
index 000000000..cd3b58cda
--- /dev/null
+++ b/demo/DemoMapSelectBuilding.js
@@ -0,0 +1,96 @@
+import { useEffect, useRef } from 'react'
+import BrowserOnly from '@docusaurus/BrowserOnly'
+
+const MAP_STYLE = {
+ url: 'https://labs.os.uk/tiles/styles/open-zoomstack-outdoor/style.json',
+ attribution: `Contains OS data © Crown copyright and database rights ${new Date().getFullYear()}`,
+ backgroundColor: '#f5f5f0'
+}
+
+const CENTER = [-1.5491, 53.8008]
+const PANEL_ID = 'building-info'
+
+function MapInner () {
+ const initialised = useRef(false)
+
+ useEffect(() => {
+ if (initialised.current) {
+ return
+ }
+ initialised.current = true
+
+ Promise.all([
+ import('../src/index.js'),
+ import('../providers/maplibre/src/index.js'),
+ import('../plugins/interact/src/index.js')
+ ]).then(([
+ { default: InteractiveMap },
+ { default: maplibreProvider },
+ { default: createInteractPlugin }
+ ]) => {
+ const interactPlugin = createInteractPlugin({
+ interactionModes: ['selectFeature'],
+ deselectOnClickOutside: true,
+ debug: true,
+ layers: [
+ { layerId: 'buildings 3D', idProperty: 'uuid' }
+ ]
+ })
+
+ const map = new InteractiveMap('demo-map-select-building', {
+ behaviour: 'inline',
+ mapProvider: maplibreProvider(),
+ mapStyle: MAP_STYLE,
+ center: CENTER,
+ zoom: 14.5,
+ maxZoom: 14.5,
+ containerHeight: '516px',
+ plugins: [interactPlugin]
+ })
+
+ map.on('map:ready', () => {
+ interactPlugin.enable()
+
+ map.addPanel(PANEL_ID, {
+ focus: false,
+ label: 'Selected building',
+ html: '',
+ mobile: { slot: 'drawer', dismissible: true, open: false },
+ tablet: { slot: 'left-top', dismissible: true, width: '300px', open: false },
+ desktop: { slot: 'left-top', dismissible: true, width: '300px', open: false }
+ })
+ })
+
+ map.on('interact:selectionchange', ({ selectedFeatures }) => {
+ if (selectedFeatures.length > 0) {
+ let html = ''
+ for (const [k, v] of Object.entries(selectedFeatures[0].properties)) {
+ html += `${k}: ${v}
`
+ }
+ document.getElementById('building-info-content').innerHTML = html
+ map.showPanel(PANEL_ID)
+ } else {
+ map.hidePanel(PANEL_ID)
+ }
+ })
+
+ map.on('app:panelclosed', ({ panelId }) => {
+ if (panelId === PANEL_ID) {
+ interactPlugin.clear()
+ }
+ })
+ })
+ }, [])
+
+ return
+}
+
+export default function DemoMapSelectBuilding () {
+ return (
+ The map requires JavaScript to be enabled.}
+ >
+ {() => }
+
+ )
+}
diff --git a/demo/DemoMapStyleSwitcher.js b/demo/DemoMapStyleSwitcher.js
new file mode 100644
index 000000000..38e200f90
--- /dev/null
+++ b/demo/DemoMapStyleSwitcher.js
@@ -0,0 +1,92 @@
+import { useEffect, useRef } from 'react'
+import BrowserOnly from '@docusaurus/BrowserOnly'
+import outdoorThumb from '../docs/assets/images/outdoor-ozs-map-thumb.jpg'
+import nightThumb from '../docs/assets/images/night-ozs-map-thumb.jpg'
+import deuteranopiaThumb from '../docs/assets/images/deuteranopia-ozs-map-thumb.jpg'
+import tritanopiaThumb from '../docs/assets/images/tritanopia-ozs-map-thumb.jpg'
+
+const ATTRIBUTION = `Contains OS data © Crown copyright and database rights ${new Date().getFullYear()}`
+
+const MAP_STYLES = [
+ {
+ id: 'outdoor',
+ label: 'Outdoor',
+ url: 'https://labs.os.uk/tiles/styles/open-zoomstack-outdoor/style.json',
+ thumbnail: outdoorThumb,
+ attribution: ATTRIBUTION,
+ backgroundColor: '#f5f5f0'
+ },
+ {
+ id: 'night',
+ label: 'Night',
+ url: 'https://labs.os.uk/tiles/styles/open-zoomstack-night/style.json',
+ thumbnail: nightThumb,
+ attribution: ATTRIBUTION,
+ mapColorScheme: 'dark',
+ appColorScheme: 'dark'
+ },
+ {
+ id: 'deuteranopia',
+ label: 'Deuteranopia',
+ url: 'https://labs.os.uk/tiles/styles/open-zoomstack-deuteranopia/style.json',
+ thumbnail: deuteranopiaThumb,
+ attribution: ATTRIBUTION,
+ backgroundColor: '#f5f5f0'
+ },
+ {
+ id: 'tritanopia',
+ label: 'Tritanopia',
+ url: 'https://labs.os.uk/tiles/styles/open-zoomstack-tritanopia/style.json',
+ thumbnail: tritanopiaThumb,
+ attribution: ATTRIBUTION,
+ backgroundColor: '#f5f5f0'
+ }
+]
+
+function MapInner () {
+ const initialised = useRef(false)
+
+ useEffect(() => {
+ if (initialised.current) return
+ initialised.current = true
+
+ Promise.all([
+ import('../src/index.js'),
+ import('../providers/maplibre/src/index.js'),
+ import('../plugins/beta/map-styles/src/index.js')
+ ]).then(([
+ { default: InteractiveMap },
+ { default: maplibreProvider },
+ { default: createMapStylesPlugin }
+ ]) => {
+ const mapStylesPlugin = createMapStylesPlugin({
+ mapStyles: MAP_STYLES,
+ manifest: {
+ buttons: [{ id: 'mapStyles', mobile: { slot: 'top-left', showLabel: true } }]
+ }
+ })
+
+ new InteractiveMap('demo-map-style-switcher', {
+ behaviour: 'inline',
+ mapProvider: maplibreProvider(),
+ mapStyle: MAP_STYLES[0],
+ center: [-0.1276, 51.5074],
+ zoom: 12,
+ containerHeight: '516px',
+ plugins: [mapStylesPlugin]
+ })
+ })
+ }, [])
+
+ return
+}
+
+export default function DemoMapStyleSwitcher () {
+ return (
+ The map requires JavaScript to be enabled.}
+ >
+ {() => }
+
+ )
+}
diff --git a/demo/DemoMapToggleMarkerLabel.js b/demo/DemoMapToggleMarkerLabel.js
index 9d7f557e4..c56a7638c 100644
--- a/demo/DemoMapToggleMarkerLabel.js
+++ b/demo/DemoMapToggleMarkerLabel.js
@@ -38,7 +38,7 @@ function MapInner () {
mapStyle: MAP_STYLE,
center: MARKER_COORDS,
zoom: 15,
- containerHeight: '500px',
+ containerHeight: '516px',
plugins: [interactPlugin]
})
diff --git a/demo/js/index.js b/demo/js/index.js
index 6064febda..9b79ac7b3 100755
--- a/demo/js/index.js
+++ b/demo/js/index.js
@@ -317,8 +317,8 @@ const interactiveMap = new InteractiveMap('map', {
osNamesURL: process.env.OS_NAMES_URL,
customDatasets: [parcelSearch, gridRefSearchETRS89],
width: '300px',
- showMarker: true
- // expanded: true
+ showMarker: true,
+ showLabel: true
}),
// useLocationPlugin(),
interactPlugin,
diff --git a/docs/assets/css/docusaurus.scss b/docs/assets/css/docusaurus.scss
index fbe82b54d..59b9f4bea 100644
--- a/docs/assets/css/docusaurus.scss
+++ b/docs/assets/css/docusaurus.scss
@@ -94,32 +94,42 @@
.app-example-card {
position: relative;
margin-bottom: 30px;
- border: 1px solid #b1b4b6;
}
.app-example-card > img {
display: block;
width: 100%;
+ border: 1px solid #b1b4b6;
}
.app-example-card__body {
- padding: 15px;
+ padding: 10px 0;
}
.app-prose-scope *:not(.app-no-prose *) .app-example-card__body h2:last-child {
margin-bottom: 0;
}
-.app-example-card__body .govuk-heading-m {
+.app-example-card__body .govuk-heading-s {
margin-bottom: 0;
}
-.app-example-card .govuk-heading-m a::after {
+.app-example-card .govuk-heading-s a::after {
content: '';
position: absolute;
inset: 0;
}
+/* Two columns at tablet */
+@media (min-width: 641px) and (max-width: 1019px) {
+ .app-example-cards .govuk-grid-column-one-third {
+ width: 50%;
+ }
+
+ .app-example-cards .govuk-grid-column-one-third:nth-child(odd) {
+ clear: left;
+ }
+}
/* GOV.UK tabs — enhanced styles for all devices; React manages active state.
Extra specificity via .app-prose-scope required to override the theme. */
@@ -210,6 +220,12 @@
margin-bottom: 20px;
}
+.app-prose-scope *:not(.app-no-prose *) .govuk-heading-s {
+ font-size: 1.1875rem;
+ line-height: 1.3157894737;
+ margin-bottom: 15px;
+}
+
.govuk-\!-margin-bottom-0 {
margin-bottom: 0 !important;
}
\ No newline at end of file
diff --git a/docs/assets/images/basic-map.jpg b/docs/assets/images/basic-map.jpg
index 2afd4cac2..98eb22445 100644
Binary files a/docs/assets/images/basic-map.jpg and b/docs/assets/images/basic-map.jpg differ
diff --git a/docs/assets/images/button-first.jpg b/docs/assets/images/button-first.jpg
index a0a7797c1..d02f520bc 100644
Binary files a/docs/assets/images/button-first.jpg and b/docs/assets/images/button-first.jpg differ
diff --git a/docs/assets/images/deuteranopia-ozs-map-thumb.jpg b/docs/assets/images/deuteranopia-ozs-map-thumb.jpg
new file mode 100644
index 000000000..1113f1d1b
Binary files /dev/null and b/docs/assets/images/deuteranopia-ozs-map-thumb.jpg differ
diff --git a/docs/assets/images/draw.jpg b/docs/assets/images/draw.jpg
new file mode 100644
index 000000000..d1a6f77eb
Binary files /dev/null and b/docs/assets/images/draw.jpg differ
diff --git a/docs/assets/images/marker-panel.jpg b/docs/assets/images/marker-panel.jpg
index 20e254c5f..894dfc6d5 100644
Binary files a/docs/assets/images/marker-panel.jpg and b/docs/assets/images/marker-panel.jpg differ
diff --git a/docs/assets/images/night-ozs-map-thumb.jpg b/docs/assets/images/night-ozs-map-thumb.jpg
new file mode 100644
index 000000000..9316d535e
Binary files /dev/null and b/docs/assets/images/night-ozs-map-thumb.jpg differ
diff --git a/docs/assets/images/outdoor-ozs-map-thumb.jpg b/docs/assets/images/outdoor-ozs-map-thumb.jpg
new file mode 100644
index 000000000..1ede6915e
Binary files /dev/null and b/docs/assets/images/outdoor-ozs-map-thumb.jpg differ
diff --git a/docs/assets/images/search.jpg b/docs/assets/images/search.jpg
new file mode 100644
index 000000000..8bc00a09b
Binary files /dev/null and b/docs/assets/images/search.jpg differ
diff --git a/docs/assets/images/select-feature.jpg b/docs/assets/images/select-feature.jpg
new file mode 100644
index 000000000..65cc6f599
Binary files /dev/null and b/docs/assets/images/select-feature.jpg differ
diff --git a/docs/assets/images/style-switcher.jpg b/docs/assets/images/style-switcher.jpg
new file mode 100644
index 000000000..1ba11da93
Binary files /dev/null and b/docs/assets/images/style-switcher.jpg differ
diff --git a/docs/assets/images/toggle-marker-label.jpg b/docs/assets/images/toggle-marker-label.jpg
index 194d11e11..0d96c1eae 100644
Binary files a/docs/assets/images/toggle-marker-label.jpg and b/docs/assets/images/toggle-marker-label.jpg differ
diff --git a/docs/assets/images/tritanopia-ozs-map-thumb.jpg b/docs/assets/images/tritanopia-ozs-map-thumb.jpg
new file mode 100644
index 000000000..928cae02d
Binary files /dev/null and b/docs/assets/images/tritanopia-ozs-map-thumb.jpg differ
diff --git a/docs/examples/add-marker-with-panel.mdx b/docs/examples/add-marker-with-panel.mdx
index d42b55d72..b8cee0b93 100644
--- a/docs/examples/add-marker-with-panel.mdx
+++ b/docs/examples/add-marker-with-panel.mdx
@@ -29,7 +29,7 @@ Add markers to the map and allow users to select them. Selecting a marker fires
},
center: [-2.96, 54.43],
zoom: 15,
- containerHeight: '500px',
+ containerHeight: '516px',
plugins: [interactPlugin]
})
@@ -85,7 +85,7 @@ Add markers to the map and allow users to select them. Selecting a marker fires
},
center: [-2.96, 54.43],
zoom: 15,
- containerHeight: '500px',
+ containerHeight: '516px',
plugins: [interactPlugin]
})
diff --git a/docs/examples/basic-map.mdx b/docs/examples/basic-map.mdx
index 93375e649..7ae57f7b0 100644
--- a/docs/examples/basic-map.mdx
+++ b/docs/examples/basic-map.mdx
@@ -24,7 +24,7 @@ Embed an interactive map directly on the page, allowing users to explore and int
},
center: [-1.6, 53.1],
zoom: 6,
- containerHeight: '500px'
+ containerHeight: '516px'
})
`
},
@@ -45,7 +45,7 @@ Embed an interactive map directly on the page, allowing users to explore and int
},
center: [-1.6, 53.1],
zoom: 6,
- containerHeight: '500px'
+ containerHeight: '516px'
})
`
diff --git a/docs/examples/draw.mdx b/docs/examples/draw.mdx
new file mode 100644
index 000000000..65f7af5e3
--- /dev/null
+++ b/docs/examples/draw.mdx
@@ -0,0 +1,263 @@
+import DemoMapDraw from '../../demo/DemoMapDraw.js'
+import CodeTabs from '../../demo/js/codeTabs.js'
+
+# Draw tools
+
+Draw, edit, select and delete polygons and lines using the draw and interact plugins. A single "Draw tools" menu button groups all drawing actions together. The menu items update their enabled state based on whether a drawn feature is selected.
+
+:::note
+The draw plugin (`draw-ml`) is currently in beta.
+:::
+
+
+
+ {
+ interactPlugin.enable()
+
+ interactiveMap.addButton('drawTools', {
+ label: 'Draw tools',
+ mobile: { slot: 'bottom-right' },
+ tablet: { slot: 'top-middle' },
+ desktop: { slot: 'top-middle' },
+ menuItems: [
+ {
+ id: 'drawPolygon',
+ label: 'Draw polygon',
+ onClick: () => {
+ interactiveMap.toggleButtonState('drawTools', 'hidden', true)
+ drawPlugin.newPolygon(crypto.randomUUID())
+ }
+ },
+ {
+ id: 'drawLine',
+ label: 'Draw line',
+ onClick: () => {
+ interactiveMap.toggleButtonState('drawTools', 'hidden', true)
+ drawPlugin.newLine(crypto.randomUUID())
+ }
+ },
+ {
+ id: 'editFeature',
+ label: 'Edit feature',
+ isDisabled: true,
+ onClick: () => {
+ if (!drawPlugin.editFeature(selectedFeatureIds[0])) return
+ interactiveMap.toggleButtonState('drawTools', 'hidden', true)
+ interactPlugin.disable()
+ }
+ },
+ {
+ id: 'deleteFeature',
+ label: 'Delete feature',
+ isDisabled: true,
+ onClick: () => {
+ drawPlugin.deleteFeature(selectedFeatureIds)
+ interactPlugin.clear()
+ interactiveMap.toggleButtonState('drawTools', 'hidden', false)
+ interactiveMap.toggleButtonState('drawPolygon', 'disabled', false)
+ interactiveMap.toggleButtonState('drawLine', 'disabled', false)
+ interactiveMap.toggleButtonState('editFeature', 'disabled', true)
+ interactiveMap.toggleButtonState('deleteFeature', 'disabled', true)
+ }
+ }
+ ]
+ })
+ })
+
+ interactiveMap.on('draw:started', () => {
+ interactPlugin.disable()
+ })
+
+ interactiveMap.on('draw:created', () => {
+ interactiveMap.toggleButtonState('drawTools', 'hidden', false)
+ interactPlugin.enable()
+ })
+
+ interactiveMap.on('draw:edited', () => {
+ interactiveMap.toggleButtonState('drawTools', 'hidden', false)
+ interactPlugin.enable()
+ })
+
+ interactiveMap.on('draw:cancelled', () => {
+ interactiveMap.toggleButtonState('drawTools', 'hidden', false)
+ interactPlugin.enable()
+ })
+
+ interactiveMap.on('interact:selectionchange', (e) => {
+ const singleFeature = e.selectedFeatures.length === 1
+ const anyFeature = e.selectedFeatures.length > 0
+ const isDrawFeature = singleFeature && DRAW_LAYERS.includes(e.selectedFeatures[0].layerId)
+ const allDrawFeatures = anyFeature && e.selectedFeatures.every(f => DRAW_LAYERS.includes(f.layerId))
+ selectedFeatureIds = e.selectedFeatures.map(f => f.featureId)
+ interactiveMap.toggleButtonState('drawPolygon', 'disabled', singleFeature)
+ interactiveMap.toggleButtonState('drawLine', 'disabled', singleFeature)
+ interactiveMap.toggleButtonState('editFeature', 'disabled', !isDrawFeature)
+ interactiveMap.toggleButtonState('deleteFeature', 'disabled', !allDrawFeatures)
+ })
+ `
+ },
+ {
+ label: 'UMD',
+ language: 'html',
+ code: `
+
+
+
+
+
+
+ `
+ }
+]} />
diff --git a/docs/examples/index.mdx b/docs/examples/index.mdx
index 02680c254..28d3397fb 100644
--- a/docs/examples/index.mdx
+++ b/docs/examples/index.mdx
@@ -3,51 +3,99 @@ import basicMapImg from '../assets/images/basic-map.jpg'
import buttonFirstImg from '../assets/images/button-first.jpg'
import markerPanelImg from '../assets/images/marker-panel.jpg'
import toggleMarkerLabelImg from '../assets/images/toggle-marker-label.jpg'
+import searchImg from '../assets/images/search.jpg'
+import selectFeatureImg from '../assets/images/select-feature.jpg'
+import styleSwitcherImg from '../assets/images/style-switcher.jpg'
+import drawImg from '../assets/images/draw.jpg'
export function ExampleCards() {
const basicHref = useBaseUrl('/examples/basic-map')
const buttonHref = useBaseUrl('/examples/button-map')
const interactHref = useBaseUrl('/examples/add-marker-with-panel')
const toggleMarkerLabelHref = useBaseUrl('/examples/toggle-marker-label')
+ const searchControlHref = useBaseUrl('/examples/search-control')
+ const selectFeatureHref = useBaseUrl('/examples/select-feature')
+ const styleSwitcherHref = useBaseUrl('/examples/style-switcher')
+ const drawHref = useBaseUrl('/examples/draw')
return (
-
-
+
+
-
+
-
+
-
+
+
+
+
+
+

+
+
+
+
+
+

+
+
+
+
+
+

+
+
+
+
+
+

+
diff --git a/docs/examples/search-control.mdx b/docs/examples/search-control.mdx
new file mode 100644
index 000000000..4c95102dd
--- /dev/null
+++ b/docs/examples/search-control.mdx
@@ -0,0 +1,69 @@
+import DemoMapSearchNominatim from '../../demo/DemoMapSearchNominatim.js'
+import CodeTabs from '../../demo/js/codeTabs.js'
+
+# Add a search control
+
+Add a search control using the built-in OS Names API integration.
+
+:::note
+The live demo below uses Nominatim (no API key required) — the code example uses OS Names, so results may differ slightly. Embedding the key in the URL is the simplest approach for prototyping; for production, `transformRequest` can be used to inject auth headers, or you may need to route requests through your own server-side proxy.
+:::
+
+
+
+
+
+
+
+
+ `
+ }
+]} />
diff --git a/docs/examples/select-feature.mdx b/docs/examples/select-feature.mdx
new file mode 100644
index 000000000..4dc03ef4e
--- /dev/null
+++ b/docs/examples/select-feature.mdx
@@ -0,0 +1,146 @@
+import DemoMapSelectBuilding from '../../demo/DemoMapSelectBuilding.js'
+import CodeTabs from '../../demo/js/codeTabs.js'
+
+# Select a feature
+
+Use the interact plugin to add feature selection to your map. This example adds the ability to select a building from the OS Open Zoomstack `Buildings 3D` layer and display its properties in a panel. Selecting another building replaces the current selection; clicking outside clears it.
+
+:::note
+The layer ID used here (`buildings 3D`) is specific to the OS Open Zoomstack outdoor style. For other tile styles, set `debug: true` in the plugin options, open the browser console, and click the map to see which layer IDs are available at that point.
+:::
+
+
+
+ {
+ interactPlugin.enable()
+
+ map.addPanel(PANEL_ID, {
+ focus: false,
+ label: 'Selected building',
+ html: '',
+ mobile: { slot: 'drawer', dismissible: true },
+ tablet: { slot: 'left-top', dismissible: true, width: '300px' },
+ desktop: { slot: 'left-top', dismissible: true, width: '300px' }
+ })
+ })
+
+ map.on('interact:selectionchange', ({ selectedFeatures }) => {
+ if (selectedFeatures.length > 0) {
+ let html = ''
+ for (const [k, v] of Object.entries(selectedFeatures[0].properties)) {
+ html += '' + k + ': ' + v + '
'
+ }
+ document.getElementById('building-info-content').innerHTML = html
+ map.showPanel(PANEL_ID)
+ } else {
+ map.hidePanel(PANEL_ID)
+ }
+ })
+
+ map.on('app:panelclosed', ({ panelId }) => {
+ if (panelId === PANEL_ID) {
+ interactPlugin.clear()
+ }
+ })
+ `
+ },
+ {
+ label: 'UMD',
+ language: 'html',
+ code: `
+
+
+
+
+
+ `
+ }
+]} />
diff --git a/docs/examples/style-switcher.mdx b/docs/examples/style-switcher.mdx
new file mode 100644
index 000000000..24a0119cb
--- /dev/null
+++ b/docs/examples/style-switcher.mdx
@@ -0,0 +1,135 @@
+import DemoMapStyleSwitcher from '../../demo/DemoMapStyleSwitcher.js'
+import CodeTabs from '../../demo/js/codeTabs.js'
+
+# Change map style
+
+Provide alternative basemaps using the map styles plugin to support different use cases — such as dark or high contrast styles for accessibility needs, or aerial views for specific mapping tasks. You can also scale the map to enlarge text and features for users with visual impairment. The plugin accepts any vector tile style — in production you would typically use the [OS Vector Tile API](https://osdatahub.os.uk/docs/vts/overview) or [OS NGD Tiles API](https://osdatahub.os.uk/docs/ngd/overview).
+
+:::note
+The live demo uses OS Open Zoomstack tiles from OS Labs, which do not require an API key. This is only suitable for the demo — it is not intended for production use.
+:::
+
+
+
+
+
+
+
+
+ `
+ }
+]} />
diff --git a/docs/examples/toggle-marker-label.mdx b/docs/examples/toggle-marker-label.mdx
index 164e21aa6..b8ea6bcab 100644
--- a/docs/examples/toggle-marker-label.mdx
+++ b/docs/examples/toggle-marker-label.mdx
@@ -1,7 +1,7 @@
import DemoMapToggleMarkerLabel from '../../demo/DemoMapToggleMarkerLabel.js'
import CodeTabs from '../../demo/js/codeTabs.js'
-# Toggle marker label on click
+# Toggle marker label
Add a marker with a hidden label, then use the interact plugin to show the label when the marker is selected and hide it when the user clicks elsewhere.
@@ -29,7 +29,7 @@ Add a marker with a hidden label, then use the interact plugin to show the label
},
center: [-2.96, 54.43],
zoom: 15,
- containerHeight: '500px',
+ containerHeight: '516px',
plugins: [interactPlugin]
})
@@ -68,7 +68,7 @@ Add a marker with a hidden label, then use the interact plugin to show the label
},
center: [-2.96, 54.43],
zoom: 15,
- containerHeight: '500px',
+ containerHeight: '516px',
plugins: [interactPlugin]
})
diff --git a/docusaurus.config.cjs b/docusaurus.config.cjs
index 47ffd340c..af990a068 100644
--- a/docusaurus.config.cjs
+++ b/docusaurus.config.cjs
@@ -92,7 +92,11 @@ const config = {
{ text: 'Basic map', href: '/examples/basic-map' },
{ text: 'Button-triggered map', href: '/examples/button-map' },
{ text: 'Add a marker with a panel', href: '/examples/add-marker-with-panel' },
- { text: 'Toggle marker label on click', href: '/examples/toggle-marker-label' },
+ { text: 'Toggle marker label', href: '/examples/toggle-marker-label' },
+ { text: 'Add a search control', href: '/examples/search-control' },
+ { text: 'Select a feature', href: '/examples/select-feature' },
+ { text: 'Change map style', href: '/examples/style-switcher' },
+ { text: 'Draw tools', href: '/examples/draw' },
],
},
{
diff --git a/plugins/beta/draw-ml/src/index.js b/plugins/beta/draw-ml/src/index.js
index 38a47fa23..272bfe8f1 100755
--- a/plugins/beta/draw-ml/src/index.js
+++ b/plugins/beta/draw-ml/src/index.js
@@ -1,5 +1,5 @@
// /plugins/draw-ml/index.js
-import './draw.scss'
+// import './draw.scss'
export default function createPlugin (options = {}) {
return {
diff --git a/plugins/interact/src/hooks/useInteractionHandlers.js b/plugins/interact/src/hooks/useInteractionHandlers.js
index 13036650e..7de4f5fab 100755
--- a/plugins/interact/src/hooks/useInteractionHandlers.js
+++ b/plugins/interact/src/hooks/useInteractionHandlers.js
@@ -166,6 +166,15 @@ const resolveContiguousDispatch = ({ featureId, feature, config, selectedFeature
return true
}
+const logDebugFeatures = (coords, features) => {
+ const [lng, lat] = coords
+ console.groupCollapsed(`[interact] click (${lng.toFixed(4)}, ${lat.toFixed(4)}) — ${features.length} feature${features.length !== 1 ? 's' : ''}`) // NOSONAR
+ features.forEach(f => { // NOSONAR
+ console.log({ layer: f.layer.id, type: f.layer.type, id: f.id ?? '—', sourceLayer: f.sourceLayer ?? '—', properties: f.properties }) // NOSONAR
+ })
+ console.groupEnd() // NOSONAR
+}
+
/**
* Core interaction hook. Processes map clicks in fixed priority order:
* selectMarker → selectFeature → placeMarker (fallback).
@@ -185,7 +194,7 @@ const useHandleInteraction = ({ mapProvider, layers, interactionModes, multiSele
return useCallback(({ point, coords }) => {
const debugFeatures = debug ? getFeaturesAtPoint(mapProvider, point, { radius: tolerance }) : null
if (debugFeatures) {
- console.log(`--- Features at ${coords} ---`, debugFeatures) // NOSONAR
+ logDebugFeatures(coords, debugFeatures)
}
if (interactionModes.includes('selectMarker')) {
const markerHit = findMarkerAtPoint(markers, point, scale)
diff --git a/plugins/interact/src/hooks/useInteractionHandlers.test.js b/plugins/interact/src/hooks/useInteractionHandlers.test.js
index 364877f02..b9b059ce2 100644
--- a/plugins/interact/src/hooks/useInteractionHandlers.test.js
+++ b/plugins/interact/src/hooks/useInteractionHandlers.test.js
@@ -423,18 +423,37 @@ it('skips emission when selection remains empty after being cleared', () => {
/* ------------------------------------------------------------------ */
it('logs features when debug mode is enabled', () => {
+ const groupSpy = jest.spyOn(console, 'groupCollapsed').mockImplementation(() => {})
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
+ const groupEndSpy = jest.spyOn(console, 'groupEnd').mockImplementation(() => {})
const { result } = setup({ debug: true })
click(result)
- expect(logSpy).toHaveBeenCalledWith(
- expect.stringContaining('--- Features at'),
- expect.any(Array)
- )
+ expect(groupSpy).toHaveBeenCalledWith(expect.stringContaining('[interact] click ('))
+ expect(groupSpy).toHaveBeenCalledWith(expect.stringContaining('1 feature'))
+ expect(logSpy).toHaveBeenCalledWith(expect.objectContaining({ layer: 'parcels' }))
+ expect(groupEndSpy).toHaveBeenCalled()
+ groupSpy.mockRestore()
logSpy.mockRestore()
+ groupEndSpy.mockRestore()
+})
+
+it('uses plural label when multiple features are found', () => {
+ const groupSpy = jest.spyOn(console, 'groupCollapsed').mockImplementation(() => {})
+ jest.spyOn(console, 'log').mockImplementation(() => {})
+ jest.spyOn(console, 'groupEnd').mockImplementation(() => {})
+
+ featureQueries.getFeaturesAtPoint.mockReturnValue([baseFeature, baseFeature])
+
+ const { result } = setup({ debug: true })
+ click(result)
+
+ expect(groupSpy).toHaveBeenCalledWith(expect.stringContaining('2 features'))
+
+ jest.restoreAllMocks()
})
/* ------------------------------------------------------------------ */
diff --git a/providers/maplibre/src/utils/highlightFeatures.js b/providers/maplibre/src/utils/highlightFeatures.js
index 3c59c911f..63e62df16 100755
--- a/providers/maplibre/src/utils/highlightFeatures.js
+++ b/providers/maplibre/src/utils/highlightFeatures.js
@@ -64,7 +64,8 @@ const clearPrefixSources = (map, prefix) => {
}
const applyHighlightLayer = (map, id, type, sourceId, srcLayer, paint, filter) => {
- if (!map.getLayer(id)) {
+ const existed = !!map.getLayer(id)
+ if (!existed) {
map.addLayer({
id,
type,
@@ -124,43 +125,40 @@ const applySymbolGeomHighlight = (map, base, sourceId, srcLayer, layerId, filter
const applyFillGeomHighlight = (map, base, sourceId, srcLayer, { isSelected, idExpression, fillIds, fill, lineColor, lineWidth, filter }) => {
if (isSelected) {
- const fillFilter = ['in', idExpression, ['literal', [...fillIds]]]
+ const fillIdsArray = []
+ fillIds.forEach(id => fillIdsArray.push(id))
+ const fillFilter = ['in', idExpression, ['literal', fillIdsArray]]
// Only apply fill highlight to polygon features, not to any co-selected line features
applyHighlightLayer(map, `${base}-fill`, 'fill', sourceId, srcLayer, { 'fill-color': fill }, fillFilter)
}
applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, { 'line-color': lineColor, 'line-width': lineWidth }, filter)
}
-const applySourceHighlight = (map, sourceId, featuresBySource, stylesMap, prefix, getSymbolImageId) => {
- const { ids, fillIds, idProperty, layerId, hasFillGeometry } = featuresBySource[sourceId]
- const baseLayer = map.getLayer(layerId)
- const srcLayer = baseLayer.sourceLayer
- const geom = hasFillGeometry ? 'fill' : baseLayer.type
- const base = `${prefix}-${sourceId}`
- const style = stylesMap[layerId]
+const applyFillExtrusionHighlight = (map, base, layerId, { ids, lineColor, lineWidth, idExpression }) => {
+ // forEach bypasses broken Set iterator polyfill in the Docusaurus/core-js environment
+ const newIds = []
+ ids.forEach(id => newIds.push(id))
- if (!style) {
- return
- }
+ const filter = ['in', idExpression, ['literal', newIds]]
+ const { source, sourceLayer } = map.getLayer(layerId)
- const { stroke, selectionStroke, strokeWidth, activeStrokeWidth, fill } = style
- const isSelected = prefix === SELECTED_PREFIX
- const selectedStyle = usesSelectedStyle(prefix)
- const lineColor = selectedStyle ? selectionStroke : stroke
- const lineWidth = selectedStyle ? strokeWidth : activeStrokeWidth
- const idExpression = idProperty ? ['get', idProperty] : ['id']
- const filter = ['in', idExpression, ['literal', [...ids]]]
+ // A line layer moved to the top of the stack renders above the fill-extrusion 3D pass.
+ // fill-extrusion has no outline/stroke property, so this is the only way to show a stroke.
+ applyHighlightLayer(map, `${base}-line`, 'line', source, sourceLayer, { 'line-color': lineColor, 'line-width': lineWidth }, filter)
+}
+const applyGeometryHighlight = (geom, map, base, sourceId, srcLayer, layerId, { isSelected, idExpression, fillIds, fill, lineColor, lineWidth, filter, getSymbolImageId }) => {
if (geom === 'fill') {
applyFillGeomHighlight(map, base, sourceId, srcLayer, { isSelected, idExpression, fillIds, fill, lineColor, lineWidth, filter })
+ return
}
if (geom === 'line') {
if (map.getLayer(`${base}-fill`)) {
- // Clear any fill highlight from a previous polygon on the same source
map.setFilter(`${base}-fill`, ['==', 'id', ''])
}
applyHighlightLayer(map, `${base}-line`, 'line', sourceId, srcLayer, { 'line-color': lineColor, 'line-width': lineWidth }, filter)
+ return
}
if (geom === 'symbol') {
@@ -168,6 +166,40 @@ const applySourceHighlight = (map, sourceId, featuresBySource, stylesMap, prefix
}
}
+const applySourceHighlight = (map, sourceId, featuresBySource, stylesMap, prefix, getSymbolImageId) => {
+ const { ids, fillIds, idProperty, layerId, hasFillGeometry } = featuresBySource[sourceId]
+ const baseLayer = map.getLayer(layerId)
+
+ if (!baseLayer) {
+ return
+ }
+
+ const style = stylesMap[layerId]
+ if (!style) {
+ return
+ }
+
+ const srcLayer = baseLayer.sourceLayer
+ const geom = hasFillGeometry ? 'fill' : baseLayer.type
+ const base = `${prefix}-${sourceId}`
+ const { stroke, selectionStroke, strokeWidth, activeStrokeWidth, fill } = style
+ const isSelected = prefix === SELECTED_PREFIX
+ const selectedStyle = usesSelectedStyle(prefix)
+ const lineColor = selectedStyle ? selectionStroke : stroke
+ const lineWidth = selectedStyle ? strokeWidth : activeStrokeWidth
+ const idExpression = idProperty ? ['get', idProperty] : ['id']
+ const idsArray = []
+ ids.forEach(id => idsArray.push(id))
+ const filter = ['in', idExpression, ['literal', idsArray]]
+
+ if (baseLayer.type === 'fill-extrusion') {
+ applyFillExtrusionHighlight(map, base, layerId, { ids, lineColor, lineWidth, idExpression })
+ return
+ }
+
+ applyGeometryHighlight(geom, map, base, sourceId, srcLayer, layerId, { isSelected, idExpression, fillIds, fill, lineColor, lineWidth, filter, getSymbolImageId })
+}
+
const applyFeatureHighlights = (map, features, stylesMap, prefix, getSymbolImageId) => {
const featuresBySource = groupFeaturesBySource(map, features)
const currentSources = new Set(Object.keys(featuresBySource))
@@ -191,7 +223,6 @@ export function updateHighlightedFeatures ({ LngLatBounds, map, selectedFeatures
if (!map) {
return null
}
-
// Active cursor features — rendered first so selected layers appear on top
if (activeFeatures?.length) {
applyFeatureHighlights(map, activeFeatures, stylesMap, ACTIVE_PREFIX, getActiveImageId)
diff --git a/providers/maplibre/src/utils/highlightFeatures.test.js b/providers/maplibre/src/utils/highlightFeatures.test.js
index b5c4e0b6d..b5c872881 100644
--- a/providers/maplibre/src/utils/highlightFeatures.test.js
+++ b/providers/maplibre/src/utils/highlightFeatures.test.js
@@ -300,6 +300,95 @@ describe('Highlighting Utils — symbol layers (committed selection)', () => {
})
})
+// ─── fill-extrusion layers (3D buildings) ────────────────────────────────────
+
+describe('Highlighting Utils — fill-extrusion layers', () => {
+ const LAYER_ID = 'buildings 3D'
+ const STYLES = { [LAYER_ID]: { stroke: '#aaa', selectionStroke: '#0073cc', strokeWidth: 3, activeStrokeWidth: 6 } }
+ const LINE_LAYER_ID = 'selected-highlight-composite-line'
+
+ let map
+
+ beforeEach(() => {
+ map = makeMap()
+ map.getLayer.mockImplementation(id => {
+ if (id === LAYER_ID) return { source: 'composite', sourceLayer: 'building', type: 'fill-extrusion' }
+ return null
+ })
+ })
+
+ test('adds a line overlay layer above the extrusion pass', () => {
+ updateHighlightedFeatures({
+ LngLatBounds,
+ map,
+ selectedFeatures: [{ featureId: 'uuid-abc', layerId: LAYER_ID, idProperty: 'uuid' }],
+ stylesMap: STYLES
+ })
+ expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
+ id: LINE_LAYER_ID,
+ type: 'line',
+ source: 'composite',
+ 'source-layer': 'building'
+ }))
+ expect(map.moveLayer).toHaveBeenCalledWith(LINE_LAYER_ID)
+ })
+
+ test('filters by idProperty expression when set', () => {
+ updateHighlightedFeatures({
+ LngLatBounds,
+ map,
+ selectedFeatures: [{ featureId: 'uuid-abc', layerId: LAYER_ID, idProperty: 'uuid' }],
+ stylesMap: STYLES
+ })
+ expect(map.setFilter).toHaveBeenCalledWith(
+ LINE_LAYER_ID,
+ ['in', ['get', 'uuid'], ['literal', ['uuid-abc']]]
+ )
+ })
+
+ test('filters by feature id when no idProperty', () => {
+ updateHighlightedFeatures({
+ LngLatBounds,
+ map,
+ selectedFeatures: [{ featureId: 42, layerId: LAYER_ID }],
+ stylesMap: STYLES
+ })
+ expect(map.setFilter).toHaveBeenCalledWith(
+ LINE_LAYER_ID,
+ ['in', ['id'], ['literal', [42]]]
+ )
+ })
+
+ test('paints the line overlay with selectionStroke colour', () => {
+ updateHighlightedFeatures({
+ LngLatBounds,
+ map,
+ selectedFeatures: [{ featureId: 'uuid-abc', layerId: LAYER_ID, idProperty: 'uuid' }],
+ stylesMap: STYLES
+ })
+ const colorCall = map.setPaintProperty.mock.calls.find(
+ c => c[0] === LINE_LAYER_ID && c[1] === 'line-color'
+ )
+ expect(colorCall?.[2]).toBe('#0073cc')
+ })
+
+ test('reuses existing line layer without re-adding', () => {
+ map.getLayer.mockImplementation(id => {
+ if (id === LAYER_ID) return { source: 'composite', sourceLayer: 'building', type: 'fill-extrusion' }
+ if (id === LINE_LAYER_ID) return { type: 'line' }
+ return null
+ })
+ updateHighlightedFeatures({
+ LngLatBounds,
+ map,
+ selectedFeatures: [{ featureId: 'uuid-abc', layerId: LAYER_ID, idProperty: 'uuid' }],
+ stylesMap: STYLES
+ })
+ expect(map.addLayer).not.toHaveBeenCalled()
+ expect(map.setPaintProperty).toHaveBeenCalledWith(LINE_LAYER_ID, 'line-color', '#0073cc')
+ })
+})
+
describe('Highlighting Utils — missing style entry', () => {
test('skips highlight when feature layerId has no entry in stylesMap', () => {
const map = makeMap()
diff --git a/src/App/renderer/mapButtons.js b/src/App/renderer/mapButtons.js
index df3ed835f..308507517 100755
--- a/src/App/renderer/mapButtons.js
+++ b/src/App/renderer/mapButtons.js
@@ -117,18 +117,20 @@ function resolveGroupOrder (group) {
}
function applySlotExclusivity (matching, appState) {
- const exclusivePluginIds = new Set()
+ let exclusivePluginId = null
+
for (const [id, config] of matching) {
if (config.exclusiveSlot && !appState.hiddenButtons.has(id) && config.pluginId) {
- exclusivePluginIds.add(config.pluginId)
+ if (exclusivePluginId !== null && exclusivePluginId !== config.pluginId) {
+ logger.warn(`Slot exclusivity conflict: plugins [${exclusivePluginId}, ${config.pluginId}] are both claiming exclusive slot ownership. Showing all buttons.`)
+ return matching
+ }
+ exclusivePluginId = config.pluginId
}
}
- if (exclusivePluginIds.size === 0) { return matching }
- if (exclusivePluginIds.size > 1) {
- logger.warn(`Slot exclusivity conflict: plugins [${[...exclusivePluginIds].join(', ')}] are both claiming exclusive slot ownership. Showing all buttons.`)
- return matching
- }
- const [exclusivePluginId] = exclusivePluginIds
+
+ if (exclusivePluginId === null) { return matching }
+
return matching.filter(([_, config]) => config.pluginId === exclusivePluginId)
}