@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,130 @@
|
|
|
1
|
+
import { setupHoverCursor } from './hoverCursor.js'
|
|
2
|
+
|
|
3
|
+
const makeMap = (layerTypes = {}, queryResults = []) => ({
|
|
4
|
+
getCanvas: () => ({ style: { cursor: '' } }),
|
|
5
|
+
getLayer: (id) => layerTypes[id] ? { type: layerTypes[id] } : undefined,
|
|
6
|
+
queryRenderedFeatures: jest.fn(() => queryResults),
|
|
7
|
+
on: jest.fn(),
|
|
8
|
+
off: jest.fn()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const move = (handler, x = 10, y = 10) =>
|
|
12
|
+
handler({ point: { x, y } })
|
|
13
|
+
|
|
14
|
+
describe('setupHoverCursor', () => {
|
|
15
|
+
/* ------------------------------------------------------------------ */
|
|
16
|
+
/* Setup / teardown */
|
|
17
|
+
/* ------------------------------------------------------------------ */
|
|
18
|
+
|
|
19
|
+
it('returns null and clears cursor when layerIds is empty', () => {
|
|
20
|
+
const map = makeMap()
|
|
21
|
+
const result = setupHoverCursor(map, [], null)
|
|
22
|
+
expect(result).toBeNull()
|
|
23
|
+
expect(map.getCanvas().style.cursor).toBe('')
|
|
24
|
+
expect(map.on).not.toHaveBeenCalled()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns null and clears cursor when layerIds is null', () => {
|
|
28
|
+
const map = makeMap()
|
|
29
|
+
const result = setupHoverCursor(map, null, null)
|
|
30
|
+
expect(result).toBeNull()
|
|
31
|
+
expect(map.getCanvas().style.cursor).toBe('')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('removes previous handler before attaching a new one', () => {
|
|
35
|
+
const map = makeMap({ 'layer-a': 'fill' })
|
|
36
|
+
const prev = jest.fn()
|
|
37
|
+
setupHoverCursor(map, ['layer-a'], prev)
|
|
38
|
+
expect(map.off).toHaveBeenCalledWith('mousemove', prev)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('removes previous handler when clearing layers', () => {
|
|
42
|
+
const map = makeMap()
|
|
43
|
+
const prev = jest.fn()
|
|
44
|
+
setupHoverCursor(map, [], prev)
|
|
45
|
+
expect(map.off).toHaveBeenCalledWith('mousemove', prev)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('attaches a mousemove listener and returns the handler', () => {
|
|
49
|
+
const map = makeMap({ 'layer-a': 'fill' })
|
|
50
|
+
const handler = setupHoverCursor(map, ['layer-a'], null)
|
|
51
|
+
expect(typeof handler).toBe('function')
|
|
52
|
+
expect(map.on).toHaveBeenCalledWith('mousemove', handler)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
/* Mousemove — cursor state */
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
|
|
59
|
+
it('sets pointer cursor when a fill layer is hit', () => {
|
|
60
|
+
const canvas = { style: { cursor: '' } }
|
|
61
|
+
const map = { ...makeMap({ 'fill-layer': 'fill' }, [{ id: 'f1' }]), getCanvas: () => canvas }
|
|
62
|
+
const handler = setupHoverCursor(map, ['fill-layer'], null)
|
|
63
|
+
move(handler)
|
|
64
|
+
expect(canvas.style.cursor).toBe('pointer')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('clears cursor when no layers are hit', () => {
|
|
68
|
+
const canvas = { style: { cursor: 'pointer' } }
|
|
69
|
+
const map = { ...makeMap({ 'fill-layer': 'fill' }, []), getCanvas: () => canvas }
|
|
70
|
+
const handler = setupHoverCursor(map, ['fill-layer'], null)
|
|
71
|
+
move(handler)
|
|
72
|
+
expect(canvas.style.cursor).toBe('')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('clears cursor when no registered layers exist on the map', () => {
|
|
76
|
+
const canvas = { style: { cursor: 'pointer' } }
|
|
77
|
+
const map = { ...makeMap({}, []), getCanvas: () => canvas }
|
|
78
|
+
const handler = setupHoverCursor(map, ['missing-layer'], null)
|
|
79
|
+
move(handler)
|
|
80
|
+
expect(canvas.style.cursor).toBe('')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/* ------------------------------------------------------------------ */
|
|
84
|
+
/* Line layer tolerance */
|
|
85
|
+
/* ------------------------------------------------------------------ */
|
|
86
|
+
|
|
87
|
+
it('uses bbox query for a pure line layer', () => {
|
|
88
|
+
const map = makeMap({ hedge: 'line' }, [{ id: 'f1' }])
|
|
89
|
+
const handler = setupHoverCursor(map, ['hedge'], null)
|
|
90
|
+
move(handler, 50, 50)
|
|
91
|
+
expect(map.queryRenderedFeatures).toHaveBeenCalledWith(
|
|
92
|
+
[[40, 40], [60, 60]],
|
|
93
|
+
{ layers: ['hedge'] }
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('sets pointer when line layer is hit via bbox', () => {
|
|
98
|
+
const canvas = { style: { cursor: '' } }
|
|
99
|
+
const map = { ...makeMap({ hedge: 'line' }, [{ id: 'f1' }]), getCanvas: () => canvas }
|
|
100
|
+
const handler = setupHoverCursor(map, ['hedge'], null)
|
|
101
|
+
move(handler)
|
|
102
|
+
expect(canvas.style.cursor).toBe('pointer')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/* ------------------------------------------------------------------ */
|
|
106
|
+
/* Stroke + fill companion */
|
|
107
|
+
/* ------------------------------------------------------------------ */
|
|
108
|
+
|
|
109
|
+
it('skips stroke layer when a companion fill layer exists', () => {
|
|
110
|
+
const map = makeMap({ poly: 'fill', 'poly-stroke': 'line' }, [])
|
|
111
|
+
const handler = setupHoverCursor(map, ['poly', 'poly-stroke'], null)
|
|
112
|
+
move(handler)
|
|
113
|
+
// Only the fill layer should be queried (exact point), not the stroke
|
|
114
|
+
expect(map.queryRenderedFeatures).toHaveBeenCalledTimes(1)
|
|
115
|
+
expect(map.queryRenderedFeatures).toHaveBeenCalledWith(
|
|
116
|
+
expect.objectContaining({ x: 10, y: 10 }),
|
|
117
|
+
{ layers: ['poly'] }
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does not skip a stroke layer that has no companion fill', () => {
|
|
122
|
+
const map = makeMap({ 'hedge-stroke': 'line' }, [])
|
|
123
|
+
const handler = setupHoverCursor(map, ['hedge-stroke'], null)
|
|
124
|
+
move(handler)
|
|
125
|
+
expect(map.queryRenderedFeatures).toHaveBeenCalledWith(
|
|
126
|
+
expect.any(Array),
|
|
127
|
+
{ layers: ['hedge-stroke'] }
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getPatternInnerContent, getPatternImageId, injectColors } from '../../../../src/utils/patternUtils.js'
|
|
2
|
+
import { getValueForStyle } from '../../../../src/utils/getValueForStyle.js'
|
|
3
|
+
import { rasteriseToImageData } from './rasteriseToImageData.js'
|
|
4
|
+
|
|
5
|
+
// Module-level cache: imageId → ImageData. Avoids re-rasterising identical patterns.
|
|
6
|
+
const imageDataCache = new Map()
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rasterises a dataset's pattern SVG to ImageData, using an in-memory cache
|
|
10
|
+
* to avoid re-rasterising identical patterns.
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} dataset
|
|
13
|
+
* @param {string} mapStyleId
|
|
14
|
+
* @param {Object} patternRegistry
|
|
15
|
+
* @returns {Promise<{ imageId: string, imageData: ImageData }|null>}
|
|
16
|
+
*/
|
|
17
|
+
const rasterisePattern = async (dataset, mapStyleId, patternRegistry) => {
|
|
18
|
+
const innerContent = getPatternInnerContent(dataset, patternRegistry)
|
|
19
|
+
if (!innerContent) {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const imageId = getPatternImageId(dataset, mapStyleId, patternRegistry)
|
|
24
|
+
if (!imageId) {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let imageData = imageDataCache.get(imageId)
|
|
29
|
+
if (!imageData) {
|
|
30
|
+
const fg = getValueForStyle(dataset.fillPatternForegroundColor, mapStyleId) || 'black'
|
|
31
|
+
const bg = getValueForStyle(dataset.fillPatternBackgroundColor, mapStyleId) || 'transparent'
|
|
32
|
+
const colored = injectColors(innerContent, fg, bg)
|
|
33
|
+
const bgRect = `<rect width="16" height="16" fill="${bg}"/>`
|
|
34
|
+
// pixelRatio: 2 means the map treats this as an 8×8 logical tile — crisp on retina screens.
|
|
35
|
+
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">${bgRect}${colored}</svg>`
|
|
36
|
+
imageData = await rasteriseToImageData(svgString, 16, 16)
|
|
37
|
+
imageDataCache.set(imageId, imageData)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { imageId, imageData }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register pattern images for the given pre-resolved pattern configs.
|
|
45
|
+
* Skips images that are already registered (safe to call on style change).
|
|
46
|
+
* Callers are responsible for sublayer merging before passing configs here
|
|
47
|
+
* (see `getPatternConfigs` in the datasets plugin adapter).
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} map - MapLibre map instance
|
|
50
|
+
* @param {Object[]} patternConfigs - Flat list of datasets/merged-sublayers with a pattern config
|
|
51
|
+
* @param {string} mapStyleId
|
|
52
|
+
* @param {Object} patternRegistry
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
*/
|
|
55
|
+
export const registerPatterns = async (map, patternConfigs, mapStyleId, patternRegistry) => {
|
|
56
|
+
if (!patternConfigs.length) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await Promise.all(patternConfigs.map(async (config) => {
|
|
61
|
+
const imageId = getPatternImageId(config, mapStyleId, patternRegistry)
|
|
62
|
+
if (!imageId || map.hasImage(imageId)) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const result = await rasterisePattern(config, mapStyleId, patternRegistry)
|
|
66
|
+
if (result) {
|
|
67
|
+
map.addImage(result.imageId, result.imageData, { pixelRatio: 2 })
|
|
68
|
+
}
|
|
69
|
+
}))
|
|
70
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { registerPatterns } from './patternImages.js'
|
|
2
|
+
|
|
3
|
+
const OUTDOOR = 'outdoor'
|
|
4
|
+
|
|
5
|
+
const SVG_CONTENT = '<path d="M0 0 L8 8"/>'
|
|
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
|
+
const makeMap = (existingIds = []) => ({
|
|
29
|
+
hasImage: jest.fn((id) => existingIds.includes(id)),
|
|
30
|
+
addImage: jest.fn()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const makePatternRegistry = (id = 'stripes', content = SVG_CONTENT) => ({
|
|
34
|
+
get: jest.fn((name) => name === id ? { svgContent: content } : undefined)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// ─── registerPatterns ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe('registerPatterns — registration', () => {
|
|
40
|
+
it('returns early and does not touch map for empty configs', async () => {
|
|
41
|
+
const map = makeMap()
|
|
42
|
+
const registry = makePatternRegistry()
|
|
43
|
+
await registerPatterns(map, [], OUTDOOR, registry)
|
|
44
|
+
expect(map.hasImage).not.toHaveBeenCalled()
|
|
45
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('calls addImage with pixelRatio 2 for a named pattern', async () => {
|
|
49
|
+
const map = makeMap()
|
|
50
|
+
const registry = makePatternRegistry()
|
|
51
|
+
const config = { fillPattern: 'stripes' }
|
|
52
|
+
await registerPatterns(map, [config], OUTDOOR, registry)
|
|
53
|
+
expect(map.addImage).toHaveBeenCalledTimes(1)
|
|
54
|
+
expect(map.addImage).toHaveBeenCalledWith(
|
|
55
|
+
expect.stringMatching(/^pattern-[a-z0-9]+$/),
|
|
56
|
+
expect.any(Object),
|
|
57
|
+
{ pixelRatio: 2 }
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('calls addImage for an inline fillPatternSvgContent config', async () => {
|
|
62
|
+
const map = makeMap()
|
|
63
|
+
const registry = makePatternRegistry()
|
|
64
|
+
const config = { fillPatternSvgContent: SVG_CONTENT }
|
|
65
|
+
await registerPatterns(map, [config], OUTDOOR, registry)
|
|
66
|
+
expect(map.addImage).toHaveBeenCalledTimes(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('skips addImage when image is already registered', async () => {
|
|
70
|
+
const registry = makePatternRegistry()
|
|
71
|
+
const config = { fillPattern: 'stripes' }
|
|
72
|
+
const { getPatternImageId } = await import('../../../../src/utils/patternUtils.js')
|
|
73
|
+
const existingId = getPatternImageId(config, OUTDOOR, registry)
|
|
74
|
+
const map = makeMap([existingId])
|
|
75
|
+
await registerPatterns(map, [config], OUTDOOR, registry)
|
|
76
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('skips config when pattern has no inner content', async () => {
|
|
80
|
+
const map = makeMap()
|
|
81
|
+
const emptyRegistry = { get: jest.fn(() => undefined) }
|
|
82
|
+
await registerPatterns(map, [{ fillPattern: 'unknown' }], OUTDOOR, emptyRegistry)
|
|
83
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('skips config when neither fillPattern nor fillPatternSvgContent is set', async () => {
|
|
87
|
+
const map = makeMap()
|
|
88
|
+
const registry = makePatternRegistry()
|
|
89
|
+
await registerPatterns(map, [{ fillColor: '#ff0000' }], OUTDOOR, registry)
|
|
90
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('processes multiple configs in parallel', async () => {
|
|
94
|
+
const map = makeMap()
|
|
95
|
+
const registry = {
|
|
96
|
+
get: jest.fn((name) => {
|
|
97
|
+
if (name === 'stripes') { return { svgContent: '<path d="M0 0"/>' } }
|
|
98
|
+
if (name === 'dots') { return { svgContent: '<circle cx="8" cy="8" r="4"/>' } }
|
|
99
|
+
return undefined
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
await registerPatterns(map, [{ fillPattern: 'stripes' }, { fillPattern: 'dots' }], OUTDOOR, registry)
|
|
103
|
+
expect(map.addImage).toHaveBeenCalledTimes(2)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('registerPatterns — color resolution and caching', () => {
|
|
108
|
+
it('applies foreground and background colors when resolving the SVG', async () => {
|
|
109
|
+
const map = makeMap()
|
|
110
|
+
const registry = makePatternRegistry()
|
|
111
|
+
const getContextSpy = HTMLCanvasElement.prototype.getContext
|
|
112
|
+
await registerPatterns(
|
|
113
|
+
map,
|
|
114
|
+
[{ fillPattern: 'stripes', fillPatternForegroundColor: '#aabbcc', fillPatternBackgroundColor: '#112233' }],
|
|
115
|
+
OUTDOOR,
|
|
116
|
+
registry
|
|
117
|
+
)
|
|
118
|
+
expect(map.addImage).toHaveBeenCalledTimes(1)
|
|
119
|
+
expect(getContextSpy).toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('resolves style-keyed foreground color for the given mapStyleId', async () => {
|
|
123
|
+
const map = makeMap()
|
|
124
|
+
const registry = makePatternRegistry()
|
|
125
|
+
const config = {
|
|
126
|
+
fillPattern: 'stripes',
|
|
127
|
+
fillPatternForegroundColor: { outdoor: '#aabbcc', dark: '#112233' }
|
|
128
|
+
}
|
|
129
|
+
await registerPatterns(map, [config], OUTDOOR, registry)
|
|
130
|
+
const map2 = makeMap()
|
|
131
|
+
await registerPatterns(map2, [config], 'dark', registry)
|
|
132
|
+
const [idOutdoor] = map.addImage.mock.calls[0]
|
|
133
|
+
const [idDark] = map2.addImage.mock.calls[0]
|
|
134
|
+
expect(idOutdoor).not.toBe(idDark)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('uses cached ImageData on second call with identical config', async () => {
|
|
138
|
+
const map = makeMap()
|
|
139
|
+
const registry = makePatternRegistry()
|
|
140
|
+
const config = { fillPattern: 'stripes', fillPatternForegroundColor: '#unique1' }
|
|
141
|
+
await registerPatterns(map, [config], OUTDOOR, registry)
|
|
142
|
+
const { getPatternImageId } = await import('../../../../src/utils/patternUtils.js')
|
|
143
|
+
const imageId = getPatternImageId(config, OUTDOOR, registry)
|
|
144
|
+
const map2 = makeMap([imageId])
|
|
145
|
+
await registerPatterns(map2, [config], OUTDOOR, registry)
|
|
146
|
+
expect(map2.addImage).not.toHaveBeenCalled()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('registerPatterns — null results', () => {
|
|
151
|
+
it('does not call addImage when innerContent becomes unavailable inside rasterisePattern', async () => {
|
|
152
|
+
// registry.get returns content on the first call (for getPatternImageId in registerPatterns)
|
|
153
|
+
// but undefined on the second call (for getPatternInnerContent inside rasterisePattern)
|
|
154
|
+
const registry = {
|
|
155
|
+
get: jest.fn()
|
|
156
|
+
.mockReturnValueOnce({ svgContent: SVG_CONTENT })
|
|
157
|
+
.mockReturnValueOnce(undefined)
|
|
158
|
+
}
|
|
159
|
+
const map = makeMap()
|
|
160
|
+
await registerPatterns(map, [{ fillPattern: 'stripes' }], OUTDOOR, registry)
|
|
161
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('does not call addImage when imageId becomes unavailable inside rasterisePattern', async () => {
|
|
165
|
+
// Three consecutive calls to registry.get:
|
|
166
|
+
// 1. getPatternImageId in registerPatterns → returns content → imageId is truthy
|
|
167
|
+
// 2. getPatternInnerContent directly in rasterisePattern → returns content → passes innerContent guard
|
|
168
|
+
// 3. getPatternInnerContent inside getPatternImageId in rasterisePattern → returns undefined
|
|
169
|
+
// → getPatternImageId returns null → hits the imageId null guard
|
|
170
|
+
const registry = {
|
|
171
|
+
get: jest.fn()
|
|
172
|
+
.mockReturnValueOnce({ svgContent: SVG_CONTENT })
|
|
173
|
+
.mockReturnValueOnce({ svgContent: SVG_CONTENT })
|
|
174
|
+
.mockReturnValueOnce(undefined)
|
|
175
|
+
}
|
|
176
|
+
const map = makeMap()
|
|
177
|
+
await registerPatterns(map, [{ fillPattern: 'stripes' }], OUTDOOR, registry)
|
|
178
|
+
expect(map.addImage).not.toHaveBeenCalled()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -83,12 +83,23 @@ const getMinDistToGeometry = (map, point, geometry) => {
|
|
|
83
83
|
*/
|
|
84
84
|
export const queryFeatures = (map, point, options = {}) => {
|
|
85
85
|
const { radius = 10 } = options
|
|
86
|
-
|
|
87
|
-
const
|
|
86
|
+
|
|
87
|
+
const bbox = [[point.x - radius, point.y - radius], [point.x + radius, point.y + radius]]
|
|
88
|
+
const rawFeatures = map.queryRenderedFeatures(bbox)
|
|
88
89
|
if (rawFeatures.length === 0) {
|
|
89
90
|
return []
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
// For symbol/point features, tolerance must not apply — selection should only
|
|
94
|
+
// fire when the click lands within the rendered icon bounds. An exact point
|
|
95
|
+
// query uses MapLibre's own icon hit-testing, mirroring the hover cursor behaviour.
|
|
96
|
+
const exactFeatureKeys = new Set(
|
|
97
|
+
map.queryRenderedFeatures([point.x, point.y]).map(f => {
|
|
98
|
+
const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
|
|
99
|
+
return `${f.layer?.source}:${rawId}`
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
|
|
92
103
|
// Identify layer visual hierarchy
|
|
93
104
|
const layerStack = []
|
|
94
105
|
rawFeatures.forEach(f => {
|
|
@@ -97,12 +108,15 @@ export const queryFeatures = (map, point, options = {}) => {
|
|
|
97
108
|
}
|
|
98
109
|
})
|
|
99
110
|
|
|
100
|
-
// Deduplicate Bottom-Up to favor data layers over highlight layers
|
|
111
|
+
// Deduplicate Bottom-Up to favor data layers over highlight layers.
|
|
112
|
+
// Key includes source ID to prevent collisions between features from different
|
|
113
|
+
// sources that share the same numeric ID (e.g. generateId: true resets per source).
|
|
101
114
|
const seenIds = new Set()
|
|
102
115
|
const uniqueFeatures = []
|
|
103
116
|
for (let i = rawFeatures.length - 1; i >= 0; i--) {
|
|
104
117
|
const f = rawFeatures[i]
|
|
105
|
-
const
|
|
118
|
+
const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
|
|
119
|
+
const featureId = `${f.layer?.source}:${rawId}`
|
|
106
120
|
if (seenIds.has(featureId) === false) {
|
|
107
121
|
seenIds.add(featureId)
|
|
108
122
|
uniqueFeatures.push(f)
|
|
@@ -112,7 +126,24 @@ export const queryFeatures = (map, point, options = {}) => {
|
|
|
112
126
|
const clickLngLat = map.unproject(point)
|
|
113
127
|
const clickPt = [clickLngLat.lng, clickLngLat.lat]
|
|
114
128
|
|
|
115
|
-
|
|
129
|
+
// Discard features where tolerance should not apply:
|
|
130
|
+
// - Polygons: only include if click is geometrically inside
|
|
131
|
+
// - Points/symbols: only include if under the exact click point (respects icon bounds)
|
|
132
|
+
// - Lines: allowed through — tolerance bbox is intentional for them
|
|
133
|
+
const candidates = uniqueFeatures.filter((f) => {
|
|
134
|
+
const type = f.geometry.type
|
|
135
|
+
if (type.includes('Polygon')) {
|
|
136
|
+
const polys = type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates
|
|
137
|
+
return polys.some((ring) => isPointInPolygon(clickPt, ring[0]))
|
|
138
|
+
}
|
|
139
|
+
if (type === 'Point' || type === 'MultiPoint') {
|
|
140
|
+
const rawId = f.id === undefined ? JSON.stringify(f.properties) : f.id
|
|
141
|
+
return exactFeatureKeys.has(`${f.layer?.source}:${rawId}`)
|
|
142
|
+
}
|
|
143
|
+
return true
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
return candidates
|
|
116
147
|
.map((f) => {
|
|
117
148
|
let score = 0
|
|
118
149
|
const type = f.geometry.type
|
|
@@ -122,18 +153,9 @@ export const queryFeatures = (map, point, options = {}) => {
|
|
|
122
153
|
const layerRank = layerStack.indexOf(f.layer.id)
|
|
123
154
|
score += (layerRank * 1000000)
|
|
124
155
|
|
|
125
|
-
// PRIORITY 2:
|
|
156
|
+
// PRIORITY 2: POLYGON BOOST (already filtered to inside-only)
|
|
126
157
|
if (type.includes('Polygon')) {
|
|
127
|
-
|
|
128
|
-
const isInside = polys.some((ring) => isPointInPolygon(clickPt, ring[0]))
|
|
129
|
-
|
|
130
|
-
if (isInside === true) {
|
|
131
|
-
// Massive boost for polygons if we are actually inside them
|
|
132
|
-
score -= 500000 // NOSONAR - tolerance used only here
|
|
133
|
-
} else {
|
|
134
|
-
// If we are outside a polygon, it loses significantly to anything we ARE inside
|
|
135
|
-
score += 100000 // NOSONAR - tolerance used only here
|
|
136
|
-
}
|
|
158
|
+
score -= 500000 // NOSONAR
|
|
137
159
|
}
|
|
138
160
|
|
|
139
161
|
// PRIORITY 3: DISTANCE (Final Tie-breaker)
|
|
@@ -20,7 +20,7 @@ describe('queryFeatures coverage', () => {
|
|
|
20
20
|
{ type: 'MultiPoint', coords: [[0, 0], [10, 10]], p: { x: 1, y: 0 } },
|
|
21
21
|
{ type: 'MultiLineString', coords: [[[0, 0], [10, 0]]], p: { x: 5, y: 1 } },
|
|
22
22
|
{ type: 'Polygon', coords: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], p: { x: 5, y: 5 } }, // Inside
|
|
23
|
-
{ type: 'MultiPolygon', coords: [[[[0, 0], [10, 0], [10, 10], [0, 0]]]], p: { x:
|
|
23
|
+
{ type: 'MultiPolygon', coords: [[[[0, 0], [10, 0], [10, 10], [0, 0]]]], p: { x: 5, y: 3 } }, // Inside
|
|
24
24
|
{ type: 'Unknown', coords: [], p: { x: 0, y: 0 } }
|
|
25
25
|
]
|
|
26
26
|
|
|
@@ -49,12 +49,29 @@ describe('queryFeatures coverage', () => {
|
|
|
49
49
|
expect(result.length).toBe(2)
|
|
50
50
|
expect(result[0].layer.id).toBe('layer-A') // Sorted by layerStack index
|
|
51
51
|
|
|
52
|
-
// 4. Hit ray-casting intersect logic
|
|
52
|
+
// 4. Hit ray-casting intersect logic — point inside the polygon
|
|
53
53
|
const polyFeat = {
|
|
54
54
|
layer: { id: 'L' },
|
|
55
55
|
geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 10], [0, 10], [0, 0]]] }
|
|
56
56
|
}
|
|
57
57
|
const rayMap = { ...mockMap, queryRenderedFeatures: () => [polyFeat] }
|
|
58
|
-
expect(queryFeatures(rayMap, { x:
|
|
58
|
+
expect(queryFeatures(rayMap, { x: 2, y: 8 }).length).toBe(1)
|
|
59
|
+
|
|
60
|
+
// 5. Outside polygon is filtered out (tolerance only applies to lines)
|
|
61
|
+
const outsideMap = { ...mockMap, queryRenderedFeatures: () => [polyFeat] }
|
|
62
|
+
expect(queryFeatures(outsideMap, { x: -1, y: 5 }).length).toBe(0)
|
|
63
|
+
|
|
64
|
+
// 6. Symbol under exact click point is included
|
|
65
|
+
const symbolFeat = { id: 'sym', layer: { id: 'S', source: 'src' }, geometry: { type: 'Point', coordinates: [0, 0] } }
|
|
66
|
+
const symbolMap = { ...mockMap, queryRenderedFeatures: () => [symbolFeat] } // both calls return it
|
|
67
|
+
expect(queryFeatures(symbolMap, { x: 5, y: 5 }).length).toBe(1)
|
|
68
|
+
|
|
69
|
+
// 7. Symbol NOT under exact click point is filtered out
|
|
70
|
+
let call = 0
|
|
71
|
+
const symbolMissMap = {
|
|
72
|
+
...mockMap,
|
|
73
|
+
queryRenderedFeatures: () => call++ === 0 ? [symbolFeat] : [] // bbox returns it, exact does not
|
|
74
|
+
}
|
|
75
|
+
expect(queryFeatures(symbolMissMap, { x: 5, y: 5 }).length).toBe(0)
|
|
59
76
|
})
|
|
60
77
|
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const SVG_ERROR_PREVIEW_LENGTH = 80
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rasterises an SVG string to an ImageData object via a canvas.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} svgString - Full SVG markup to render
|
|
7
|
+
* @param {number} width - Canvas width in pixels
|
|
8
|
+
* @param {number} height - Canvas height in pixels
|
|
9
|
+
* @returns {Promise<ImageData>}
|
|
10
|
+
*/
|
|
11
|
+
export const rasteriseToImageData = (svgString, width, height) =>
|
|
12
|
+
new Promise((resolve, reject) => {
|
|
13
|
+
const blob = new Blob([svgString], { type: 'image/svg+xml' })
|
|
14
|
+
const url = URL.createObjectURL(blob)
|
|
15
|
+
const img = new Image(width, height)
|
|
16
|
+
img.onload = () => {
|
|
17
|
+
const canvas = document.createElement('canvas')
|
|
18
|
+
canvas.width = width
|
|
19
|
+
canvas.height = height
|
|
20
|
+
const ctx = canvas.getContext('2d')
|
|
21
|
+
ctx.drawImage(img, 0, 0, width, height)
|
|
22
|
+
URL.revokeObjectURL(url)
|
|
23
|
+
resolve(ctx.getImageData(0, 0, width, height))
|
|
24
|
+
}
|
|
25
|
+
img.onerror = () => {
|
|
26
|
+
URL.revokeObjectURL(url)
|
|
27
|
+
reject(new Error(`Failed to rasterise SVG: ${svgString.slice(0, SVG_ERROR_PREVIEW_LENGTH)}`))
|
|
28
|
+
}
|
|
29
|
+
img.src = url
|
|
30
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { rasteriseToImageData } from './rasteriseToImageData.js'
|
|
2
|
+
|
|
3
|
+
const SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="8"/></svg>'
|
|
4
|
+
const WIDTH = 32
|
|
5
|
+
const HEIGHT = 32
|
|
6
|
+
|
|
7
|
+
// Mirrors SVG_ERROR_PREVIEW_LENGTH in rasteriseToImageData.js
|
|
8
|
+
const ERROR_PREVIEW_LENGTH = 80
|
|
9
|
+
// Length chosen to be well over ERROR_PREVIEW_LENGTH so truncation is exercised
|
|
10
|
+
const LONG_CONTENT_LENGTH = 200
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
|
|
14
|
+
globalThis.URL.revokeObjectURL = jest.fn()
|
|
15
|
+
|
|
16
|
+
HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
|
|
17
|
+
drawImage: jest.fn(),
|
|
18
|
+
getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h }))
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
globalThis.Image = class {
|
|
22
|
+
constructor (w, h) {
|
|
23
|
+
this.width = w
|
|
24
|
+
this.height = h
|
|
25
|
+
this._src = ''
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get src () { return this._src }
|
|
29
|
+
set src (val) { this._src = val; this.onload?.() }
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
jest.clearAllMocks()
|
|
35
|
+
globalThis.URL.createObjectURL.mockReturnValue('blob:mock')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('rasteriseToImageData', () => {
|
|
39
|
+
it('resolves with ImageData at the requested dimensions, draws via canvas, and revokes the blob URL', async () => {
|
|
40
|
+
const getContext = HTMLCanvasElement.prototype.getContext
|
|
41
|
+
const result = await rasteriseToImageData(SVG, WIDTH, HEIGHT)
|
|
42
|
+
expect(result).toMatchObject({ width: WIDTH, height: HEIGHT })
|
|
43
|
+
expect(globalThis.URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
|
44
|
+
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock')
|
|
45
|
+
const { drawImage, getImageData } = getContext.mock.results[0].value
|
|
46
|
+
expect(drawImage).toHaveBeenCalledWith(expect.any(Object), 0, 0, WIDTH, HEIGHT)
|
|
47
|
+
expect(getImageData).toHaveBeenCalledWith(0, 0, WIDTH, HEIGHT)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects with a truncated SVG preview and revokes the blob URL on error', async () => {
|
|
51
|
+
const originalImage = globalThis.Image
|
|
52
|
+
globalThis.Image = class {
|
|
53
|
+
constructor (w, h) { this.width = w; this.height = h; this._src = '' }
|
|
54
|
+
get src () { return this._src }
|
|
55
|
+
set src (val) { this._src = val; this.onerror?.() }
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const longSvg = `<svg>${'x'.repeat(LONG_CONTENT_LENGTH)}</svg>`
|
|
59
|
+
const error = await rasteriseToImageData(longSvg, WIDTH, HEIGHT).catch(e => e)
|
|
60
|
+
expect(error.message).toMatch('Failed to rasterise SVG')
|
|
61
|
+
const preview = error.message.replace('Failed to rasterise SVG: ', '')
|
|
62
|
+
expect(preview).toHaveLength(ERROR_PREVIEW_LENGTH)
|
|
63
|
+
expect(preview).toBe(longSvg.slice(0, ERROR_PREVIEW_LENGTH))
|
|
64
|
+
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock')
|
|
65
|
+
} finally {
|
|
66
|
+
globalThis.Image = originalImage
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
})
|