diff --git a/demo/js/index.js b/demo/js/index.js
index 846216a5..a63d48cb 100755
--- a/demo/js/index.js
+++ b/demo/js/index.js
@@ -26,14 +26,33 @@ import searchPlugin from '/plugins/search/src/index.js'
import createInteractPlugin from '/plugins/interact/src/index.js'
import createFramePlugin from '/plugins/beta/frame/src/index.js'
-const pointData = {type: 'FeatureCollection',features: [{type: 'Feature',properties: { category:'prehistoric', name: 'Prehistoric feature' }, geometry: { coordinates: [-2.4558622,54.5617135], type: 'Point' }},{ type: 'Feature', properties: { category: 'roman', name: 'Roman feature' }, geometry: { coordinates: [-2.439823,54.5525437], type: 'Point' }},{ type: 'Feature', properties: { category:'medieval', name: 'Medieval feature' }, geometry: { coordinates: [-2.4481939,54.5575261], type: 'Point'} }]}
+const pointData = {
+ type: 'FeatureCollection',
+ features: [{
+ type: 'Feature',
+ properties: { category:'prehistoric', name: 'Prehistoric feature' },
+ geometry: { coordinates: [-2.4558622,54.5617135], type: 'Point' }
+ },
+ {
+ type: 'Feature',
+ properties: { category: 'roman', name: 'Roman feature' },
+ geometry: { coordinates: [-2.439823,54.5525437], type: 'Point' }
+ },
+ {
+ type: 'Feature',
+ properties: { category:'medieval', name: 'Medieval feature' },
+ geometry: { coordinates: [-2.4481939,54.5575261], type: 'Point'}
+ }]
+}
const interactPlugin = createInteractPlugin({
layers: [{
layerId: 'historic-monuments-prehistoric-symbol',
- // labelProperty: 'name'
- // idProperty: 'gid'
- },{
+ }, {
+ layerId: 'historic-monuments-roman-symbol',
+ }, {
+ layerId: 'historic-monuments-medieval-symbol',
+ }, {
layerId: 'land-covers-110',
// labelProperty: 'gid'
// idProperty: 'gid'
@@ -257,7 +276,7 @@ const interactiveMap = new InteractiveMap('map', {
maxZoom: 20,
autoColorScheme: true,
// center: [-2.938769, 54.893806],
- bounds: [-2.989707, 54.864555, -2.878635, 54.937635],
+ bounds: [-2.450804, 54.5599279, -2.403804, 54.6199279],
containerHeight: '650px',
transformRequest: transformTileRequest,
readMapText: true,
diff --git a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js
index 71efa378..e59d3a21 100644
--- a/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js
+++ b/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js
@@ -2,7 +2,7 @@ import { getValueForStyle } from '../../../../../../src/utils/getValueForStyle.j
import { hasPattern } from './patternImages.js'
import { mergeSublayer } from '../../utils/mergeSublayer.js'
import { getSourceId, getLayerIds, getSublayerLayerIds, isDynamicSource, MAX_TILE_ZOOM } from './layerIds.js'
-import { hasSymbol, getSymbolDef, getSymbolAnchor, anchorToMaplibre, getSymbolImageId } from './symbolImages.js'
+import { hasSymbol, getSymbolAnchor, anchorToMaplibre } from './symbolImages.js'
// ─── Source ───────────────────────────────────────────────────────────────────
@@ -81,9 +81,9 @@ export const addStrokeLayer = (map, config, layerId, sourceId, sourceLayer, visi
export const addSymbolLayer = (map, dataset, layerId, sourceId, sourceLayer, visibility, { mapStyle, symbolRegistry, pixelRatio }) => {
if (!layerId || map.getLayer(layerId)) { return }
- const symbolDef = getSymbolDef(dataset, symbolRegistry)
+ const symbolDef = symbolRegistry.getSymbolDef(dataset)
if (!symbolDef) { return }
- const imageId = getSymbolImageId(dataset, mapStyle, symbolRegistry, false, pixelRatio)
+ const imageId = symbolRegistry.getSymbolImageId(dataset, mapStyle, false, pixelRatio)
if (!imageId) { return }
const anchor = getSymbolAnchor(dataset, symbolDef)
map.addLayer({
diff --git a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js
index 6e36acbb..769db79d 100644
--- a/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js
+++ b/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js
@@ -2,7 +2,7 @@ import { applyExclusionFilter } from '../../utils/filters.js'
import { getSourceId, getLayerIds, getSublayerLayerIds, getAllLayerIds } from './layerIds.js'
import { addDatasetLayers, addSublayerLayers } from './layerBuilders.js'
import { getPatternConfigs, hasPattern } from './patternImages.js'
-import { getSymbolConfigs, getSymbolImageId } from './symbolImages.js'
+import { getSymbolConfigs } from './symbolImages.js'
import { mergeSublayer } from '../../utils/mergeSublayer.js'
import { scaleFactor } from '../../../../../../src/config/appConfig.js'
@@ -127,7 +127,7 @@ export default class MaplibreLayerAdapter {
datasets.forEach(dataset => {
getAllLayerIds(dataset).forEach(layerId => {
if (!this._symbolLayerIds.has(layerId) || !this._map.getLayer(layerId)) { return }
- const imageId = getSymbolImageId(dataset, mapStyle, this._symbolRegistry, false, pixelRatio)
+ const imageId = this._symbolRegistry.getSymbolImageId(dataset, mapStyle, false, pixelRatio)
if (imageId) {
this._map.setLayoutProperty(layerId, 'icon-image', imageId)
}
@@ -145,7 +145,7 @@ export default class MaplibreLayerAdapter {
const merged = mergeSublayer(dataset, sublayer)
const { symbolLayerId, fillLayerId } = getSublayerLayerIds(dataset.id, sublayer.id)
if (this._map.getLayer(symbolLayerId)) {
- const imageId = getSymbolImageId(merged, mapStyle, this._symbolRegistry, false, pixelRatio)
+ const imageId = this._symbolRegistry.getSymbolImageId(merged, mapStyle, false, pixelRatio)
if (imageId) {
this._map.setLayoutProperty(symbolLayerId, 'icon-image', imageId)
}
diff --git a/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js b/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js
index f0ffe90b..bb6ebc9a 100644
--- a/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js
+++ b/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js
@@ -2,12 +2,12 @@
import { hasSymbol } from '../../../../../../src/utils/symbolUtils.js'
import { mergeSublayer } from '../../utils/mergeSublayer.js'
-export { hasSymbol, getSymbolDef, getSymbolStyleColors, getSymbolViewBox, getSymbolAnchor } from '../../../../../../src/utils/symbolUtils.js'
+export { hasSymbol, getSymbolStyleColors, getSymbolViewBox, getSymbolAnchor } from '../../../../../../src/utils/symbolUtils.js'
// Re-export MapLibre-specific symbol utilities from the provider.
// This is the single cross-boundary import in the adapter; in a separate-package
// setup this would be: '@interactive-map/maplibre-provider/utils/symbolImages'
-export { anchorToMaplibre, getSymbolImageId } from '../../../../../../providers/maplibre/src/utils/symbolImages.js'
+export { anchorToMaplibre } from '../../../../../../providers/maplibre/src/utils/symbolImages.js'
/**
* Returns a flat list of datasets and merged sublayers that require symbol images.
diff --git a/plugins/beta/datasets/src/components/KeySvg.jsx b/plugins/beta/datasets/src/components/KeySvg.jsx
index 70fd3eb7..3a46fe7f 100644
--- a/plugins/beta/datasets/src/components/KeySvg.jsx
+++ b/plugins/beta/datasets/src/components/KeySvg.jsx
@@ -1,4 +1,4 @@
-import { hasSymbol, getSymbolDef } from '../../../../../src/utils/symbolUtils.js'
+import { hasSymbol } from '../../../../../src/utils/symbolUtils.js'
import { hasPattern } from '../../../../../src/utils/patternUtils.js'
import { KeySvgPattern } from './KeySvgPattern.jsx'
import { KeySvgSymbol } from './KeySvgSymbol.jsx'
@@ -7,7 +7,7 @@ import { KeySvgRect } from './KeySvgRect.jsx'
export const KeySvg = (props) => {
const { symbolRegistry } = props
- const symbolDef = hasSymbol(props) && getSymbolDef(props, symbolRegistry)
+ const symbolDef = hasSymbol(props) && symbolRegistry.getSymbolDef(props)
if (symbolDef) {
return
}
diff --git a/providers/maplibre/src/maplibreProvider.js b/providers/maplibre/src/maplibreProvider.js
index db4a96a6..8140d044 100755
--- a/providers/maplibre/src/maplibreProvider.js
+++ b/providers/maplibre/src/maplibreProvider.js
@@ -13,7 +13,6 @@ import { createMapLabelNavigator } from './utils/labels.js'
import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
import { queryFeatures } from './utils/queryFeatures.js'
import { setupHoverCursor } from './utils/hoverCursor.js'
-import { addSymbolsToMap } from './utils/symbolImages.js'
import { addPatternsToMap } from './utils/patternImages.js'
/**
@@ -326,7 +325,7 @@ export default class MapLibreProvider {
*/
async addSymbolsToMap (symbolConfigs, mapStyle, symbolRegistry) {
const pixelRatio = (this.map.getPixelRatio() || 1) * (scaleFactor[this.mapSize] || 1)
- return addSymbolsToMap(this.map, symbolConfigs, mapStyle, symbolRegistry, pixelRatio)
+ return symbolRegistry.addSymbolsToMap(this.map, symbolConfigs, mapStyle, pixelRatio)
}
/**
diff --git a/providers/maplibre/src/maplibreProvider.test.js b/providers/maplibre/src/maplibreProvider.test.js
index 9e06f8ce..3f98e897 100644
--- a/providers/maplibre/src/maplibreProvider.test.js
+++ b/providers/maplibre/src/maplibreProvider.test.js
@@ -4,9 +4,10 @@ import { attachAppEvents } from './appEvents.js'
import { createMapLabelNavigator } from './utils/labels.js'
import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
import { queryFeatures } from './utils/queryFeatures.js'
-import { addSymbolsToMap } from './utils/symbolImages.js'
import { addPatternsToMap } from './utils/patternImages.js'
import { getAreaDimensions, getCardinalMove, getResolution, getPaddedBounds, isGeometryObscured } from './utils/spatial.js'
+import { symbolRegistry } from '../../../src/services/symbolRegistry.js'
+const addSymbolsToMapSpy = jest.spyOn(symbolRegistry, 'addSymbolsToMap').mockImplementation(() => Promise.resolve())
jest.mock('./defaults.js', () => ({
DEFAULTS: { animationDuration: 400, coordinatePrecision: 7 },
@@ -35,7 +36,6 @@ jest.mock('./utils/labels.js', () => ({
}))
jest.mock('./utils/highlightFeatures.js', () => ({ updateHighlightedFeatures: jest.fn(() => []) }))
jest.mock('./utils/queryFeatures.js', () => ({ queryFeatures: jest.fn(() => []) }))
-jest.mock('./utils/symbolImages.js', () => ({ addSymbolsToMap: jest.fn(() => Promise.resolve()) }))
jest.mock('./utils/patternImages.js', () => ({ addPatternsToMap: jest.fn(() => Promise.resolve()) }))
describe('MapLibreProvider', () => {
@@ -273,9 +273,8 @@ describe('MapLibreProvider', () => {
await doInitMap(p)
const configs = [{ symbol: 'pin' }]
const mapStyle = { id: 'test', selectedColor: '#0b0c0c' }
- const registry = {}
- await p.addSymbolsToMap(configs, mapStyle, registry)
- expect(addSymbolsToMap).toHaveBeenCalledWith(map, configs, mapStyle, registry, expect.any(Number))
+ await p.addSymbolsToMap(configs, mapStyle, symbolRegistry)
+ expect(addSymbolsToMapSpy).toHaveBeenCalledWith(map, configs, mapStyle, expect.any(Number))
})
test('addSymbolsToMap computes pixelRatio from getPixelRatio and mapSize scale factor', async () => {
@@ -283,9 +282,8 @@ describe('MapLibreProvider', () => {
await doInitMap(p)
map.getPixelRatio.mockReturnValue(2)
p.mapSize = 'medium' // scaleFactor['medium'] = 1.5
- const registry = {}
- await p.addSymbolsToMap([], { id: 'test' }, registry)
- expect(addSymbolsToMap).toHaveBeenCalledWith(map, [], { id: 'test' }, registry, 3) // 2 * 1.5
+ await p.addSymbolsToMap([], { id: 'test' }, symbolRegistry)
+ expect(addSymbolsToMapSpy).toHaveBeenCalledWith(map, [], { id: 'test' }, 3)
})
test('addSymbolsToMap falls back to pixelRatio 1 when getPixelRatio returns 0', async () => {
@@ -293,9 +291,8 @@ describe('MapLibreProvider', () => {
await doInitMap(p)
map.getPixelRatio.mockReturnValue(0)
p.mapSize = 'small' // scaleFactor['small'] = 1
- const registry = {}
- await p.addSymbolsToMap([], { id: 'test' }, registry)
- expect(addSymbolsToMap).toHaveBeenCalledWith(map, [], { id: 'test' }, registry, 1) // (0 || 1) * 1
+ await p.addSymbolsToMap([], { id: 'test' }, symbolRegistry)
+ expect(addSymbolsToMapSpy).toHaveBeenCalledWith(map, [], { id: 'test' }, 1)
})
test('addPatternsToMap delegates to utility with map instance and pixelRatio', async () => {
diff --git a/providers/maplibre/src/utils/symbolImages.js b/providers/maplibre/src/utils/symbolImages.js
index a15d8d75..a9c2e653 100644
--- a/providers/maplibre/src/utils/symbolImages.js
+++ b/providers/maplibre/src/utils/symbolImages.js
@@ -1,17 +1,5 @@
-import { getSymbolDef, getSymbolStyleColors, getSymbolViewBox } from '../../../../src/utils/symbolUtils.js'
-import { rasteriseToImageData } from './rasteriseToImageData.js'
-
const ANCHOR_LOW = 0.25
const ANCHOR_HIGH = 0.75
-const HASH_BASE = 36
-
-const hashString = (str) => {
- let hash = 0
- for (const ch of str) {
- hash = Math.trunc(((hash << 5) - hash) + ch.codePointAt(0))
- }
- return Math.abs(hash).toString(HASH_BASE)
-}
// ─── MapLibre-specific anchor conversion ──────────────────────────────────────
@@ -47,128 +35,3 @@ export const anchorToMaplibre = ([ax, ay]) => {
const y = yAnchor(ay)
return (y + (x && y ? '-' : '') + x) || 'center'
}
-
-// ─── Image IDs ────────────────────────────────────────────────────────────────
-
-/**
- * Returns a deterministic image ID for a symbol in normal or selected state.
- * Based on the hash of the fully resolved SVG content and the pixel ratio.
- *
- * @param {Object} dataset
- * @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
- * @param {Object} symbolRegistry
- * @param {boolean} [selected=false]
- * @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor
- * @returns {string|null}
- */
-export const getSymbolImageId = (dataset, mapStyle, symbolRegistry, active = false, pixelRatio = 2) => {
- const symbolDef = getSymbolDef(dataset, symbolRegistry)
- if (!symbolDef) {
- return null
- }
- const styleColors = getSymbolStyleColors(dataset)
- const resolved = active
- ? symbolRegistry.resolveActive(symbolDef, styleColors, mapStyle)
- : symbolRegistry.resolve(symbolDef, styleColors, mapStyle)
- return `symbol-${active ? 'act-' : ''}${hashString(resolved)}-${pixelRatio}x`
-}
-
-// ─── Rasterisation ────────────────────────────────────────────────────────────
-
-// Module-level cache: imageId → ImageData. Avoids re-rasterising identical symbols.
-const imageDataCache = new Map()
-
-/**
- * Rasterise one variant of a symbol to ImageData for use as a MapLibre image.
- * Results are cached by imageId so identical symbols are only rendered once.
- *
- * @param {Object} dataset - Dataset or marker config with symbol properties
- * @param {Object} mapStyle - Current map style config
- * @param {Object} symbolRegistry
- * @param {'normal'|'active'|'selected'} variant
- * - `'normal'` — no rings (default display)
- * - `'active'` — both rings (keyboard cursor, yellow + black)
- * - `'selected'` — selected ring only (black)
- * @param {number} pixelRatio - Device pixel ratio × map size scale factor
- * @returns {Promise<{imageId: string, imageData: ImageData}|null>}
- */
-const rasteriseSymbolImage = async (dataset, mapStyle, symbolRegistry, variant, pixelRatio) => {
- const symbolDef = getSymbolDef(dataset, symbolRegistry)
- if (!symbolDef) {
- return null
- }
- const styleColors = getSymbolStyleColors(dataset)
- let resolvedContent, prefix
- if (variant === 'active') {
- resolvedContent = symbolRegistry.resolveActive(symbolDef, styleColors, mapStyle)
- prefix = 'act-'
- } else if (variant === 'selected') {
- resolvedContent = symbolRegistry.resolveSelected(symbolDef, styleColors, mapStyle)
- prefix = 'sel-'
- } else {
- resolvedContent = symbolRegistry.resolve(symbolDef, styleColors, mapStyle)
- prefix = ''
- }
-
- const imageId = `symbol-${prefix}${hashString(resolvedContent)}-${pixelRatio}x`
-
- let imageData = imageDataCache.get(imageId)
- if (!imageData) {
- const viewBox = getSymbolViewBox(dataset, symbolDef)
- const [,, width, height] = viewBox.split(' ').map(Number)
- // Render at pixelRatio× to keep icons crisp at the current device DPI and map size.
- // MapLibre receives the matching pixelRatio so the image displays at its original logical size.
- const svgString = ``
- imageData = await rasteriseToImageData(svgString, width * pixelRatio, height * pixelRatio)
- imageDataCache.set(imageId, imageData)
- }
-
- return { imageId, imageData }
-}
-
-/**
- * Register normal, active (both rings) and selected (black ring) symbol images.
- * Skips images that are already registered (safe to call on style change).
- * Updates `map._activeSymbolImageMap` (normal→active) and `map._selectedSymbolImageMap` (normal→selected).
- *
- * Callers are responsible for resolving sublayers before calling this function
- * (see `getSymbolConfigs` in the datasets plugin adapter).
- *
- * @param {Object} map - MapLibre map instance
- * @param {Object[]} styleArray - Flat list of datasets/merged-sublayers that have a symbol config
- * @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
- * @param {Object} symbolRegistry
- * @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor (computed by caller)
- * @returns {Promise}
- */
-export const addSymbolsToMap = async (map, styleArray, mapStyle, symbolRegistry, pixelRatio = 2) => {
- if (!styleArray.length) {
- return
- }
-
- map._activeSymbolImageMap = {}
- map._selectedSymbolImageMap = {}
-
- await Promise.all(styleArray.flatMap(config => {
- const normalId = getSymbolImageId(config, mapStyle, symbolRegistry, false, pixelRatio)
- const activeId = getSymbolImageId(config, mapStyle, symbolRegistry, true, pixelRatio)
- if (normalId && activeId) {
- map._activeSymbolImageMap[normalId] = activeId
- }
- return ['normal', 'active', 'selected'].map(async (variant) => {
- const imageId = variant === 'active' ? activeId : normalId
- if (variant !== 'selected' && (!imageId || map.hasImage(imageId))) {
- return
- }
- const result = await rasteriseSymbolImage(config, mapStyle, symbolRegistry, variant, pixelRatio)
- if (result) {
- if (variant === 'selected' && normalId) {
- map._selectedSymbolImageMap[normalId] = result.imageId
- }
- if (!map.hasImage(result.imageId)) {
- map.addImage(result.imageId, result.imageData, { pixelRatio })
- }
- }
- })
- }))
-}
diff --git a/providers/maplibre/src/utils/symbolImages.test.js b/providers/maplibre/src/utils/symbolImages.test.js
index c3da60e4..d51d7762 100644
--- a/providers/maplibre/src/utils/symbolImages.test.js
+++ b/providers/maplibre/src/utils/symbolImages.test.js
@@ -1,30 +1,6 @@
-import { anchorToMaplibre, getSymbolImageId, addSymbolsToMap } from './symbolImages.js'
+import { anchorToMaplibre } from './symbolImages.js'
import { symbolRegistry } from '../../../../src/services/symbolRegistry.js'
-const STYLE_ID = 'test'
-const mapStyle = { id: STYLE_ID }
-
-beforeAll(() => {
- globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
- globalThis.URL.revokeObjectURL = jest.fn()
-
- HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
- drawImage: jest.fn(),
- getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h }))
- }))
-
- globalThis.Image = class {
- constructor (w, h) {
- this.width = w
- this.height = h
- this._src = ''
- }
-
- get src () { return this._src }
- set src (val) { this._src = val; this.onload?.() }
- }
-})
-
beforeEach(() => {
symbolRegistry.setDefaults({})
})
@@ -86,177 +62,3 @@ describe('anchorToMaplibre', () => {
expect(anchorToMaplibre([0.5, 0.75])).toBe('bottom') // NOSONAR S109 — ANCHOR_HIGH boundary
})
})
-
-// ─── getSymbolImageId ─────────────────────────────────────────────────────────
-
-describe('getSymbolImageId', () => {
- it('returns null when dataset has no symbol', () => {
- expect(getSymbolImageId({}, mapStyle, symbolRegistry)).toBeNull()
- })
-
- it('returns null for an unregistered symbol id', () => {
- expect(getSymbolImageId({ symbol: 'does-not-exist' }, mapStyle, symbolRegistry)).toBeNull()
- })
-
- it('returns a string prefixed symbol- for normal state', () => {
- const id = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
- expect(typeof id).toBe('string')
- expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
- })
-
- it('returns a string prefixed symbol-act- for active state', () => {
- const id = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
- expect(typeof id).toBe('string')
- expect(id).toMatch(/^symbol-act-[a-z0-9]+-\d+(\.\d+)?x$/)
- })
-
- it('normal and active ids differ for the same dataset', () => {
- const normalId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, false)
- const activeId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
- expect(normalId).not.toBe(activeId)
- })
-
- it('same dataset and style always produces the same id', () => {
- const id1 = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
- const id2 = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
- expect(id1).toBe(id2)
- })
-
- it('different symbols produce different ids', () => {
- const pinId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
- const circleId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry)
- expect(pinId).not.toBe(circleId)
- })
-
- it('different backgrounds produce different ids', () => {
- const redId = getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#ff0000' }, mapStyle, symbolRegistry)
- const blueId = getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#0000ff' }, mapStyle, symbolRegistry)
- expect(redId).not.toBe(blueId)
- })
-
- it('resolves inline symbolSvgContent', () => {
- const dataset = {
- symbolSvgContent: '',
- symbolViewBox: '0 0 38 38',
- symbolAnchor: [0.5, 0.5]
- }
- const id = getSymbolImageId(dataset, mapStyle, symbolRegistry)
- expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
- })
-})
-
-// ─── addSymbolsToMap ──────────────────────────────────────────────────────────
-
-const makeMap = (existingIds = []) => ({
- _activeSymbolImageMap: {},
- _selectedSymbolImageMap: {},
- hasImage: jest.fn((id) => existingIds.includes(id)),
- addImage: jest.fn()
-})
-
-describe('addSymbolsToMap — registration', () => {
- it('returns early and does not touch map for empty configs', async () => {
- const map = makeMap()
- await addSymbolsToMap(map, [], mapStyle, symbolRegistry)
- expect(map.hasImage).not.toHaveBeenCalled()
- expect(map.addImage).not.toHaveBeenCalled()
- })
-
- it('resets _activeSymbolImageMap and _selectedSymbolImageMap before processing', async () => {
- const map = makeMap()
- map._activeSymbolImageMap = { stale: 'entry' }
- map._selectedSymbolImageMap = { stale: 'entry' }
- await addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
- expect(map._activeSymbolImageMap).not.toHaveProperty('stale')
- expect(map._selectedSymbolImageMap).not.toHaveProperty('stale')
- })
-
- it('calls addImage for normal, active and selected variants', async () => {
- const map = makeMap()
- await addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
- expect(map.addImage).toHaveBeenCalledTimes(3) // NOSONAR S109 — normal, active, selected
- expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
- expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-act-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
- expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
- })
-
- it('populates _activeSymbolImageMap and _selectedSymbolImageMap with normal → variant id pairs', async () => {
- const map = makeMap()
- await addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
- const normalId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, false)
- const activeId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
- const selectedId = map._selectedSymbolImageMap[normalId]
- expect(map._activeSymbolImageMap[normalId]).toBe(activeId)
- expect(selectedId).toMatch(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/)
- })
-
- it('skips addImage when all three variant images are already registered', async () => {
- // Run once to discover the selected image ID (not derivable without rasterising)
- const setupMap = makeMap()
- await addSymbolsToMap(setupMap, [{ symbol: 'circle' }], mapStyle, symbolRegistry)
- const normalId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry, false)
- const activeId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry, true)
- const selectedId = setupMap._selectedSymbolImageMap[normalId]
-
- const map = makeMap([normalId, activeId, selectedId])
- await addSymbolsToMap(map, [{ symbol: 'circle' }], mapStyle, symbolRegistry)
- expect(map.addImage).not.toHaveBeenCalled()
- })
-
- it('processes multiple configs independently', async () => {
- const map = makeMap()
- await addSymbolsToMap(map, [{ symbol: 'pin' }, { symbol: 'circle' }], mapStyle, symbolRegistry)
- expect(map.addImage).toHaveBeenCalledTimes(6) // NOSONAR S109 — 2 configs × 3 variants each
- expect(Object.keys(map._activeSymbolImageMap)).toHaveLength(2)
- expect(Object.keys(map._selectedSymbolImageMap)).toHaveLength(2)
- })
-})
-
-describe('addSymbolsToMap — null results and caching', () => {
- it('does not call addImage when rasteriseSymbolImage returns null', async () => {
- // getSymbolImageId (called twice — normal + active) needs a real symbolDef to produce imageIds,
- // but rasteriseSymbolImage must get undefined from getSymbolDef so it returns null.
- // The registry.get call order: [1] getSymbolImageId normal, [2] getSymbolImageId active,
- // [3] rasteriseSymbolImage normal, [4] rasteriseSymbolImage active, [5] rasteriseSymbolImage selected.
- const pinDef = symbolRegistry.get('pin')
- const getSpy = jest.spyOn(symbolRegistry, 'get')
- .mockReturnValueOnce(pinDef)
- .mockReturnValueOnce(pinDef)
- .mockReturnValueOnce(undefined)
- .mockReturnValueOnce(undefined)
- .mockReturnValueOnce(undefined)
- const map = makeMap()
- await addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
- expect(map.addImage).not.toHaveBeenCalled()
- getSpy.mockRestore()
- })
-
- it('skips config when symbolDef cannot be resolved', async () => {
- const map = makeMap()
- await addSymbolsToMap(map, [{ symbol: 'no-such-symbol' }], mapStyle, symbolRegistry)
- expect(map.addImage).not.toHaveBeenCalled()
- expect(map._activeSymbolImageMap).toEqual({})
- expect(map._selectedSymbolImageMap).toEqual({})
- })
-
- it('reuses cached imageData when called again with the same pixelRatio', async () => {
- // Use an unusual ratio so this test owns its cache entries
- const uniqueRatio = 7
-
- const map1 = makeMap()
- const getContextCallsBefore = HTMLCanvasElement.prototype.getContext.mock.calls.length
- await addSymbolsToMap(map1, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
- const getContextCallsAfterFirst = HTMLCanvasElement.prototype.getContext.mock.calls.length
- // Rasterisation ran — canvas was used
- expect(getContextCallsAfterFirst).toBeGreaterThan(getContextCallsBefore)
-
- // Second call with a fresh map (hasImage → false) but same ratio → cache hit
- const map2 = makeMap()
- await addSymbolsToMap(map2, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
- const getContextCallsAfterSecond = HTMLCanvasElement.prototype.getContext.mock.calls.length
- // No new canvas — rasterisation was skipped via cache
- expect(getContextCallsAfterSecond).toBe(getContextCallsAfterFirst)
- // addImage still called because map2 has no pre-registered images
- expect(map2.addImage).toHaveBeenCalledTimes(3) // NOSONAR S109 — normal, active, selected
- })
-})
diff --git a/src/services/symbolRegistry.js b/src/services/symbolRegistry.js
index eb0b4d66..01b6b533 100644
--- a/src/services/symbolRegistry.js
+++ b/src/services/symbolRegistry.js
@@ -1,10 +1,24 @@
import { getValueForStyle } from '../utils/getValueForStyle.js'
import { symbolDefaults, pin, circle, square, graphics } from '../config/symbolConfig.js'
+import { getSymbolStyleColors, getSymbolViewBox } from '../utils/symbolUtils.js'
import { THEME_COLORS } from '../config/mapTheme.js'
+import { rasteriseToImageData } from '../../providers/maplibre/src/utils/rasteriseToImageData.js'
const symbols = new Map()
+// Module-level cache: imageId → ImageData. Avoids re-rasterising identical symbols.
+const imageDataCache = new Map()
let _constructorDefaults = {}
+const HASH_BASE = 36
+
+const hashString = (str) => {
+ let hash = 0
+ for (const ch of str) {
+ hash = Math.trunc(((hash << 5) - hash) + ch.codePointAt(0))
+ }
+ return Math.abs(hash).toString(HASH_BASE)
+}
+
// Keys that are structural — not token values for SVG substitution
const STRUCTURAL = new Set(['id', 'svg', 'viewBox', 'anchor', 'symbol', 'symbolSvgContent'])
@@ -75,6 +89,22 @@ export const symbolRegistry = {
return [...symbols.values()]
},
+ /**
+ * Clears all registered symbols (including built-ins). Mainly for testing purposes.
+ */
+ clear () {
+ symbols.clear()
+ },
+
+ /**
+ * Clears all registered symbols (including built-ins). Mainly for testing purposes.
+ */
+ initialise () {
+ this.register(pin)
+ this.register(circle)
+ this.register(square)
+ },
+
/**
* Resolve a symbol's SVG string for normal (unselected, inactive) rendering.
* Both selectedColor and activeColor are set to 'none' — all rings hidden.
@@ -122,9 +152,143 @@ export const symbolRegistry = {
if (!symbolDef) { return '' }
colors.activeColor = 'none'
return resolveLayer(symbolDef.svg, colors)
+ },
+
+ // ─── Image IDs ────────────────────────────────────────────────────────────────
+
+ /**
+ * Returns a deterministic image ID for a symbol in normal or selected state.
+ * Based on the hash of the fully resolved SVG content and the pixel ratio.
+ *
+ * @param {Object} style
+ * @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
+ * @param {boolean} [selected=false]
+ * @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor
+ * @returns {string|null}
+ */
+ getSymbolImageId (style, mapStyle, active = false, pixelRatio = 2) {
+ const symbolDef = this.getSymbolDef(style)
+ if (!symbolDef) {
+ return null
+ }
+ const styleColors = getSymbolStyleColors(style)
+ const resolved = active
+ ? this.resolveActive(symbolDef, styleColors, mapStyle)
+ : this.resolve(symbolDef, styleColors, mapStyle)
+ return `symbol-${active ? 'act-' : ''}${hashString(resolved)}-${pixelRatio}x`
+ },
+
+ /**
+ * Resolves the symbolDef for a style's symbol config.
+ *
+ * style.symbol is a string symbol ID (e.g. 'pin').
+ * style.symbolSvgContent is inline SVG content for a custom symbol.
+ *
+ * @param {Object} style
+ * @returns {Object|undefined}
+ */
+ getSymbolDef (style) {
+ if (style.symbolSvgContent) {
+ return { svg: style.symbolSvgContent }
+ }
+ if (style.symbol) {
+ return this.get(style.symbol)
+ }
+ return undefined
+ },
+
+ /**
+ * Register normal, active (both rings) and selected (black ring) symbol images.
+ * Skips images that are already registered (safe to call on style change).
+ * Updates `map._activeSymbolImageMap` (normal→active) and `map._selectedSymbolImageMap` (normal→selected).
+ *
+ * Callers are responsible for resolving sublayers before calling this function
+ * (see `getSymbolConfigs` in the datasets plugin adapter).
+ *
+ * @param {Object} map - MapLibre map instance
+ * @param {Object[]} styleArray - Flat list of datasets/merged-sublayers that have a symbol config
+ * @param {Object} mapStyle - Current map style config (provides id, selectedColor, haloColor)
+ * @param {number} [pixelRatio=2] - Device pixel ratio × map size scale factor (computed by caller)
+ * @returns {Promise}
+ */
+ async addSymbolsToMap (map, styleArray, mapStyle, pixelRatio = 2) {
+ if (!styleArray.length) {
+ return
+ }
+
+ map._activeSymbolImageMap = {}
+ map._selectedSymbolImageMap = {}
+
+ await Promise.all(styleArray.flatMap(config => {
+ const normalId = this.getSymbolImageId(config, mapStyle, false, pixelRatio)
+ const activeId = this.getSymbolImageId(config, mapStyle, true, pixelRatio)
+ if (normalId && activeId) {
+ map._activeSymbolImageMap[normalId] = activeId
+ }
+ return ['normal', 'active', 'selected'].map(async (variant) => {
+ const imageId = variant === 'active' ? activeId : normalId
+ if (variant !== 'selected' && (!imageId || map.hasImage(imageId))) {
+ return
+ }
+ const result = await this.rasteriseSymbolImage(config, mapStyle, variant, pixelRatio)
+ if (result) {
+ if (variant === 'selected' && normalId) {
+ map._selectedSymbolImageMap[normalId] = result.imageId
+ }
+ if (!map.hasImage(result.imageId)) {
+ map.addImage(result.imageId, result.imageData, { pixelRatio })
+ }
+ }
+ })
+ }))
+ },
+
+ /**
+ * Rasterise one variant of a symbol to ImageData for use as a MapLibre image.
+ * Results are cached by imageId so identical symbols are only rendered once.
+ *
+ * @param {Object} style - Dataset or marker config with symbol properties
+ * @param {Object} mapStyle - Current map style config
+ * @param {'normal'|'active'|'selected'} variant
+ * - `'normal'` — no rings (default display)
+ * - `'active'` — both rings (keyboard cursor, yellow + black)
+ * - `'selected'` — selected ring only (black)
+ * @param {number} pixelRatio - Device pixel ratio × map size scale factor
+ * @returns {Promise<{imageId: string, imageData: ImageData}|null>}
+ */
+ async rasteriseSymbolImage (style, mapStyle, variant, pixelRatio) {
+ const symbolDef = this.getSymbolDef(style)
+ if (!symbolDef) {
+ return null
+ }
+ const styleColors = getSymbolStyleColors(style)
+ let resolvedContent, prefix
+ if (variant === 'active') {
+ resolvedContent = this.resolveActive(symbolDef, styleColors, mapStyle)
+ prefix = 'act-'
+ } else if (variant === 'selected') {
+ resolvedContent = this.resolveSelected(symbolDef, styleColors, mapStyle)
+ prefix = 'sel-'
+ } else {
+ resolvedContent = this.resolve(symbolDef, styleColors, mapStyle)
+ prefix = ''
+ }
+
+ const imageId = `symbol-${prefix}${hashString(resolvedContent)}-${pixelRatio}x`
+
+ let imageData = imageDataCache.get(imageId)
+ if (!imageData) {
+ const viewBox = getSymbolViewBox(style, symbolDef)
+ const [,, width, height] = viewBox.split(' ').map(Number)
+ // Render at pixelRatio× to keep icons crisp at the current device DPI and map size.
+ // MapLibre receives the matching pixelRatio so the image displays at its original logical size.
+ const svgString = ``
+ imageData = await rasteriseToImageData(svgString, width * pixelRatio, height * pixelRatio)
+ imageDataCache.set(imageId, imageData)
+ }
+
+ return { imageId, imageData }
}
}
-symbolRegistry.register(pin)
-symbolRegistry.register(circle)
-symbolRegistry.register(square)
+symbolRegistry.initialise()
diff --git a/src/services/symbolRegistry.test.js b/src/services/symbolRegistry.test.js
index 46d14579..08efa7ec 100644
--- a/src/services/symbolRegistry.test.js
+++ b/src/services/symbolRegistry.test.js
@@ -1,5 +1,5 @@
import { symbolRegistry } from './symbolRegistry.js'
-import { symbolDefaults } from '../config/symbolConfig.js'
+import { symbolDefaults, pin } from '../config/symbolConfig.js'
import { THEME_COLORS } from '../config/mapTheme.js'
import { getValueForStyle } from '../utils/getValueForStyle.js'
@@ -8,7 +8,30 @@ const mapStyle = { id: STYLE_ID }
const COLOR_OVERRIDE = '#ff0000'
const FILL_OVERRIDE = `fill="${COLOR_OVERRIDE}"`
+beforeAll(() => {
+ globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
+ globalThis.URL.revokeObjectURL = jest.fn()
+
+ HTMLCanvasElement.prototype.getContext = jest.fn(() => ({
+ drawImage: jest.fn(),
+ getImageData: jest.fn((_x, _y, w, h) => ({ width: w, height: h }))
+ }))
+
+ globalThis.Image = class {
+ constructor (w, h) {
+ this.width = w
+ this.height = h
+ this._src = ''
+ }
+
+ get src () { return this._src }
+ set src (val) { this._src = val; this.onload?.() }
+ }
+})
+
beforeEach(() => {
+ symbolRegistry.clear()
+ symbolRegistry.initialise()
symbolRegistry.setDefaults({})
})
@@ -283,3 +306,206 @@ describe('symbolRegistry — graphic token', () => {
expect(resolved).toContain('translate(22, 19) scale(0.8) translate(-8, -8)')
})
})
+
+// ─── getSymbolDef ─────────────────────────────────────────────────────────────
+
+describe('getSymbolDef', () => {
+ it('returns undefined when dataset has no symbol', () => {
+ expect(symbolRegistry.getSymbolDef({})).toBeUndefined()
+ })
+
+ it('looks up string symbol id in the registry', () => {
+ expect(symbolRegistry.getSymbolDef({ symbol: 'pin' })).toBe(pin)
+ })
+
+ it('returns undefined for an unregistered string symbol', () => {
+ expect(symbolRegistry.getSymbolDef({ symbol: 'missing' })).toBeUndefined()
+ })
+
+ it('returns inline def from symbolSvgContent with svg key', () => {
+ const dataset = { symbolSvgContent: '', symbolViewBox: '0 0 10 10' }
+ const result = symbolRegistry.getSymbolDef(dataset)
+ expect(result.svg).toBe('')
+ })
+
+ it('symbolSvgContent takes precedence over symbol id', () => {
+ const result = symbolRegistry.getSymbolDef({ symbol: 'pin', symbolSvgContent: '' })
+ expect(result.svg).toBe('')
+ })
+})
+
+// ─── getSymbolImageId ─────────────────────────────────────────────────────────
+
+describe('getSymbolImageId', () => {
+ it('returns null when dataset has no symbol', () => {
+ expect(symbolRegistry.getSymbolImageId({}, mapStyle)).toBeNull()
+ })
+
+ it('returns null for an unregistered symbol id', () => {
+ expect(symbolRegistry.getSymbolImageId({ symbol: 'does-not-exist' }, mapStyle)).toBeNull()
+ })
+
+ it('returns a string prefixed symbol- for normal state', () => {
+ const id = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
+ expect(typeof id).toBe('string')
+ expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
+ })
+
+ it('returns a string prefixed symbol-act- for active state', () => {
+ const id = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, true)
+ expect(typeof id).toBe('string')
+ expect(id).toMatch(/^symbol-act-[a-z0-9]+-\d+(\.\d+)?x$/)
+ })
+
+ it('normal and active ids differ for the same dataset', () => {
+ const normalId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, false)
+ const activeId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, true)
+ expect(normalId).not.toBe(activeId)
+ })
+
+ it('same dataset and style always produces the same id', () => {
+ const id1 = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
+ const id2 = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
+ expect(id1).toBe(id2)
+ })
+
+ it('different symbols produce different ids', () => {
+ const pinId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle)
+ const circleId = symbolRegistry.getSymbolImageId({ symbol: 'circle' }, mapStyle)
+ expect(pinId).not.toBe(circleId)
+ })
+
+ it('different backgrounds produce different ids', () => {
+ const redId = symbolRegistry.getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#ff0000' }, mapStyle)
+ const blueId = symbolRegistry.getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#0000ff' }, mapStyle)
+ expect(redId).not.toBe(blueId)
+ })
+
+ it('resolves inline symbolSvgContent', () => {
+ const dataset = {
+ symbolSvgContent: '',
+ symbolViewBox: '0 0 38 38',
+ symbolAnchor: [0.5, 0.5]
+ }
+ const id = symbolRegistry.getSymbolImageId(dataset, mapStyle)
+ expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
+ })
+})
+
+describe('addSymbolsToMap', () => {
+ const makeMap = (existingIds = []) => ({
+ _activeSymbolImageMap: {},
+ _selectedSymbolImageMap: {},
+ hasImage: jest.fn((id) => existingIds.includes(id)),
+ addImage: jest.fn()
+ })
+
+ describe('registration', () => {
+ it('returns early and does not touch map for empty configs', async () => {
+ const map = makeMap()
+ await symbolRegistry.addSymbolsToMap(map, [], mapStyle)
+ expect(map.hasImage).not.toHaveBeenCalled()
+ expect(map.addImage).not.toHaveBeenCalled()
+ })
+
+ it('resets _activeSymbolImageMap and _selectedSymbolImageMap before processing', async () => {
+ const map = makeMap()
+ map._activeSymbolImageMap = { stale: 'entry' }
+ map._selectedSymbolImageMap = { stale: 'entry' }
+ await symbolRegistry.addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle)
+ expect(map._activeSymbolImageMap).not.toHaveProperty('stale')
+ expect(map._selectedSymbolImageMap).not.toHaveProperty('stale')
+ })
+
+ it('calls addImage for normal, active and selected variants', async () => {
+ const map = makeMap()
+ await symbolRegistry.addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle)
+ expect(map.addImage).toHaveBeenCalledTimes(3) // NOSONAR S109 — normal, active, selected
+ expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
+ expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-act-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
+ expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
+ })
+
+ it('populates _activeSymbolImageMap and _selectedSymbolImageMap with normal → variant id pairs', async () => {
+ const map = makeMap()
+ await symbolRegistry.addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle)
+ const normalId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, false)
+ const activeId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, true)
+ const selectedId = map._selectedSymbolImageMap[normalId]
+ expect(map._activeSymbolImageMap[normalId]).toBe(activeId)
+ expect(selectedId).toMatch(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/)
+ })
+
+ it('skips addImage when all three variant images are already registered', async () => {
+ // Run once to discover the selected image ID (not derivable without rasterising)
+ const setupMap = makeMap()
+ await symbolRegistry.addSymbolsToMap(setupMap, [{ symbol: 'circle' }], mapStyle)
+ const normalId = symbolRegistry.getSymbolImageId({ symbol: 'circle' }, mapStyle, false)
+ const activeId = symbolRegistry.getSymbolImageId({ symbol: 'circle' }, mapStyle, true)
+ const selectedId = setupMap._selectedSymbolImageMap[normalId]
+
+ const map = makeMap([normalId, activeId, selectedId])
+ await symbolRegistry.addSymbolsToMap(map, [{ symbol: 'circle' }], mapStyle)
+ expect(map.addImage).not.toHaveBeenCalled()
+ })
+
+ it('processes multiple configs independently', async () => {
+ const map = makeMap()
+ await symbolRegistry.addSymbolsToMap(map, [{ symbol: 'pin' }, { symbol: 'circle' }], mapStyle, symbolRegistry)
+ expect(map.addImage).toHaveBeenCalledTimes(6) // NOSONAR S109 — 2 configs × 3 variants each
+ expect(Object.keys(map._activeSymbolImageMap)).toHaveLength(2)
+ expect(Object.keys(map._selectedSymbolImageMap)).toHaveLength(2)
+ })
+ })
+
+ describe('null results and caching', () => {
+ it('does not call addImage when rasteriseSymbolImage returns null', async () => {
+ // getSymbolImageId (called twice — normal + active) needs a real symbolDef to produce imageIds,
+ // but rasteriseSymbolImage must get undefined from getSymbolDef so it returns null.
+ // The registry.get call order: [1] getSymbolImageId normal, [2] getSymbolImageId active,
+ // [3] rasteriseSymbolImage normal, [4] rasteriseSymbolImage active, [5] rasteriseSymbolImage selected.
+ const pinDef = symbolRegistry.get('pin')
+ const getSpy = jest.spyOn(symbolRegistry, 'get')
+ .mockReturnValueOnce(pinDef)
+ .mockReturnValueOnce(pinDef)
+ .mockReturnValueOnce(undefined)
+ .mockReturnValueOnce(undefined)
+ .mockReturnValueOnce(undefined)
+ const map = makeMap()
+ await symbolRegistry.addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle)
+ expect(map.addImage).not.toHaveBeenCalled()
+ getSpy.mockRestore()
+ })
+
+ it('skips config when symbolDef cannot be resolved', async () => {
+ const map = makeMap()
+ await symbolRegistry.addSymbolsToMap(map, [{ symbol: 'no-such-symbol' }], mapStyle)
+ expect(map.addImage).not.toHaveBeenCalled()
+ expect(map._activeSymbolImageMap).toEqual({})
+ expect(map._selectedSymbolImageMap).toEqual({})
+ })
+
+ it('reuses cached imageData when called again with the same pixelRatio', async () => {
+ // Use an unusual ratio so this test owns its cache entries
+ const uniqueRatio = 7
+ symbolRegistry.clear()
+ symbolRegistry.initialise()
+
+ const map1 = makeMap()
+ const getContextCallsBefore = HTMLCanvasElement.prototype.getContext.mock.calls.length
+ await symbolRegistry.addSymbolsToMap(map1, [{ symbol: 'pin' }], mapStyle, uniqueRatio)
+ const getContextCallsAfterFirst = HTMLCanvasElement.prototype.getContext.mock.calls.length
+ // Rasterisation ran — canvas was used
+ expect(getContextCallsAfterFirst).toBeGreaterThan(getContextCallsBefore)
+
+ // Second call with a fresh map (hasImage → false) but same ratio → cache hit
+ const map2 = makeMap()
+ await symbolRegistry.addSymbolsToMap(map2, [{ symbol: 'pin' }], mapStyle, uniqueRatio)
+ const getContextCallsAfterSecond = HTMLCanvasElement.prototype.getContext.mock.calls.length
+ // No new canvas — rasterisation was skipped via cache
+ expect(getContextCallsAfterSecond).toBe(getContextCallsAfterFirst)
+ // addImage still called because map2 has no pre-registered images
+ expect(map2.addImage).toHaveBeenCalledTimes(3) // NOSONAR S109 — normal, active, selected
+ })
+ })
+})
diff --git a/src/utils/symbolUtils.js b/src/utils/symbolUtils.js
index a3e4de0b..2b561292 100644
--- a/src/utils/symbolUtils.js
+++ b/src/utils/symbolUtils.js
@@ -31,26 +31,6 @@ export const isStandaloneLabel = (marker) => {
return marker.symbol === null || marker.symbolSvgContent === null
}
-/**
- * Resolves the symbolDef for a dataset's symbol config.
- *
- * dataset.symbol is a string symbol ID (e.g. 'pin').
- * dataset.symbolSvgContent is inline SVG content for a custom symbol.
- *
- * @param {Object} dataset
- * @param {Object} symbolRegistry
- * @returns {Object|undefined}
- */
-export const getSymbolDef = (dataset, symbolRegistry) => {
- if (dataset.symbolSvgContent) {
- return { svg: dataset.symbolSvgContent }
- }
- if (dataset.symbol) {
- return symbolRegistry.get(dataset.symbol)
- }
- return undefined
-}
-
/**
* Extracts token overrides from a dataset's flat symbol style props.
* Strips the 'symbol' prefix to produce internal token names (e.g. symbolBackgroundColor → backgroundColor).
diff --git a/src/utils/symbolUtils.test.js b/src/utils/symbolUtils.test.js
index 6207749e..f5d49538 100644
--- a/src/utils/symbolUtils.test.js
+++ b/src/utils/symbolUtils.test.js
@@ -1,16 +1,11 @@
import {
hasSymbol,
isStandaloneLabel,
- getSymbolDef,
getSymbolStyleColors,
getSymbolViewBox,
getSymbolAnchor
} from './symbolUtils.js'
-const mockRegistry = (defs = {}) => ({
- get: jest.fn((id) => defs[id])
-})
-
// ─── hasSymbol ────────────────────────────────────────────────────────────────
describe('hasSymbol', () => {
@@ -60,37 +55,6 @@ describe('isStandaloneLabel', () => {
})
})
-// ─── getSymbolDef ─────────────────────────────────────────────────────────────
-
-describe('getSymbolDef', () => {
- it('returns undefined when dataset has no symbol', () => {
- expect(getSymbolDef({}, mockRegistry())).toBeUndefined()
- })
-
- it('looks up string symbol id in the registry', () => {
- const pinDef = { id: 'pin', svg: '' }
- const registry = mockRegistry({ pin: pinDef })
- expect(getSymbolDef({ symbol: 'pin' }, registry)).toBe(pinDef)
- })
-
- it('returns undefined for an unregistered string symbol', () => {
- expect(getSymbolDef({ symbol: 'missing' }, mockRegistry())).toBeUndefined()
- })
-
- it('returns inline def from symbolSvgContent with svg key', () => {
- const dataset = { symbolSvgContent: '', symbolViewBox: '0 0 10 10' }
- const result = getSymbolDef(dataset, mockRegistry())
- expect(result.svg).toBe('')
- })
-
- it('symbolSvgContent takes precedence over symbol id', () => {
- const pinDef = { id: 'pin', svg: '' }
- const registry = mockRegistry({ pin: pinDef })
- const result = getSymbolDef({ symbol: 'pin', symbolSvgContent: '' }, registry)
- expect(result.svg).toBe('')
- })
-})
-
// ─── getSymbolStyleColors ─────────────────────────────────────────────────────
describe('getSymbolStyleColors', () => {