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 = `${resolvedContent}` - 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 = `${resolvedContent}` + 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', () => {