diff --git a/examples/Centre Pompidou.md b/examples/Centre Pompidou.md index 37807b1..dacb29a 100644 --- a/examples/Centre Pompidou.md +++ b/examples/Centre Pompidou.md @@ -10,4 +10,5 @@ coordinates: - "2.352245" architect: "[[Renzo Piano]]" url: https://www.centrepompidou.fr +size: 4 --- diff --git a/examples/Eiffel Tower.md b/examples/Eiffel Tower.md index 11d280a..fb2cfd5 100644 --- a/examples/Eiffel Tower.md +++ b/examples/Eiffel Tower.md @@ -3,7 +3,7 @@ categories: - "[[Places]]" type: - "[[Landmarks]]" -rating: 5 +rating: 4 address: Av. Gustave Eiffel, 75007 Paris, France coordinates: - "48.85837" @@ -11,4 +11,8 @@ coordinates: architect: "[[Gustave Eiffel]]" color: red icon: landmark +size: 1 +entry svg marker: |- + + --- diff --git a/examples/Jardin des Plantes.md b/examples/Jardin des Plantes.md index 18633b0..b824157 100644 --- a/examples/Jardin des Plantes.md +++ b/examples/Jardin des Plantes.md @@ -10,4 +10,5 @@ coordinates: - "2.3595996" icon: trees color: green +size: 2 --- diff --git "a/examples/Mus\303\251e d'Orsay.md" "b/examples/Mus\303\251e d'Orsay.md" index 7457f6b..e865ef1 100644 --- "a/examples/Mus\303\251e d'Orsay.md" +++ "b/examples/Mus\303\251e d'Orsay.md" @@ -3,9 +3,10 @@ categories: - "[[Places]]" type: - "[[Museums]]" -rating: 5 +rating: 3 address: Esplanade Valéry Giscard d'Estaing, 75007 Paris, France coordinates: - "48.8599614" - "2.3265614" +size: 3 --- diff --git a/examples/Places.base b/examples/Places.base index b1d2f8d..e324339 100644 --- a/examples/Places.base +++ b/examples/Places.base @@ -4,6 +4,9 @@ filters: formulas: Type icon: list(type)[0].asFile().properties.icon Type color: list(type)[0].asFile().properties.color + SVG Marker: "''" + Dynamic SVG Marker: "'' + rating + ''" + Dynamic Size SVG Marker: "''" properties: file.name: displayName: Place @@ -62,3 +65,50 @@ views: coordinates: note.coordinates markerIcon: formula.Type icon markerColor: formula.Type color + - type: map + name: SVG markers + order: + - file.name + - architect + - rating + - formula.SVG Marker + coordinates: note.coordinates + markerIcon: note.icon + markerColor: note.color + defaultZoom: 12 + markerSvg: formula.SVG Marker + - type: map + name: Dynamic text SVG markers + order: + - file.name + - architect + - rating + - formula.Dynamic SVG Marker + coordinates: note.coordinates + markerIcon: note.icon + markerColor: note.color + defaultZoom: 12 + markerSvg: formula.Dynamic SVG Marker + - type: map + name: Dynamic size SVG markers + order: + - file.name + - architect + - rating + - formula.Dynamic Size SVG Marker + coordinates: note.coordinates + markerIcon: note.icon + markerColor: note.color + defaultZoom: 12 + markerSvg: formula.Dynamic Size SVG Marker + - type: map + name: Per-entry SVG markers + order: + - file.name + - architect + - rating + coordinates: note.coordinates + markerIcon: note.icon + markerColor: note.color + defaultZoom: 12 + markerSvg: note.entry svg marker diff --git a/examples/Readme.md b/examples/Readme.md index c47cbbd..9f9f065 100644 --- a/examples/Readme.md +++ b/examples/Readme.md @@ -31,3 +31,29 @@ You can see these properties by selecting **Properties** at the top of the base ## Related notes map See the [[Museums]] note for an example of a map that only displays markers for its assigned type. + +## Custom SVG markers + +For full control over marker appearance, use the **Marker SVG** property to provide custom SVG markup. This works well with formulas to create dynamic markers. + +![[Places.base#SVG markers]] + +The **SVG Marker** formula renders a custom pin shape: + +```js +'...' +``` + +You can also create markers with dynamic content. The **Dynamic SVG marker** formula displays each place's rating inside a pill: + +```js +'...' + rating + '...' +``` + +Or vary marker size based on data as in the **Dynamic size SVG marker** formula: + +```js +'...' +``` + +SVGs with explicit `width` and `height` display at that fixed size. SVGs with only a `viewBox` scale with zoom like default markers. diff --git a/src/map-view.ts b/src/map-view.ts index 5dedcf6..0f78f48 100644 --- a/src/map-view.ts +++ b/src/map-view.ts @@ -24,6 +24,7 @@ interface MapConfig { coordinatesProp: BasesPropertyId | null; markerIconProp: BasesPropertyId | null; markerColorProp: BasesPropertyId | null; + markerSvgProp: BasesPropertyId | null; mapHeight: number; defaultZoom: number; center: [number, number]; @@ -113,7 +114,7 @@ export class MapView extends BasesView { if (!this.map || !this.mapConfig) return; const newStyle = await this.styleManager.getMapStyle(this.mapConfig.mapTiles, this.mapConfig.mapTilesDark); this.map.setStyle(newStyle); - this.markerManager.clearLoadedIcons(); + this.markerManager.clearLoadedMarkerImages(); // Re-add markers after style change since setStyle removes all runtime layers this.map.once('styledata', () => { @@ -407,7 +408,7 @@ export class MapView extends BasesView { const currentStyle = this.map.getStyle(); if (JSON.stringify(newStyle) !== JSON.stringify(currentStyle)) { this.map.setStyle(newStyle); - this.markerManager.clearLoadedIcons(); + this.markerManager.clearLoadedMarkerImages(); } } @@ -442,6 +443,7 @@ export class MapView extends BasesView { const coordinatesProp = this.config.getAsPropertyId('coordinates'); const markerIconProp = this.config.getAsPropertyId('markerIcon'); const markerColorProp = this.config.getAsPropertyId('markerColor'); + const markerSvgProp = this.config.getAsPropertyId('markerSvg'); // Load numeric configurations with validation const minZoom = this.getNumericConfig('minZoom', 0, 0, 24); @@ -493,6 +495,7 @@ export class MapView extends BasesView { coordinatesProp, markerIconProp, markerColorProp, + markerSvgProp, mapHeight, defaultZoom, center, @@ -753,6 +756,13 @@ export class MapView extends BasesView { filter: prop => !prop.startsWith('file.'), placeholder: 'Property', }, + { + displayName: 'Marker SVG', + type: 'property', + key: 'markerSvg', + filter: prop => !prop.startsWith('file.'), + placeholder: 'Property', + }, ] }, { diff --git a/src/map/constants.ts b/src/map/constants.ts index e97eb6f..29e0ae7 100644 --- a/src/map/constants.ts +++ b/src/map/constants.ts @@ -2,3 +2,5 @@ export const DEFAULT_MAP_HEIGHT = 400; export const DEFAULT_MAP_CENTER: [number, number] = [0, 0]; export const DEFAULT_MAP_ZOOM = 4; +export const SVG_MARKER_RENDER_SCALE = 2; +export const SVG_MARKER_REFERENCE_SIZE = 48; diff --git a/src/map/markers.ts b/src/map/markers.ts index 8f28c45..820e5cd 100644 --- a/src/map/markers.ts +++ b/src/map/markers.ts @@ -1,16 +1,17 @@ import { App, BasesEntry, BasesPropertyId, Keymap, Menu, setIcon } from 'obsidian'; -import { Map, LngLatBounds, GeoJSONSource, MapLayerMouseEvent } from 'maplibre-gl'; +import { Map as MapLibreMap, LngLatBounds, GeoJSONSource, MapLayerMouseEvent } from 'maplibre-gl'; import { MapMarker, MapMarkerProperties } from './types'; import { coordinateFromValue } from './utils'; import { PopupManager } from './popup'; +import {SVG_MARKER_REFERENCE_SIZE, SVG_MARKER_RENDER_SCALE} from "./constants"; export class MarkerManager { - private map: Map | null = null; + private map: MapLibreMap | null = null; private app: App; private mapEl: HTMLElement; private markers: MapMarker[] = []; private bounds: LngLatBounds | null = null; - private loadedIcons: Set = new Set(); + private loadedMarkerImages: Map = new Map(); private popupManager: PopupManager; private onOpenFile: (path: string, newLeaf: boolean) => void; private getData: () => any; @@ -35,7 +36,7 @@ export class MarkerManager { this.getDisplayName = getDisplayName; } - setMap(map: Map | null): void { + setMap(map: MapLibreMap | null): void { this.map = map; } @@ -47,8 +48,8 @@ export class MarkerManager { return this.bounds; } - clearLoadedIcons(): void { - this.loadedIcons.clear(); + clearLoadedMarkerImages(): void { + this.loadedMarkerImages.clear(); } async updateMarkers(data: { data: BasesEntry[] }): Promise { @@ -88,8 +89,8 @@ export class MarkerManager { bounds.extend([lng, lat]); }); - // Load all custom icons and create GeoJSON features - await this.loadCustomIcons(validMarkers); + // Load all marker images and create GeoJSON features + await this.loadMarkerImages(validMarkers); const features = this.createGeoJSONFeatures(validMarkers); // Update or create the markers source @@ -162,48 +163,77 @@ export class MarkerManager { } } - private async loadCustomIcons(markers: MapMarker[]): Promise { + private getCustomSvg(entry: BasesEntry): string | null { + const mapConfig = this.getMapConfig(); + if (!mapConfig || !mapConfig.markerSvgProp) return null; + + try { + const value = entry.getValue(mapConfig.markerSvgProp); + if (!value || !value.isTruthy()) return null; + return value.toString().trim() || null; + } + catch { + return null; + } + } + + private async loadMarkerImages(markers: MapMarker[]): Promise { if (!this.map) return; - // Collect all unique icon+color combinations that need to be loaded - const compositeImagesToLoad: Array<{ icon: string | null; color: string }> = []; - const uniqueKeys = new Set(); - + // Collect all unique marker image combinations that need to be loaded + const markerImagesToLoad: Array<{ icon: string | null; color: string; svgString: string | null; imageKey: string }> = []; + for (const markerData of markers) { const icon = this.getCustomIcon(markerData.entry); const color = this.getCustomColor(markerData.entry) || 'var(--bases-map-marker-background)'; - const compositeKey = this.getCompositeImageKey(icon, color); - - if (!this.loadedIcons.has(compositeKey)) { - if (!uniqueKeys.has(compositeKey)) { - compositeImagesToLoad.push({ icon, color }); - uniqueKeys.add(compositeKey); + const svgString = this.getCustomSvg(markerData.entry); + const imageKey = this.getMarkerImageKey(icon, color, svgString); + + if (!this.loadedMarkerImages.has(imageKey)) { + // Check if we already queued this key in current batch + if (!markerImagesToLoad.some(item => item.imageKey === imageKey)) { + markerImagesToLoad.push({ icon, color, svgString, imageKey }); } } } - // Create composite images for each unique icon+color combination - for (const { icon, color } of compositeImagesToLoad) { + // Create images for each unique combination + for (const { icon, color, svgString, imageKey } of markerImagesToLoad) { try { - const compositeKey = this.getCompositeImageKey(icon, color); - const img = await this.createCompositeMarkerImage(icon, color); - + // Use custom SVG rendering when svgString is provided, otherwise use composite marker + const { img, fixedSize, svgError } = svgString + ? await this.createSvgMarkerImage(svgString) + : { img: await this.createCompositeMarkerImage(icon, color), fixedSize: false, svgError: undefined }; + if (this.map) { // Force update of the image on theme change - if (this.map.hasImage(compositeKey)) { - this.map.removeImage(compositeKey); + if (this.map.hasImage(imageKey)) { + this.map.removeImage(imageKey); } - this.map.addImage(compositeKey, img); - this.loadedIcons.add(compositeKey); + this.map.addImage(imageKey, img); + this.loadedMarkerImages.set(imageKey, { fixedSize, svgError }); } } catch (error) { - console.warn(`Failed to create composite marker for icon ${icon}:`, error); + console.warn(`Failed to create marker image for icon ${icon}:`, error); } } } - private getCompositeImageKey(icon: string | null, color: string): string { - return `marker-${icon || 'dot'}-${color.replace(/[^a-zA-Z0-9]/g, '')}`; + private hashSvg(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash).toString(36); + } + + private getMarkerImageKey(icon: string | null, color: string, svgString: string | null): string { + if (svgString) { + return `marker-svg-${this.hashSvg(svgString)}-${svgString.length}`; + } + const colorKey = color.replace(/[^a-zA-Z0-9]/g, ''); + return `marker-${icon || 'dot'}-${colorKey}`; } private resolveColor(color: string): string { @@ -234,7 +264,7 @@ export class MarkerManager { canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); - + if (!ctx) { throw new Error('Failed to get canvas context'); } @@ -247,33 +277,33 @@ export class MarkerManager { const centerX = size / 2; const centerY = size / 2; const radius = 12 * scale; - + ctx.fillStyle = resolvedColor; ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); ctx.fill(); - + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; ctx.lineWidth = 1 * scale; ctx.stroke(); - + // Draw the icon or dot if (icon) { // Load and draw custom icon const iconDiv = createDiv(); setIcon(iconDiv, icon); const svgEl = iconDiv.querySelector('svg'); - + if (svgEl) { svgEl.setAttribute('stroke', 'currentColor'); svgEl.setAttribute('fill', 'none'); svgEl.setAttribute('stroke-width', '2'); svgEl.style.color = resolvedIconColor; - + const svgString = new XMLSerializer().serializeToString(svgEl); const iconImg = new Image(); iconImg.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgString); - + await new Promise((resolve, reject) => { iconImg.onload = () => { // Draw icon centered and scaled @@ -306,25 +336,156 @@ export class MarkerManager { reject(new Error('Failed to create image blob')); return; } - + const img = new Image(); - img.onload = () => resolve(img); + img.onload = () => { + resolve(img); + URL.revokeObjectURL(img.src); + }; img.onerror = reject; img.src = URL.createObjectURL(blob); }); }); } + private createInvalidSvgFallbackImage() { + return this.createCompositeMarkerImage('help-circle', 'var(--bases-map-marker-background)'); + } + + private getSvgDimensions(svgEl: Element): { width: number; height: number; fixedSize: boolean } | null { + const width = this.parseNumericSvgValue(svgEl.getAttribute('width')); + const height = this.parseNumericSvgValue(svgEl.getAttribute('height')); + + // Fixed size: both width AND height explicitly specified + if (width !== null && height !== null) { + return { width, height, fixedSize: true }; + } + + const viewBox = svgEl.getAttribute('viewBox'); + if (viewBox) { + const parts = viewBox.split(/[\s,]+/).map(Number); + if (parts.length === 4 && parts.every(n => !isNaN(n))) { + const [, , vbWidth, vbHeight] = parts; + // Partial dimensions + viewBox: derive missing dimension, treat as fixed + if (width !== null) return { width, height: width * (vbHeight / vbWidth), fixedSize: true }; + if (height !== null) return { width: height * (vbWidth / vbHeight), height, fixedSize: true }; + // Only viewBox: scalable like default markers + return { width: vbWidth, height: vbHeight, fixedSize: false }; + } + } + + // Partial dimensions without viewBox: can't determine aspect ratio reliably + // Fall back to scalable behavior with a warning + if (width !== null) { + console.warn('SVG marker has width but no viewBox. Add viewBox for correct aspect ratio.'); + return { width, height: width, fixedSize: false }; + } + if (height !== null) { + console.warn('SVG marker has height but no viewBox. Add viewBox for correct aspect ratio.'); + return { width: height, height, fixedSize: false }; + } + + // No usable dimensions - signal to use fallback marker + console.warn('SVG marker missing viewBox and dimensions. Add viewBox for correct rendering.'); + return null; + } + + private parseNumericSvgValue(value: string | null): number | null { + if (!value) return null; + // Accept plain numbers or px values, reject %, em, etc. + const match = value.match(/^(\d+(?:\.\d+)?)(px)?$/); + return match ? parseFloat(match[1]) : null; + } + + private async createSvgMarkerImage(svgString: string): Promise<{ img: HTMLImageElement; fixedSize: boolean; svgError?: string }> { + // Parse SVG + const parser = new DOMParser(); + const svgDoc = parser.parseFromString(svgString, 'image/svg+xml'); + const svgEl = svgDoc.documentElement; + + // Check for parse errors + const parserError = svgDoc.querySelector('parsererror'); + if (parserError) { + const errorMsg = `${parserError.textContent || 'Parse error'}`; + console.warn(errorMsg); + const fallbackImg = await this.createInvalidSvgFallbackImage(); + return { img: fallbackImg, fixedSize: false, svgError: errorMsg }; + } + + // Verify it's actually an SVG element + if (svgEl.tagName.toLowerCase() !== 'svg') { + const errorMsg = 'Expected element'; + console.warn(errorMsg); + const fallbackImg = await this.createInvalidSvgFallbackImage(); + return { img: fallbackImg, fixedSize: false, svgError: errorMsg }; + } + + // Get dimensions + const dimensions = this.getSvgDimensions(svgEl); + if (!dimensions) { + const errorMsg = 'Custom SVG marker needs a viewBox and/or explicit width and height for correct rendering.'; + const fallbackImg = await this.createInvalidSvgFallbackImage(); + return { img: fallbackImg, fixedSize: false, svgError: errorMsg }; + } + + const { width, height, fixedSize } = dimensions; + + let finalWidth: number; + let finalHeight: number; + + if (fixedSize) { + // Use specified dimensions + finalWidth = width; + finalHeight = height; + } else { + // Normalize to reference size (like composite markers), preserving aspect ratio + const maxDim = Math.max(width, height); + const scale = SVG_MARKER_REFERENCE_SIZE / maxDim; + finalWidth = width * scale; + finalHeight = height * scale; + } + + // Ensure xmlns is set (required for data URL serialization) + if (!svgEl.getAttribute('xmlns')) { + svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + } + + // Create viewBox from explicit dimensions if not present + if (!svgEl.getAttribute('viewBox') && fixedSize) { + svgEl.setAttribute('viewBox', `0 0 ${width} ${height}`); + } + + svgEl.setAttribute('width', String(finalWidth * SVG_MARKER_RENDER_SCALE)); + svgEl.setAttribute('height', String(finalHeight * SVG_MARKER_RENDER_SCALE)); + + const img = new Image(); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent( + new XMLSerializer().serializeToString(svgEl) + ); + + return new Promise((resolve, reject) => { + img.onload = () => resolve({ img, fixedSize }); + img.onerror = () => reject(new Error('Failed to load SVG')); + }); + } + private createGeoJSONFeatures(markers: MapMarker[]): GeoJSON.Feature[] { return markers.map((markerData, index) => { const [lat, lng] = markerData.coordinates; const icon = this.getCustomIcon(markerData.entry); const color = this.getCustomColor(markerData.entry) || 'var(--bases-map-marker-background)'; - const compositeKey = this.getCompositeImageKey(icon, color); + const svgString = this.getCustomSvg(markerData.entry); + const imageKey = this.getMarkerImageKey(icon, color, svgString); + + const cachedImage = this.loadedMarkerImages.get(imageKey); + const fixedSize = cachedImage?.fixedSize ?? false; + const svgError = cachedImage?.svgError; const properties: MapMarkerProperties = { entryIndex: index, - icon: compositeKey, // Use composite image key + imageKey, + fixedSize, + ...(svgError && { svgError }), }; return { @@ -341,21 +502,23 @@ export class MarkerManager { private addMarkerLayers(): void { if (!this.map) return; - // Add a single symbol layer for composite marker images + const svgFixedSize = 1 / SVG_MARKER_RENDER_SCALE; + + // Add a single symbol layer for marker images this.map.addLayer({ id: 'marker-pins', type: 'symbol', source: 'markers', layout: { - 'icon-image': ['get', 'icon'], + 'icon-image': ['get', 'imageKey'], 'icon-size': [ 'interpolate', ['linear'], ['zoom'], - 0, 0.12, // Very small - 4, 0.18, - 14, 0.22, // Normal size - 18, 0.24 + 0, ['case', ['get', 'fixedSize'], svgFixedSize, 0.12], + 4, ['case', ['get', 'fixedSize'], svgFixedSize, 0.18], + 14, ['case', ['get', 'fixedSize'], svgFixedSize, 0.22], + 18, ['case', ['get', 'fixedSize'], svgFixedSize, 0.24] ], 'icon-allow-overlap': true, 'icon-ignore-placement': true, @@ -386,6 +549,7 @@ export class MarkerManager { const data = this.getData(); const mapConfig = this.getMapConfig(); if (data && data.properties && mapConfig) { + const svgError = feature.properties?.svgError; this.popupManager.showPopup( markerData.entry, markerData.coordinates, @@ -393,7 +557,9 @@ export class MarkerManager { mapConfig.coordinatesProp, mapConfig.markerIconProp, mapConfig.markerColorProp, - this.getDisplayName + mapConfig.markerSvgProp, + this.getDisplayName, + svgError ); } } diff --git a/src/map/popup.ts b/src/map/popup.ts index f6609a5..178b98d 100644 --- a/src/map/popup.ts +++ b/src/map/popup.ts @@ -25,12 +25,15 @@ export class PopupManager { coordinatesProp: BasesPropertyId | null, markerIconProp: BasesPropertyId | null, markerColorProp: BasesPropertyId | null, - getDisplayName: (prop: BasesPropertyId) => string + markerSvgProp: BasesPropertyId | null, + getDisplayName: (prop: BasesPropertyId) => string, + svgError?: string ): void { if (!this.map) return; - // Only show popup if there are properties to display - if (!properties || properties.length === 0 || !this.hasAnyPropertyValues(entry, properties, coordinatesProp, markerIconProp, markerColorProp)) { + // Show popup if there are properties to display OR if there's an SVG error + const hasProperties = properties && properties.length > 0 && this.hasAnyPropertyValues(entry, properties, coordinatesProp, markerIconProp, markerColorProp, markerSvgProp); + if (!hasProperties && !svgError) { return; } @@ -60,7 +63,7 @@ export class PopupManager { // Update popup content and position const [lat, lng] = coordinates; - const popupContent = this.createPopupContent(entry, properties, coordinatesProp, markerIconProp, markerColorProp, getDisplayName); + const popupContent = this.createPopupContent(entry, properties, coordinatesProp, markerIconProp, markerColorProp, markerSvgProp, getDisplayName, svgError); this.sharedPopup .setDOMContent(popupContent) .setLngLat([lng, lat]) @@ -104,7 +107,9 @@ export class PopupManager { coordinatesProp: BasesPropertyId | null, markerIconProp: BasesPropertyId | null, markerColorProp: BasesPropertyId | null, - getDisplayName: (prop: BasesPropertyId) => string + markerSvgProp: BasesPropertyId | null, + getDisplayName: (prop: BasesPropertyId) => string, + svgError?: string ): HTMLElement { const containerEl = createDiv('bases-map-popup'); @@ -113,7 +118,7 @@ export class PopupManager { const propertiesWithValues = []; for (const prop of propertiesSlice) { - if (prop === coordinatesProp || prop === markerIconProp || prop === markerColorProp) continue; // Skip coordinates, marker icon, and marker color properties + if (prop === coordinatesProp || prop === markerIconProp || prop === markerColorProp || prop === markerSvgProp) continue; // Skip coordinates, marker icon, marker color, and marker SVG properties try { const value = entry.getValue(prop); @@ -140,6 +145,13 @@ export class PopupManager { // Render the first property value inside the link firstProperty.value.renderTo(titleLinkEl, this.app.renderContext); + // Show custom SVG error below title if present + if (svgError) { + const errorEl = containerEl.createDiv('bases-map-popup-error'); + errorEl.createSpan({ cls: 'bases-map-popup-error-icon', text: '⚠' }); + errorEl.createSpan({ cls: 'bases-map-popup-error-message', text: `Invalid marker SVG: ${svgError}` }); + } + // Show remaining properties (excluding the first one used as title) const remainingProperties = propertiesWithValues.slice(1); if (remainingProperties.length > 0) { @@ -179,12 +191,13 @@ export class PopupManager { properties: BasesPropertyId[], coordinatesProp: BasesPropertyId | null, markerIconProp: BasesPropertyId | null, - markerColorProp: BasesPropertyId | null + markerColorProp: BasesPropertyId | null, + markerSvgProp: BasesPropertyId | null ): boolean { const propertiesSlice = properties.slice(0, 20); // Max 20 properties for (const prop of propertiesSlice) { - if (prop === coordinatesProp || prop === markerIconProp || prop === markerColorProp) continue; // Skip coordinates, marker icon, and marker color properties + if (prop === coordinatesProp || prop === markerIconProp || prop === markerColorProp || prop === markerSvgProp) continue; // Skip coordinates, marker icon, marker color, and marker SVG properties try { const value = entry.getValue(prop); diff --git a/src/map/types.ts b/src/map/types.ts index 958e1b7..563b6f6 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -7,6 +7,8 @@ export interface MapMarker { export interface MapMarkerProperties { entryIndex: number; - icon: string; // Composite image key combining icon and color + imageKey: string; // Cache key for the marker image (composite or custom SVG) + fixedSize: boolean; // SVGs with explicit dimensions are fixed; others scale with zoom + svgError?: string; // Error message if SVG parsing/rendering failed } diff --git a/styles.css b/styles.css index 89ffd37..6508f3d 100644 --- a/styles.css +++ b/styles.css @@ -106,6 +106,27 @@ body { padding: 0 var(--size-4-2); } +.bases-map-popup-error { + display: flex; + align-items: flex-start; + gap: var(--size-4-1); + margin: 0 var(--size-4-2); + margin-bottom: var(--size-4-2); + padding: var(--size-4-2); + font-size: var(--font-ui-smaller); + color: var(--text-error); + border-radius: var(--radius-s); + border: var(--background-modifier-error) solid 1px; +} + +.bases-map-popup-error-icon { + flex-shrink: 0; +} + +.bases-map-popup-error-message { + word-break: break-word; +} + .maplibregl-ctrl-attrib { background-color: rgba(var(--mono-rgb-0), 0.8); color: var(--text-muted); @@ -474,4 +495,4 @@ a.maplibregl-ctrl-logo.maplibregl-compact { background: var(--background-primary); border: 2px dotted #202020; opacity: 0.5; -} \ No newline at end of file +}