diff --git a/examples/snowbox/index.js b/examples/snowbox/index.js index accd300b1a5..d1faa4ab916 100644 --- a/examples/snowbox/index.js +++ b/examples/snowbox/index.js @@ -178,6 +178,7 @@ const map = await createMap( }, ], clusterClickZoom: true, + displayFeatureCount: true, }, // theme: dataportTheme, locales: [ diff --git a/src/core/stores/marker.ts b/src/core/stores/marker.ts index abb9894cc5e..128890b9838 100644 --- a/src/core/stores/marker.ts +++ b/src/core/stores/marker.ts @@ -20,6 +20,10 @@ export const useMarkerStore = defineStore('marker', () => { () => (configuration.value?.clusterClickZoom as boolean) || false ) + const displayFeatureCount = computed( + () => configuration.value?.displayFeatureCount || false + ) + const hovered = shallowRef(null) const selected = shallowRef(null) const selectedCoordinates = computed(() => @@ -32,6 +36,7 @@ export const useMarkerStore = defineStore('marker', () => { configuration, callOnMapSelect, clusterClickZoom, + displayFeatureCount, hovered, selected, diff --git a/src/core/types/marker.ts b/src/core/types/marker.ts index fd6747e231f..f5b516ea74b 100644 --- a/src/core/types/marker.ts +++ b/src/core/types/marker.ts @@ -1,7 +1,19 @@ import type { Feature } from 'ol' +import type { Style } from 'ol/style' export type MarkersIsSelectableFunction = (feature: Feature) => boolean - +export type GetMarkerFunction = ( + style: MarkerStyle, + count: number, + displayFeatureCount: boolean +) => Style +export type GetSVGConfigFunction = (digits: string) => MarkerSVGConfig +export type GetTextPositionFunction = (path: string) => TextPosition +export type PinShape = 'circle' | 'pill' +export interface TextPosition { + x: number + y: number +} export interface CallOnMapSelect { action: string payload: unknown @@ -33,7 +45,7 @@ export interface MarkerStyle { /** * `width` and `height` of the ``-cluster-marker. * - * @defaultValue `[40, 36]` + * @defaultValue `[40 * 2, 36 * 2]` */ clusterSize: [number, number] @@ -45,7 +57,7 @@ export interface MarkerStyle { /** * `width` and `height` of the ``-marker. * - * @defaultValue `[26, 36]` + * @defaultValue `[40 * 2, 36 * 2]` */ size: [number, number] @@ -62,6 +74,74 @@ export interface MarkerStyle { * @defaultValue `'2'` */ strokeWidth: string | number + + /** + * Text to display on the marker. + * + */ + clusterCount?: number +} + +/** + * The MarkerSVGConfig contains the SVG-informations needed to create the svg for the marker. + */ +export interface MarkerSVGConfig { + /** + * The SVG path for the markershape where the text is displayed in. + */ + contentPath: string + + /** + * The definitions for the marker (e.g. shadow patterns with image data). + */ + defs: string + + /** + * Calculates the x and y coordinates for the text position within the marker. + * The calculation is based on the provided SVG path + * @throws Error If the provided path does not match the expected marker pattern. + * @returns The x and y coordinates for the text position within the marker. + */ + getTextPosition: GetTextPositionFunction + + /** + * The shape of the marker. + */ + pinShape: PinShape + + /** + * The shadow path for the main/front marker layer. + */ + shadowPath: string + + /** The outer shape of the marker. */ + shapePath: string + + /** + * The SVG shadow paths for the stacked marker layers. + */ + stackedShadow1: string + stackedShadow2: string + + /** + * The SVG paths for the stacked shape of the marker. + */ + stackedShape1: string + stackedShape2: string + stackedTip1: string + stackedTip2: string + + readonly textPosition: TextPosition + + /** + * The SVG path for the tip of the marker. + */ + tipPath: string + + /** + * The viewBox for the marker. + */ + viewBox: string } export interface MarkerLayer { @@ -150,4 +230,10 @@ export interface MarkerConfiguration { * take place. Defaults to `false`. */ clusterClickZoom?: boolean + + /** + * If `true`, the number of features in a cluster will be displayed on the cluster marker. + * @defaultValue `false` + */ + displayFeatureCount?: boolean } diff --git a/src/core/utils/map/setupMarkers.ts b/src/core/utils/map/setupMarkers.ts index de632359f76..2f4d25b226e 100644 --- a/src/core/utils/map/setupMarkers.ts +++ b/src/core/utils/map/setupMarkers.ts @@ -1,7 +1,12 @@ import type { Feature, Map, MapBrowserEvent, MapEvent } from 'ol' import type BaseLayer from 'ol/layer/Base' import type VectorSource from 'ol/source/Vector' -import type { MarkerLayer, MarkerStyle, PluginId } from '../../types' +import type { + GetMarkerFunction, + MarkerLayer, + MarkerStyle, + PluginId, +} from '../../types' import { toMerged } from 'es-toolkit' import { createEmpty, extend } from 'ol/extent' @@ -16,13 +21,14 @@ import { isVisible } from '@/lib/invisibleStyle' import { useMainStore } from '../../stores/main' import { useMarkerStore } from '../../stores/marker' import { usePluginStore } from '../../stores/plugin' -import { getMarkerStyle } from '../markers' +import { createGetMarkerStyle } from '../markers' let stopWatcher: (() => void) | null = null +let getMarkerStyle: GetMarkerFunction = createGetMarkerStyle(() => {}) // these have been measured to fit once and influence marker size -const imgSize: [number, number] = [26, 36] -const imgSizeMulti: [number, number] = [40, 36] +const imgSize: [number, number] = [40 * 2, 36 * 2] +const imgSizeMulti: [number, number] = [40 * 2, 36 * 2] const defaultStroke = '#FFFFFF' const defaultStrokeWidth = '2' @@ -110,12 +116,12 @@ function updateSelection( typeof findLayer(map, layerId)?.getSource().getDistance === 'function' ? getCluster(map, feature, '_polarLayerId') : feature - selectedCluster.setStyle( getMarkerStyle( getLayerConfiguration(feature.get('_polarLayerId') as string) .selectionStyle, - selectedCluster.get('features')?.length > 1 + selectedCluster.get('features')?.length, + store.displayFeatureCount ) ) @@ -162,6 +168,10 @@ export function setupMarkers(map: Map) { return } + getMarkerStyle = createGetMarkerStyle(() => { + map.render() + }) + layers = configuration.layers.map((layer) => toMerged( { @@ -199,7 +209,8 @@ export function setupMarkers(map: Map) { layerConfiguration.isSelectable(feature as Feature) ? layerConfiguration.defaultStyle : layerConfiguration.unselectableStyle, - feature.get('features')?.length > 1 + feature.get('features')?.length, + store.displayFeatureCount ) ) }) @@ -208,20 +219,19 @@ export function setupMarkers(map: Map) { stopWatcher = watch( () => store.hovered, - (feature) => { - if (feature !== null && feature !== toRaw(store.selected)) { - store.hovered?.setStyle(undefined) - store.hovered = null + (feature, oldFeature) => { + if (oldFeature !== null && oldFeature !== toRaw(store.selected)) { + oldFeature.setStyle(undefined) } if (feature !== null && feature !== toRaw(store.selected)) { - store.hovered = markRaw(feature) - const isMultiFeature = store.hovered.get('features')?.length > 1 + const featureCount = feature.get('features')?.length const style = getMarkerStyle( getLayerConfiguration(feature.get('_polarLayerId') as string) .hoverStyle, - isMultiFeature + featureCount, + store.displayFeatureCount ) - store.hovered.setStyle(style) + feature.setStyle(style) } } ) @@ -242,6 +252,7 @@ export function teardownMarkers(map: Map) { stopWatcher = null layers = [] lastClickEvent = null + getMarkerStyle = createGetMarkerStyle(() => {}) map.un('moveend', mapMoveEnd) map.un('pointermove', mapPointerMove) @@ -274,18 +285,15 @@ function mapPointerMove({ map, pixel }: MapBrowserEvent) { layerFilter, })[0] - if (feature === toRaw(store.selected) || feature instanceof RenderFeature) { + if (feature === toRaw(store.hovered)) { return } - if ( - toRaw(store.hovered) !== null && - toRaw(store.hovered) !== toRaw(store.selected) - ) { - store.hovered?.setStyle(undefined) - store.hovered = null - } + if (feature === toRaw(store.selected) || feature instanceof RenderFeature) { + return + } if (!feature) { + store.hovered = null return } setLayerId(map, feature) @@ -293,12 +301,9 @@ function mapPointerMove({ map, pixel }: MapBrowserEvent) { feature.get('_polarLayerId') as string ) if (!layerConfiguration.isSelectable(feature)) { + store.hovered = null return } - const isMultiFeature = feature.get('features')?.length > 1 - feature.setStyle( - getMarkerStyle(layerConfiguration.hoverStyle, isMultiFeature) - ) store.hovered = markRaw(feature) } diff --git a/src/core/utils/markerSVG.ts b/src/core/utils/markerSVG.ts new file mode 100644 index 00000000000..0d13b85c74b --- /dev/null +++ b/src/core/utils/markerSVG.ts @@ -0,0 +1,295 @@ +/** + * This file contains the svgConfigs and functions + * it will be used in the marker.ts file to create the marker icons + * for the map. + */ + +import type { + GetSVGConfigFunction, + GetTextPositionFunction, + MarkerSVGConfig, + TextPosition, +} from '../types' + +/** + * This function returns the correct svgConfig based on the number of digits in the clusterCount. + * @param digits - expects a number to decide which config should be used + * @returns - the correct svgConfig for the marker. + */ +export const getSVGConfig: GetSVGConfigFunction = (digits: string) => { + switch (digits.length) { + case 0: + return circlePin + case 1: + return circlePinStacked + case 2: + return twoDigitsPin + case 3: + return threeDigitsPin + case 4: + return fourDigitsPin + case 5: + return fiveDigitsPin + default: + throw new Error('Unsupported number of digits') + } +} + +/** + * Calculates the visually centered text position for a pill SVG path. + * @param path - Expected paths in the scheme: M[x] [y] C... [xLeftCap] [yTop] h[width] C... S... [xRightCap] [yBottom] h-[width] C... Z + */ +const getPillTextPosition: GetTextPositionFunction = ( + path: string +): TextPosition => { + const topMatch = path.match( + /C\s*[-\d.]+\s+[-\d.]+\s+[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*h\s*-?[\d.]+/ + ) + const bottomMatch = path.match( + /S\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*h\s*-?[\d.]+/ + ) + + if (!topMatch || !bottomMatch) { + throw new Error('Path does not match expected marker pattern') + } + + const [, xLeftCapStr, yTopStr] = topMatch as [string, string, string] + const [, xRightCapStr, yBottomStr] = bottomMatch as [string, string, string] + + const xLeftCap = parseFloat(xLeftCapStr) + const xRightCap = parseFloat(xRightCapStr) + const yTop = parseFloat(yTopStr) + const yBottom = parseFloat(yBottomStr) + + return { + x: (xLeftCap + xRightCap) / 2, + y: (yTop + yBottom) / 2, + } +} + +/** + * Calculates the visually centered text position for a round SVG path (circle with tip). + * @param path - Expected paths in the scheme: M[x0] [y0]C[x1] [y1] [x2] [y2] [x3] [y3]S[x4] [y4] [x5] [y5] [x6] [y6] [x7] [y7]Z + */ +const getCircleCenterPosition: GetTextPositionFunction = ( + path: string +): TextPosition => { + const mMatch = path.match(/M\s*([\d.]+)\s+([\d.]+)/) + const sMatch = path.match(/S\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)/) + + if (!mMatch || !sMatch) { + throw new Error( + 'Path does not match expected circle pattern (M x y ... S cx cy x y)' + ) + } + + const [, leftXStr, centerYStr] = mMatch as [string, string, string] + const [, rightXStr] = sMatch as [string, string] + + return { + x: (parseFloat(leftXStr) + parseFloat(rightXStr)) / 2, + y: parseFloat(centerYStr), + } +} +// The paths always follow the scheme: scheme: M[x0] [y0]C[x1] [y1] [x2] [y2] [x3] [y3]S[x4] [y4] [x5] [y5] [x6] [y6] [x7] [y7]Z +export const circlePin: MarkerSVGConfig = { + contentPath: + 'd="M11 21.5C11 14.596 16.596 9 23.5 9S36 14.596 36 21.5 30.404 34 23.5 34 11 28.404 11 21.5"', + shapePath: + 'd="M13.247 31.753c-5.663-5.663-5.663-14.843 0-20.506s14.843-5.663 20.506 0 5.663 14.843 0 20.506L24.706 40.8a1.706 1.706 0 0 1-2.412 0z"', + shadowPath: 'd="M6 6h35v47.5H6z"', + stackedShape1: '', + stackedShadow1: '', + stackedShape2: '', + stackedShadow2: '', + stackedTip1: '', + stackedTip2: '', + tipPath: '', + viewBox: '0 0 48 54', + defs: ` + + + + + `, + get textPosition() { + return this.getTextPosition(this.contentPath) + }, + getTextPosition: getCircleCenterPosition, + pinShape: 'circle', +} +// The paths always follow the scheme: scheme: M[x0] [y0]C[x1] [y1] [x2] [y2] [x3] [y3]S[x4] [y4] [x5] [y5] [x6] [y6] [x7] [y7]Z +export const circlePinStacked: MarkerSVGConfig = { + contentPath: + 'd="M8.5 20c0-6.904 5.596-12.5 12.5-12.5S33.5 13.096 33.5 20 27.904 32.5 21 32.5 8.5 26.904 8.5 20"', + shapePath: + 'd="M10.747 30.253c-5.663-5.663-5.663-14.843 0-20.506s14.843-5.663 20.506 0 5.663 14.843 0 20.506L22.206 39.3a1.706 1.706 0 0 1-2.412 0z"', + shadowPath: 'd="M5 6h33v45H5z"', + stackedShape1: + 'd="M12.253 31.253c-5.663-5.663-5.663-14.843 0-20.506s14.843-5.663 20.506 0 5.663 14.843 0 20.506L23.712 40.3a1.706 1.706 0 0 1-2.412 0z"', + stackedShadow1: 'd="M7 6h33v45H7z"', + stackedShape2: + 'd="M14.253 32.253c-5.663-5.663-5.663-14.843 0-20.506s14.843-5.663 20.506 0 5.663 14.843 0 20.506L25.712 41.3a1.706 1.706 0 0 1-2.412 0z"', + stackedShadow2: 'd="M9 7h33v45H9z"', + stackedTip1: '', + stackedTip2: '', + tipPath: '', + viewBox: '0 0 48 52', + defs: ` + + + + + + + + + + + `, + get textPosition() { + return this.getTextPosition(this.contentPath) + }, + getTextPosition: getCircleCenterPosition, + pinShape: 'circle', +} + +export const twoDigitsPin: MarkerSVGConfig = { + contentPath: + 'd="M5 19.5C5 12.596 10.596 7 17.5 7h11C35.404 7 41 12.596 41 19.5S35.404 32 28.5 32h-11C10.596 32 5 26.404 5 19.5"', + shapePath: + 'd="M3 19.5C3 11.492 9.492 5 17.5 5h11C36.508 5 43 11.492 43 19.5S36.508 34 28.5 34h-11C9.492 34 3 27.508 3 19.5"', + shadowPath: 'd="M0 5h46v48H0z"', + stackedShape1: + 'd="M5 20.5C5 12.492 11.492 6 19.5 6h11C38.508 6 45 12.492 45 20.5S38.508 35 30.5 35h-11C11.492 35 5 28.508 5 20.5"', + stackedShadow1: 'd="M2 5h46v48H2z"', + stackedShape2: + 'd="M7 21.5C7 13.492 13.492 7 21.5 7h11C40.508 7 47 13.492 47 21.5S40.508 36 32.5 36h-11C13.492 36 7 29.508 7 21.5"', + stackedShadow2: 'd="M4 6h46v48H4z"', + stackedTip1: 'd="M25 35h-6l4.482 5.228a2 2 0 0 0 3.037 0L31 35z"', + stackedTip2: 'd="M27 36h-6l4.482 5.228a2 2 0 0 0 3.037 0L33 36z"', + tipPath: 'd="M23 34h-6l4.482 5.228a2 2 0 0 0 3.037 0L29 34z"', + viewBox: '0 0 50 54', + defs: ` + + + + + + + + + + + `, + get textPosition() { + return this.getTextPosition(this.contentPath) + }, + getTextPosition: getPillTextPosition, + pinShape: 'pill', +} + +export const threeDigitsPin: MarkerSVGConfig = { + contentPath: + 'd="M8 20.5C8 13.596 13.596 8 20.5 8h19C46.404 8 52 13.596 52 20.5S46.404 33 39.5 33h-19C13.596 33 8 27.404 8 20.5"', + shapePath: + 'd="M6 20.5C6 12.492 12.492 6 20.5 6h19C47.508 6 54 12.492 54 20.5S47.508 35 39.5 35h-19C12.492 35 6 28.508 6 20.5"', + shadowPath: 'd="M3 5h54v48H3z"', + stackedShape1: + 'd="M8 21.5C8 13.492 14.492 7 22.5 7h19C49.508 7 56 13.492 56 21.5S49.508 36 41.5 36h-19C14.492 36 8 29.508 8 21.5"', + stackedShadow1: 'd="M5 6h54v48H5z"', + stackedShape2: + 'd="M10 22.5C10 14.492 16.492 8 24.5 8h19C51.508 8 58 14.492 58 22.5S51.508 37 43.5 37h-19C16.492 37 10 30.508 10 22.5"', + stackedShadow2: 'd="M7 7h54v48H7z"', + stackedTip1: 'd="M32 36h-6l4.482 5.228a2 2 0 0 0 3.037 0L38 36z"', + stackedTip2: 'd="M34 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L40 37z"', + tipPath: 'd="M30 35h-6l4.482 5.228a2 2 0 0 0 3.037 0L36 35z"', + viewBox: '0 0 64 55', + defs: ` + + + + + + + + + + + `, + get textPosition() { + return this.getTextPosition(this.contentPath) + }, + getTextPosition: getPillTextPosition, + pinShape: 'pill', +} + +export const fourDigitsPin: MarkerSVGConfig = { + contentPath: + 'd="M6 20.5C6 13.596 11.596 8 18.5 8h30C55.404 8 61 13.596 61 20.5S55.404 33 48.5 33h-30C11.596 33 6 27.404 6 20.5"', + shapePath: + 'd="M4 20.5C4 12.492 10.492 6 18.5 6h30C56.508 6 63 12.492 63 20.5S56.508 35 48.5 35h-30C10.492 35 4 28.508 4 20.5"', + shadowPath: 'd="M1 5h65v48H1z"', + stackedShape1: + 'd="M6 21.5C6 13.492 12.492 7 20.5 7h30C58.508 7 65 13.492 65 21.5S58.508 36 50.5 36h-30C12.492 36 6 29.508 6 21.5"', + stackedShadow1: 'd="M3 6h65v48H3z"', + stackedShape2: + 'd="M8 22.5C8 14.492 14.492 8 22.5 8h30C60.508 8 67 14.492 67 22.5S60.508 37 52.5 37h-30C14.492 37 8 30.508 8 22.5"', + stackedShadow2: 'd="M5 7h65v48H5z"', + stackedTip1: 'd="M35.5 36h-6l4.481 5.228a2 2 0 0 0 3.038 0L41.5 36z"', + stackedTip2: 'd="M37.5 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L43.5 37z"', + tipPath: 'd="M33.5 35h-6l4.482 5.228a2 2 0 0 0 3.037 0L39.5 35z"', + viewBox: '0 0 72 55', + defs: ` + + + + + + + + + + + `, + get textPosition() { + return this.getTextPosition(this.contentPath) + }, + getTextPosition: getPillTextPosition, + pinShape: 'pill', +} + +export const fiveDigitsPin: MarkerSVGConfig = { + contentPath: + 'd="M7 20.5C7 13.596 12.596 8 19.5 8h38C64.404 8 70 13.596 70 20.5S64.404 33 57.5 33h-38C12.596 33 7 27.404 7 20.5"', + shapePath: + 'd="M5 20.5C5 12.492 11.492 6 19.5 6h38C65.508 6 72 12.492 72 20.5S65.508 35 57.5 35h-38C11.492 35 5 28.508 5 20.5"', + shadowPath: 'd="M2 5h73v48H2z"', + stackedShape1: + 'd="M7 21.5C7 13.492 13.492 7 21.5 7h38C67.508 7 74 13.492 74 21.5S67.508 36 59.5 36h-38C13.492 36 7 29.508 7 21.5"', + stackedShadow1: 'd="M4 6h73v48H4z"', + stackedShape2: + 'd="M9 22.5C9 14.492 15.492 8 23.5 8h38C69.508 8 76 14.492 76 22.5S69.508 37 61.5 37h-38C15.492 37 9 30.508 9 22.5"', + stackedShadow2: 'd="M6 7h73v48H6z"', + stackedTip1: 'd="M40.5 36h-6l4.481 5.228a2 2 0 0 0 3.038 0L46.5 36z"', + stackedTip2: 'd="M42.5 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L48.5 37z"', + tipPath: 'd="M38.5 35h-6l4.481 5.228a2 2 0 0 0 3.038 0L44.5 35z"', + viewBox: '0 0 80 55', + defs: ` + + + + + + + + + + + `, + get textPosition() { + return this.getTextPosition(this.contentPath) + }, + getTextPosition: getPillTextPosition, + pinShape: 'pill', +} diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 3f855b38c31..57bb71d9b80 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -1,14 +1,14 @@ -import type { MarkerStyle } from '../types' +import type { GetMarkerFunction, MarkerStyle, MarkerSVGConfig } from '../types' import PolygonStyle from '@masterportal/masterportalapi/src/vectorStyle/styles/polygon/stylePolygon' import Icon from 'ol/style/Icon' import Style from 'ol/style/Style' -const polygonStyle = new PolygonStyle() - -type GetMarkerFunction = (style: MarkerStyle, multi: boolean) => Style +import { circlePin, getSVGConfig } from './markerSVG' -const prefix = 'data:image/svg+xml,' +const polygonStyle = new PolygonStyle() +const prefix = 'data:image/svg+xml;base64,' +const encodeSVG = (svg: string) => btoa(svg) const getImagePattern = (fill: MarkerStyle['fill']) => typeof fill === 'string' @@ -23,66 +23,144 @@ const getImagePattern = (fill: MarkerStyle['fill']) => ` -/* Path of marker svg used in this file copied and adapted from - * @masterportal/masterportalapi/public/marker.svg. */ - -const makeMarker = ({ fill, size, stroke, strokeWidth }: MarkerStyle) => - `${prefix}${encodeURIComponent(` - +const makeMarker = ({ fill, size, stroke }: MarkerStyle) => + `${prefix}${encodeSVG(` + DB6C494E-88E8-49F1-89CE-97CBEC3A5240 ${getImagePattern(fill)} - + + + + + + ${circlePin.defs} `)}` -const makeMultiMarker = ({ - fill, - clusterSize, - stroke, - strokeWidth, -}: MarkerStyle) => - `${prefix}${encodeURIComponent(` - +const makeMultiMarker = ( + { fill, clusterSize, stroke, clusterCount }: MarkerStyle, + displayFeatureCount: boolean, + svgConfig: MarkerSVGConfig +) => { + return svgConfig.pinShape === 'circle' + ? `${prefix}${encodeSVG(` + + 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF + ${getImagePattern(fill)} + + + + + + + + + + ${ + !displayFeatureCount || clusterCount === undefined + ? '' + : `${String(clusterCount)}` + } + + ${svgConfig.defs} + +`)}` + : `${prefix}${encodeSVG(` + 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF ${getImagePattern(fill)} - - - - + + + + + + + + + + + + + + + ${ + !displayFeatureCount || clusterCount === undefined + ? '' + : `${String(clusterCount)}` + } + + + + ${svgConfig.defs} `)}` +} // center bottom of marker 📍 is intended to show the spot const anchor = [0.5, 1] +function variantKey(count: number, displayFeatureCount: boolean) { + if (count <= 1) { + return 'single' + } + return displayFeatureCount ? `multi:${count}` : 'multi' +} + /** * The map became a little laggy due to constant re-generation of styles. * This memoization function optimises this issue by reusing styles. - * */ + * Styles are cached per (referentially stable) style object and a variant that + * captures whether it is a single feature, a generic cluster, or a cluster + * showing a specific count. + */ const memoizeStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { - const singleCache = new Map() - const multiCache = new Map() - return (style, multi) => { - const cache = multi ? multiCache : singleCache - if (cache.has(style)) { - return cache.get(style) + const cache = new Map>() + let totalStyles = 0 + return (style, count, displayFeatureCount) => { + const variant = variantKey(count, displayFeatureCount) + let byVariant = cache.get(style) + if (!byVariant) { + byVariant = new Map() + cache.set(style, byVariant) } - const markerStyle = getMarker(style, multi) - cache.set(style, markerStyle) - if (cache.size > 1000) { + const cached = byVariant.get(variant) + if (cached) { + return cached + } + const markerStyle = getMarker(style, count, displayFeatureCount) + byVariant.set(variant, markerStyle) + totalStyles += 1 + if (totalStyles > 1000) { console.warn( `1000+ styles have been created. This is possibly a memory leak. Please mind that the methods exported by this module are memoized. You *may* be calling the methods with constantly newly generated objects, or maybe there's just a lot of styles.` ) @@ -91,12 +169,62 @@ const memoizeStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { } } -const getStyleFunction: GetMarkerFunction = (style, multi) => - new Style({ - image: new Icon({ - src: (multi ? makeMultiMarker : makeMarker)(style), - anchor, - }), +/* + * Rasterizes the (expensive) marker SVG into a canvas once and uses that canvas + * as the icon image. + */ +function buildCanvasIcon( + requestRender: () => void, + src: string, + width: number, + height: number +) { + const pixelRatio = window.devicePixelRatio || 1 + const canvas = document.createElement('canvas') + canvas.width = width * pixelRatio + canvas.height = height * pixelRatio + const icon = new Icon({ img: canvas, anchor, scale: 1 / pixelRatio }) + const image = new Image() + image.onload = () => { + canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height) + requestRender() + } + image.src = src + return icon +} + +function buildStyle( + requestRender: () => void, + style: MarkerStyle, + count: number, + displayFeatureCount: boolean +) { + const [width, height] = count > 1 ? style.clusterSize : style.size + const src = + count > 1 + ? makeMultiMarker( + { + ...style, + clusterCount: count, + }, + displayFeatureCount, + getSVGConfig(String(count)) + ) + : makeMarker(style) + return new Style({ + image: buildCanvasIcon(requestRender, src, width, height), }) +} -export const getMarkerStyle = memoizeStyle(getStyleFunction) +/** + * Creates a memoized marker-style getter. + * + * @param requestRender - Called to trigger a map re-render once an + * asynchronously rasterized marker icon becomes available. + */ +export const createGetMarkerStyle = ( + requestRender: () => void +): GetMarkerFunction => + memoizeStyle((style, count, displayFeatureCount) => + buildStyle(requestRender, style, count, displayFeatureCount) + )