From 0dae3e4b3ed418faba745fae7fb3dd0236da4ab2 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 08:24:17 +0200 Subject: [PATCH 01/36] refactor: make marker feature count display configurable from index.js --- examples/snowbox/index.js | 1 + src/core/stores/marker.ts | 5 +++++ src/core/types/marker.ts | 12 ++++++++++++ src/core/utils/map/setupMarkers.ts | 19 +++++++++++++------ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/examples/snowbox/index.js b/examples/snowbox/index.js index accd300b1a5..3c0c99f1714 100644 --- a/examples/snowbox/index.js +++ b/examples/snowbox/index.js @@ -178,6 +178,7 @@ const map = await createMap( }, ], clusterClickZoom: true, + displayFeatureCount: false, }, // theme: dataportTheme, locales: [ diff --git a/src/core/stores/marker.ts b/src/core/stores/marker.ts index abb9894cc5e..0bcd22792ab 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 as boolean) || 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..100ce42586c 100644 --- a/src/core/types/marker.ts +++ b/src/core/types/marker.ts @@ -1,6 +1,12 @@ 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 interface CallOnMapSelect { action: string @@ -150,4 +156,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..bdbc8e92920 100644 --- a/src/core/utils/map/setupMarkers.ts +++ b/src/core/utils/map/setupMarkers.ts @@ -115,7 +115,8 @@ function updateSelection( getMarkerStyle( getLayerConfiguration(feature.get('_polarLayerId') as string) .selectionStyle, - selectedCluster.get('features')?.length > 1 + selectedCluster.get('features')?.length, + store.displayFeatureCount ) ) @@ -199,7 +200,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 ) ) }) @@ -215,11 +217,12 @@ export function setupMarkers(map: Map) { } if (feature !== null && feature !== toRaw(store.selected)) { store.hovered = markRaw(feature) - const isMultiFeature = store.hovered.get('features')?.length > 1 + const featureCount = store.hovered.get('features')?.length const style = getMarkerStyle( getLayerConfiguration(feature.get('_polarLayerId') as string) .hoverStyle, - isMultiFeature + featureCount, + store.displayFeatureCount ) store.hovered.setStyle(style) } @@ -295,9 +298,13 @@ function mapPointerMove({ map, pixel }: MapBrowserEvent) { if (!layerConfiguration.isSelectable(feature)) { return } - const isMultiFeature = feature.get('features')?.length > 1 + const featureCount = feature.get('features')?.length feature.setStyle( - getMarkerStyle(layerConfiguration.hoverStyle, isMultiFeature) + getMarkerStyle( + layerConfiguration.hoverStyle, + featureCount, + store.displayFeatureCount + ) ) store.hovered = markRaw(feature) } From 8d37be11c65fd8f242e56ed60001831a5fdfce04 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 08:31:06 +0200 Subject: [PATCH 02/36] refactor: correction for linting, unecessary blank line --- src/core/utils/map/setupMarkers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/utils/map/setupMarkers.ts b/src/core/utils/map/setupMarkers.ts index bdbc8e92920..6368bbc7045 100644 --- a/src/core/utils/map/setupMarkers.ts +++ b/src/core/utils/map/setupMarkers.ts @@ -110,7 +110,6 @@ function updateSelection( typeof findLayer(map, layerId)?.getSource().getDistance === 'function' ? getCluster(map, feature, '_polarLayerId') : feature - selectedCluster.setStyle( getMarkerStyle( getLayerConfiguration(feature.get('_polarLayerId') as string) From 9ca13aca11c17dd9fb5813a77800ff45b7730f83 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 08:39:24 +0200 Subject: [PATCH 03/36] refactor: add marker SVG config and text position types --- src/core/types/marker.ts | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/core/types/marker.ts b/src/core/types/marker.ts index 100ce42586c..7eb220e5439 100644 --- a/src/core/types/marker.ts +++ b/src/core/types/marker.ts @@ -7,7 +7,13 @@ export type GetMarkerFunction = ( count: number, displayFeatureCount: boolean ) => Style +export type GetSVGConfigFunction = (digits: string) => MarkerSVGConfig +export type GetTextPositionFunction = (path: string) => TextPosition +export interface TextPosition { + x: number + y: number +} export interface CallOnMapSelect { action: string payload: unknown @@ -68,6 +74,49 @@ export interface MarkerStyle { * @defaultValue `'2'` */ strokeWidth: string | number + + /** + * Text to display on the marker.. + * + * @defaultValue `''` + */ + displayedText?: string | number +} + +export interface MarkerSVGConfig { + /** + * Calculates the x and y coordinates for the text position within the marker. + * The calculation is based on the provided SVG path, which is expected to follow the schema: + * M[xStart] [yMid] C... [xLeftCap] [yTop] h[width] C... [xRight] [yMid] S... [xRightCap] [yBottom] h-[width] C... Z + * + * @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 SVG path for the markershape. + */ + path: string + + /** + * The SVG path for the stacked markershape. + */ + stackedPath1: string + + stackedPath2: 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 { From b2a94e905aeeed8de082dee0b79c1fbc09b0340f Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 08:55:09 +0200 Subject: [PATCH 04/36] refactor: add functions to calculate the center for the textposition --- src/core/utils/markerSVG.ts | 79 +++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/core/utils/markerSVG.ts diff --git a/src/core/utils/markerSVG.ts b/src/core/utils/markerSVG.ts new file mode 100644 index 00000000000..17063f0fb79 --- /dev/null +++ b/src/core/utils/markerSVG.ts @@ -0,0 +1,79 @@ +/** + * This file contains the SVG-informations + * the 'path' / 'viewBox' / 'position of the text element' of the markers + * it will be used in the marker.ts file to create the marker icons + * for the map. + */ + +import type { + GetTextPositionFunction, + TextPosition, +} from '../types' + + +/** + * Calculates the optically centered text position for a pill SVG path. + * Expected paths in the scheme: M[x] [y] C... [xLeftCap] [yTop] h[width] C... S... [xRightCap] [yBottom] h-[width] C... Z + */ +export const getPillTextPosition: GetTextPositionFunction = ( + path: string +): TextPosition => { + const nums = path.match( + /C[\d.]+ [\d.]+ [\d.]+ [\d.]+ ([\d.]+) ([\d.]+)\s*h([\d.]+)\s*C[\d.]+ [\d.]+ [\d.]+ [\d.]+ [\d.]+ [\d.]+\s*S[\d.]+ ([\d.]+)/ + ) + if (!nums) { + throw new Error('Path does not match expected marker pattern') + } + const [, xLeftCapStr, yTopStr, hWidthStr, yBottomStr] = nums as [ + string, + string, + string, + string, + string, + ] + const xLeftCap = parseFloat(xLeftCapStr) + const yTop = parseFloat(yTopStr) + const hWidth = parseFloat(hWidthStr) + const yBottom = parseFloat(yBottomStr) + const textPosition: TextPosition = { + x: xLeftCap + hWidth / 2 - 3, // The −n offset on x and y corrects the optical shift caused by the stacked shapes + y: (yTop + yBottom) / 2 - 1, + } + + return textPosition +} + +/** + * Calculates the optically centered text position for a round SVG path (circle with tip). + * Expected paths in the scheme: M[xStart] [yStart]c[dx1][dy1][dx2][dy2] [dxEnd][dyEnd]s[dx2][dy2] [dxEnd] [dyEnd] [dx2] [dy2] [dxEnd] [dyEnd]L[tipX] [tipY]a...l...Z + */ +export const getCircleTextPosition: GetTextPositionFunction = ( + path: string +): TextPosition => { + const mMatch = path.match(/M([\d.]+)\s+([\d.]+)/) + const cSection = path.match(/c([^sS]+)/) + const sSection = path.match(/s([^lL]+)/) + if (!mMatch || !cSection || !sSection) { + throw new Error('Path does not match expected round marker pattern') + } + if (!cSection[1] || !sSection[1]) { + throw new Error('Path does not match expected round marker pattern') + } + const cNums = cSection[1].match(/-?[\d.]+/g) + const sNums = sSection[1].match(/-?[\d.]+/g) + if (!cNums || cNums.length < 6 || !sNums || sNums.length < 4) { + throw new Error('Path does not match expected round marker pattern') + } + if (!mMatch[1] || !mMatch[2] || !cNums[5] || !sNums[2]) { + throw new Error('Path does not match expected round marker pattern') + } + const startX = parseFloat(mMatch[1]) + const startY = parseFloat(mMatch[2]) + const cDy = parseFloat(cNums[5]) // relative dy des c-Endpunkts (negativ = nach oben) + const sDx = parseFloat(sNums[2]) // relative dx des ersten s-Endpunkts (positiv = nach rechts) + + return { + x: startX + sDx / 2 - 4, // The −4 offset on x and y corrects the optical shift caused by the stacked shapes + y: startY + cDy / 2, + } +} From 7d625f456d7c9dd4d33375b30b1f98cb079bbdc6 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 08:56:52 +0200 Subject: [PATCH 05/36] refactor: add svgConfig for 1-5 digits --- src/core/utils/markerSVG.ts | 70 +++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/core/utils/markerSVG.ts b/src/core/utils/markerSVG.ts index 17063f0fb79..3d432e65c0e 100644 --- a/src/core/utils/markerSVG.ts +++ b/src/core/utils/markerSVG.ts @@ -7,6 +7,7 @@ import type { GetTextPositionFunction, + MarkerSVGConfig, TextPosition, } from '../types' @@ -77,3 +78,72 @@ export const getCircleTextPosition: GetTextPositionFunction = ( y: startY + cDy / 2, } } + +// The paths always follow the scheme: M[xStart] [yMid] C... [xLeftCap] [yTop] h[width] C... [xRight] [yMid] S... [xRightCap] [yBottom] h-[width] C... Z +export const singleMarkerSVG: MarkerSVGConfig = { + path: 'd="M14.747 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.506L26.206 41.3a1.706 1.706 0 0 1-2.412 0l-9.047-9.047Z"', + stackedPath1: + 'd="M12.747 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.506L24.206 40.3a1.706 1.706 0 0 1-2.412 0l-9.047-9.047Z"', + stackedPath2: + '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 0l-9.047-9.047Z"', + tipPath: '', + viewBox: '"0 0 48 54"', + get textPosition() { + return this.getTextPosition(this.path) + }, + getTextPosition: getCircleTextPosition, +} + +export const twoDigitsMarkerSVG: MarkerSVGConfig = { + path: '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.5Z"', + stackedPath1: + '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.5Z"', + stackedPath2: + '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.5Z"', + tipPath: 'd="M27 36h-6l4.482 5.228a2 2 0 0 0 3.037 0L33 36h-6Z"', + viewBox: '"0 0 50 54"', + get textPosition() { + return this.getTextPosition(this.path) + }, + getTextPosition: getPillTextPosition, +} + +export const threeDigitsMarkerSVG: MarkerSVGConfig = { + path: '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.5Z"', + stackedPath1: + '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.5Z"', + stackedPath2: '', + tipPath: 'd="M34 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L40 37h-6Z"', + viewBox: '"0 0 64 55"', + get textPosition() { + return this.getTextPosition(this.path) + }, + getTextPosition: getPillTextPosition, +} + +export const fourDigitsMarkerSVG: MarkerSVGConfig = { + path: '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.5Z"', + stackedPath1: + '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.5Z"', + stackedPath2: '', + tipPath: 'd="M37.5 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L43.5 37h-6Z"', + viewBox: '"0 0 72 55"', + get textPosition() { + return this.getTextPosition(this.path) + }, + getTextPosition: getPillTextPosition, +} + +export const fiveDigitsMarkerSVG: MarkerSVGConfig = { + path: '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.5Z"', + stackedPath1: + '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.5Z"', + stackedPath2: + '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.5Z"', + tipPath: 'd="M42.5 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L48.5 37h-6Z"', + viewBox: '"0 0 80 55"', + get textPosition() { + return this.getTextPosition(this.path) + }, + getTextPosition: getPillTextPosition, +} From cc455bf99dc77cb0bc6cb19e3d5498374994a0a8 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 08:57:31 +0200 Subject: [PATCH 06/36] refactor: add a function to get svgConfig --- src/core/utils/markerSVG.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/core/utils/markerSVG.ts b/src/core/utils/markerSVG.ts index 3d432e65c0e..74948de1571 100644 --- a/src/core/utils/markerSVG.ts +++ b/src/core/utils/markerSVG.ts @@ -6,11 +6,27 @@ */ import type { + GetSVGConfigFunction, GetTextPositionFunction, MarkerSVGConfig, TextPosition, } from '../types' +export const getSVGConfig: GetSVGConfigFunction = (digits: string) => { + switch (digits.length) { + case 0: + case 1: + case 2: + return singleMarkerSVG + case 3: + case 4: + return twoDigitsMarkerSVG + case 5: + return threeDigitsMarkerSVG + default: + throw new Error('Unsupported number of digits') + } +} /** * Calculates the optically centered text position for a pill SVG path. From e76c70cc90f39dc87b3c6bfb05de385511eba396 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 09:09:04 +0200 Subject: [PATCH 07/36] refactor: introduce the singleSVGMarker to the makeMarker-function --- src/core/utils/markers.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 3f855b38c31..73697296bbc 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -1,13 +1,12 @@ -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 { getSVGConfig, singleMarkerSVG } from './markerSVG' +const polygonStyle = new PolygonStyle() const prefix = 'data:image/svg+xml,' const getImagePattern = (fill: MarkerStyle['fill']) => @@ -23,18 +22,15 @@ 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(` + }" viewBox=${singleMarkerSVG.viewBox} xmlns="http://www.w3.org/2000/svg"> DB6C494E-88E8-49F1-89CE-97CBEC3A5240 ${getImagePattern(fill)} Date: Tue, 11 Aug 2026 09:11:06 +0200 Subject: [PATCH 08/36] refactor: use the svgConfig in the makeMultiMarker-function --- src/core/utils/markers.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 73697296bbc..7ab70159cfd 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -39,24 +39,23 @@ const makeMarker = ({ fill, size, stroke, strokeWidth }: MarkerStyle) => `)}` -const makeMultiMarker = ({ - fill, - clusterSize, - stroke, - strokeWidth, -}: MarkerStyle) => +const makeMultiMarker = ( + { fill, clusterSize, stroke, strokeWidth, displayedText }: MarkerStyle, + displayFeatureCount: boolean, + svgConfig: MarkerSVGConfig +) => `${prefix}${encodeURIComponent(` + }" viewBox=${svgConfig.viewBox} xmlns="http://www.w3.org/2000/svg"> 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF ${getImagePattern(fill)} - - - + + + `)}` From 26af65b42ef964805b325447668f6a6749b0a65a Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 09:13:09 +0200 Subject: [PATCH 09/36] refactor: add textelement to the marker --- src/core/utils/markers.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 7ab70159cfd..fdc8499dfd8 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -57,6 +57,24 @@ const makeMultiMarker = ( + ${ + !displayFeatureCount || displayedText === undefined + ? '' + : `${String(displayedText)}` + } + + + `)}` From 5bb8e819a4581fb12a729a9302fc21ddd9d5ff12 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 09:18:25 +0200 Subject: [PATCH 10/36] refactor: extract warning for MeoLeak into separate function --- src/core/utils/markers.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index fdc8499dfd8..973237804f9 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -81,11 +81,13 @@ const makeMultiMarker = ( // center bottom of marker 📍 is intended to show the spot const anchor = [0.5, 1] -/** - * The map became a little laggy due to constant re-generation of styles. - * This memoization function optimises this issue by reusing styles. - * */ -const memoizeStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { +const warnMemoLeak = (styleCount: number) => { + if (styleCount > 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.` + ) + } +} const singleCache = new Map() const multiCache = new Map() return (style, multi) => { From eb9ee1954cba676e202ca2dcc0522cc209e5587d Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 09:59:45 +0200 Subject: [PATCH 11/36] refactor: memoStyle-function is now using count and displayFeatureCount --- src/core/utils/markers.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 973237804f9..4463e55d22b 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -88,19 +88,21 @@ const warnMemoLeak = (styleCount: number) => { ) } } +const memoStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { const singleCache = new Map() const multiCache = new Map() - return (style, multi) => { - const cache = multi ? multiCache : singleCache + return (style, count, displayFeatureCount) => { + const cache = count > 1 ? multiCache : singleCache if (cache.has(style)) { return cache.get(style) } - const markerStyle = getMarker(style, multi) + const markerStyle = getMarker(style, count, displayFeatureCount) cache.set(style, markerStyle) - if (cache.size > 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.` - ) + warnMemoLeak(cache.size) + return markerStyle + } +} + } return markerStyle } From 09ab9631fe4bee5d80d72caf30fe5e4631adbaae Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 10:01:10 +0200 Subject: [PATCH 12/36] refactor: add memoCountStyle-function --- src/core/utils/markers.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 4463e55d22b..14ca0c70597 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -103,7 +103,28 @@ const memoStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { } } +const memoCountStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { + const countCache = new Map>() + return (style, count, displayFeatureCount) => { + // vielleicht auslagern und ind warnMeoLeak einbinden? + const getTotalCachedStyles = ( + countCache: Map> + ): number => { + let total = 0 + for (const stylesByCount of countCache.values()) { + total += stylesByCount.size + } + return total + } + + const cache = countCache.get(count) || new Map() + if (cache.has(style)) { + return cache.get(style) as Style } + const markerStyle = getMarker(style, count, displayFeatureCount) + cache.set(style, markerStyle) + countCache.set(count, cache) + warnMemoLeak(getTotalCachedStyles(countCache)) return markerStyle } } From 9df517633b3ff990bb1166b58c59928b36b1533b Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 10:06:08 +0200 Subject: [PATCH 13/36] refactor: split style memoization-function by displayFeatureCount --- src/core/utils/markers.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 14ca0c70597..9c2cbc2b215 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -129,7 +129,19 @@ const memoCountStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { } } -const getStyleFunction: GetMarkerFunction = (style, multi) => +/** + * The map became a little laggy due to constant re-generation of styles. + * This memoization function optimises this issue by reusing styles. + * */ +const memoizeStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { + const memoizedCountStyle = memoCountStyle(getMarker) + const memoizedStyle = memoStyle(getMarker) + return (style, count, displayFeatureCount) => + displayFeatureCount + ? memoizedCountStyle(style, count, displayFeatureCount) + : memoizedStyle(style, count, displayFeatureCount) +} + new Style({ image: new Icon({ src: (multi ? makeMultiMarker : makeMarker)(style), From d2433d552b2ca812401084e43f32c254e97376af Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 11 Aug 2026 10:07:21 +0200 Subject: [PATCH 14/36] refactor: update the getStyleFunction --- src/core/utils/markers.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 9c2cbc2b215..536357e8395 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -142,9 +142,24 @@ const memoizeStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { : memoizedStyle(style, count, displayFeatureCount) } +const getStyleFunction: GetMarkerFunction = ( + style, + count, + displayFeatureCount +) => new Style({ image: new Icon({ - src: (multi ? makeMultiMarker : makeMarker)(style), + src: + count > 1 + ? makeMultiMarker( + { + ...style, + displayedText: count, + }, + displayFeatureCount, + getSVGConfig(String(count)) + ) + : makeMarker(style), anchor, }), }) From 012d72d14ad6eba576e245908484321b0753ba7c Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Thu, 13 Aug 2026 12:43:59 +0200 Subject: [PATCH 15/36] refactor: use more complex pins --- examples/snowbox/index.js | 2 +- src/core/types/marker.ts | 29 +- src/core/utils/markerSVG.ts | 682 +++++++++++++++++++++++++++++++----- src/core/utils/markers.ts | 124 ++++--- 4 files changed, 708 insertions(+), 129 deletions(-) diff --git a/examples/snowbox/index.js b/examples/snowbox/index.js index 3c0c99f1714..d1faa4ab916 100644 --- a/examples/snowbox/index.js +++ b/examples/snowbox/index.js @@ -178,7 +178,7 @@ const map = await createMap( }, ], clusterClickZoom: true, - displayFeatureCount: false, + displayFeatureCount: true, }, // theme: dataportTheme, locales: [ diff --git a/src/core/types/marker.ts b/src/core/types/marker.ts index 7eb220e5439..8add3a49a22 100644 --- a/src/core/types/marker.ts +++ b/src/core/types/marker.ts @@ -9,7 +9,7 @@ export type GetMarkerFunction = ( ) => 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 @@ -84,6 +84,16 @@ export interface MarkerStyle { } export interface MarkerSVGConfig { + /** + * The SVG path for the markershape where the text ist displayed in. + */ + contentPath: string + + /** + * the definitions for the marker. e.g. shadows + */ + defs: string + /** * Calculates the x and y coordinates for the text position within the marker. * The calculation is based on the provided SVG path, which is expected to follow the schema: @@ -95,16 +105,21 @@ export interface MarkerSVGConfig { getTextPosition: GetTextPositionFunction /** - * The SVG path for the markershape. + * The shape of the marker, which can be either a circle or a pill. + * This property is used to determine the visual representation during the rendering of the marker. */ - path: string + pinShape: PinShape + + /** The outer shape of the marker. */ + shapePath: string /** - * The SVG path for the stacked markershape. + * The SVG paths for the stacked markershape. */ - stackedPath1: string - - stackedPath2: string + stackedShape1: string + stackedShape2: string + stackedTip1: string + stackedTip2: string readonly textPosition: TextPosition diff --git a/src/core/utils/markerSVG.ts b/src/core/utils/markerSVG.ts index 74948de1571..9eadca6f58f 100644 --- a/src/core/utils/markerSVG.ts +++ b/src/core/utils/markerSVG.ts @@ -15,14 +15,17 @@ import type { export const getSVGConfig: GetSVGConfigFunction = (digits: string) => { switch (digits.length) { case 0: + return circlePin case 1: + return circlePinStacked case 2: - return singleMarkerSVG + return twoDigitsPin case 3: + return threeDigitsPin case 4: - return twoDigitsMarkerSVG + return fourDigitsPin case 5: - return threeDigitsMarkerSVG + return fiveDigitsPin default: throw new Error('Unsupported number of digits') } @@ -35,131 +38,648 @@ export const getSVGConfig: GetSVGConfigFunction = (digits: string) => { export const getPillTextPosition: GetTextPositionFunction = ( path: string ): TextPosition => { - const nums = path.match( - /C[\d.]+ [\d.]+ [\d.]+ [\d.]+ ([\d.]+) ([\d.]+)\s*h([\d.]+)\s*C[\d.]+ [\d.]+ [\d.]+ [\d.]+ [\d.]+ [\d.]+\s*S[\d.]+ ([\d.]+)/ + const topMatch = path.match( + /C\s*[-\d.]+\s+[-\d.]+\s+[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*h\s*-?[\d.]+/ ) - if (!nums) { + 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, hWidthStr, yBottomStr] = nums as [ - string, - string, - string, - string, - string, - ] + + 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 hWidth = parseFloat(hWidthStr) const yBottom = parseFloat(yBottomStr) - const textPosition: TextPosition = { - x: xLeftCap + hWidth / 2 - 3, // The −n offset on x and y corrects the optical shift caused by the stacked shapes - y: (yTop + yBottom) / 2 - 1, - } - return textPosition + return { + x: (xLeftCap + xRightCap) / 2, + y: (yTop + yBottom) / 2, + } } /** * Calculates the optically centered text position for a round SVG path (circle with tip). - * Expected paths in the scheme: M[xStart] [yStart]c[dx1][dy1][dx2][dy2] [dxEnd][dyEnd]s[dx2][dy2] [dxEnd] [dyEnd] [dx2] [dy2] [dxEnd] [dyEnd]L[tipX] [tipY]a...l...Z + * 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 */ -export const getCircleTextPosition: GetTextPositionFunction = ( +export const getCircleCenterPosition: GetTextPositionFunction = ( path: string ): TextPosition => { - const mMatch = path.match(/M([\d.]+)\s+([\d.]+)/) - const cSection = path.match(/c([^sS]+)/) - const sSection = path.match(/s([^lL]+)/) - if (!mMatch || !cSection || !sSection) { - throw new Error('Path does not match expected round marker pattern') - } - if (!cSection[1] || !sSection[1]) { - throw new Error('Path does not match expected round marker pattern') - } - const cNums = cSection[1].match(/-?[\d.]+/g) - const sNums = sSection[1].match(/-?[\d.]+/g) - if (!cNums || cNums.length < 6 || !sNums || sNums.length < 4) { - throw new Error('Path does not match expected round marker pattern') - } - if (!mMatch[1] || !mMatch[2] || !cNums[5] || !sNums[2]) { - throw new Error('Path does not match expected round marker pattern') + 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 startX = parseFloat(mMatch[1]) - const startY = parseFloat(mMatch[2]) - const cDy = parseFloat(cNums[5]) // relative dy des c-Endpunkts (negativ = nach oben) - const sDx = parseFloat(sNums[2]) // relative dx des ersten s-Endpunkts (positiv = nach rechts) + + const [, leftXStr, centerYStr] = mMatch as [string, string, string] + const [, rightXStr] = sMatch as [string, string] return { - x: startX + sDx / 2 - 4, // The −4 offset on x and y corrects the optical shift caused by the stacked shapes - y: startY + cDy / 2, + x: (parseFloat(leftXStr) + parseFloat(rightXStr)) / 2, + y: parseFloat(centerYStr), } } -// The paths always follow the scheme: M[xStart] [yMid] C... [xLeftCap] [yTop] h[width] C... [xRight] [yMid] S... [xRightCap] [yBottom] h-[width] C... Z -export const singleMarkerSVG: MarkerSVGConfig = { - path: 'd="M14.747 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.506L26.206 41.3a1.706 1.706 0 0 1-2.412 0l-9.047-9.047Z"', - stackedPath1: - 'd="M12.747 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.506L24.206 40.3a1.706 1.706 0 0 1-2.412 0l-9.047-9.047Z"', - stackedPath2: +// 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.5Z"', + 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 0l-9.047-9.047Z"', + stackedShape1: '', + stackedShape2: '', + 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 20Z"', + 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 0l-9.047-9.047Z"', + stackedShape1: + 'd="M12.747 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.506L24.206 40.3a1.706 1.706 0 0 1-2.412 0l-9.047-9.047Z"', + stackedShape2: + 'd="M14.747 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.506L26.206 41.3a1.706 1.706 0 0 1-2.412 0l-9.047-9.047Z"', + stackedTip1: '', + stackedTip2: '', tipPath: '', - viewBox: '"0 0 48 54"', + viewBox: '0 0 48 54', + defs: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, get textPosition() { - return this.getTextPosition(this.path) + return this.getTextPosition(this.contentPath) }, - getTextPosition: getCircleTextPosition, + getTextPosition: getCircleCenterPosition, + pinShape: 'circle', } -export const twoDigitsMarkerSVG: MarkerSVGConfig = { - path: '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.5Z"', - stackedPath1: - '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.5Z"', - stackedPath2: +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.5Z"', + 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.5Z"', - tipPath: 'd="M27 36h-6l4.482 5.228a2 2 0 0 0 3.037 0L33 36h-6Z"', - viewBox: '"0 0 50 54"', + tipPath: 'd="M23 34h-6l4.482 5.228a2 2 0 0 0 3.037 0L29 34h-6Z"', + 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.5Z"', + stackedTip1: 'd="M25 35h-6l4.482 5.228a2 2 0 0 0 3.037 0L31 35h-6Z"', + 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.5Z"', + stackedTip2: 'd="M27 36h-6l4.482 5.228a2 2 0 0 0 3.037 0L33 36h-6Z"', + viewBox: '0 0 50 54', + defs: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, get textPosition() { - return this.getTextPosition(this.path) + return this.getTextPosition(this.contentPath) }, getTextPosition: getPillTextPosition, + pinShape: 'pill', } -export const threeDigitsMarkerSVG: MarkerSVGConfig = { - path: '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.5Z"', - stackedPath1: +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.5Z"', + 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.5Z"', - stackedPath2: '', - tipPath: 'd="M34 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L40 37h-6Z"', - viewBox: '"0 0 64 55"', + tipPath: 'd="M30 35h-6l4.482 5.228a2 2 0 0 0 3.037 0L36 35h-6Z"', + 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.5Z"', + stackedTip1: 'd="M32 36h-6l4.482 5.228a2 2 0 0 0 3.037 0L38 36h-6Z"', + 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.5Z"', + stackedTip2: 'd="M34 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L40 37h-6Z"', + viewBox: '0 0 64 55', + defs: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, get textPosition() { - return this.getTextPosition(this.path) + return this.getTextPosition(this.contentPath) }, getTextPosition: getPillTextPosition, + pinShape: 'pill', } -export const fourDigitsMarkerSVG: MarkerSVGConfig = { - path: '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.5Z"', - stackedPath1: +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.5Z"', - stackedPath2: '', - tipPath: 'd="M37.5 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L43.5 37h-6Z"', - viewBox: '"0 0 72 55"', + 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.5Z"', + tipPath: 'd="M33.5 35h-6l4.482 5.228a2 2 0 0 0 3.037 0L39.5 35h-6Z"', + 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.5Z"', + stackedTip1: 'd="M35.5 36h-6l4.481 5.228a2 2 0 0 0 3.038 0L41.5 36h-6Z"', + 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.5Z"', + stackedTip2: 'd="M37.5 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L43.5 37h-6Z"', + viewBox: '0 0 72 55', + defs: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, get textPosition() { - return this.getTextPosition(this.path) + return this.getTextPosition(this.contentPath) }, getTextPosition: getPillTextPosition, + pinShape: 'pill', } -export const fiveDigitsMarkerSVG: MarkerSVGConfig = { - path: '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.5Z"', - stackedPath1: +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.5Z"', - stackedPath2: + 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.5Z"', - tipPath: 'd="M42.5 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L48.5 37h-6Z"', - viewBox: '"0 0 80 55"', + tipPath: 'd="M38.5 35h-6l4.481 5.228a2 2 0 0 0 3.038 0L44.5 35h-6Z"', + 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.5Z"', + stackedTip1: 'd="M40.5 36h-6l4.481 5.228a2 2 0 0 0 3.038 0L46.5 36h-6Z"', + 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.5Z"', + stackedTip2: 'd="M42.5 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L48.5 37h-6Z"', + viewBox: '0 0 80 55', + defs: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, get textPosition() { - return this.getTextPosition(this.path) + return this.getTextPosition(this.contentPath) }, getTextPosition: getPillTextPosition, + pinShape: 'pill', } diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 536357e8395..64cf003e784 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -4,7 +4,7 @@ import PolygonStyle from '@masterportal/masterportalapi/src/vectorStyle/styles/p import Icon from 'ol/style/Icon' import Style from 'ol/style/Style' -import { getSVGConfig, singleMarkerSVG } from './markerSVG' +import { circlePin, getSVGConfig } from './markerSVG' const polygonStyle = new PolygonStyle() const prefix = 'data:image/svg+xml,' @@ -22,59 +22,103 @@ const getImagePattern = (fill: MarkerStyle['fill']) => ` -const makeMarker = ({ fill, size, stroke, strokeWidth }: MarkerStyle) => +const makeMarker = ({ fill, size, stroke }: MarkerStyle) => `${prefix}${encodeURIComponent(` - + }" viewBox="${circlePin.viewBox}" xmlns="http://www.w3.org/2000/svg"> DB6C494E-88E8-49F1-89CE-97CBEC3A5240 ${getImagePattern(fill)} - + + + + + + + + + ${circlePin.defs} `)}` const makeMultiMarker = ( - { fill, clusterSize, stroke, strokeWidth, displayedText }: MarkerStyle, + { fill, clusterSize, stroke, displayedText }: MarkerStyle, displayFeatureCount: boolean, svgConfig: MarkerSVGConfig ) => `${prefix}${encodeURIComponent(` - + 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF ${getImagePattern(fill)} - - - - - - ${ - !displayFeatureCount || displayedText === undefined - ? '' - : `${String(displayedText)}` - } - - - +${ + svgConfig.pinShape === 'circle' + ? ` + + + + + + + + + + + + ${ + !displayFeatureCount || displayedText === undefined + ? '' + : `${String(displayedText)}` + } + + ` + : ` + + + + + + + + + + + + + ${ + !displayFeatureCount || displayedText === undefined + ? '' + : `${String(displayedText)}` + } + + + + ` +} +${svgConfig.defs} `)}` From fd31cf0ba37d2e5fb41367af1defb4a8dff201e7 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Fri, 14 Aug 2026 07:57:51 +0200 Subject: [PATCH 16/36] refactor: adjust skale --- src/core/utils/markers.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 64cf003e784..0cefd53f7a7 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -24,9 +24,7 @@ const getImagePattern = (fill: MarkerStyle['fill']) => const makeMarker = ({ fill, size, stroke }: MarkerStyle) => `${prefix}${encodeURIComponent(` - + DB6C494E-88E8-49F1-89CE-97CBEC3A5240 ${getImagePattern(fill)} @@ -47,7 +45,7 @@ const makeMultiMarker = ( svgConfig: MarkerSVGConfig ) => `${prefix}${encodeURIComponent(` - 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF ${getImagePattern(fill)} From 746f668c35cef7f8662f3399e19c6fa625ef93db Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Fri, 14 Aug 2026 12:31:04 +0200 Subject: [PATCH 17/36] refactor: improve perfomance --- src/core/utils/markers.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 0cefd53f7a7..8bb6d329d12 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -7,7 +7,8 @@ import Style from 'ol/style/Style' import { circlePin, getSVGConfig } from './markerSVG' const polygonStyle = new PolygonStyle() -const prefix = 'data:image/svg+xml,' +const prefix = 'data:image/svg+xml;base64,' +const encodeSVG = (svg: string) => btoa(unescape(encodeURIComponent(svg))) const getImagePattern = (fill: MarkerStyle['fill']) => typeof fill === 'string' @@ -23,7 +24,7 @@ const getImagePattern = (fill: MarkerStyle['fill']) => ` const makeMarker = ({ fill, size, stroke }: MarkerStyle) => - `${prefix}${encodeURIComponent(` + `${prefix}${encodeSVG(` DB6C494E-88E8-49F1-89CE-97CBEC3A5240 ${getImagePattern(fill)} @@ -43,15 +44,14 @@ const makeMultiMarker = ( { fill, clusterSize, stroke, displayedText }: MarkerStyle, displayFeatureCount: boolean, svgConfig: MarkerSVGConfig -) => - `${prefix}${encodeURIComponent(` +) => { + return svgConfig.pinShape === 'circle' + ? `${prefix}${encodeSVG(` 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF ${getImagePattern(fill)} -${ - svgConfig.pinShape === 'circle' - ? ` + @@ -81,8 +81,15 @@ ${ >${String(displayedText)}` } - ` - : ` + + +`)}` + : `${prefix}${encodeSVG(` + + 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF + ${getImagePattern(fill)} + @@ -114,11 +121,10 @@ ${ - ` -} -${svgConfig.defs} + `)}` +} // center bottom of marker 📍 is intended to show the spot const anchor = [0.5, 1] From 6c6c39d2cfa5261d1e72a6f86a27b97fa578a285 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Fri, 14 Aug 2026 12:34:38 +0200 Subject: [PATCH 18/36] fix: prevent redundant hover style updates in mapPointerMove and removed double setStyle --- src/core/utils/map/setupMarkers.ts | 34 ++++++++++-------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/src/core/utils/map/setupMarkers.ts b/src/core/utils/map/setupMarkers.ts index 6368bbc7045..ba8a89a2634 100644 --- a/src/core/utils/map/setupMarkers.ts +++ b/src/core/utils/map/setupMarkers.ts @@ -209,21 +209,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 featureCount = store.hovered.get('features')?.length + const featureCount = feature.get('features')?.length const style = getMarkerStyle( getLayerConfiguration(feature.get('_polarLayerId') as string) .hoverStyle, featureCount, store.displayFeatureCount ) - store.hovered.setStyle(style) + feature.setStyle(style) } } ) @@ -276,18 +274,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) @@ -295,16 +290,9 @@ function mapPointerMove({ map, pixel }: MapBrowserEvent) { feature.get('_polarLayerId') as string ) if (!layerConfiguration.isSelectable(feature)) { + store.hovered = null return } - const featureCount = feature.get('features')?.length - feature.setStyle( - getMarkerStyle( - layerConfiguration.hoverStyle, - featureCount, - store.displayFeatureCount - ) - ) store.hovered = markRaw(feature) } From 06cce037a9189d9a1a7702f37658b06881a73c93 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Mon, 17 Aug 2026 08:27:05 +0200 Subject: [PATCH 19/36] refactor: set displayFeatureCount ture --- examples/snowbox/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/snowbox/index.js b/examples/snowbox/index.js index 3c0c99f1714..d1faa4ab916 100644 --- a/examples/snowbox/index.js +++ b/examples/snowbox/index.js @@ -178,7 +178,7 @@ const map = await createMap( }, ], clusterClickZoom: true, - displayFeatureCount: false, + displayFeatureCount: true, }, // theme: dataportTheme, locales: [ From 6272014c8b76f995f4ce5d060a644a9d3305c164 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Mon, 17 Aug 2026 12:49:59 +0200 Subject: [PATCH 20/36] refactor: adjust font and correct clustermarker size --- src/core/utils/markers.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 8bb6d329d12..fcd1b091de0 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -25,7 +25,7 @@ const getImagePattern = (fill: MarkerStyle['fill']) => const makeMarker = ({ fill, size, stroke }: MarkerStyle) => `${prefix}${encodeSVG(` - + DB6C494E-88E8-49F1-89CE-97CBEC3A5240 ${getImagePattern(fill)} @@ -47,7 +47,7 @@ const makeMultiMarker = ( ) => { return svgConfig.pinShape === 'circle' ? `${prefix}${encodeSVG(` - 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF ${getImagePattern(fill)} @@ -71,10 +71,10 @@ const makeMultiMarker = ( x="${svgConfig.textPosition.x}" y="${svgConfig.textPosition.y}" text-anchor="middle" - dominant-baseline="central" + dy="0.35em" font-size="18" font-weight="400" - font-family="'Fira Code', 'Fira Mono', ui-monospace" + font-family="'Fira-sans', sans-serif" font-variant-numeric="slashed-zero" font-feature-settings="'zero' 1" fill="${stroke}" @@ -85,7 +85,7 @@ const makeMultiMarker = ( `)}` : `${prefix}${encodeSVG(` - 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF ${getImagePattern(fill)} @@ -109,10 +109,10 @@ const makeMultiMarker = ( x="${svgConfig.textPosition.x}" y="${svgConfig.textPosition.y}" text-anchor="middle" - dominant-baseline="central" + dy="0.35em" font-size="18" font-weight="400" - font-family="'Fira Code', 'Fira Mono', ui-monospace" + font-family="'Fira-sans', sans-serif" font-variant-numeric="slashed-zero" font-feature-settings="'zero' 1" fill="${stroke}" From a02d79ddc2b04e8dd410760ad7c4776753407d4f Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Mon, 17 Aug 2026 13:06:23 +0200 Subject: [PATCH 21/36] refactor: correct size of marker and font --- src/core/utils/markerSVG.ts | 10 ++++++---- src/core/utils/markers.ts | 7 ++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/core/utils/markerSVG.ts b/src/core/utils/markerSVG.ts index 74948de1571..de354f07840 100644 --- a/src/core/utils/markerSVG.ts +++ b/src/core/utils/markerSVG.ts @@ -15,14 +15,16 @@ import type { export const getSVGConfig: GetSVGConfigFunction = (digits: string) => { switch (digits.length) { case 0: - case 1: - case 2: + case 1: return singleMarkerSVG + case 2: + return twoDigitsMarkerSVG case 3: + return threeDigitsMarkerSVG case 4: - return twoDigitsMarkerSVG + return fourDigitsMarkerSVG case 5: - return threeDigitsMarkerSVG + return fiveDigitsMarkerSVG default: throw new Error('Unsupported number of digits') } diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 536357e8395..253f130cced 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -64,9 +64,10 @@ const makeMultiMarker = ( x="${svgConfig.textPosition.x}" y="${svgConfig.textPosition.y}" text-anchor="middle" - dominant-baseline="central" - font-size="14" - font-weight="700" + dy="0.35em" + ont-size="18" + font-weight="400" + font-family="'Fira-sans', sans-serif" fill="#ffffff" >${String(displayedText)}` } From 0a145ffb430704724d259d1e938baaf8ad3c63a6 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Mon, 17 Aug 2026 13:45:00 +0200 Subject: [PATCH 22/36] refactor: use new magic numbers to make the markers bigger --- src/core/utils/map/setupMarkers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/utils/map/setupMarkers.ts b/src/core/utils/map/setupMarkers.ts index ba8a89a2634..7a88ce3e204 100644 --- a/src/core/utils/map/setupMarkers.ts +++ b/src/core/utils/map/setupMarkers.ts @@ -21,8 +21,8 @@ import { getMarkerStyle } from '../markers' let stopWatcher: (() => void) | null = null // 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] = [26 * 2, 36 * 2] +const imgSizeMulti: [number, number] = [40 * 2, 36 * 2] const defaultStroke = '#FFFFFF' const defaultStrokeWidth = '2' From d6d1d8ad8808fbbee0ab7c16b0bc1381badf9d34 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 18 Aug 2026 11:10:17 +0200 Subject: [PATCH 23/36] fix: withou the defs in the svg - the markes will not render probably for mobile --- src/core/utils/markers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index fcd1b091de0..5333e63f624 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -82,6 +82,7 @@ const makeMultiMarker = ( } + ${svgConfig.defs} `)}` : `${prefix}${encodeSVG(` @@ -122,6 +123,7 @@ const makeMultiMarker = ( + ${svgConfig.defs} `)}` } From ff3b00098f1d4f25241b18fd2716724aa9690503 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Tue, 18 Aug 2026 13:52:51 +0200 Subject: [PATCH 24/36] fix: implement baked shadows to increase the performance --- src/core/types/marker.ts | 22 +- src/core/utils/markerSVG.ts | 617 +++++++----------------------------- src/core/utils/markers.ts | 98 +++--- 3 files changed, 174 insertions(+), 563 deletions(-) diff --git a/src/core/types/marker.ts b/src/core/types/marker.ts index 8add3a49a22..21d3c9e46cd 100644 --- a/src/core/types/marker.ts +++ b/src/core/types/marker.ts @@ -85,34 +85,42 @@ export interface MarkerStyle { export interface MarkerSVGConfig { /** - * The SVG path for the markershape where the text ist displayed in. + * The SVG path for the markershape where the text is displayed in. */ contentPath: string /** - * the definitions for the marker. e.g. shadows + * 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, which is expected to follow the schema: - * M[xStart] [yMid] C... [xLeftCap] [yTop] h[width] C... [xRight] [yMid] S... [xRightCap] [yBottom] h-[width] C... Z - * + * 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, which can be either a circle or a pill. - * This property is used to determine the visual representation during the rendering of the marker. + * 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 markershape. */ diff --git a/src/core/utils/markerSVG.ts b/src/core/utils/markerSVG.ts index 9eadca6f58f..2d911d47cbb 100644 --- a/src/core/utils/markerSVG.ts +++ b/src/core/utils/markerSVG.ts @@ -15,9 +15,8 @@ import type { export const getSVGConfig: GetSVGConfigFunction = (digits: string) => { switch (digits.length) { case 0: - return circlePin case 1: - return circlePinStacked + return circlePin case 2: return twoDigitsPin case 3: @@ -87,49 +86,26 @@ export const getCircleCenterPosition: GetTextPositionFunction = ( 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.5Z"', + '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 0l-9.047-9.047Z"', + '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: ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + defs: ` + + + + `, get textPosition() { return this.getTextPosition(this.contentPath) @@ -137,109 +113,34 @@ export const circlePin: MarkerSVGConfig = { 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 20Z"', + '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 0l-9.047-9.047Z"', + '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.747 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.506L24.206 40.3a1.706 1.706 0 0 1-2.412 0l-9.047-9.047Z"', + '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.747 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.506L26.206 41.3a1.706 1.706 0 0 1-2.412 0l-9.047-9.047Z"', + '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 54', - defs: ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + viewBox: '0 0 48 52', + defs: ` + + + + + + + + + + `, get textPosition() { return this.getTextPosition(this.contentPath) @@ -250,105 +151,31 @@ export const circlePinStacked: MarkerSVGConfig = { 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.5Z"', + '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.5Z"', - tipPath: 'd="M23 34h-6l4.482 5.228a2 2 0 0 0 3.037 0L29 34h-6Z"', + '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.5Z"', - stackedTip1: 'd="M25 35h-6l4.482 5.228a2 2 0 0 0 3.037 0L31 35h-6Z"', + '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.5Z"', - stackedTip2: 'd="M27 36h-6l4.482 5.228a2 2 0 0 0 3.037 0L33 36h-6Z"', + '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) @@ -359,105 +186,31 @@ export const twoDigitsPin: MarkerSVGConfig = { 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.5Z"', + '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.5Z"', - tipPath: 'd="M30 35h-6l4.482 5.228a2 2 0 0 0 3.037 0L36 35h-6Z"', + '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.5Z"', - stackedTip1: 'd="M32 36h-6l4.482 5.228a2 2 0 0 0 3.037 0L38 36h-6Z"', + '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.5Z"', - stackedTip2: 'd="M34 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L40 37h-6Z"', + '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) @@ -468,105 +221,31 @@ export const threeDigitsPin: MarkerSVGConfig = { 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.5Z"', + '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.5Z"', - tipPath: 'd="M33.5 35h-6l4.482 5.228a2 2 0 0 0 3.037 0L39.5 35h-6Z"', + '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.5Z"', - stackedTip1: 'd="M35.5 36h-6l4.481 5.228a2 2 0 0 0 3.038 0L41.5 36h-6Z"', + '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.5Z"', - stackedTip2: 'd="M37.5 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L43.5 37h-6Z"', + '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) @@ -577,105 +256,31 @@ export const fourDigitsPin: MarkerSVGConfig = { 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.5Z"', + '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.5Z"', - tipPath: 'd="M38.5 35h-6l4.481 5.228a2 2 0 0 0 3.038 0L44.5 35h-6Z"', + '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.5Z"', - stackedTip1: 'd="M40.5 36h-6l4.481 5.228a2 2 0 0 0 3.038 0L46.5 36h-6Z"', + '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.5Z"', - stackedTip2: 'd="M42.5 37h-6l4.481 5.228a2 2 0 0 0 3.038 0L48.5 37h-6Z"', + '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: ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + defs: ` + + + + + + + + + + `, get textPosition() { return this.getTextPosition(this.contentPath) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 5333e63f624..026a9eb8e3b 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -28,13 +28,15 @@ const makeMarker = ({ fill, size, stroke }: MarkerStyle) => DB6C494E-88E8-49F1-89CE-97CBEC3A5240 ${getImagePattern(fill)} - - - - - - - + + + + ${circlePin.defs} @@ -51,25 +53,21 @@ const makeMultiMarker = ( viewBox="${svgConfig.viewBox}" xmlns="http://www.w3.org/2000/svg"> 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF ${getImagePattern(fill)} - - - - - - - - - - - - - ${ - !displayFeatureCount || displayedText === undefined - ? '' - : ` + + + + ${ + !displayFeatureCount || displayedText === undefined + ? '' + : `${String(displayedText)}` - } - - - ${svgConfig.defs} + } + + ${circlePin.defs} `)}` : `${prefix}${encodeSVG(` @@ -90,23 +87,25 @@ const makeMultiMarker = ( viewBox="${svgConfig.viewBox}" xmlns="http://www.w3.org/2000/svg"> 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF ${getImagePattern(fill)} - - - - - - - - - - - - - - ${ - !displayFeatureCount || displayedText === undefined - ? '' - : ` + + + + + + + + + + + + + + + ${ + !displayFeatureCount || displayedText === undefined + ? '' + : `${String(displayedText)}` - } - - + } + ${svgConfig.defs} From b50a0052c1e1cecaa934990c080f33c5725aa4de Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Thu, 20 Aug 2026 13:20:00 +0200 Subject: [PATCH 25/36] fix: removed deprecated function --- src/core/utils/markers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 026a9eb8e3b..41983b9b894 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -7,8 +7,8 @@ import Style from 'ol/style/Style' import { circlePin, getSVGConfig } from './markerSVG' const polygonStyle = new PolygonStyle() -const prefix = 'data:image/svg+xml;base64,' -const encodeSVG = (svg: string) => btoa(unescape(encodeURIComponent(svg))) +const prefix = 'data:image/svg+xml,' +const encodeSVG = (svg: string) => encodeURIComponent(svg) const getImagePattern = (fill: MarkerStyle['fill']) => typeof fill === 'string' From be122e055e9b82139ca5e3542b6f47921f9e2c3b Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Thu, 20 Aug 2026 13:20:57 +0200 Subject: [PATCH 26/36] fix: give multimarker with 1-digit a stacked shadow --- src/core/utils/markerSVG.ts | 3 ++- src/core/utils/markers.ts | 32 ++++++++++++++++---------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/core/utils/markerSVG.ts b/src/core/utils/markerSVG.ts index 2d911d47cbb..bf15506318b 100644 --- a/src/core/utils/markerSVG.ts +++ b/src/core/utils/markerSVG.ts @@ -15,8 +15,9 @@ import type { export const getSVGConfig: GetSVGConfigFunction = (digits: string) => { switch (digits.length) { case 0: - case 1: return circlePin + case 1: + return circlePinStacked case 2: return twoDigitsPin case 3: diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 41983b9b894..66fba683040 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -53,21 +53,21 @@ const makeMultiMarker = ( viewBox="${svgConfig.viewBox}" xmlns="http://www.w3.org/2000/svg"> 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF ${getImagePattern(fill)} - + - - - ${ - !displayFeatureCount || displayedText === undefined - ? '' - : ` + + + + + + + ${ + !displayFeatureCount || displayedText === undefined + ? '' + : `${String(displayedText)}` - } + } - ${circlePin.defs} + ${svgConfig.defs} `)}` : `${prefix}${encodeSVG(` From 0c0c07f1f46ee5b9b944f9ce5aab4e85bd07e9d6 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Fri, 21 Aug 2026 09:08:50 +0200 Subject: [PATCH 27/36] fix: use faster encoding fct to reduce lags while zooming in and out --- src/core/utils/markers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index 66fba683040..ca078cb723a 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -4,11 +4,11 @@ import PolygonStyle from '@masterportal/masterportalapi/src/vectorStyle/styles/p import Icon from 'ol/style/Icon' import Style from 'ol/style/Style' -import { circlePin, getSVGConfig } from './markerSVG' +import { circlePinStacked, getSVGConfig } from './markerSVG' const polygonStyle = new PolygonStyle() -const prefix = 'data:image/svg+xml,' -const encodeSVG = (svg: string) => encodeURIComponent(svg) +const prefix = 'data:image/svg+xml;base64,' +const encodeSVG = (svg: string) => btoa(svg) const getImagePattern = (fill: MarkerStyle['fill']) => typeof fill === 'string' From 7728138aaad8f8bb047902a404706fc255be558a Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Fri, 21 Aug 2026 09:49:37 +0200 Subject: [PATCH 28/36] fix: adjust the marker size to be the same size --- src/core/types/marker.ts | 4 ++-- src/core/utils/map/setupMarkers.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/types/marker.ts b/src/core/types/marker.ts index 21d3c9e46cd..6a26af031fb 100644 --- a/src/core/types/marker.ts +++ b/src/core/types/marker.ts @@ -45,7 +45,7 @@ export interface MarkerStyle { /** * `width` and `height` of the ``-cluster-marker. * - * @defaultValue `[40, 36]` + * @defaultValue `[40 * 2, 36 * 2]` */ clusterSize: [number, number] @@ -57,7 +57,7 @@ export interface MarkerStyle { /** * `width` and `height` of the ``-marker. * - * @defaultValue `[26, 36]` + * @defaultValue `[40 * 2, 36 * 2]` */ size: [number, number] diff --git a/src/core/utils/map/setupMarkers.ts b/src/core/utils/map/setupMarkers.ts index 7a88ce3e204..59a04b04c43 100644 --- a/src/core/utils/map/setupMarkers.ts +++ b/src/core/utils/map/setupMarkers.ts @@ -21,7 +21,7 @@ import { getMarkerStyle } from '../markers' let stopWatcher: (() => void) | null = null // these have been measured to fit once and influence marker size -const imgSize: [number, number] = [26 * 2, 36 * 2] +const imgSize: [number, number] = [40 * 2, 36 * 2] const imgSizeMulti: [number, number] = [40 * 2, 36 * 2] const defaultStroke = '#FFFFFF' From 41ace1af76e593082356d42e915e8351e5c036c7 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Fri, 21 Aug 2026 10:39:14 +0200 Subject: [PATCH 29/36] fix: import correct shape --- src/core/utils/markers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index ca078cb723a..a8a53c645cf 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -4,7 +4,7 @@ import PolygonStyle from '@masterportal/masterportalapi/src/vectorStyle/styles/p import Icon from 'ol/style/Icon' import Style from 'ol/style/Style' -import { circlePinStacked, getSVGConfig } from './markerSVG' +import { circlePin, getSVGConfig } from './markerSVG' const polygonStyle = new PolygonStyle() const prefix = 'data:image/svg+xml;base64,' From 21aaa9a55e1d9b628029974e38954a90ffd75ab2 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp <152861941+dMapybara@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:04:57 +0200 Subject: [PATCH 30/36] refactor(core): drop redundant cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Pascal Röhling <73653210+dopenguin@users.noreply.github.com> --- src/core/stores/marker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/stores/marker.ts b/src/core/stores/marker.ts index 0bcd22792ab..128890b9838 100644 --- a/src/core/stores/marker.ts +++ b/src/core/stores/marker.ts @@ -21,7 +21,7 @@ export const useMarkerStore = defineStore('marker', () => { ) const displayFeatureCount = computed( - () => (configuration.value?.displayFeatureCount as boolean) || false + () => configuration.value?.displayFeatureCount || false ) const hovered = shallowRef(null) From 8c119b7bb471f9fbd76fb04c4daf34be2958256e Mon Sep 17 00:00:00 2001 From: Pascal Roehling Date: Mon, 24 Aug 2026 11:24:26 +0200 Subject: [PATCH 31/36] perf(core): rasterize cluster marker icons once to fix zoom/hover lag --- src/core/utils/map/setupMarkers.ts | 15 ++- src/core/utils/markers.ts | 153 ++++++++++++++++------------- 2 files changed, 97 insertions(+), 71 deletions(-) diff --git a/src/core/utils/map/setupMarkers.ts b/src/core/utils/map/setupMarkers.ts index 59a04b04c43..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,9 +21,10 @@ 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] = [40 * 2, 36 * 2] @@ -162,6 +168,10 @@ export function setupMarkers(map: Map) { return } + getMarkerStyle = createGetMarkerStyle(() => { + map.render() + }) + layers = configuration.layers.map((layer) => toMerged( { @@ -242,6 +252,7 @@ export function teardownMarkers(map: Map) { stopWatcher = null layers = [] lastClickEvent = null + getMarkerStyle = createGetMarkerStyle(() => {}) map.un('moveend', mapMoveEnd) map.un('pointermove', mapPointerMove) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index a8a53c645cf..bbb3f78d647 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -129,87 +129,102 @@ const makeMultiMarker = ( // center bottom of marker 📍 is intended to show the spot const anchor = [0.5, 1] -const warnMemoLeak = (styleCount: number) => { - if (styleCount > 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.` - ) - } -} -const memoStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { - const singleCache = new Map() - const multiCache = new Map() - return (style, count, displayFeatureCount) => { - const cache = count > 1 ? multiCache : singleCache - if (cache.has(style)) { - return cache.get(style) - } - const markerStyle = getMarker(style, count, displayFeatureCount) - cache.set(style, markerStyle) - warnMemoLeak(cache.size) - return markerStyle +function variantKey(count: number, displayFeatureCount: boolean) { + if (count <= 1) { + return 'single' } + return displayFeatureCount ? `multi:${count}` : 'multi' } -const memoCountStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { - const countCache = new Map>() +/** + * 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 cache = new Map>() + let totalStyles = 0 return (style, count, displayFeatureCount) => { - // vielleicht auslagern und ind warnMeoLeak einbinden? - const getTotalCachedStyles = ( - countCache: Map> - ): number => { - let total = 0 - for (const stylesByCount of countCache.values()) { - total += stylesByCount.size - } - return total + const variant = variantKey(count, displayFeatureCount) + let byVariant = cache.get(style) + if (!byVariant) { + byVariant = new Map() + cache.set(style, byVariant) } - - const cache = countCache.get(count) || new Map() - if (cache.has(style)) { - return cache.get(style) as Style + const cached = byVariant.get(variant) + if (cached) { + return cached } const markerStyle = getMarker(style, count, displayFeatureCount) - cache.set(style, markerStyle) - countCache.set(count, cache) - warnMemoLeak(getTotalCachedStyles(countCache)) + 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.` + ) + } return markerStyle } } -/** - * The map became a little laggy due to constant re-generation of styles. - * This memoization function optimises this issue by reusing styles. - * */ -const memoizeStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => { - const memoizedCountStyle = memoCountStyle(getMarker) - const memoizedStyle = memoStyle(getMarker) - return (style, count, displayFeatureCount) => - displayFeatureCount - ? memoizedCountStyle(style, count, displayFeatureCount) - : memoizedStyle(style, count, displayFeatureCount) +/* + * 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 } -const getStyleFunction: GetMarkerFunction = ( - style, - count, - displayFeatureCount -) => - new Style({ - image: new Icon({ - src: - count > 1 - ? makeMultiMarker( - { - ...style, - displayedText: count, - }, - displayFeatureCount, - getSVGConfig(String(count)) - ) - : makeMarker(style), - anchor, - }), +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, + displayedText: 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) + ) From 0c69b4efc79c40dddf8c834cc260367fa17fadc4 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Fri, 4 Sep 2026 12:54:15 +0200 Subject: [PATCH 32/36] style: apply suggestions to make code nicer --- src/core/types/marker.ts | 11 +++++------ src/core/utils/markerSVG.ts | 12 ++++++------ src/core/utils/markers.ts | 12 ++++++------ 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/core/types/marker.ts b/src/core/types/marker.ts index 6a26af031fb..ee74dd007af 100644 --- a/src/core/types/marker.ts +++ b/src/core/types/marker.ts @@ -76,11 +76,10 @@ export interface MarkerStyle { strokeWidth: string | number /** - * Text to display on the marker.. + * Text to display on the marker. * - * @defaultValue `''` */ - displayedText?: string | number + clusterCount?: number } export interface MarkerSVGConfig { @@ -97,13 +96,13 @@ export interface MarkerSVGConfig { /** * 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. + * @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 + * The shape of the marker. */ pinShape: PinShape @@ -122,7 +121,7 @@ export interface MarkerSVGConfig { stackedShadow2: string /** - * The SVG paths for the stacked markershape. + * The SVG paths for the stacked shape of the marker. */ stackedShape1: string stackedShape2: string diff --git a/src/core/utils/markerSVG.ts b/src/core/utils/markerSVG.ts index bf15506318b..60c7cd7affc 100644 --- a/src/core/utils/markerSVG.ts +++ b/src/core/utils/markerSVG.ts @@ -32,10 +32,10 @@ export const getSVGConfig: GetSVGConfigFunction = (digits: string) => { } /** - * Calculates the optically centered text position for a pill SVG path. - * Expected paths in the scheme: M[x] [y] C... [xLeftCap] [yTop] h[width] C... S... [xRightCap] [yBottom] h-[width] C... Z + * 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 */ -export const getPillTextPosition: GetTextPositionFunction = ( +const getPillTextPosition: GetTextPositionFunction = ( path: string ): TextPosition => { const topMatch = path.match( @@ -64,10 +64,10 @@ export const getPillTextPosition: GetTextPositionFunction = ( } /** - * Calculates the optically centered text position for a round SVG path (circle with tip). - * 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 + * 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 */ -export const getCircleCenterPosition: GetTextPositionFunction = ( +const getCircleCenterPosition: GetTextPositionFunction = ( path: string ): TextPosition => { const mMatch = path.match(/M\s*([\d.]+)\s+([\d.]+)/) diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts index bbb3f78d647..57bb71d9b80 100644 --- a/src/core/utils/markers.ts +++ b/src/core/utils/markers.ts @@ -43,7 +43,7 @@ const makeMarker = ({ fill, size, stroke }: MarkerStyle) => `)}` const makeMultiMarker = ( - { fill, clusterSize, stroke, displayedText }: MarkerStyle, + { fill, clusterSize, stroke, clusterCount }: MarkerStyle, displayFeatureCount: boolean, svgConfig: MarkerSVGConfig ) => { @@ -63,7 +63,7 @@ const makeMultiMarker = ( ${ - !displayFeatureCount || displayedText === undefined + !displayFeatureCount || clusterCount === undefined ? '' : `${String(displayedText)}` + >${String(clusterCount)}` } ${svgConfig.defs} @@ -103,7 +103,7 @@ const makeMultiMarker = ( ${ - !displayFeatureCount || displayedText === undefined + !displayFeatureCount || clusterCount === undefined ? '' : `${String(displayedText)}` + >${String(clusterCount)}` } @@ -205,7 +205,7 @@ function buildStyle( ? makeMultiMarker( { ...style, - displayedText: count, + clusterCount: count, }, displayFeatureCount, getSVGConfig(String(count)) From daae70e8a8bf27f67b2d4e6e11345ec37af4a4f4 Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Fri, 4 Sep 2026 12:56:08 +0200 Subject: [PATCH 33/36] fix: add hyphen --- src/core/utils/markerSVG.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/utils/markerSVG.ts b/src/core/utils/markerSVG.ts index 60c7cd7affc..1c6dee53601 100644 --- a/src/core/utils/markerSVG.ts +++ b/src/core/utils/markerSVG.ts @@ -33,7 +33,7 @@ export const getSVGConfig: GetSVGConfigFunction = (digits: string) => { /** * 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 + * @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 @@ -65,7 +65,7 @@ const getPillTextPosition: GetTextPositionFunction = ( /** * 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 + * @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 From a59a7090e7f572ef372664937a48c8500ba9dcaa Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Fri, 4 Sep 2026 13:06:30 +0200 Subject: [PATCH 34/36] style: apply suggestions to make code nicer --- src/core/types/marker.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/types/marker.ts b/src/core/types/marker.ts index ee74dd007af..54dddc09726 100644 --- a/src/core/types/marker.ts +++ b/src/core/types/marker.ts @@ -82,6 +82,9 @@ export interface MarkerStyle { clusterCount?: number } +/** + * The MarkerSVGConfig contains the SVG-informations needed to create the svg + */ export interface MarkerSVGConfig { /** * The SVG path for the markershape where the text is displayed in. From a44def94410425865b4a2b4d7ba16da45391e01b Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Fri, 4 Sep 2026 13:07:01 +0200 Subject: [PATCH 35/36] style: apply suggestions to make code nicer --- src/core/types/marker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/types/marker.ts b/src/core/types/marker.ts index 54dddc09726..f5b516ea74b 100644 --- a/src/core/types/marker.ts +++ b/src/core/types/marker.ts @@ -83,7 +83,7 @@ export interface MarkerStyle { } /** - * The MarkerSVGConfig contains the SVG-informations needed to create the svg + * The MarkerSVGConfig contains the SVG-informations needed to create the svg for the marker. */ export interface MarkerSVGConfig { /** From f8acef8b9ee1e501254b3e935b62bb41769e230c Mon Sep 17 00:00:00 2001 From: Melanie Michaela Klamp Date: Fri, 4 Sep 2026 13:11:17 +0200 Subject: [PATCH 36/36] style: apply suggestions to make code nicer --- src/core/utils/markerSVG.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/utils/markerSVG.ts b/src/core/utils/markerSVG.ts index 1c6dee53601..0d13b85c74b 100644 --- a/src/core/utils/markerSVG.ts +++ b/src/core/utils/markerSVG.ts @@ -1,6 +1,5 @@ /** - * This file contains the SVG-informations - * the 'path' / 'viewBox' / 'position of the text element' of the markers + * This file contains the svgConfigs and functions * it will be used in the marker.ts file to create the marker icons * for the map. */ @@ -12,6 +11,11 @@ import type { 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: