From 4c8e366fa7df9e7962a7c42cf45bde445b87b539 Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 11 Feb 2026 20:21:44 +0000 Subject: [PATCH 1/2] More sonar fixes --- plugins/search/src/datasets.js | 2 +- providers/beta/esri/src/index.js | 2 +- providers/maplibre/src/index.js | 2 +- providers/maplibre/src/utils/detectWebgl.js | 3 +- providers/maplibre/src/utils/spatial.js | 24 ++--- src/App/components/Markers/Markers.jsx | 2 +- src/App/components/Viewport/MapController.jsx | 2 +- src/App/controls/keyboardActions.js | 5 +- src/App/hooks/useCrossHairAPI.js | 96 ++++++++++--------- src/App/hooks/useFocusVisible.js | 4 +- src/App/hooks/useKeyboardHint.js | 2 +- src/App/hooks/useMapAnnouncements.js | 66 ++++++------- src/App/hooks/useMapEvents.js | 13 ++- src/App/hooks/useMapStateSync.js | 8 +- src/App/hooks/useMapURLSync.js | 7 +- src/App/hooks/useModalPanelBehaviour.js | 2 +- src/App/hooks/useResizeObserver.js | 2 +- src/App/layout/Layout.jsx | 2 +- src/App/layout/layout.module.scss | 20 +--- src/App/registry/mergeManifests.js | 2 +- src/App/registry/panelRegistry.js | 2 +- src/App/registry/pluginRegistry.js | 63 +++--------- src/App/renderer/mapPanels.js | 90 +++++++++++------ src/App/store/PluginProvider.jsx | 12 ++- src/App/store/mapActionsMap.js | 36 +++---- src/App/store/mapReducer.js | 2 +- src/InteractiveMap/InteractiveMap.js | 4 +- src/InteractiveMap/deviceChecker.js | 2 +- src/InteractiveMap/deviceChecker.test.js | 4 +- src/index.umd.js | 2 +- src/utils/getIsFullscreen.js | 4 +- src/utils/getSafeZoneInset.js | 30 ++++-- 32 files changed, 268 insertions(+), 249 deletions(-) diff --git a/plugins/search/src/datasets.js b/plugins/search/src/datasets.js index ea4f00c7..c125d707 100755 --- a/plugins/search/src/datasets.js +++ b/plugins/search/src/datasets.js @@ -8,7 +8,7 @@ export function createDatasets({ customDatasets = [], osNamesURL, crs }) { urlTemplate: osNamesURL, parseResults: (json, query) => parseOsNamesResults(json, query, crs), includeRegex: /^[a-zA-Z0-9\s,-]+$/, - excludeRegex: /^(?:[A-Za-z]{2}\s*(?:\d{3}\s*\d{3}|\d{4}\s*\d{4}|\d{5}\s*\d{5})|\d+\s*,?\s*\d+)$/i // exclude gridrefs/numeric coords + excludeRegex: /^(?:[a-z]{2}\s*(?:\d{3}\s*\d{3}|\d{4}\s*\d{4}|\d{5}\s*\d{5})|\d+\s*,?\s*\d+)$/i // NOSONAR - complexity unavoidable for gridref/coordinate matching }] return [...defaultDatasets, ...customDatasets] diff --git a/providers/beta/esri/src/index.js b/providers/beta/esri/src/index.js index db5744ae..1720dbe3 100644 --- a/providers/beta/esri/src/index.js +++ b/providers/beta/esri/src/index.js @@ -13,7 +13,7 @@ const arrayFindLast = { const webGL = getWebGL(['webgl2', 'webgl1']) // ESRI provider descriptor -export default function (config = {}) { +export default function createEsriProvider (config = {}) { return { checkDeviceCapabilities: () => { return { diff --git a/providers/maplibre/src/index.js b/providers/maplibre/src/index.js index 7ab652cd..ff628497 100755 --- a/providers/maplibre/src/index.js +++ b/providers/maplibre/src/index.js @@ -32,7 +32,7 @@ function supportsModernMaplibre() { * @param {Partial} [config={}] - Optional provider configuration overrides. * @returns {MapProviderDescriptor} The map provider descriptor. */ -export default function (config = {}) { +export default function createMapLibreProvider (config = {}) { return { checkDeviceCapabilities: () => { const webGL = getWebGL(['webgl2', 'webgl1']) diff --git a/providers/maplibre/src/utils/detectWebgl.js b/providers/maplibre/src/utils/detectWebgl.js index 43ca4610..ba4eacb0 100755 --- a/providers/maplibre/src/utils/detectWebgl.js +++ b/providers/maplibre/src/utils/detectWebgl.js @@ -16,8 +16,7 @@ export const getWebGL = (names) => { isEnabled: true } } - } catch (e) { - // No action required + } catch (_) { // NOSONAR - getContext may throw; failure is handled by the loop fallthrough } } // WebGL is supported, but disabled diff --git a/providers/maplibre/src/utils/spatial.js b/providers/maplibre/src/utils/spatial.js index 3965adad..821feb9d 100755 --- a/providers/maplibre/src/utils/spatial.js +++ b/providers/maplibre/src/utils/spatial.js @@ -123,6 +123,16 @@ const getCardinalMove = (from, to) => { * @param {Array<[number, number]>} pixels - Array of pixel coordinates. * @returns {number} Index of the closest pixel in the given direction. */ +const isInDirection = (direction, dx, dy) => { + switch (direction) { + case 'ArrowUp': return dy < 0 && Math.abs(dy) >= Math.abs(dx) + case 'ArrowDown': return dy > 0 && Math.abs(dy) >= Math.abs(dx) + case 'ArrowLeft': return dx < 0 && Math.abs(dx) > Math.abs(dy) + case 'ArrowRight': return dx > 0 && Math.abs(dx) > Math.abs(dy) + default: return false + } +} + const spatialNavigate = (direction, start, pixels) => { const [sx, sy] = start @@ -131,17 +141,7 @@ const spatialNavigate = (direction, start, pixels) => { if (x === sx && y === sy) { return false } - - const dx = x - sx - const dy = y - sy - - switch (direction) { - case 'ArrowUp': return dy < 0 && Math.abs(dy) >= Math.abs(dx) - case 'ArrowDown': return dy > 0 && Math.abs(dy) >= Math.abs(dx) - case 'ArrowLeft': return dx < 0 && Math.abs(dx) > Math.abs(dy) - case 'ArrowRight': return dx > 0 && Math.abs(dx) > Math.abs(dy) - default: return false - } + return isInDirection(direction, x - sx, y - sy) }) if (!candidates.length) { @@ -169,7 +169,7 @@ const getResolution = (center, zoom) => { const TILE_SIZE = 512 const lat = center.lat const scale = Math.pow(2, zoom) - const resolution = (EARTH_CIRCUMFERENCE * Math.cos((lat * Math.PI) / 180)) / (scale * TILE_SIZE) + const resolution = (EARTH_CIRCUMFERENCE * Math.cos((lat * Math.PI) / 180)) / (scale * TILE_SIZE) // NOSONAR - 180 is degrees-to-radians conversion return resolution } diff --git a/src/App/components/Markers/Markers.jsx b/src/App/components/Markers/Markers.jsx index b8c784d2..653216fb 100755 --- a/src/App/components/Markers/Markers.jsx +++ b/src/App/components/Markers/Markers.jsx @@ -13,7 +13,7 @@ export const Markers = () => { const { markers, markerRef } = useMarkers() if (!mapStyle) { - return + return undefined } const defaultSvgPaths = markerSvgPaths.find(m => m.shape === markerShape) diff --git a/src/App/components/Viewport/MapController.jsx b/src/App/components/Viewport/MapController.jsx index 20729cae..ac860b9c 100755 --- a/src/App/components/Viewport/MapController.jsx +++ b/src/App/components/Viewport/MapController.jsx @@ -60,7 +60,7 @@ export const MapController = ({ mapContainerRef }) => { // Update padding when breakpoint or mapSize change useEffect(() => { if (!isMapReady || !syncMapPadding) { - return undefined + return } mapProvider.setPadding(scalePoints(safeZoneInset, scaleFactor[mapSize])) }, [isMapReady, mapSize, breakpoint, safeZoneInset]) diff --git a/src/App/controls/keyboardActions.js b/src/App/controls/keyboardActions.js index c16fa99d..920c91e2 100755 --- a/src/App/controls/keyboardActions.js +++ b/src/App/controls/keyboardActions.js @@ -35,11 +35,12 @@ export const createKeyboardActions = (mapProvider, announce, { zoomOut: (e) => mapProvider.zoomOut(getZoom(e.shiftKey)), - getInfo: async (e) => { + getInfo: async (_e) => { const coord = mapProvider.getCenter() const place = await reverseGeocode(mapProvider.getZoom(), coord) const area = mapProvider.getAreaDimensions?.() - announce(`${place}.${area ? ' Covering ' + area + '.' : ''}`, 'core') + const message = area ? `${place}. Covering ${area}.` : `${place}.` + announce(message, 'core') }, highlightNextLabel: (e) => { diff --git a/src/App/hooks/useCrossHairAPI.js b/src/App/hooks/useCrossHairAPI.js index 63ed55e3..d4cef349 100755 --- a/src/App/hooks/useCrossHairAPI.js +++ b/src/App/hooks/useCrossHairAPI.js @@ -6,6 +6,53 @@ import { useService } from '../store/serviceContext.js' import { scaleFactor } from '../../config/appConfig.js' import { EVENTS as events } from '../../config/events.js' +const assignCrossHairAPI = (crossHair, el, mapProvider, dispatch, updatePosition) => { + crossHair.pinToMap = (coords, state) => { + const { x, y } = mapProvider.mapToScreen(coords) + crossHair.coords = coords + dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: true, isVisible: true, coords, state } }) + updatePosition(el, x, y) + } + + crossHair.fixAtCenter = () => { + el.style.left = '50%' + el.style.top = '50%' + el.style.transform = 'translate(0,0)' + el.style.display = 'block' + dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: false, isVisible: true } }) + } + + crossHair.remove = () => { + el.style.display = 'none' + dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: false, isVisible: false } }) + } + + crossHair.show = () => { + el.style.display = 'block' + dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: true } }) + } + + crossHair.hide = () => { + el.style.display = 'none' + dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } }) + } + + crossHair.setStyle = (state) => { + dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { state } }) + } + + crossHair.getDetail = () => { + const coords = crossHair.isPinnedToMap ? crossHair.coords : mapProvider.getCenter() + + return { + state: crossHair.state, + point: mapProvider.mapToScreen(coords), + zoom: mapProvider.getZoom(), + coords + } + } +} + export const useCrossHair = () => { const { mapProvider } = useConfig() const { safeZoneInset } = useApp() @@ -25,55 +72,10 @@ export const useCrossHair = () => { const crossHairRef = useCallback(el => { if (!el) { - return - } - - // --- API --- - - crossHair.pinToMap = (coords, state) => { - const { x, y } = mapProvider.mapToScreen(coords) - crossHair.coords = coords - dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: true, isVisible: true, coords, state } }) - updatePosition(el, x, y) - } - - crossHair.fixAtCenter = () => { - el.style.left = '50%' - el.style.top = '50%' - el.style.transform = 'translate(0,0)' - el.style.display = 'block' - dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: false, isVisible: true } }) - } - - crossHair.remove = () => { - el.style.display = 'none' - dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isPinnedToMap: false, isVisible: false } }) + return undefined } - crossHair.show = () => { - el.style.display = 'block' - dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: true } }) - } - - crossHair.hide = () => { - el.style.display = 'none' - dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { isVisible: false } }) - } - - crossHair.setStyle = (state) => { - dispatch({ type: 'UPDATE_CROSS_HAIR', payload: { state } }) - } - - crossHair.getDetail = () => { - const coords = crossHair.isPinnedToMap ? crossHair.coords : mapProvider.getCenter() - - return { - state: crossHair.state, - point: mapProvider.mapToScreen(coords), - zoom: mapProvider.getZoom(), - coords - } - } + assignCrossHairAPI(crossHair, el, mapProvider, dispatch, updatePosition) const handleRender = () => { if (crossHair.coords && crossHair.isPinnedToMap) { diff --git a/src/App/hooks/useFocusVisible.js b/src/App/hooks/useFocusVisible.js index f6c55552..778c3b32 100755 --- a/src/App/hooks/useFocusVisible.js +++ b/src/App/hooks/useFocusVisible.js @@ -11,7 +11,7 @@ export function useFocusVisible () { useEffect(() => { const scope = layoutRefs.appContainerRef.current if (!scope) { - return + return undefined } function handleFocusIn (e) { @@ -22,7 +22,7 @@ export function useFocusVisible () { delete e.target.dataset.focusVisible } - function handlePointerdown (e) { + function handlePointerdown () { delete document.activeElement.dataset.focusVisible } diff --git a/src/App/hooks/useKeyboardHint.js b/src/App/hooks/useKeyboardHint.js index 93bccc7b..8308a8cb 100755 --- a/src/App/hooks/useKeyboardHint.js +++ b/src/App/hooks/useKeyboardHint.js @@ -11,7 +11,7 @@ export function useKeyboardHint ({ useEffect(() => { if (!showHint || !containerRef.current) { - return + return undefined } const containerEl = containerRef.current diff --git a/src/App/hooks/useMapAnnouncements.js b/src/App/hooks/useMapAnnouncements.js index 3979e0d9..16224a06 100755 --- a/src/App/hooks/useMapAnnouncements.js +++ b/src/App/hooks/useMapAnnouncements.js @@ -5,6 +5,39 @@ import { useService } from '../store/serviceContext.js' import { getMapStatusMessage } from '../../utils/getMapStatusMessage.js' import { EVENTS as events } from '../../config/events.js' +const resolveMessage = (previous, current, mapProvider) => { + const zoomChanged = previous.zoom !== current.zoom + const centerChanged = + previous.center[0] !== current.center[0] || + previous.center[1] !== current.center[1] + + const areaDimensions = mapProvider.getAreaDimensions() + + // Panned only + if (centerChanged && !zoomChanged) { + const direction = mapProvider.getCardinalMove(previous.center, current.center) + return getMapStatusMessage.moved({ direction, areaDimensions }) + } + + // Zoomed only + if (!centerChanged && zoomChanged) { + return getMapStatusMessage.zoomed({ + ...current, + from: previous.zoom, + to: current.zoom, + areaDimensions + }) + } + + // No change + if (!centerChanged && !zoomChanged) { + return getMapStatusMessage.noChange({ ...current }) + } + + // Panned and zoomed + return getMapStatusMessage.newArea({ ...current, areaDimensions }) +} + export function useMapAnnouncements () { const { mapProvider } = useConfig() const { eventBus, announce } = useService() @@ -15,38 +48,7 @@ export function useMapAnnouncements () { return } - const zoomChanged = previous.zoom !== current.zoom - const centerChanged = - previous.center[0] !== current.center[0] || - previous.center[1] !== current.center[1] - - const areaDimensions = mapProvider.getAreaDimensions() - let message - - if (centerChanged && !zoomChanged) { - const direction = mapProvider.getCardinalMove(previous.center, current.center) - message = getMapStatusMessage.moved({ - direction, - areaDimensions - }) - } else if (!centerChanged && zoomChanged) { - message = getMapStatusMessage.zoomed({ - ...current, - from: previous.zoom, - to: current.zoom, - areaDimensions - }) - } else if (!centerChanged && !zoomChanged) { - message = getMapStatusMessage.noChange({ - ...current - }) - } else { - message = getMapStatusMessage.newArea({ - ...current, - areaDimensions - }) - } - + const message = resolveMessage(previous, current, mapProvider) if (message) { announce(message, 'core') } diff --git a/src/App/hooks/useMapEvents.js b/src/App/hooks/useMapEvents.js index a339b56d..1a79185d 100755 --- a/src/App/hooks/useMapEvents.js +++ b/src/App/hooks/useMapEvents.js @@ -2,13 +2,24 @@ import { useEffect } from 'react' import { useConfig } from '../store/configContext.js' import { useService } from '../store/serviceContext.js' +/** + * Subscribes to map events via the shared event bus for the lifetime of + * the consuming component. Handlers are automatically unsubscribed on + * unmount or when the eventMap reference changes. + * + * Supported events include clicks, pans, zooms, style changes and more + * — see `EVENTS` in `config/events.js` for the full list. + * + * @param {Object} [eventMap={}] - A map of event names to + * callback functions. + */ export function useMapEvents (eventMap = {}) { const { mapProvider } = useConfig() const { eventBus } = useService() useEffect(() => { if (!mapProvider) { - return + return undefined } const handlers = {} diff --git a/src/App/hooks/useMapStateSync.js b/src/App/hooks/useMapStateSync.js index 312f6c32..7b1cc3a9 100755 --- a/src/App/hooks/useMapStateSync.js +++ b/src/App/hooks/useMapStateSync.js @@ -4,6 +4,12 @@ import { useMap } from '../store/mapContext.js' import { useService } from '../store/serviceContext.js' import { EVENTS as events } from '../../config/events.js' +/** + * Keeps React state in sync with the map provider by listening to move, + * move-end and first-idle events. On each move-end it also emits a + * MAP_STATE_UPDATED event carrying both the previous and current state, + * which other hooks (e.g. useMapAnnouncements) use to determine what changed. + */ export function useMapStateSync () { const { mapProvider } = useConfig() const { dispatch } = useMap() @@ -14,7 +20,7 @@ export function useMapStateSync () { useEffect(() => { if (!mapProvider) { - return + return undefined } // Handle map move diff --git a/src/App/hooks/useMapURLSync.js b/src/App/hooks/useMapURLSync.js index c46c0e65..6910d646 100755 --- a/src/App/hooks/useMapURLSync.js +++ b/src/App/hooks/useMapURLSync.js @@ -4,13 +4,18 @@ import { useConfig } from '../store/configContext.js' import { useService } from '../store/serviceContext.js' import { EVENTS as events } from '../../config/events.js' +/** + * Persists the current map center and zoom into the page URL whenever the + * map state changes, allowing the view to be restored on page reload or + * shared via a link. + */ export function useMapURLSync () { const { id } = useConfig() const { eventBus } = useService() useEffect(() => { if (!id) { - return + return undefined } const handleStateUpdate = ({ current }) => { diff --git a/src/App/hooks/useModalPanelBehaviour.js b/src/App/hooks/useModalPanelBehaviour.js index dc27240f..49d217af 100755 --- a/src/App/hooks/useModalPanelBehaviour.js +++ b/src/App/hooks/useModalPanelBehaviour.js @@ -41,7 +41,7 @@ const useFocusRedirect = (isModal, panelRef, rootEl) => { const panelEl = panelRef.current if (!focusedEl || !panelEl || !rootEl) { - return undefined + return } const isInsideApp = rootEl.contains(focusedEl) diff --git a/src/App/hooks/useResizeObserver.js b/src/App/hooks/useResizeObserver.js index 156e2885..6944d46e 100755 --- a/src/App/hooks/useResizeObserver.js +++ b/src/App/hooks/useResizeObserver.js @@ -9,7 +9,7 @@ export function useResizeObserver (targetRefs, callback) { const elements = refs.map(r => r?.current).filter(Boolean) if (!elements.length || !callback) { - return + return undefined } const observer = new window.ResizeObserver(entries => { diff --git a/src/App/layout/Layout.jsx b/src/App/layout/Layout.jsx index 26525a8a..dab0e9c6 100755 --- a/src/App/layout/Layout.jsx +++ b/src/App/layout/Layout.jsx @@ -36,7 +36,7 @@ export const Layout = () => { ref={layoutRefs.appContainerRef} > -
+
diff --git a/src/App/layout/layout.module.scss b/src/App/layout/layout.module.scss index 9bf54ee1..3dc6fb46 100755 --- a/src/App/layout/layout.module.scss +++ b/src/App/layout/layout.module.scss @@ -328,11 +328,15 @@ } } -// Mobile and tablet modal banner margin +// Mobile and tablet .im-o-app--mobile, .im-o-app--tablet { .im-o-app__modal .im-c-panel--banner { margin: var(--primary-gap); } + + .im-o-app__banner { + z-index: 9; + } } // Desktop modal banner margin @@ -353,7 +357,6 @@ .im-o-app__right, .im-o-app__right-bottom { opacity: 0; - // visibility: hidden; Breaks focus order in Preact } } @@ -363,13 +366,6 @@ opacity: 0; } -// Banner on mobile and tablet -.im-o-app--mobile, .im-o-app--tablet { - .im-o-app__banner { - z-index: 9; - } -} - // Banner on desktop .im-o-app--desktop .im-c-panel--banner { margin-left: auto; @@ -410,12 +406,6 @@ clip-path: inset(-20px 0 0 0); } -// Bottom inset on large devices -// .im-o-app--tablet .im-o-app__bottom, -// .im-o-app--desktop .im-o-app__bottom { -// margin-top: var(--primary-gap); -// } - // 4. State styles // 5. Responsive tweaks diff --git a/src/App/registry/mergeManifests.js b/src/App/registry/mergeManifests.js index b36cf5be..eab0ebb4 100755 --- a/src/App/registry/mergeManifests.js +++ b/src/App/registry/mergeManifests.js @@ -8,7 +8,7 @@ export function mergeManifests (base = {}, override = {}) { } const map = new Map(baseArr.map(item => [item.id, item])) overrideArr.forEach(item => { - if (!item || !item.id) { + if (!item?.id) { return } if (map.has(item.id)) { diff --git a/src/App/registry/panelRegistry.js b/src/App/registry/panelRegistry.js index 17bb45ff..4cedc735 100755 --- a/src/App/registry/panelRegistry.js +++ b/src/App/registry/panelRegistry.js @@ -30,7 +30,7 @@ export const addPanel = (currentConfig, id, config) => { } export const removePanel = (currentConfig, id) => { - const { [id]: _, ...rest } = currentConfig + const { [id]: _, ...rest } = currentConfig // NOSONAR - _ is required to destructure out the key return rest } diff --git a/src/App/registry/pluginRegistry.js b/src/App/registry/pluginRegistry.js index 51bdc576..8e46902f 100755 --- a/src/App/registry/pluginRegistry.js +++ b/src/App/registry/pluginRegistry.js @@ -2,6 +2,8 @@ import { registerIcon } from './iconRegistry.js' import { registerKeyboardShortcut } from './keyboardShortcutRegistry.js' +const asArray = (value) => Array.isArray(value) ? value : [value] + export function createPluginRegistry ({ registerButton, registerPanel, registerControl }) { const registeredPlugins = [] @@ -14,76 +16,33 @@ export function createPluginRegistry ({ registerButton, registerPanel, registerC excludeModes: plugin.config?.excludeModes } - // --- Register buttons --- if (manifest.buttons) { - const buttons = Array.isArray(manifest.buttons) - ? manifest.buttons - : [manifest.buttons] - - buttons.forEach(button => { - registerButton({ - [button.id]: { - ...pluginConfig, - ...button - } - }) + asArray(manifest.buttons).forEach(button => { + registerButton({ [button.id]: { ...pluginConfig, ...button } }) }) } - // --- Register panels --- if (manifest.panels) { - const panels = Array.isArray(manifest.panels) - ? manifest.panels - : [manifest.panels] - - panels.forEach(panel => { - registerPanel({ - [panel.id]: { - ...pluginConfig, - ...panel - } - }) + asArray(manifest.panels).forEach(panel => { + registerPanel({ [panel.id]: { ...pluginConfig, ...panel } }) }) } - // --- Register controls --- if (manifest.controls) { - const controls = Array.isArray(manifest.controls) - ? manifest.controls - : [manifest.controls] - - controls.forEach(control => { - registerControl({ - [control.id]: { - ...pluginConfig, - ...control - } - }) + asArray(manifest.controls).forEach(control => { + registerControl({ [control.id]: { ...pluginConfig, ...control } }) }) } - // --- Register icons --- if (manifest.icons) { - const icons = Array.isArray(manifest.icons) - ? manifest.icons - : [manifest.icons] - - icons.forEach(icon => + asArray(manifest.icons).forEach(icon => registerIcon({ [icon.id]: icon.svgContent }) ) } - // --- Register keyboard shortcuts --- if (manifest.keyboardShortcuts) { - const shortcuts = Array.isArray(manifest.keyboardShortcuts) - ? manifest.keyboardShortcuts - : [manifest.keyboardShortcuts] - - shortcuts.forEach(shortcut => - registerKeyboardShortcut({ - ...pluginConfig, - shortcut - }) + asArray(manifest.keyboardShortcuts).forEach(shortcut => + registerKeyboardShortcut({ ...pluginConfig, shortcut }) ) } diff --git a/src/App/renderer/mapPanels.js b/src/App/renderer/mapPanels.js index 3d1ec6b8..f09cd1df 100755 --- a/src/App/renderer/mapPanels.js +++ b/src/App/renderer/mapPanels.js @@ -5,6 +5,65 @@ import { withPluginContexts } from './pluginWrapper.js' import { Panel } from '../components/Panel/Panel.jsx' import { allowedSlots } from './slots.js' +/** + * Resolves the target slot for a panel based on its breakpoint config. + * Modal panels always render in the 'modal' slot, and the bottom slot + * is only available on mobile — tablet and desktop fall back to 'inset'. + */ +const resolveTargetSlot = (bpConfig, breakpoint) => { + if (bpConfig.modal) { + return 'modal' + } + if (bpConfig.slot === 'bottom' && ['tablet', 'desktop'].includes(breakpoint)) { + return 'inset' + } + return bpConfig.slot +} + +/** + * Checks whether the current application mode permits the panel to be shown, + * based on its includeModes and excludModes configuration. + */ +const isModeAllowed = (config, mode) => { + if (config.includeModes && !config.includeModes.includes(mode)) { + return false + } + if (config.excludeModes?.includes(mode)) { + return false + } + return true +} + +/** + * Determines whether a panel should be rendered in the given slot. + * Checks slot eligibility, mode restrictions, inline/fullscreen constraints, + * and ensures only the topmost modal panel is shown. + */ +const isPanelVisible = (panelId, config, bpConfig, { targetSlot, slot, mode, isFullscreen, allowedModalPanelId }) => { + const isNextToButton = `${stringToKebab(panelId)}-button` === targetSlot + if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) { + return false + } + if (!isModeAllowed(config, mode)) { + return false + } + if (config.inline === false && !isFullscreen) { + return false + } + if (targetSlot !== slot) { + return false + } + if (bpConfig.modal && panelId !== allowedModalPanelId) { + return false + } + return true +} + +/** + * Maps open panels to renderable entries for a given layout slot. + * Filters panels by slot, breakpoint, mode, and modal state, then wraps + * each panel's render function with the appropriate plugin contexts. + */ export function mapPanels ({ slot, appState, evaluateProp }) { const { breakpoint, pluginRegistry, panelConfig, mode, openPanels } = appState @@ -18,7 +77,6 @@ export function mapPanels ({ slot, appState, evaluateProp }) { return openPanelEntries.map(([panelId, { props }]) => { const config = panelConfig[panelId] - if (!config) { return null } @@ -28,33 +86,11 @@ export function mapPanels ({ slot, appState, evaluateProp }) { return null } - // Slot constriant: modal panels have a dedicated slot - let targetSlot = bpConfig.modal ? 'modal' : bpConfig.slot - - // Slot constraint: bottom slot only permitted for mobile, revert to inset - if (targetSlot === 'bottom' && ['tablet', 'desktop'].includes(breakpoint)) { - targetSlot = 'inset' - } - - const isNextToButton = `${stringToKebab(panelId)}-button` === targetSlot - const slotAllowed = allowedSlots.panel.includes(targetSlot) || isNextToButton - const inModeWhitelist = config.includeModes?.includes(mode) ?? true - const inExcludeModes = config.excludeModes?.includes(mode) ?? false - - if (!slotAllowed || !inModeWhitelist || inExcludeModes) { - return null - } - - // Skip panels marked as inline:false when not in fullscreen mode - if (config.inline === false && !appState.isFullscreen) { - return null - } - - if (targetSlot !== slot) { - return null - } + const targetSlot = resolveTargetSlot(bpConfig, breakpoint) - if (bpConfig.modal && panelId !== allowedModalPanelId) { + if (!isPanelVisible(panelId, config, bpConfig, { + targetSlot, slot, mode, isFullscreen: appState.isFullscreen, allowedModalPanelId + })) { return null } diff --git a/src/App/store/PluginProvider.jsx b/src/App/store/PluginProvider.jsx index 87702109..285a5d62 100755 --- a/src/App/store/PluginProvider.jsx +++ b/src/App/store/PluginProvider.jsx @@ -27,19 +27,21 @@ export const PluginProvider = ({ children }) => { } // Initialize ref registry for each plugin - if (!refs.current[plugin.id]) refs.current[plugin.id] = {} + if (!refs.current[plugin.id]) { + refs.current[plugin.id] = {} + } }) // Combined reducer - const combinedReducer = (state, action) => { + const combinedReducer = (pluginState, action) => { const { pluginId } = action if (pluginId && pluginReducers[pluginId]) { return { - ...state, - [pluginId]: pluginReducers[pluginId](state[pluginId], action) + ...pluginState, + [pluginId]: pluginReducers[pluginId](pluginState[pluginId], action) } } - return state + return pluginState } const [state, dispatch] = useReducer(combinedReducer, initialState) diff --git a/src/App/store/mapActionsMap.js b/src/App/store/mapActionsMap.js index 0bf2d69a..dac7191e 100755 --- a/src/App/store/mapActionsMap.js +++ b/src/App/store/mapActionsMap.js @@ -1,3 +1,12 @@ +/** + * Generic reducer action that shallow-merges the payload into state. + * Shared by MAP_MOVE, MAP_MOVE_END and MAP_FIRST_IDLE. + */ +const mergePayload = (state, payload) => ({ + ...state, + ...payload +}) + const setMapReady = (state) => { return { ...state, @@ -5,27 +14,6 @@ const setMapReady = (state) => { } } -const mapMove = (state, payload) => { - return { - ...state, - ...payload - } -} - -const mapMoveEnd = (state, payload) => { - return { - ...state, - ...payload - } -} - -const mapFirstIdle = (state, payload) => { - return { - ...state, - ...payload - } -} - const setMapStyle = (state, payload) => { return { ...state, @@ -78,9 +66,9 @@ const removeMarker = (state, payload) => { export const actionsMap = { SET_MAP_READY: setMapReady, - MAP_MOVE: mapMove, - MAP_MOVE_END: mapMoveEnd, - MAP_FIRST_IDLE: mapFirstIdle, + MAP_MOVE: mergePayload, + MAP_MOVE_END: mergePayload, + MAP_FIRST_IDLE: mergePayload, SET_MAP_STYLE: setMapStyle, SET_MAP_SIZE: setMapSize, UPDATE_CROSS_HAIR: updateCrossHair, diff --git a/src/App/store/mapReducer.js b/src/App/store/mapReducer.js index 3b557e34..5163df09 100755 --- a/src/App/store/mapReducer.js +++ b/src/App/store/mapReducer.js @@ -19,7 +19,7 @@ export const initialState = (config) => { return { isMapReady: false, - mapStyle: !pluginHandlesMapStyles ? mapStyle : null, + mapStyle: pluginHandlesMapStyles ? null : mapStyle, mapSize, center, zoom, diff --git a/src/InteractiveMap/InteractiveMap.js b/src/InteractiveMap/InteractiveMap.js index d7912ae9..253e9a4a 100755 --- a/src/InteractiveMap/InteractiveMap.js +++ b/src/InteractiveMap/InteractiveMap.js @@ -165,10 +165,10 @@ export default class InteractiveMap { delete appInstance._root // Only assign properties but don't eventBus methods - const protectedKeys = ['on', 'off', 'emit'] + const protectedKeys = new Set(['on', 'off', 'emit']) Object.keys(appInstance).forEach(key => { - if (!protectedKeys.includes(key)) { + if (!protectedKeys.has(key)) { this[key] = appInstance[key] } }) diff --git a/src/InteractiveMap/deviceChecker.js b/src/InteractiveMap/deviceChecker.js index b2072273..8119a663 100755 --- a/src/InteractiveMap/deviceChecker.js +++ b/src/InteractiveMap/deviceChecker.js @@ -19,7 +19,7 @@ export function checkDeviceSupport (rootEl, config) { if (!mapProvider) { console.log('No map provider') - return + return false } if (!device?.isSupported) { diff --git a/src/InteractiveMap/deviceChecker.test.js b/src/InteractiveMap/deviceChecker.test.js index 904ec7df..274f52e3 100755 --- a/src/InteractiveMap/deviceChecker.test.js +++ b/src/InteractiveMap/deviceChecker.test.js @@ -49,13 +49,13 @@ describe('checkDeviceSupport', () => { expect(console.log).toHaveBeenCalledWith('WebGL not available') }) - it('logs "No map provider" and returns undefined if mapProvider is missing', () => { + it('logs "No map provider" and returns false if mapProvider is missing', () => { config = { mapProvider: null, deviceNotSupportedText: 'Device not supported' } - expect(checkDeviceSupport(rootEl, config)).toBeUndefined() + expect(checkDeviceSupport(rootEl, config)).toBe(false) expect(console.log).toHaveBeenCalledWith('No map provider') expect(renderError).not.toHaveBeenCalled() expect(removeLoadingState).not.toHaveBeenCalled() diff --git a/src/index.umd.js b/src/index.umd.js index 8b560f8b..6403566f 100644 --- a/src/index.umd.js +++ b/src/index.umd.js @@ -6,7 +6,7 @@ import * as JSXRuntime from 'react/jsx-runtime' import InteractiveMap from './index.js' -const g = typeof window !== 'undefined' ? window : globalThis +const g = typeof window === 'undefined' ? globalThis : window // Create `defra` namespace if missing g.defra = g.defra || {} diff --git a/src/utils/getIsFullscreen.js b/src/utils/getIsFullscreen.js index 684aa86e..0ff47fe9 100755 --- a/src/utils/getIsFullscreen.js +++ b/src/utils/getIsFullscreen.js @@ -10,7 +10,9 @@ */ export const isHybridFullscreen = (config) => { const { behaviour, hybridWidth, maxMobileWidth } = config - if (behaviour !== 'hybrid') return false + if (behaviour !== 'hybrid') { + return false + } const threshold = hybridWidth ?? maxMobileWidth return window.matchMedia(`(max-width: ${threshold}px)`).matches } diff --git a/src/utils/getSafeZoneInset.js b/src/utils/getSafeZoneInset.js index 6b206208..98748200 100755 --- a/src/utils/getSafeZoneInset.js +++ b/src/utils/getSafeZoneInset.js @@ -1,3 +1,21 @@ +/** + * Calculates the safe zone inset — the unobscured region of the map viewport + * not hidden behind overlay panels, action bars or the footer. Used as padding + * for map operations like setCenter or fitBounds so the full extent is visible. + * + * The algorithm measures the available space around the inset panel and decides + * whether to push the safe area below or beside it, depending on whether the + * layout is landscape or portrait oriented. + * + * @param {Object} refs - React refs for the key layout elements. + * @param {React.RefObject} refs.mainRef - The main content area. + * @param {React.RefObject} refs.insetRef - The inset panel (e.g. search results). + * @param {React.RefObject} refs.rightRef - The right-hand button column. + * @param {React.RefObject} refs.actionsRef - The bottom action bar. + * @param {React.RefObject} refs.footerRef - The footer (logo, copyright etc). + * @returns {{ top: number, right: number, left: number, bottom: number } | undefined} + * Pixel insets from each edge of the main area, or undefined if any ref is missing. + */ export const getSafeZoneInset = ({ mainRef, insetRef, @@ -5,16 +23,14 @@ export const getSafeZoneInset = ({ actionsRef, footerRef }) => { - const main = mainRef.current - const inset = insetRef.current - const right = rightRef.current - const actions = actionsRef.current - const footer = footerRef.current + const refs = [mainRef, insetRef, rightRef, actionsRef, footerRef] - if (!main || !inset || !right || !actions || !footer) { - return + if (refs.some(ref => !ref.current)) { + return undefined } + const [main, inset, right, actions, footer] = refs.map(ref => ref.current) + const root = document.documentElement const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10) From bcde8b342608104d9be23a582aab42e178120c5c Mon Sep 17 00:00:00 2001 From: Dan Leech Date: Wed, 11 Feb 2026 20:28:32 +0000 Subject: [PATCH 2/2] Sonar fix --- providers/maplibre/src/utils/spatial.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/providers/maplibre/src/utils/spatial.js b/providers/maplibre/src/utils/spatial.js index 821feb9d..715b695e 100755 --- a/providers/maplibre/src/utils/spatial.js +++ b/providers/maplibre/src/utils/spatial.js @@ -137,12 +137,9 @@ const spatialNavigate = (direction, start, pixels) => { const [sx, sy] = start // Direction filters - const candidates = pixels.filter(([x, y]) => { - if (x === sx && y === sy) { - return false - } - return isInDirection(direction, x - sx, y - sy) - }) + const candidates = pixels.filter(([x, y]) => + (x !== sx || y !== sy) && isInDirection(direction, x - sx, y - sy) + ) if (!candidates.length) { return pixels.findIndex(p => p[0] === sx && p[1] === sy)