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: "''"
+ 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
+''
+```
+
+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