@defra/interactive-map 0.0.16-alpha → 0.0.18-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/images/slot-map.svg +264 -0
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/context.md +53 -7
- package/docs/api/map-style-config.md +41 -2
- package/docs/api/marker-config.md +53 -11
- package/docs/api/slots.md +16 -15
- package/docs/api/symbol-config.md +160 -0
- package/docs/api/symbol-registry.md +115 -0
- package/docs/api.md +25 -22
- package/docs/getting-started.md +4 -1
- package/docs/plugins/datasets.md +657 -0
- package/docs/plugins/interact.md +68 -43
- package/docs/plugins/search.md +15 -3
- package/docs/plugins.md +1 -1
- package/package.json +2 -2
- package/plugins/beta/datasets/dist/css/index.css +103 -15
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/esm/index.js +1 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/index.js +1 -1
- package/plugins/beta/datasets/src/DatasetsInit.jsx +29 -9
- package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
- package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +159 -0
- package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +75 -0
- package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +440 -0
- package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
- package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
- package/plugins/beta/datasets/src/api/addDataset.js +2 -8
- package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
- package/plugins/beta/datasets/src/api/getStyle.js +13 -0
- package/plugins/beta/datasets/src/api/removeDataset.js +2 -44
- package/plugins/beta/datasets/src/api/setData.js +10 -0
- package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
- package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
- package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
- package/plugins/beta/datasets/src/api/setStyle.js +22 -0
- package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
- package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
- package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
- package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
- package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
- package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
- package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
- package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
- package/plugins/beta/datasets/src/datasets.js +39 -56
- package/plugins/beta/datasets/src/defaults.js +44 -8
- package/plugins/beta/datasets/src/fetch/createDynamicSource.js +34 -25
- package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
- package/plugins/beta/datasets/src/index.js +2 -1
- package/plugins/beta/datasets/src/manifest.js +25 -17
- package/plugins/beta/datasets/src/panels/Key.jsx +51 -51
- package/plugins/beta/datasets/src/panels/Key.module.scss +59 -9
- package/plugins/beta/datasets/src/panels/Layers.jsx +132 -29
- package/plugins/beta/datasets/src/panels/Layers.module.scss +56 -8
- package/plugins/beta/datasets/src/reducer.js +134 -9
- package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
- package/plugins/beta/datasets/src/utils/bbox.js +7 -5
- package/plugins/beta/datasets/src/utils/filters.js +5 -2
- package/plugins/beta/datasets/src/utils/mergeSublayer.js +86 -0
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
- package/plugins/beta/draw-ml/dist/css/index.css +21 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
- package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
- package/plugins/beta/draw-ml/src/draw.scss +0 -7
- package/plugins/beta/draw-ml/src/manifest.js +16 -16
- package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
- package/plugins/beta/frame/src/Frame.jsx +5 -5
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +1 -1
- package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
- package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
- package/plugins/beta/map-styles/src/manifest.js +1 -1
- package/plugins/beta/scale-bar/dist/css/index.css +1 -1
- package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
- package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
- package/plugins/beta/scale-bar/src/index.test.js +3 -3
- package/plugins/beta/scale-bar/src/manifest.js +3 -3
- package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
- package/plugins/interact/dist/css/index.css +1 -1
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +14 -5
- package/plugins/interact/src/InteractInit.test.js +26 -6
- package/plugins/interact/src/api/enable.test.js +7 -7
- package/plugins/interact/src/defaults.js +4 -6
- package/plugins/interact/src/events.js +9 -6
- package/plugins/interact/src/events.test.js +28 -4
- package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
- package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
- package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
- package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
- package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
- package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
- package/plugins/interact/src/interact.scss +0 -7
- package/plugins/interact/src/manifest.js +14 -18
- package/plugins/interact/src/manifest.test.js +3 -1
- package/plugins/interact/src/reducer.js +23 -4
- package/plugins/interact/src/reducer.test.js +60 -11
- package/plugins/interact/src/utils/buildStylesMap.js +17 -4
- package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
- package/plugins/interact/src/utils/featureQueries.js +11 -6
- package/plugins/interact/src/utils/featureQueries.test.js +8 -1
- package/plugins/search/dist/css/index.css +1 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/src/Search.jsx +3 -1
- package/plugins/search/src/components/Form/Form.module.scss +2 -1
- package/plugins/search/src/events/fetchSuggestions.js +6 -4
- package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
- package/plugins/search/src/events/formHandlers.js +3 -3
- package/plugins/search/src/events/formHandlers.test.js +1 -1
- package/plugins/search/src/events/suggestionHandlers.js +2 -2
- package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
- package/plugins/search/src/utils/updateMap.js +3 -3
- package/plugins/search/src/utils/updateMap.test.js +3 -3
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/index.js +1 -1
- package/providers/maplibre/src/appEvents.js +7 -0
- package/providers/maplibre/src/appEvents.test.js +18 -4
- package/providers/maplibre/src/maplibreProvider.js +52 -0
- package/providers/maplibre/src/maplibreProvider.test.js +105 -1
- package/providers/maplibre/src/utils/highlightFeatures.js +37 -7
- package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -95
- package/providers/maplibre/src/utils/hoverCursor.js +61 -0
- package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
- package/providers/maplibre/src/utils/patternImages.js +70 -0
- package/providers/maplibre/src/utils/patternImages.test.js +180 -0
- package/providers/maplibre/src/utils/queryFeatures.js +38 -16
- package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
- package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
- package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
- package/providers/maplibre/src/utils/symbolImages.js +147 -0
- package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
- package/src/App/components/Actions/Actions.jsx +2 -2
- package/src/App/components/Actions/Actions.module.scss +0 -7
- package/src/App/components/Actions/Actions.test.jsx +1 -1
- package/src/App/components/Icon/Icon.jsx +3 -2
- package/src/App/components/Icon/Icon.module.scss +4 -0
- package/src/App/components/Icon/Icon.test.jsx +43 -4
- package/src/App/components/MapButton/MapButton.jsx +42 -17
- package/src/App/components/MapButton/MapButton.module.scss +4 -13
- package/src/App/components/MapButton/MapButton.test.jsx +27 -3
- package/src/App/components/Markers/Markers.jsx +122 -27
- package/src/App/components/Markers/Markers.module.scss +0 -10
- package/src/App/components/Markers/Markers.test.jsx +246 -0
- package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
- package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
- package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
- package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
- package/src/App/hooks/useButtonStateEvaluator.js +12 -2
- package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
- package/src/App/hooks/useInterfaceAPI.js +6 -0
- package/src/App/hooks/useInterfaceAPI.test.js +156 -0
- package/src/App/hooks/useLayoutMeasurements.js +84 -18
- package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
- package/src/App/hooks/useMarkersAPI.js +2 -5
- package/src/App/hooks/useMarkersAPI.test.js +4 -4
- package/src/App/layout/Layout.jsx +14 -9
- package/src/App/layout/Layout.test.jsx +6 -4
- package/src/App/layout/layout.module.scss +67 -29
- package/src/App/registry/pluginRegistry.js +1 -1
- package/src/App/renderer/HtmlElementHost.jsx +2 -1
- package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
- package/src/App/renderer/mapButtons.js +1 -1
- package/src/App/renderer/mapPanels.test.js +2 -2
- package/src/App/renderer/slotHelpers.js +2 -2
- package/src/App/renderer/slotHelpers.test.js +5 -5
- package/src/App/renderer/slots.js +9 -5
- package/src/App/store/AppProvider.jsx +3 -1
- package/src/App/store/AppProvider.test.jsx +1 -1
- package/src/App/store/ServiceProvider.jsx +8 -4
- package/src/App/store/appActionsMap.js +16 -0
- package/src/App/store/appActionsMap.test.js +27 -0
- package/src/App/store/appDispatchMiddleware.js +1 -1
- package/src/App/store/appDispatchMiddleware.test.js +2 -2
- package/src/App/store/appReducer.js +2 -0
- package/src/App/store/mapActionsMap.js +4 -6
- package/src/App/store/mapActionsMap.test.js +3 -2
- package/src/App/store/mapReducer.js +2 -1
- package/src/InteractiveMap/InteractiveMap.js +4 -0
- package/src/config/appConfig.js +5 -8
- package/src/config/appConfig.test.js +1 -2
- package/src/config/defaults.js +0 -2
- package/src/config/events.js +28 -0
- package/src/config/mapTheme.js +56 -0
- package/src/config/patternConfig.js +16 -0
- package/src/config/symbolConfig.js +80 -0
- package/src/scss/main.scss +1 -0
- package/src/scss/settings/_colors.scss +0 -9
- package/src/scss/settings/_dimensions.scss +0 -1
- package/src/services/patternRegistry.js +40 -0
- package/src/services/patternRegistry.test.js +48 -0
- package/src/services/symbolRegistry.js +113 -0
- package/src/services/symbolRegistry.test.js +262 -0
- package/src/types.js +93 -11
- package/src/utils/getSafeZoneInset.js +9 -7
- package/src/utils/getSafeZoneInset.test.js +10 -10
- package/src/utils/patternUtils.js +94 -0
- package/src/utils/patternUtils.test.js +160 -0
- package/src/utils/symbolUtils.js +85 -0
- package/src/utils/symbolUtils.test.js +156 -0
- package/webpack.dev.mjs +1 -1
- package/docs/api/slot-map.svg +0 -1
- package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
- package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
- package/plugins/beta/datasets/src/api/showDataset.js +0 -14
- package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
- package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
- package/plugins/beta/datasets/src/mapLayers.js +0 -164
- /package/src/{utils → services}/logger.js +0 -0
- /package/src/{utils → services}/logger.test.js +0 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { getSymbolDef, getSymbolStyleColors, getSymbolViewBox } from '../../../../src/utils/symbolUtils.js'
|
|
2
|
+
import { rasteriseToImageData } from './rasteriseToImageData.js'
|
|
3
|
+
|
|
4
|
+
const ANCHOR_LOW = 0.25
|
|
5
|
+
const ANCHOR_HIGH = 0.75
|
|
6
|
+
const HASH_BASE = 36
|
|
7
|
+
|
|
8
|
+
const hashString = (str) => {
|
|
9
|
+
let hash = 0
|
|
10
|
+
for (const ch of str) {
|
|
11
|
+
hash = Math.trunc(((hash << 5) - hash) + ch.codePointAt(0))
|
|
12
|
+
}
|
|
13
|
+
return Math.abs(hash).toString(HASH_BASE)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── MapLibre-specific anchor conversion ──────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Converts a fractional [ax, ay] anchor to a MapLibre icon-anchor string.
|
|
20
|
+
* Snaps to the nearest of the 9 standard positions.
|
|
21
|
+
*
|
|
22
|
+
* @param {number[]} anchor - [x, y] in 0–1 space
|
|
23
|
+
* @returns {string} MapLibre icon-anchor value
|
|
24
|
+
*/
|
|
25
|
+
const xAnchor = (ax) => {
|
|
26
|
+
if (ax <= ANCHOR_LOW) {
|
|
27
|
+
return 'left'
|
|
28
|
+
}
|
|
29
|
+
if (ax >= ANCHOR_HIGH) {
|
|
30
|
+
return 'right'
|
|
31
|
+
}
|
|
32
|
+
return ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const yAnchor = (ay) => {
|
|
36
|
+
if (ay <= ANCHOR_LOW) {
|
|
37
|
+
return 'top'
|
|
38
|
+
}
|
|
39
|
+
if (ay >= ANCHOR_HIGH) {
|
|
40
|
+
return 'bottom'
|
|
41
|
+
}
|
|
42
|
+
return ''
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const anchorToMaplibre = ([ax, ay]) => {
|
|
46
|
+
const x = xAnchor(ax)
|
|
47
|
+
const y = yAnchor(ay)
|
|
48
|
+
return (y + (x && y ? '-' : '') + x) || 'center'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Image IDs ────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns a deterministic image ID for a symbol in normal or selected state.
|
|
55
|
+
* Based on the hash of the fully resolved SVG content and the pixel ratio.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} dataset
|
|
58
|
+
* @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
|
|
59
|
+
* @param {Object} symbolRegistry
|
|
60
|
+
* @param {boolean} [selected=false]
|
|
61
|
+
* @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor
|
|
62
|
+
* @returns {string|null}
|
|
63
|
+
*/
|
|
64
|
+
export const getSymbolImageId = (dataset, mapStyle, symbolRegistry, selected = false, pixelRatio = 2) => {
|
|
65
|
+
const symbolDef = getSymbolDef(dataset, symbolRegistry)
|
|
66
|
+
if (!symbolDef) {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
const styleColors = getSymbolStyleColors(dataset)
|
|
70
|
+
const resolved = selected
|
|
71
|
+
? symbolRegistry.resolveSelected(symbolDef, styleColors, mapStyle)
|
|
72
|
+
: symbolRegistry.resolve(symbolDef, styleColors, mapStyle)
|
|
73
|
+
return `symbol-${selected ? 'sel-' : ''}${hashString(resolved)}-${pixelRatio}x`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Rasterisation ────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
// Module-level cache: imageId → ImageData. Avoids re-rasterising identical symbols.
|
|
79
|
+
const imageDataCache = new Map()
|
|
80
|
+
|
|
81
|
+
const rasteriseSymbolImage = async (dataset, mapStyle, symbolRegistry, selected, pixelRatio) => {
|
|
82
|
+
const symbolDef = getSymbolDef(dataset, symbolRegistry)
|
|
83
|
+
if (!symbolDef) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
const styleColors = getSymbolStyleColors(dataset)
|
|
87
|
+
const resolvedContent = selected
|
|
88
|
+
? symbolRegistry.resolveSelected(symbolDef, styleColors, mapStyle)
|
|
89
|
+
: symbolRegistry.resolve(symbolDef, styleColors, mapStyle)
|
|
90
|
+
|
|
91
|
+
const imageId = `symbol-${selected ? 'sel-' : ''}${hashString(resolvedContent)}-${pixelRatio}x`
|
|
92
|
+
|
|
93
|
+
let imageData = imageDataCache.get(imageId)
|
|
94
|
+
if (!imageData) {
|
|
95
|
+
const viewBox = getSymbolViewBox(dataset, symbolDef)
|
|
96
|
+
const [,, width, height] = viewBox.split(' ').map(Number)
|
|
97
|
+
// Render at pixelRatio× to keep icons crisp at the current device DPI and map size.
|
|
98
|
+
// MapLibre receives the matching pixelRatio so the image displays at its original logical size.
|
|
99
|
+
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="${width * pixelRatio}" height="${height * pixelRatio}" viewBox="${viewBox}">${resolvedContent}</svg>`
|
|
100
|
+
imageData = await rasteriseToImageData(svgString, width * pixelRatio, height * pixelRatio)
|
|
101
|
+
imageDataCache.set(imageId, imageData)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { imageId, imageData }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Register normal and selected symbol images for the given pre-resolved symbol configs.
|
|
109
|
+
* Skips images that are already registered (safe to call on style change).
|
|
110
|
+
* Updates `map._symbolImageMap` with normal→selected image ID pairs.
|
|
111
|
+
*
|
|
112
|
+
* Callers are responsible for resolving sublayers before calling this function
|
|
113
|
+
* (see `getSymbolConfigs` in the datasets plugin adapter).
|
|
114
|
+
*
|
|
115
|
+
* @param {Object} map - MapLibre map instance
|
|
116
|
+
* @param {Object[]} symbolConfigs - Flat list of datasets/merged-sublayers that have a symbol config
|
|
117
|
+
* @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
|
|
118
|
+
* @param {Object} symbolRegistry
|
|
119
|
+
* @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor (computed by caller)
|
|
120
|
+
* @returns {Promise<void>}
|
|
121
|
+
*/
|
|
122
|
+
export const registerSymbols = async (map, symbolConfigs, mapStyle, symbolRegistry, pixelRatio = 2) => {
|
|
123
|
+
if (!symbolConfigs.length) {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Reset the normal→selected image ID lookup so stale entries don't persist after a style change
|
|
128
|
+
map._symbolImageMap = {}
|
|
129
|
+
|
|
130
|
+
await Promise.all(symbolConfigs.flatMap(config => {
|
|
131
|
+
const normalId = getSymbolImageId(config, mapStyle, symbolRegistry, false, pixelRatio)
|
|
132
|
+
const selectedId = getSymbolImageId(config, mapStyle, symbolRegistry, true, pixelRatio)
|
|
133
|
+
if (normalId && selectedId) {
|
|
134
|
+
map._symbolImageMap[normalId] = selectedId
|
|
135
|
+
}
|
|
136
|
+
return [false, true].map(async (selected) => {
|
|
137
|
+
const imageId = selected ? selectedId : normalId
|
|
138
|
+
if (!imageId || map.hasImage(imageId)) {
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
const result = await rasteriseSymbolImage(config, mapStyle, symbolRegistry, selected, pixelRatio)
|
|
142
|
+
if (result && !map.hasImage(result.imageId)) {
|
|
143
|
+
map.addImage(result.imageId, result.imageData, { pixelRatio })
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
}))
|
|
147
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { anchorToMaplibre, getSymbolImageId, registerSymbols } from './symbolImages.js'
|
|
2
|
+
import { symbolRegistry } from '../../../../src/services/symbolRegistry.js'
|
|
3
|
+
|
|
4
|
+
const STYLE_ID = 'test'
|
|
5
|
+
const mapStyle = { id: STYLE_ID }
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
|
|
9
|
+
globalThis.URL.revokeObjectURL = jest.fn()
|
|
10
|
+
|
|
11
|
+
HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
|
|
12
|
+
drawImage: jest.fn(),
|
|
13
|
+
getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h }))
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
globalThis.Image = class {
|
|
17
|
+
constructor (w, h) {
|
|
18
|
+
this.width = w
|
|
19
|
+
this.height = h
|
|
20
|
+
this._src = ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get src () { return this._src }
|
|
24
|
+
set src (val) { this._src = val; this.onload?.() }
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
symbolRegistry.setDefaults({})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// ─── anchorToMaplibre ─────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe('anchorToMaplibre', () => {
|
|
35
|
+
it('returns center for [0.5, 0.5]', () => {
|
|
36
|
+
expect(anchorToMaplibre([0.5, 0.5])).toBe('center')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns top for [0.5, 0]', () => {
|
|
40
|
+
expect(anchorToMaplibre([0.5, 0])).toBe('top')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns bottom for [0.5, 1]', () => {
|
|
44
|
+
expect(anchorToMaplibre([0.5, 1])).toBe('bottom')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns left for [0, 0.5]', () => {
|
|
48
|
+
expect(anchorToMaplibre([0, 0.5])).toBe('left')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns right for [1, 0.5]', () => {
|
|
52
|
+
expect(anchorToMaplibre([1, 0.5])).toBe('right')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns top-left for [0, 0]', () => {
|
|
56
|
+
expect(anchorToMaplibre([0, 0])).toBe('top-left')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('returns top-right for [1, 0]', () => {
|
|
60
|
+
expect(anchorToMaplibre([1, 0])).toBe('top-right')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns bottom-left for [0, 1]', () => {
|
|
64
|
+
expect(anchorToMaplibre([0, 1])).toBe('bottom-left')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns bottom-right for [1, 1]', () => {
|
|
68
|
+
expect(anchorToMaplibre([1, 1])).toBe('bottom-right')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('snaps pin anchor [0.5, 0.9] to bottom', () => {
|
|
72
|
+
expect(anchorToMaplibre([0.5, 0.9])).toBe('bottom') // NOSONAR S109 — deliberate boundary test value
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns center for values in the middle band', () => {
|
|
76
|
+
expect(anchorToMaplibre([0.5, 0.5])).toBe('center')
|
|
77
|
+
expect(anchorToMaplibre([0.26, 0.26])).toBe('center') // NOSONAR S109 — just inside center band
|
|
78
|
+
expect(anchorToMaplibre([0.74, 0.74])).toBe('center') // NOSONAR S109 — just inside center band
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('returns top at boundary value 0.25', () => {
|
|
82
|
+
expect(anchorToMaplibre([0.5, 0.25])).toBe('top')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('returns bottom at boundary value 0.75', () => {
|
|
86
|
+
expect(anchorToMaplibre([0.5, 0.75])).toBe('bottom') // NOSONAR S109 — ANCHOR_HIGH boundary
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// ─── getSymbolImageId ─────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('getSymbolImageId', () => {
|
|
93
|
+
it('returns null when dataset has no symbol', () => {
|
|
94
|
+
expect(getSymbolImageId({}, mapStyle, symbolRegistry)).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('returns null for an unregistered symbol id', () => {
|
|
98
|
+
expect(getSymbolImageId({ symbol: 'does-not-exist' }, mapStyle, symbolRegistry)).toBeNull()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('returns a string prefixed symbol- for normal state', () => {
|
|
102
|
+
const id = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
|
|
103
|
+
expect(typeof id).toBe('string')
|
|
104
|
+
expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('returns a string prefixed symbol-sel- for selected state', () => {
|
|
108
|
+
const id = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
|
|
109
|
+
expect(typeof id).toBe('string')
|
|
110
|
+
expect(id).toMatch(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('normal and selected ids differ for the same dataset', () => {
|
|
114
|
+
const normalId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, false)
|
|
115
|
+
const selectedId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
|
|
116
|
+
expect(normalId).not.toBe(selectedId)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('same dataset and style always produces the same id', () => {
|
|
120
|
+
const id1 = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
|
|
121
|
+
const id2 = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
|
|
122
|
+
expect(id1).toBe(id2)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('different symbols produce different ids', () => {
|
|
126
|
+
const pinId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
|
|
127
|
+
const circleId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry)
|
|
128
|
+
expect(pinId).not.toBe(circleId)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('different backgrounds produce different ids', () => {
|
|
132
|
+
const redId = getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#ff0000' }, mapStyle, symbolRegistry)
|
|
133
|
+
const blueId = getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#0000ff' }, mapStyle, symbolRegistry)
|
|
134
|
+
expect(redId).not.toBe(blueId)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('resolves inline symbolSvgContent', () => {
|
|
138
|
+
const dataset = {
|
|
139
|
+
symbolSvgContent: '<circle cx="19" cy="19" r="12" fill="{{backgroundColor}}"/>',
|
|
140
|
+
symbolViewBox: '0 0 38 38',
|
|
141
|
+
symbolAnchor: [0.5, 0.5]
|
|
142
|
+
}
|
|
143
|
+
const id = getSymbolImageId(dataset, mapStyle, symbolRegistry)
|
|
144
|
+
expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// ─── registerSymbols ──────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
const makeMap = (existingIds = []) => ({
|
|
151
|
+
_symbolImageMap: {},
|
|
152
|
+
hasImage: jest.fn((id) => existingIds.includes(id)),
|
|
153
|
+
addImage: jest.fn()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('registerSymbols — registration', () => {
|
|
157
|
+
it('returns early and does not touch map for empty configs', async () => {
|
|
158
|
+
const map = makeMap()
|
|
159
|
+
await registerSymbols(map, [], mapStyle, symbolRegistry)
|
|
160
|
+
expect(map.hasImage).not.toHaveBeenCalled()
|
|
161
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('resets _symbolImageMap before processing', async () => {
|
|
165
|
+
const map = makeMap()
|
|
166
|
+
map._symbolImageMap = { stale: 'entry' }
|
|
167
|
+
await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
|
|
168
|
+
expect(map._symbolImageMap).not.toHaveProperty('stale')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('calls addImage for normal and selected variants', async () => {
|
|
172
|
+
const map = makeMap()
|
|
173
|
+
await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
|
|
174
|
+
expect(map.addImage).toHaveBeenCalledTimes(2)
|
|
175
|
+
expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
|
|
176
|
+
expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('populates _symbolImageMap with normal → selected id pairs', async () => {
|
|
180
|
+
const map = makeMap()
|
|
181
|
+
await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
|
|
182
|
+
const normalId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, false)
|
|
183
|
+
const selectedId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
|
|
184
|
+
expect(map._symbolImageMap[normalId]).toBe(selectedId)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('skips addImage when image is already registered', async () => {
|
|
188
|
+
const normalId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry, false)
|
|
189
|
+
const selectedId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry, true)
|
|
190
|
+
const map = makeMap([normalId, selectedId])
|
|
191
|
+
await registerSymbols(map, [{ symbol: 'circle' }], mapStyle, symbolRegistry)
|
|
192
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('processes multiple configs independently', async () => {
|
|
196
|
+
const map = makeMap()
|
|
197
|
+
await registerSymbols(map, [{ symbol: 'pin' }, { symbol: 'circle' }], mapStyle, symbolRegistry)
|
|
198
|
+
expect(map.addImage).toHaveBeenCalledTimes(4)
|
|
199
|
+
expect(Object.keys(map._symbolImageMap)).toHaveLength(2)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('registerSymbols — null results and caching', () => {
|
|
204
|
+
it('does not call addImage when rasteriseSymbolImage returns null', async () => {
|
|
205
|
+
// getSymbolImageId (called twice — normal + selected) needs a real symbolDef to produce imageIds,
|
|
206
|
+
// but rasteriseSymbolImage must get undefined from getSymbolDef so it returns null.
|
|
207
|
+
// The registry.get call order: [1] getSymbolImageId normal, [2] getSymbolImageId selected,
|
|
208
|
+
// [3] rasteriseSymbolImage normal, [4] rasteriseSymbolImage selected.
|
|
209
|
+
const pinDef = symbolRegistry.get('pin')
|
|
210
|
+
const getSpy = jest.spyOn(symbolRegistry, 'get')
|
|
211
|
+
.mockReturnValueOnce(pinDef)
|
|
212
|
+
.mockReturnValueOnce(pinDef)
|
|
213
|
+
.mockReturnValueOnce(undefined)
|
|
214
|
+
.mockReturnValueOnce(undefined)
|
|
215
|
+
const map = makeMap()
|
|
216
|
+
await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
|
|
217
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
218
|
+
getSpy.mockRestore()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('skips config when symbolDef cannot be resolved', async () => {
|
|
222
|
+
const map = makeMap()
|
|
223
|
+
await registerSymbols(map, [{ symbol: 'no-such-symbol' }], mapStyle, symbolRegistry)
|
|
224
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
225
|
+
expect(map._symbolImageMap).toEqual({})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('reuses cached imageData when called again with the same pixelRatio', async () => {
|
|
229
|
+
// Use an unusual ratio so this test owns its cache entries
|
|
230
|
+
const uniqueRatio = 7
|
|
231
|
+
|
|
232
|
+
const map1 = makeMap()
|
|
233
|
+
const blobCallsBefore = globalThis.URL.createObjectURL.mock.calls.length
|
|
234
|
+
await registerSymbols(map1, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
|
|
235
|
+
const blobCallsAfterFirst = globalThis.URL.createObjectURL.mock.calls.length
|
|
236
|
+
// Rasterisation ran — blob was created
|
|
237
|
+
expect(blobCallsAfterFirst).toBeGreaterThan(blobCallsBefore)
|
|
238
|
+
|
|
239
|
+
// Second call with a fresh map (hasImage → false) but same ratio → cache hit
|
|
240
|
+
const map2 = makeMap()
|
|
241
|
+
await registerSymbols(map2, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
|
|
242
|
+
const blobCallsAfterSecond = globalThis.URL.createObjectURL.mock.calls.length
|
|
243
|
+
// No new blob created — rasterisation was skipped via cache
|
|
244
|
+
expect(blobCallsAfterSecond).toBe(blobCallsAfterFirst)
|
|
245
|
+
// addImage still called because map2 has no pre-registered images
|
|
246
|
+
expect(map2.addImage).toHaveBeenCalledTimes(2)
|
|
247
|
+
})
|
|
248
|
+
})
|
|
@@ -7,11 +7,11 @@ export const Actions = ({ children }) => {
|
|
|
7
7
|
const { openPanels, panelConfig, breakpoint } = useApp()
|
|
8
8
|
|
|
9
9
|
const childArray = React.Children.toArray(children)
|
|
10
|
-
const visibleChild = childArray.find(c => c.props?.isHidden === false)
|
|
10
|
+
const visibleChild = childArray.find(c => c.props?.isHidden === false && c.props?.variant !== 'touch')
|
|
11
11
|
|
|
12
12
|
// If a panel exists above we need so css adjustment
|
|
13
13
|
const isBottomSlotUsed = Object.keys(openPanels).some(panelId => {
|
|
14
|
-
return breakpoint === 'mobile' && panelConfig[panelId]?.[breakpoint]?.slot === '
|
|
14
|
+
return breakpoint === 'mobile' && panelConfig[panelId]?.[breakpoint]?.slot === 'drawer'
|
|
15
15
|
})
|
|
16
16
|
|
|
17
17
|
const className = [
|
|
@@ -6,19 +6,12 @@
|
|
|
6
6
|
padding: var(--panel-margin);
|
|
7
7
|
max-height: 200px;
|
|
8
8
|
|
|
9
|
-
@media (prefers-reduced-motion: no-preference) {
|
|
10
|
-
transition: max-height var(--duration) ease, opacity var(--duration) ease,
|
|
11
|
-
padding-top var(--duration) ease, padding-bottom var(--duration) ease;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
9
|
&--border-top {
|
|
15
10
|
border-top: 1px solid var(--app-border-color);
|
|
16
11
|
}
|
|
17
12
|
|
|
18
13
|
&--hidden {
|
|
19
14
|
max-height: 0;
|
|
20
|
-
overflow: hidden;
|
|
21
|
-
opacity: 0;
|
|
22
15
|
padding-top: 0;
|
|
23
16
|
padding-bottom: 0;
|
|
24
17
|
border: 0;
|
|
@@ -29,7 +29,7 @@ describe('Actions component', () => {
|
|
|
29
29
|
|
|
30
30
|
it('adds the border class if a bottom slot panel is open', () => {
|
|
31
31
|
mockUseApp.openPanels = { key: {} }
|
|
32
|
-
mockUseApp.panelConfig = { key: { mobile: { slot: '
|
|
32
|
+
mockUseApp.panelConfig = { key: { mobile: { slot: 'drawer' } } }
|
|
33
33
|
|
|
34
34
|
render(<Actions slot='actions'>Content</Actions>)
|
|
35
35
|
const container = screen.getByText('Content').closest('div')
|
|
@@ -3,8 +3,8 @@ import { getIconRegistry } from '../../registry/iconRegistry.js'
|
|
|
3
3
|
|
|
4
4
|
// eslint-disable-next-line camelcase, react/jsx-pascal-case
|
|
5
5
|
// sonarjs/disable-next-line function-name
|
|
6
|
-
export const Icon = ({ id, svgContent }) => {
|
|
7
|
-
const icon = getIconRegistry()[id] || svgContent
|
|
6
|
+
export const Icon = ({ id, svgContent, isMenu }) => {
|
|
7
|
+
const icon = isMenu ? getIconRegistry().chevron : (getIconRegistry()[id] || svgContent)
|
|
8
8
|
|
|
9
9
|
return (
|
|
10
10
|
<svg
|
|
@@ -20,6 +20,7 @@ export const Icon = ({ id, svgContent }) => {
|
|
|
20
20
|
aria-hidden='true'
|
|
21
21
|
focusable='false'
|
|
22
22
|
dangerouslySetInnerHTML={{ __html: icon }}
|
|
23
|
+
className={`im-c-icon${isMenu ? ' im-c-icon--narrow' : ''}`}
|
|
23
24
|
/>
|
|
24
25
|
)
|
|
25
26
|
}
|
|
@@ -33,27 +33,66 @@ describe('Icon component', () => {
|
|
|
33
33
|
it('renders the SVG from the registry when id is provided', () => {
|
|
34
34
|
getIconRegistry.mockReturnValue({ close: '<path d="M0 0 L10 10"/>' })
|
|
35
35
|
const { container } = render(<Icon id='close' />)
|
|
36
|
-
expect(container.querySelector('svg').innerHTML)
|
|
36
|
+
expect(container.querySelector('svg').innerHTML)
|
|
37
|
+
.toContain('<path d="M0 0 L10 10"')
|
|
37
38
|
})
|
|
38
39
|
|
|
39
40
|
it('falls back to svgContent if id not found in registry', () => {
|
|
40
41
|
getIconRegistry.mockReturnValue({})
|
|
41
42
|
const fallbackSVG = '<circle cx="5" cy="5" r="5"/>'
|
|
42
43
|
const { container } = render(<Icon id='unknown' svgContent={fallbackSVG} />)
|
|
43
|
-
expect(container.querySelector('svg').innerHTML)
|
|
44
|
+
expect(container.querySelector('svg').innerHTML)
|
|
45
|
+
.toContain('<circle cx="5" cy="5" r="5"')
|
|
44
46
|
})
|
|
45
47
|
|
|
46
48
|
it('renders svgContent directly if no id provided', () => {
|
|
47
49
|
const fallbackSVG = '<rect x="0" y="0" width="10" height="10"/>'
|
|
48
50
|
getIconRegistry.mockReturnValue({})
|
|
49
51
|
const { container } = render(<Icon svgContent={fallbackSVG} />)
|
|
50
|
-
expect(container.querySelector('svg').innerHTML)
|
|
52
|
+
expect(container.querySelector('svg').innerHTML)
|
|
53
|
+
.toContain('<rect x="0" y="0" width="10" height="10"')
|
|
51
54
|
})
|
|
52
55
|
|
|
53
56
|
it('uses registry icon if both id and svgContent provided', () => {
|
|
54
57
|
getIconRegistry.mockReturnValue({ check: '<path d="M1 1 L5 5"/>' })
|
|
55
58
|
const fallbackSVG = '<circle cx="5" cy="5" r="5"/>'
|
|
56
59
|
const { container } = render(<Icon id='check' svgContent={fallbackSVG} />)
|
|
57
|
-
expect(container.querySelector('svg').innerHTML)
|
|
60
|
+
expect(container.querySelector('svg').innerHTML)
|
|
61
|
+
.toContain('<path d="M1 1 L5 5"')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('uses chevron icon when isMenu is true', () => {
|
|
65
|
+
getIconRegistry.mockReturnValue({
|
|
66
|
+
chevron: '<path d="M2 2 L8 8"/>',
|
|
67
|
+
close: '<path d="M0 0 L10 10"/>'
|
|
68
|
+
})
|
|
69
|
+
const { container } = render(<Icon id='close' isMenu />)
|
|
70
|
+
expect(container.querySelector('svg').innerHTML).toContain('<path d="M2 2 L8 8"')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('ignores id and svgContent when isMenu is true', () => {
|
|
74
|
+
getIconRegistry.mockReturnValue({
|
|
75
|
+
chevron: '<path d="M2 2 L8 8"/>'
|
|
76
|
+
})
|
|
77
|
+
const fallbackSVG = '<circle cx="5" cy="5" r="5"/>'
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<Icon id='close' svgContent={fallbackSVG} isMenu />
|
|
80
|
+
)
|
|
81
|
+
expect(container.querySelector('svg').innerHTML).toContain('<path d="M2 2 L8 8"')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('adds narrow class when isMenu is true', () => {
|
|
85
|
+
getIconRegistry.mockReturnValue({
|
|
86
|
+
chevron: '<path d="M2 2 L8 8"/>'
|
|
87
|
+
})
|
|
88
|
+
const { container } = render(<Icon isMenu />)
|
|
89
|
+
const svg = container.querySelector('svg')
|
|
90
|
+
expect(svg).toHaveClass('im-c-icon--narrow')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('renders nothing if isMenu is true and chevron is missing', () => {
|
|
94
|
+
getIconRegistry.mockReturnValue({})
|
|
95
|
+
const { container } = render(<Icon isMenu />)
|
|
96
|
+
expect(container.querySelector('svg').innerHTML).toBe('')
|
|
58
97
|
})
|
|
59
98
|
})
|
|
@@ -46,6 +46,37 @@ const handleKeyUp = (e) => {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
const captureMenuRect = (buttonRefs, buttonId, setMenuRect) => {
|
|
50
|
+
const btn = buttonRefs.current[buttonId]
|
|
51
|
+
if (!btn) {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
setMenuRect(btn.getBoundingClientRect().toJSON())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns a keyup handler for buttons that control a popup menu.
|
|
59
|
+
* ArrowDown opens the menu at the first item; ArrowUp opens at the last.
|
|
60
|
+
* @param {boolean} hasMenu - Whether the button has a popup menu
|
|
61
|
+
* @param {Object} buttonRefs - React ref map of button elements
|
|
62
|
+
* @param {string} buttonId - Unique button identifier
|
|
63
|
+
* @param {Function} setMenuStartPos - State setter for menu start position
|
|
64
|
+
* @param {Function} setMenuRect - State setter for button bounding rect
|
|
65
|
+
* @param {Function} setIsPopupOpen - State setter for popup open state
|
|
66
|
+
* @returns {Function} Keyboard event handler
|
|
67
|
+
*/
|
|
68
|
+
const makePopupKeyUpHandler = (hasMenu, buttonRefs, buttonId, setMenuStartPos, setMenuRect, setIsPopupOpen) => (e) => {
|
|
69
|
+
if (hasMenu && ['ArrowDown', 'ArrowUp'].includes(e.key)) {
|
|
70
|
+
e.preventDefault()
|
|
71
|
+
setMenuStartPos(e.key === 'ArrowUp' ? 'last' : 'first')
|
|
72
|
+
captureMenuRect(buttonRefs, buttonId, setMenuRect)
|
|
73
|
+
setIsPopupOpen(true)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const getButtonSlot = (panelId, buttonId) =>
|
|
78
|
+
panelId ? `${stringToKebab(buttonId)}-button` : undefined
|
|
79
|
+
|
|
49
80
|
/**
|
|
50
81
|
* Determines the controlled element (panel or popup menu) for ARIA attributes.
|
|
51
82
|
* @param {Object} options - Configuration options
|
|
@@ -177,10 +208,13 @@ export const MapButton = ({
|
|
|
177
208
|
const { buttonRefs } = useApp()
|
|
178
209
|
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
|
179
210
|
const [menuStartPos, setMenuStartPos] = useState(null)
|
|
211
|
+
const [menuRect, setMenuRect] = useState(null)
|
|
180
212
|
const menuRef = useRef(null)
|
|
181
213
|
|
|
182
214
|
const Element = href ? 'a' : 'button'
|
|
183
215
|
const hasMenu = menuItems?.length >= 1
|
|
216
|
+
const showIcon = iconId || iconSvgContent || hasMenu
|
|
217
|
+
const buttonSlot = getButtonSlot(panelId, buttonId)
|
|
184
218
|
const controlledElement = getControlledElement({ idPrefix, panelId, buttonId, hasMenu })
|
|
185
219
|
|
|
186
220
|
/**
|
|
@@ -197,6 +231,9 @@ export const MapButton = ({
|
|
|
197
231
|
const isKeyboard = e.nativeEvent.pointerType === ''
|
|
198
232
|
/* istanbul ignore next as pointerType can't be tested in jest */
|
|
199
233
|
setMenuStartPos(isKeyboard ? 'first' : null)
|
|
234
|
+
if (!isPopupOpen) {
|
|
235
|
+
captureMenuRect(buttonRefs, buttonId, setMenuRect)
|
|
236
|
+
}
|
|
200
237
|
setIsPopupOpen((prev) => !prev)
|
|
201
238
|
}
|
|
202
239
|
if (onClick) {
|
|
@@ -204,19 +241,7 @@ export const MapButton = ({
|
|
|
204
241
|
}
|
|
205
242
|
}
|
|
206
243
|
|
|
207
|
-
|
|
208
|
-
* Handles key up events on buttons that control popup menus.
|
|
209
|
-
* ArrowDown opens the menu at the first item.
|
|
210
|
-
* ArrowUp opens the menu at the last item.
|
|
211
|
-
* @param {React.KeyboardEvent} e - The keyboard event
|
|
212
|
-
*/
|
|
213
|
-
const handleButtonKeyUp = e => {
|
|
214
|
-
if (hasMenu && ['ArrowDown', 'ArrowUp'].includes(e.key)) {
|
|
215
|
-
e.preventDefault()
|
|
216
|
-
setMenuStartPos(e.key === 'ArrowUp' ? 'last' : 'first')
|
|
217
|
-
setIsPopupOpen(true)
|
|
218
|
-
}
|
|
219
|
-
}
|
|
244
|
+
const handleButtonKeyUp = makePopupKeyUpHandler(hasMenu, buttonRefs, buttonId, setMenuStartPos, setMenuRect, setIsPopupOpen)
|
|
220
245
|
|
|
221
246
|
const buttonProps = buildButtonProps({
|
|
222
247
|
appId,
|
|
@@ -236,7 +261,7 @@ export const MapButton = ({
|
|
|
236
261
|
|
|
237
262
|
const buttonEl = (
|
|
238
263
|
<Element {...buttonProps}>
|
|
239
|
-
{
|
|
264
|
+
{showIcon && <Icon id={iconId} svgContent={iconSvgContent} isMenu={hasMenu} />}
|
|
240
265
|
{showLabel && <span>{label}</span>}
|
|
241
266
|
</Element>
|
|
242
267
|
)
|
|
@@ -244,12 +269,12 @@ export const MapButton = ({
|
|
|
244
269
|
return (
|
|
245
270
|
<div
|
|
246
271
|
className={buildWrapperClassNames(buttonId, showLabel)}
|
|
247
|
-
data-button-slot={
|
|
272
|
+
data-button-slot={buttonSlot}
|
|
248
273
|
style={isHidden ? { display: 'none' } : undefined}
|
|
249
274
|
>
|
|
250
275
|
{showLabel ? buttonEl : <Tooltip content={label}>{buttonEl}</Tooltip>}
|
|
251
|
-
{
|
|
252
|
-
{isPopupOpen && <PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} />}
|
|
276
|
+
{buttonSlot && <SlotRenderer slot={buttonSlot} />}
|
|
277
|
+
{isPopupOpen && <PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} buttonRect={menuRect} />}
|
|
253
278
|
</div>
|
|
254
279
|
)
|
|
255
280
|
}
|
|
@@ -124,19 +124,6 @@
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
.im-c-map-button--touch {
|
|
128
|
-
background-color: var(--map-overlay-foreground-color);
|
|
129
|
-
color: var(--map-overlay-halo-color);
|
|
130
|
-
|
|
131
|
-
width: var(--touch-button-size);
|
|
132
|
-
height: var(--touch-button-size);
|
|
133
|
-
border-radius: 100%;
|
|
134
|
-
|
|
135
|
-
&::before {
|
|
136
|
-
box-shadow: none;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
127
|
.im-o-app__right {
|
|
141
128
|
.im-c-button-group {
|
|
142
129
|
display: flex;
|
|
@@ -185,6 +172,10 @@
|
|
|
185
172
|
}
|
|
186
173
|
|
|
187
174
|
// 4. State styles
|
|
175
|
+
.im-c-map-button[aria-haspopup="true"][aria-expanded="true"] svg {
|
|
176
|
+
transform: rotate(180deg);
|
|
177
|
+
}
|
|
178
|
+
|
|
188
179
|
.im-c-map-button[aria-disabled="true"]:not(.im-c-map-button--primary):not(.im-c-map-button--tertiary) {
|
|
189
180
|
svg, span {
|
|
190
181
|
opacity: var(--disabled-button-opacity);
|