Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions demo/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions plugins/beta/datasets/src/adapters/maplibre/symbolImages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions plugins/beta/datasets/src/components/KeySvg.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 <KeySvgSymbol {...props} symbolDef={symbolDef} />
}
Expand Down
3 changes: 1 addition & 2 deletions providers/maplibre/src/maplibreProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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)
}

/**
Expand Down
19 changes: 8 additions & 11 deletions providers/maplibre/src/maplibreProvider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -273,29 +273,26 @@ 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 () => {
const p = makeProvider()
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 () => {
const p = makeProvider()
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 () => {
Expand Down
137 changes: 0 additions & 137 deletions providers/maplibre/src/utils/symbolImages.js
Original file line number Diff line number Diff line change
@@ -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 ──────────────────────────────────────

Expand Down Expand Up @@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${width * pixelRatio}" height="${height * pixelRatio}" viewBox="${viewBox}">${resolvedContent}</svg>`
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<void>}
*/
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 })
}
}
})
}))
}
Loading
Loading