This commit is contained in:
ccmdi 2025-10-09 09:26:37 -04:00
commit 2f1c4e0708
No known key found for this signature in database
21 changed files with 5643 additions and 0 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
main.js

23
.eslintrc Normal file
View file

@ -0,0 +1,23 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
*.md
!README.md

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

5
LICENSE Normal file
View file

@ -0,0 +1,5 @@
Copyright (C) 2020-2025 by Dynalist Inc.
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

2
README.md Normal file
View file

@ -0,0 +1,2 @@
# TODO
standalone view maybe

49
esbuild.config.mjs Normal file
View file

@ -0,0 +1,49 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2020",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

11
manifest.json Normal file
View file

@ -0,0 +1,11 @@
{
"id": "map",
"name": "Map",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "A performant and customizable map inside Bases",
"author": "ccmdi",
"authorUrl": "https://github.com/ccmdi",
"fundingUrl": "https://github.com/sponsors/ccmdi",
"isDesktopOnly": false
}

3509
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"@deck.gl/core": "^9.1.15",
"@deck.gl/geo-layers": "^9.1.15",
"@deck.gl/layers": "^9.1.15"
}
}

60
src/main.ts Normal file
View file

@ -0,0 +1,60 @@
import { Plugin } from 'obsidian';
import { MapBasesView } from './views/map-bases-view';
import { MapTagSettings, DEFAULT_MAP_TAG_SETTINGS } from './settings/map-tag-settings';
import { MapSettingTab } from './settings/map-settings';
interface MapPluginSettings {
latKey: string;
lngKey: string;
strokeWidth: number;
iconFill: boolean;
tagSettings: MapTagSettings;
}
const DEFAULT_SETTINGS: MapPluginSettings = {
latKey: '',
lngKey: '',
strokeWidth: 2.5,
iconFill: false,
tagSettings: DEFAULT_MAP_TAG_SETTINGS
};
export default class MapPlugin extends Plugin {
settings: MapPluginSettings;
get tagSettings(): MapTagSettings {
return this.settings.tagSettings;
}
async onload() {
await this.loadSettings();
this.registerBasesView('map', {
name: 'Map',
icon: 'lucide-map',
factory: (controller, containerEl) => new MapBasesView(controller, containerEl, this),
options: MapBasesView.getViewOptions,
});
this.addSettingTab(new MapSettingTab(this.app, this));
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
async saveTagSettings() {
await this.saveSettings();
this.refreshAllMapViews();
}
refreshAllMapViews() {
this.app.workspace.trigger('map:refresh-all-views');
}
onunload() {}
}

626
src/map-renderer.ts Normal file
View file

@ -0,0 +1,626 @@
import { App, TFile, setIcon } from 'obsidian';
import { Deck, PickingInfo, MapViewState } from '@deck.gl/core';
import { BitmapLayer, IconLayer, ScatterplotLayer } from '@deck.gl/layers';
import { TileLayer } from '@deck.gl/geo-layers';
import type { TileLayerProps, _Tile2DHeader as Tile2DHeader } from '@deck.gl/geo-layers';
import type { MjolnirEvent } from 'mjolnir.js';
import { MapView as MapViewType } from '@deck.gl/core';
import { MapTagSettings } from './settings/map-tag-settings';
import type MapPlugin from './main';
type PropertyValue = string | number | boolean | string[] | null;
interface MapProperty {
name: string;
value: PropertyValue;
}
export interface MapPoint {
lat: number;
lng: number;
title: string;
color?: string;
size?: number;
cover?: string;
file?: TFile | string;
tags?: string[];
properties?: MapProperty[];
}
interface TileIndex {
index: {
x: number;
y: number;
z: number;
};
}
interface DeckDataPoint {
position: [number, number];
color: [number, number, number];
radius: number;
point: MapPoint;
}
export interface MapRendererOptions {
containerEl: HTMLElement;
points: MapPoint[];
app: App;
settings: MapPlugin['settings'];
tagSettings?: MapTagSettings;
options: {
center?: [number, number];
zoom?: number;
height?: string;
markerType?: 'pins' | 'dots';
markerSize?: number;
markerColor?: string;
tileLayer?: string;
showSearch?: boolean;
showTags?: boolean;
onMarkerClick?: (point: MapPoint, event: MjolnirEvent) => void;
onTilesLoaded?: () => void;
};
}
function handlePointClick(info: PickingInfo<DeckDataPoint>, event: MjolnirEvent, options: MapRendererOptions['options'], app: App) {
if (info.object) {
if (options.onMarkerClick) {
options.onMarkerClick(info.object.point, event);
} else if (info.object.point.file) {
const file = typeof info.object.point.file === 'string'
? app.vault.getAbstractFileByPath(info.object.point.file)
: info.object.point.file;
if (file) {
const srcEvent = event?.srcEvent;
const newTab =
(srcEvent && 'button' in srcEvent && srcEvent.button === 1) ||
srcEvent?.ctrlKey ||
srcEvent?.metaKey;
app.workspace.getLeaf(newTab).openFile(file as TFile);
}
}
}
}
export function updateMapPoints(deck: Deck<MapViewType[]>, points: MapPoint[], config: Pick<MapRendererOptions, 'settings' | 'tagSettings' | 'options' | 'app'>): void {
const { settings, tagSettings, options, app } = config;
const markerType = options.markerType || 'pins';
const markerSize = options.markerSize || 100;
const hexToRgb = (hex: string): [number, number, number] => {
// Handle CSS variables like var(--color-accent)
if (hex.startsWith('var(')) {
const tempEl = document.body.createDiv();
tempEl.style.color = hex;
document.body.appendChild(tempEl);
const computedColor = getComputedStyle(tempEl).color;
document.body.removeChild(tempEl);
const rgbMatch = computedColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
if (rgbMatch) {
return [parseInt(rgbMatch[1]), parseInt(rgbMatch[2]), parseInt(rgbMatch[3])];
}
}
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
: [118, 109, 243];
};
const getPointColor = (point: MapPoint): string => {
if (point.color) {
return point.color;
}
if (tagSettings && point.tags && point.tags.length > 0) {
for (const priorityTag of tagSettings.tagPriority) {
if (point.tags.includes(priorityTag)) {
const customization = tagSettings.tagCustomizations[priorityTag];
if (customization) {
return customization.color;
}
}
}
}
return options.markerColor || 'var(--color-accent)';
};
const getPointIcon = (point: MapPoint): string | null => {
if (tagSettings && point.tags && point.tags.length > 0) {
for (const priorityTag of tagSettings.tagPriority) {
if (point.tags.includes(priorityTag)) {
const customization = tagSettings.tagCustomizations[priorityTag];
if (customization && customization.icon) {
return customization.icon;
}
}
}
}
return null;
};
const iconSVGCache = new Map<string, string | null>();
const getIconSVG = (iconName: string, strokeWidth: number, fill: boolean): string | null => {
const cacheKey = `${iconName}-${strokeWidth}-${fill}`;
if (iconSVGCache.has(cacheKey)) {
return iconSVGCache.get(cacheKey)!;
}
try {
const tempDiv = document.createElement('div');
setIcon(tempDiv, iconName);
const svg = tempDiv.querySelector('svg');
if (svg) {
let svgContent = svg.outerHTML;
svgContent = svgContent.replace(/stroke-width="[^"]*"/g, `stroke-width="${strokeWidth}"`);
if (fill) {
svgContent = svgContent.replace(/fill="[^"]*"/g, 'fill="white"');
}
iconSVGCache.set(cacheKey, svgContent);
return svgContent;
}
} catch (e) {
// Icon not available
}
iconSVGCache.set(cacheKey, null);
return null;
};
const deckData: DeckDataPoint[] = points.map(point => ({
position: [point.lng, point.lat] as [number, number],
color: hexToRgb(getPointColor(point)),
radius: point.size || markerSize,
point: point,
}));
const currentLayers = deck.props.layers;
if (!currentLayers || currentLayers.length === 0) return;
const tileLayer = currentLayers[0];
const markerLayer = markerType === 'pins'
? new IconLayer({
id: 'icon-layer',
data: deckData,
pickable: true,
getIcon: (d: DeckDataPoint) => {
const [r, g, b] = d.color;
const icon = getPointIcon(d.point);
let innerContent = `<circle cx="12" cy="12" r="4" fill="white"/>`;
if (icon) {
const iconSVG = getIconSVG(icon, settings.strokeWidth, settings.iconFill);
if (iconSVG) {
innerContent = `<g transform="translate(3.5, 3.5) scale(0.7)" style="color: white;">${iconSVG}</g>`;
}
}
return {
url:
'data:image/svg+xml;charset=utf-8,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="36" viewBox="0 0 24 36">
<path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24c0-6.6-5.4-12-12-12z" fill="rgb(${r},${g},${b})"/>
${innerContent}
</svg>`
),
width: 24,
height: 36,
anchorY: 36,
};
},
getPosition: (d: DeckDataPoint) => d.position,
getSize: (d: DeckDataPoint) => d.radius * 0.3,
sizeScale: 1,
sizeMinPixels: 8,
sizeMaxPixels: 60,
onClick: (info: PickingInfo<DeckDataPoint>, event: MjolnirEvent) => handlePointClick(info, event, options, app),
})
: new ScatterplotLayer({
id: 'scatterplot-layer',
data: deckData,
pickable: true,
opacity: 0.8,
stroked: false,
filled: true,
radiusScale: 1,
radiusMinPixels: 3,
radiusMaxPixels: 100,
getPosition: (d: DeckDataPoint) => d.position,
getRadius: (d: DeckDataPoint) => d.radius,
getFillColor: (d: DeckDataPoint) => d.color,
onClick: (info: PickingInfo<DeckDataPoint>, event: MjolnirEvent) => handlePointClick(info, event, options, app),
});
deck.setProps({ layers: [tileLayer, markerLayer] });
}
export async function createMapRenderer(config: MapRendererOptions): Promise<Deck<MapViewType[]>> {
const { containerEl, points, app, settings, tagSettings, options } = config;
containerEl.addClass('map-container');
const markerType = options.markerType || 'pins';
const markerSize = options.markerSize || 100;
const markerColor = options.markerColor || 'var(--color-accent)';
const tileLayer = options.tileLayer || 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
containerEl.empty();
containerEl.style.setProperty('--bases-map-height', options.height || '100%');
const mapCanvas = containerEl.createEl('canvas', { cls: 'map-canvas' });
const tooltip = containerEl.createEl('div', { cls: 'map-tooltip' });
const hexToRgb = (hex: string): [number, number, number] => {
// Handle CSS variables like var(--color-accent)
if (hex.startsWith('var(')) {
const tempEl = document.body.createDiv();
tempEl.style.color = hex;
document.body.appendChild(tempEl);
const computedColor = getComputedStyle(tempEl).color;
document.body.removeChild(tempEl);
const rgbMatch = computedColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
if (rgbMatch) {
return [parseInt(rgbMatch[1]), parseInt(rgbMatch[2]), parseInt(rgbMatch[3])];
}
}
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
: [0, 0, 0];
};
const getPointColor = (point: MapPoint): string => {
if (point.color) {
return point.color;
}
if (tagSettings && point.tags && point.tags.length > 0) {
for (const priorityTag of tagSettings.tagPriority) {
if (point.tags.includes(priorityTag)) {
const customization = tagSettings.tagCustomizations[priorityTag];
if (customization) {
return customization.color;
}
}
}
}
// default
return markerColor;
};
const getPointIcon = (point: MapPoint): string | null => {
if (tagSettings && point.tags && point.tags.length > 0) {
for (const priorityTag of tagSettings.tagPriority) {
if (point.tags.includes(priorityTag)) {
const customization = tagSettings.tagCustomizations[priorityTag];
if (customization && customization.icon) {
return customization.icon;
}
}
}
}
return null;
};
const numPoints = points.length;
const deckData: DeckDataPoint[] = new Array(numPoints);
for (let i = 0; i < numPoints; i++) {
const point = points[i];
deckData[i] = {
position: [point.lng, point.lat] as [number, number],
color: hexToRgb(getPointColor(point)),
radius: point.size || markerSize,
point: point,
};
}
// calculate bounds
let initialViewState: MapViewState;
if (options.center && options.center[0] !== 0 && options.center[1] !== 0) {
initialViewState = {
longitude: options.center[1],
latitude: options.center[0],
zoom: options.zoom || 4,
pitch: 0,
bearing: 0,
};
} else {
// fit
let minLat = Infinity, maxLat = -Infinity;
let minLng = Infinity, maxLng = -Infinity;
for (let i = 0; i < numPoints; i++) {
const p = points[i];
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
if (p.lng < minLng) minLng = p.lng;
if (p.lng > maxLng) maxLng = p.lng;
}
const centerLat = (maxLat + minLat) / 2;
const centerLng = (maxLng + minLng) / 2;
const maxDiff = Math.max(maxLat - minLat, maxLng - minLng);
const autoZoom = Math.max(1, Math.min(15, Math.floor(Math.log2(360 / maxDiff)) - 1));
initialViewState = {
longitude: centerLng,
latitude: centerLat,
zoom: autoZoom,
pitch: 0,
bearing: 0,
};
}
let currentCursor = 'grab';
let currentPoint: MapPoint | null = null;
let tooltipUpdateId = 0;
const updateTooltip = async (point: MapPoint, x: number, y: number) => {
const thisUpdateId = ++tooltipUpdateId;
tooltip.textContent = '';
const renderPromises: Promise<void>[] = [];
// show cover image
if (point.cover && point.file) {
const filePath = typeof point.file === 'string' ? point.file : point.file.path;
const coverFile = app.metadataCache.getFirstLinkpathDest(point.cover, filePath);
if (coverFile) {
const img = new Image();
img.classList.add('map-tooltip-cover-image');
const src = app.vault.getResourcePath(coverFile);
img.src = src;
// Decode image off main thread, then append to DOM
const imagePromise = img.decode()
.then(() => {
if (thisUpdateId === tooltipUpdateId) {
tooltip.insertBefore(img, tooltip.firstChild);
}
})
.catch(() => {
if (thisUpdateId === tooltipUpdateId) {
tooltip.insertBefore(img, tooltip.firstChild);
}
});
renderPromises.push(imagePromise);
}
}
const titleEl = tooltip.createEl('div', { cls: 'map-tooltip-title' });
titleEl.textContent = point.title;
// show properties
if (point.properties && point.properties.length > 0) {
const propsContainer = tooltip.createEl('div', { cls: 'map-tooltip-property-container' });
point.properties.forEach(prop => {
const propEl = propsContainer.createEl('div', { cls: 'map-tooltip-property' });
const labelEl = propEl.createEl('span', { cls: 'map-tooltip-property-label' });
labelEl.textContent = prop.name + ':';
const valueEl = propEl.createEl('span', { cls: 'map-tooltip-property-value' });
// render value
if (typeof prop.value === 'string' || typeof prop.value === 'number') {
valueEl.textContent = String(prop.value);
} else if (Array.isArray(prop.value)) {
valueEl.textContent = prop.value.join(', ');
} else if (prop.value === null) {
valueEl.textContent = 'null';
} else if (typeof prop.value === 'boolean') {
valueEl.textContent = String(prop.value);
} else {
valueEl.textContent = JSON.stringify(prop.value);
}
});
}
//todo
if (options.showTags && point.tags && point.tags.length > 0) {
const tagsEl = tooltip.createEl('div', { cls: 'map-tooltip-tags' });
point.tags.forEach((tag) => {
const tagEl = tagsEl.createEl('a', { cls: 'tag' });
tagEl.textContent = `#${tag}`;
});
tooltip.appendChild(tagsEl);
}
// Wait for all images to load before showing tooltip
await Promise.all(renderPromises);
if (thisUpdateId === tooltipUpdateId) {
tooltip.style.setProperty('--tooltip-x', `${x + 15}px`);
tooltip.style.setProperty('--tooltip-y', `${y - 30}px`);
tooltip.addClass('visible');
}
};
const iconSVGCache = new Map<string, string | null>();
const getIconSVG = (iconName: string, strokeWidth: number, fill: boolean): string | null => {
const cacheKey = `${iconName}-${strokeWidth}-${fill}`;
if (iconSVGCache.has(cacheKey)) {
return iconSVGCache.get(cacheKey)!;
}
try {
const tempDiv = document.createElement('div');
setIcon(tempDiv, iconName);
const svg = tempDiv.querySelector('svg');
if (svg) {
let svgContent = svg.outerHTML;
svgContent = svgContent.replace(/stroke-width="[^"]*"/g, `stroke-width="${strokeWidth}"`);
if (fill) {
svgContent = svgContent.replace(/fill="[^"]*"/g, 'fill="white"');
}
iconSVGCache.set(cacheKey, svgContent);
return svgContent;
}
} catch (e) {
console.warn('Failed to get icon SVG:', iconName, e);
}
iconSVGCache.set(cacheKey, null);
return null;
};
const createMarkerLayer = (data: DeckDataPoint[]) => {
if (markerType === 'pins') {
return new IconLayer({
id: 'icon-layer',
data: data,
pickable: true,
getIcon: (d: DeckDataPoint) => {
const [r, g, b] = d.color;
const icon = getPointIcon(d.point);
let innerContent = `<circle cx="12" cy="12" r="4" fill="white"/>`;
if (icon) {
const iconSVG = getIconSVG(icon, settings.strokeWidth, settings.iconFill);
if (iconSVG) {
innerContent = `<g transform="translate(3.5, 3.5) scale(0.7)" style="color: white;">${iconSVG}</g>`;
}
}
return {
url:
'data:image/svg+xml;charset=utf-8,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="36" viewBox="0 0 24 36">
<path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24c0-6.6-5.4-12-12-12z" fill="rgb(${r},${g},${b})"/>
${innerContent}
</svg>`
),
width: 24,
height: 36,
anchorY: 36,
};
},
getPosition: (d: DeckDataPoint) => d.position,
getSize: (d: DeckDataPoint) => d.radius * 0.3,
sizeScale: 1,
sizeMinPixels: 8,
sizeMaxPixels: 60,
onClick: (info: PickingInfo<DeckDataPoint>, event: MjolnirEvent) => handlePointClick(info, event, options, app),
});
} else {
return new ScatterplotLayer({
id: 'scatterplot-layer',
data: data,
pickable: true,
opacity: 0.8,
stroked: false,
filled: true,
radiusScale: 1,
radiusMinPixels: 3,
radiusMaxPixels: 100,
getPosition: (d: DeckDataPoint) => d.position,
getRadius: (d: DeckDataPoint) => d.radius,
getFillColor: (d: DeckDataPoint) => d.color,
onClick: (info: PickingInfo<DeckDataPoint>, event: MjolnirEvent) => handlePointClick(info, event, options, app),
});
}
};
const deck = new Deck({
canvas: mapCanvas,
initialViewState: { MapView: initialViewState },
controller: {
inertia: true,
scrollZoom: { speed: 0.02, smooth: true },
touchRotate: false,
keyboard: false,
},
views: [new MapViewType({ repeat: true })],
onAfterRender: () => {
if (options.onTilesLoaded) {
setTimeout(() => {
options.onTilesLoaded?.();
}, 100);
}
},
getCursor: () => currentCursor,
onHover: (info: PickingInfo<DeckDataPoint>) => {
if (!info) return;
if (info.object) {
// Only update if it's a different point
if (currentPoint !== info.object.point) {
currentPoint = info.object.point;
updateTooltip(info.object.point, info.x, info.y);
} else {
tooltip.style.setProperty('--tooltip-x', `${info.x + 15}px`);
tooltip.style.setProperty('--tooltip-y', `${info.y - 30}px`);
}
currentCursor = 'pointer';
} else {
currentPoint = null;
tooltip.removeClass('visible');
currentCursor = 'grab';
}
},
layers: [
new TileLayer({
id: 'tile-layer',
data: tileLayer,
minZoom: 0,
maxZoom: 19,
tileSize: 256,
refinementStrategy: 'best-available',
// Converts each loaded tile (HTMLImageElement) into a BitmapLayer
// positioned at the tile's geographic bounds for rendering
renderSubLayers: (props: TileLayerProps<HTMLImageElement> & {
id: string;
data: HTMLImageElement;
_offset: number;
tile: Tile2DHeader<HTMLImageElement>;
}) => {
const bbox = props.tile.bbox;
if (!('west' in bbox)) return null;
const { west, south, east, north } = bbox;
return new BitmapLayer({
...props,
data: undefined,
image: props.data,
bounds: [west, south, east, north],
});
},
getTileData: ({ index: { x, y, z } }: TileIndex) => {
const url = tileLayer
.replace('{s}', ['a', 'b', 'c'][Math.abs(x + y) % 3])
.replace('{z}', String(z))
.replace('{x}', String(x))
.replace('{y}', String(y))
.replace('{r}', '');
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
},
}),
createMarkerLayer(deckData),
],
});
return deck;
}

View file

@ -0,0 +1,67 @@
import { PluginSettingTab, App, Setting } from "obsidian";
import MapPlugin from "../main";
import { renderTagCustomizations } from "./map-tag-settings";
export class MapSettingTab extends PluginSettingTab {
plugin: MapPlugin;
constructor(app: App, plugin: MapPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.classList.add('map-settings-container');
containerEl.createEl('h2', { text: 'Map' });
new Setting(containerEl)
.setName('Latitude key')
.setDesc('Frontmatter key for latitude (default for all bases)')
.addText(text => text
.setPlaceholder('lat')
.setValue(this.plugin.settings.latKey)
.onChange(async (value) => {
this.plugin.settings.latKey = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Longitude key')
.setDesc('Frontmatter key for longitude (default for all bases)')
.addText(text => text
.setPlaceholder('lng')
.setValue(this.plugin.settings.lngKey)
.onChange(async (value) => {
this.plugin.settings.lngKey = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Stroke width')
.setDesc('Stroke width for icons')
.addSlider(slider => slider
.setLimits(0.5, 5, 0.1)
.setValue(this.plugin.settings.strokeWidth)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.strokeWidth = value;
await this.plugin.saveSettings();
this.plugin.refreshAllMapViews();
}));
new Setting(containerEl)
.setName('Fill icons')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.iconFill)
.onChange(async (value) => {
this.plugin.settings.iconFill = value;
await this.plugin.saveSettings();
this.plugin.refreshAllMapViews();
}));
renderTagCustomizations(containerEl, this.app, this.plugin);
}
}

View file

@ -0,0 +1,338 @@
import { App, PluginSettingTab, Setting, Modal, TextComponent, setIcon, getIconIds, SuggestModal } from 'obsidian';
import type MapPlugin from '../main';
export interface TagCustomization {
color: string;
icon?: string;
}
export interface MapTagSettings {
tagCustomizations: Record<string, TagCustomization>;
tagPriority: string[];
}
export const DEFAULT_MAP_TAG_SETTINGS: MapTagSettings = {
tagCustomizations: {},
tagPriority: []
};
export class MapTagSettingTab extends PluginSettingTab {
plugin: MapPlugin;
constructor(app: App, plugin: MapPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
renderTagCustomizations(containerEl, this.app, this.plugin);
}
}
export function renderTagCustomizations(containerEl: HTMLElement, app: App, plugin: MapPlugin) {
containerEl.createEl('h3', { text: 'Tags' });
containerEl.createEl('p', {
text: 'Drag to reorder priority. Higher tags take precedence.',
cls: 'setting-item-description'
});
const customizationsContainer = containerEl.createDiv('tag-customizations-container');
displayTagCustomizations(customizationsContainer, app, plugin);
new Setting(containerEl)
.setName('Tag customization')
.addButton(button => button
.setButtonText('Add')
.setCta()
.onClick(() => {
addNewTagCustomization(customizationsContainer, app, plugin);
}));
}
function displayTagCustomizations(container: HTMLElement, app: App, plugin: MapPlugin) {
container.empty();
const tagSettings = plugin.tagSettings;
tagSettings.tagPriority.forEach((tag, index) => {
const customization = tagSettings.tagCustomizations[tag];
if (customization) {
createTagCustomizationSetting(container, tag, customization, index, app, plugin);
}
});
if (tagSettings.tagPriority.length === 0) {
const emptyMessage = container.createDiv();
emptyMessage.textContent = 'No tags configured yet. Add a tag customization below.';
emptyMessage.classList.add('map-empty-state');
}
}
function createTagCustomizationSetting(container: HTMLElement, tag: string, customization: TagCustomization, index: number, app: App, plugin: MapPlugin) {
const settingEl = container.createDiv('draggable-setting');
settingEl.draggable = true;
const handle = settingEl.createSpan('drag-handle');
handle.textContent = '⋮⋮';
const tagLabel = settingEl.createDiv('tag-label');
tagLabel.createSpan().textContent = `#${tag}`;
const colorInput = settingEl.createEl('input', { type: 'color', cls: 'color-input' });
colorInput.value = customization.color;
colorInput.onchange = async () => {
plugin.tagSettings.tagCustomizations[tag].color = colorInput.value;
await plugin.saveTagSettings();
};
const iconBtn = settingEl.createEl('button', { cls: 'icon-btn' });
if (customization.icon) {
setIcon(iconBtn, customization.icon);
} else {
iconBtn.textContent = '';
}
iconBtn.onclick = () => {
new IconPickerModal(app, customization.icon || '', (icon) => {
plugin.tagSettings.tagCustomizations[tag].icon = icon;
plugin.saveTagSettings();
displayTagCustomizations(container, app, plugin);
}).open();
};
settingEl.createDiv({cls: 'spacer'});
const deleteBtn = settingEl.createEl('button', { text: '×', cls: 'delete-btn' });
deleteBtn.onclick = async () => {
delete plugin.tagSettings.tagCustomizations[tag];
const priorityIndex = plugin.tagSettings.tagPriority.indexOf(tag);
if (priorityIndex > -1) {
plugin.tagSettings.tagPriority.splice(priorityIndex, 1);
}
await plugin.saveTagSettings();
displayTagCustomizations(container, app, plugin);
};
settingEl.addEventListener('dragstart', (e) => {
settingEl.addClass('dragging');
e.dataTransfer?.setData('text/plain', index.toString());
});
settingEl.addEventListener('dragend', () => {
settingEl.removeClass('dragging');
});
settingEl.addEventListener('dragover', (e) => {
e.preventDefault();
settingEl.addClass('drag-over');
});
settingEl.addEventListener('dragleave', () => {
settingEl.removeClass('drag-over');
});
settingEl.addEventListener('drop', async (e) => {
e.preventDefault();
settingEl.removeClass('drag-over');
const draggedIndex = parseInt(e.dataTransfer?.getData('text/plain') || '0');
const targetIndex = index;
if (draggedIndex !== targetIndex) {
const draggedItem = plugin.tagSettings.tagPriority.splice(draggedIndex, 1)[0];
plugin.tagSettings.tagPriority.splice(targetIndex, 0, draggedItem);
await plugin.saveTagSettings();
displayTagCustomizations(container, app, plugin);
}
});
}
function addNewTagCustomization(container: HTMLElement, app: App, plugin: MapPlugin) {
new TagSuggestModal(app, (tagName: string) => {
new AddTagModal(app, tagName, (color: string, icon?: string) => {
plugin.tagSettings.tagCustomizations[tagName] = { color, icon };
if (!plugin.tagSettings.tagPriority.includes(tagName)) {
plugin.tagSettings.tagPriority.push(tagName);
}
plugin.saveTagSettings();
displayTagCustomizations(container, app, plugin);
}).open();
}).open();
}
class TagSuggestModal extends SuggestModal<string> {
private onSelect: (tag: string) => void;
constructor(app: App, onSelect: (tag: string) => void) {
super(app);
this.onSelect = onSelect;
this.setPlaceholder('Type tag name...');
}
getSuggestions(query: string): string[] {
const tags = new Set<string>();
const allTags = this.app.metadataCache.getTags();
console.log(allTags);
Object.keys(allTags).forEach(tag => {
const cleanTag = tag.startsWith('#') ? tag.substring(1) : tag;
tags.add(cleanTag);
});
const allTagsList = Array.from(tags);
const lowerQuery = query.toLowerCase().replace('#', '');
if (!lowerQuery) return allTagsList.slice(0, 10);
return allTagsList
.filter(tag => tag.toLowerCase().includes(lowerQuery))
.slice(0, 10);
}
renderSuggestion(tag: string, el: HTMLElement): void {
el.createEl('div', { text: `#${tag}` });
}
onChooseSuggestion(tag: string): void {
this.onSelect(tag);
}
}
export class AddTagModal extends Modal {
private onSubmit: (color: string, icon?: string) => void;
private tagName: string;
private selectedIcon: string = '';
constructor(app: App, tagName: string, onSubmit: (color: string, icon?: string) => void) {
super(app);
this.tagName = tagName;
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('add-tag-modal');
contentEl.createEl('h2', { text: `Customize #${this.tagName}` });
const form = contentEl.createDiv();
const colorIconContainer = form.createDiv({ cls: 'color-icon-container' });
const colorInput = colorIconContainer.createEl('input', { type: 'color', cls: 'color-input' });
colorInput.value = 'var(--color-accent)';
const iconContainer = colorIconContainer.createDiv({ cls: 'icon-container' });
const iconPreview = iconContainer.createEl('div', { cls: 'icon-preview' });
const iconBtn = iconContainer.createEl('button', { text: 'Choose Icon', cls: 'icon-btn' });
iconBtn.onclick = () => {
new IconPickerModal(this.app, this.selectedIcon, (icon) => {
this.selectedIcon = icon;
iconPreview.empty();
if (icon) {
setIcon(iconPreview, icon);
}
}).open();
};
const buttonContainer = form.createDiv({ cls: 'button-container' });
const addButton = buttonContainer.createEl('button', { text: 'Add' });
addButton.classList.add('mod-cta');
addButton.onclick = () => {
this.onSubmit(colorInput.value, this.selectedIcon);
this.close();
};
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
class IconPickerModal extends Modal {
private onSubmit: (icon: string) => void;
private currentIcon: string;
private searchInput: TextComponent;
constructor(app: App, currentIcon: string, onSubmit: (icon: string) => void) {
super(app);
this.currentIcon = currentIcon;
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('icon-picker-modal');
contentEl.createEl('h2', { text: 'Choose Icon' });
const searchContainer = contentEl.createDiv({ cls: 'search-container' });
this.searchInput = new TextComponent(searchContainer);
this.searchInput.setPlaceholder('Search icons...');
const iconsContainer = contentEl.createDiv({ cls: 'icons-grid' });
const allIcons = getIconIds();
const renderIcons = (filter: string = '') => {
iconsContainer.empty();
let filtered: string[];
if (filter) {
const lowerFilter = filter.toLowerCase();
filtered = [];
for (let i = 0; i < allIcons.length && filtered.length < 100; i++) {
if (allIcons[i].toLowerCase().includes(lowerFilter)) {
filtered.push(allIcons[i]);
}
}
} else {
filtered = allIcons.slice(0, 100);
}
filtered.forEach(iconName => {
const iconBtn = iconsContainer.createEl('button', { cls: 'icon-picker-item' });
if (this.currentIcon === iconName) {
iconBtn.classList.add('selected');
}
try {
setIcon(iconBtn, iconName);
} catch (e) {
return;
}
iconBtn.onclick = () => {
this.onSubmit(iconName);
this.close();
};
iconBtn.setAttribute('aria-label', iconName);
});
};
renderIcons();
this.searchInput.onChange((value) => {
renderIcons(value);
});
const buttonContainer = contentEl.createDiv({ cls: 'button-container' });
const clearButton = buttonContainer.createEl('button', { text: 'Clear Icon' });
clearButton.onclick = () => {
this.onSubmit('');
this.close();
};
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

11
src/types/obsidian-ex.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
import "obsidian";
declare module "obsidian" {
interface MetadataCache {
getTags(): Record<string, number>;
}
interface Workspace {
on(name: "map:refresh-all-views", callback: () => void, ctx?: any): EventRef;
}
}

481
src/views/map-bases-view.ts Normal file
View file

@ -0,0 +1,481 @@
import {
BasesEntry,
BasesPropertyId,
BasesView,
ListValue,
NumberValue,
QueryController,
StringValue,
ViewOption,
} from 'obsidian';
import { Deck } from '@deck.gl/core';
import { MapView as MapViewType } from '@deck.gl/core';
import { createMapRenderer, MapPoint, updateMapPoints } from '../map-renderer';
import MapPlugin from '../main';
export const MapBasesViewType = 'map';
const DEFAULT_MAP_HEIGHT = 400;
const DEFAULT_MAP_ZOOM = 4;
export class MapBasesView extends BasesView {
type = MapBasesViewType;
scrollEl: HTMLElement;
containerEl: HTMLElement;
mapEl: HTMLElement;
plugin: MapPlugin;
private deck: Deck<MapViewType[]> | null = null;
private coordinatesProp: BasesPropertyId | null = null;
private coverProp: BasesPropertyId | null = null;
private mapHeight: number = DEFAULT_MAP_HEIGHT;
private defaultZoom: number = DEFAULT_MAP_ZOOM;
private center: [number, number] = [0, 0];
private markerType: 'pins' | 'dots' = 'pins';
private tileLayer: string = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
private savedViewState: { latitude: number; longitude: number; zoom: number } | null = null;
constructor(controller: QueryController, scrollEl: HTMLElement, plugin: MapPlugin) {
super(controller);
this.scrollEl = scrollEl;
this.containerEl = scrollEl.createDiv({ cls: 'bases-map-container is-loading' });
this.mapEl = this.containerEl.createDiv('bases-map');
this.plugin = plugin;
}
onload(): void {
this.registerEvent(
this.app.workspace.on('map:refresh-all-views', () => {
this.refresh();
})
);
}
refresh(): void {
this.destroyMap();
this.onDataUpdated();
}
onunload() {
this.destroyMap();
}
onResize(): void {
if (this.deck) {
this.deck.setProps({ width: '100%', height: '100%' });
}
}
public focus(): void {
this.containerEl.focus({ preventScroll: true });
}
private destroyMap(): void {
if (this.deck) {
try {
// @ts-ignore - accessing protected viewState
const viewState = this.deck.viewState?.MapView;
if (viewState) {
this.savedViewState = viewState;
}
this.deck.finalize();
} catch (e) {
console.error('Error finalizing deck:', e);
}
this.deck = null;
}
}
public onDataUpdated(): void {
this.loadConfig();
// If map exists, just update points. Otherwise create map.
if (this.deck) {
this.updatePointsOnly();
} else {
this.renderMap();
}
}
private loadConfig(): void {
this.coordinatesProp = this.config.getAsPropertyId('coordinates');
this.coverProp = this.config.getAsPropertyId('coverImage');
const heightVal = this.config.get('mapHeight');
if (heightVal && typeof heightVal === 'number') {
this.mapHeight = heightVal;
}
const zoomVal = this.config.get('defaultZoom');
if (zoomVal && typeof zoomVal === 'number') {
this.defaultZoom = zoomVal;
}
const centerVal = this.config.get('center');
if (centerVal && typeof centerVal === 'string') {
const parts = centerVal.split(',').map(p => parseFloat(p.trim()));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
this.center = [parts[0], parts[1]];
}
}
const markerTypeVal = this.config.get('markerType');
if (markerTypeVal === 'pins' || markerTypeVal === 'dots') {
this.markerType = markerTypeVal;
}
const tileLayerVal = this.config.get('tileLayer');
const oldTileLayer = this.tileLayer;
if (tileLayerVal && typeof tileLayerVal === 'string') {
this.tileLayer = tileLayerVal;
} else {
this.tileLayer = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
}
// If tile layer changed, destroy and recreate map
if (oldTileLayer !== this.tileLayer && this.deck) {
this.destroyMap();
}
}
private async renderMap(): Promise<void> {
// Need either coordinates property OR global lat/lng keys
if (!this.data || (!this.coordinatesProp && (!this.plugin.settings.latKey || !this.plugin.settings.lngKey))) {
this.containerEl.removeClass('is-loading');
return;
}
const loadingOverlay = this.containerEl.createDiv({ cls: 'map-loading-overlay' });
const spinner = loadingOverlay.createDiv();
const svg = spinner.createSvg('svg');
svg.setAttr('width', '40');
svg.setAttr('height', '40');
svg.setAttr('viewBox', '0 0 40 40');
svg.setAttr('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttr('stroke', 'var(--interactive-accent)');
const g1 = svg.createSvg('g');
g1.setAttr('fill', 'none');
g1.setAttr('fill-rule', 'evenodd');
const g2 = g1.createSvg('g');
g2.setAttr('transform', 'translate(2 2)');
g2.setAttr('stroke-width', '4');
const circle = g2.createSvg('circle');
circle.setAttr('stroke-opacity', '.2');
circle.setAttr('cx', '18');
circle.setAttr('cy', '18');
circle.setAttr('r', '18');
const path = g2.createSvg('path');
path.setAttr('d', 'M36 18c0-9.94-8.06-18-18-18');
const animateTransform = path.createSvg('animateTransform');
animateTransform.setAttr('attributeName', 'transform');
animateTransform.setAttr('type', 'rotate');
animateTransform.setAttr('from', '0 18 18');
animateTransform.setAttr('to', '360 18 18');
animateTransform.setAttr('dur', '1s');
animateTransform.setAttr('repeatCount', 'indefinite');
const points = this.extractPointsFromData();
if (points.length === 0) {
this.mapEl.empty();
const emptyState = this.mapEl.createDiv({ cls: 'map-empty-state' });
emptyState.textContent = 'No entries with valid coordinates found';
return;
}
const isEmbedded = this.isEmbedded();
const height = isEmbedded ? `${this.mapHeight}px` : '100%';
const tagSettings = this.plugin.tagSettings;
const settings = this.plugin.settings;
let tilesLoaded = false;
let overlayHidden = false;
const hideOverlay = () => {
if (overlayHidden) return;
overlayHidden = true;
loadingOverlay.addClass('fade-out');
setTimeout(() => {
loadingOverlay.remove();
}, 300);
};
this.deck = await createMapRenderer({
containerEl: this.mapEl,
points,
app: this.app,
settings: settings,
tagSettings: tagSettings,
options: {
center: this.savedViewState ? [this.savedViewState.latitude, this.savedViewState.longitude] : this.center,
zoom: this.savedViewState ? this.savedViewState.zoom : this.defaultZoom,
height: height,
markerType: this.markerType,
tileLayer: this.tileLayer,
showSearch: false,
onTilesLoaded: () => {
tilesLoaded = true;
hideOverlay();
}
}
});
this.containerEl.removeClass('is-loading');
setTimeout(() => {
if (!tilesLoaded) {
hideOverlay();
}
}, 1000);
}
private isEmbedded(): boolean {
let element = this.scrollEl.parentElement;
while (element) {
if (element.hasClass('bases-embed') || element.hasClass('block-language-base')) {
return true;
}
element = element.parentElement;
}
return false;
}
private updatePointsOnly(): void {
if (!this.deck || !this.data) return;
const points = this.extractPointsFromData();
updateMapPoints(this.deck, points, {
app: this.app,
settings: this.plugin.settings,
tagSettings: this.plugin.tagSettings,
options: {
markerType: this.markerType
}
});
}
private extractPointsFromData(): MapPoint[] {
if (!this.data) return [];
const points: MapPoint[] = [];
for (const entry of this.data.data) {
const coordinates = this.extractCoordinates(entry);
if (!coordinates) continue;
const point: MapPoint = {
lat: coordinates[0],
lng: coordinates[1],
title: entry.file.basename,
file: entry.file,
};
const fileCache = this.app.metadataCache.getFileCache(entry.file);
if (fileCache?.frontmatter?.tags) {
const tags = fileCache.frontmatter.tags;
point.tags = Array.isArray(tags) ? tags : [tags];
}
if (this.coverProp) {
const coverVal = entry.getValue(this.coverProp);
if (coverVal) {
point.cover = coverVal.toString();
}
}
const properties: Array<{ name: string; value: string }> = [];
if (this.data.properties) {
for (const prop of this.data.properties.slice(0, 20)) {
if (prop === this.coordinatesProp || prop === this.coverProp) continue;
try {
const value = entry.getValue(prop);
if (value && value.isTruthy()) {
properties.push({
name: this.config.getDisplayName(prop),
value: value.toString()
});
}
} catch {
// Property value not available
}
}
}
if (properties.length > 0) {
point.properties = properties;
}
points.push(point);
}
return points;
}
private extractCoordinates(entry: BasesEntry): [number, number] | null {
if (this.coordinatesProp) {
try {
const value = entry.getValue(this.coordinatesProp);
if (value) {
// Handle list values [lat, lng]
if (value instanceof ListValue) {
if (value.length() >= 2) {
const lat = this.parseCoordinate(value.get(0));
const lng = this.parseCoordinate(value.get(1));
if (lat !== null && lng !== null) {
return [lat, lng];
}
}
}
// Handle string values "lat,lng"
else if (value instanceof StringValue) {
const stringData = value.toString().trim();
const parts = stringData.split(',');
if (parts.length >= 2) {
const lat = this.parseCoordinate(parts[0].trim());
const lng = this.parseCoordinate(parts[1].trim());
if (lat !== null && lng !== null) {
return [lat, lng];
}
}
}
}
} catch (error) {
console.error(`Error extracting coordinates for ${entry.file.name}:`, error);
}
}
// Fallback to global settings if coordinates property not set or failed
if (this.plugin.settings.latKey && this.plugin.settings.lngKey) {
try {
const fileCache = this.app.metadataCache.getFileCache(entry.file);
if (fileCache?.frontmatter) {
const latValue = this.extractFromFrontmatter(fileCache.frontmatter, this.plugin.settings.latKey);
const lngValue = this.extractFromFrontmatter(fileCache.frontmatter, this.plugin.settings.lngKey);
if (latValue !== undefined && lngValue !== undefined) {
const lat = this.parseCoordinate(latValue);
const lng = this.parseCoordinate(lngValue);
if (lat !== null && lng !== null) {
return [lat, lng];
}
}
}
} catch (error) {
console.error(`Error extracting coordinates from frontmatter for ${entry.file.name}:`, error);
}
}
return null;
}
private extractFromFrontmatter(frontmatter: Record<string, unknown>, key: string): unknown {
const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
if (arrayMatch) {
const arrayKey = arrayMatch[1];
const index = parseInt(arrayMatch[2]);
const arrayValue = frontmatter[arrayKey];
if (Array.isArray(arrayValue) && index >= 0 && index < arrayValue.length) {
return arrayValue[index];
}
return undefined;
}
// Regular property access
return frontmatter[key];
}
private parseCoordinate(value: unknown): number | null {
if (value instanceof NumberValue) {
const numData = Number(value.toString());
return isNaN(numData) ? null : numData;
}
if (value instanceof StringValue) {
const num = parseFloat(value.toString());
return isNaN(num) ? null : num;
}
if (typeof value === 'string') {
const num = parseFloat(value);
return isNaN(num) ? null : num;
}
if (typeof value === 'number') {
return isNaN(value) ? null : value;
}
return null;
}
static getViewOptions(): ViewOption[] {
return [
{
displayName: 'Display',
type: 'group',
items: [
{
displayName: 'Embedded height',
type: 'slider',
key: 'mapHeight',
min: 200,
max: 800,
step: 20,
default: DEFAULT_MAP_HEIGHT,
},
{
displayName: 'Center coordinates (lat,lng)',
type: 'text',
key: 'center',
placeholder: '0,0',
},
{
displayName: 'Marker type',
type: 'dropdown',
key: 'markerType',
options: {
"pins": "Pins",
"dots": "Dots",
},
default: 'pins',
},
],
},
{
displayName: 'Markers',
type: 'group',
items: [
{
displayName: 'Coordinates property',
type: 'property',
key: 'coordinates',
filter: (prop) => !prop.startsWith('file.'),
placeholder: 'Property',
},
{
displayName: 'Cover image property',
type: 'property',
key: 'coverImage',
filter: (prop) => !prop.startsWith('file.'),
placeholder: 'Property',
},
],
},
{
displayName: 'Background',
type: 'group',
items: [
{
displayName: 'Tile layer URL',
type: 'text',
key: 'tileLayer',
placeholder: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
},
],
},
];
}
}

349
styles.css Normal file
View file

@ -0,0 +1,349 @@
.bases-view:has(.bases-map-container) {
padding: 0px;
}
.bases-map-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.bases-map {
width: 100%;
height: var(--bases-map-embed-height);
overflow: hidden;
position: relative;
}
.map-view-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.map-search-container {
padding: 10px;
background: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
}
.map-canvas-container {
flex: 1;
position: relative;
overflow: hidden;
}
.map-empty-state {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--text-muted);
font-style: italic;
}
.map-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--background-primary);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
transition: opacity 0.3s ease;
}
.map-loading-overlay.fade-out {
opacity: 0;
}
.map-tooltip {
position: absolute;
left: var(--tooltip-x, 0);
top: var(--tooltip-y, 0);
padding: 8px 12px;
background: var(--background-primary);
color: var(--text-normal);
border-radius: 4px;
pointer-events: none;
opacity: 0;
z-index: 1000;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
max-width: 250px;
transition: opacity 0.15s ease;
.map-tooltip-property {
display: flex;
gap: 8px;
margin-top: 4px;
}
.map-tooltip-tags {
margin-top: 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.map-tooltip-property-label {
color: var(--text-muted);
font-size: 12px;
}
.map-tooltip-property-value {
font-size: 12px;
word-break: break-word;
overflow-wrap: break-word;
}
.map-tooltip-cover-image {
width: 100%;
max-height: 150px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 8px;
display: block;
}
.map-tooltip-title {
font-weight: bold;
}
.map-tooltip-property-container {
margin-top: 8px;
}
}
.map-tooltip.visible {
opacity: 1;
}
.map-canvas {
width: 100%;
height: 100%;
display: block;
}
.map-settings-container {
.delete-btn {
width: 24px;
height: 24px;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.delete-btn:hover {
color: var(--text-normal);
}
.spacer {
flex: 1;
}
.draggable-setting {
display: flex;
align-items: center;
padding: 12px;
margin: 4px 0;
background: var(--background-secondary);
border-radius: 6px;
cursor: grab;
border: 1px solid var(--background-modifier-border);
transition: opacity 0.2s ease, background 0.15s ease;
}
.draggable-setting.dragging {
opacity: 0.5;
}
.draggable-setting.drag-over {
background: var(--background-modifier-hover);
}
.drag-handle {
margin-right: 12px;
color: var(--text-muted);
font-size: 16px;
cursor: grab;
}
.tag-label {
min-width: 120px;
margin-right: 12px;
font-weight: 500;
}
.color-input {
width: 40px;
height: 30px;
border: none;
border-radius: 4px;
margin-right: 12px;
cursor: pointer;
padding: 0;
}
.color-input::-webkit-color-swatch-wrapper {
padding: 0;
height: 100%;
}
.color-input::-webkit-color-swatch {
border: none;
border-radius: 4px;
height: 100%;
}
.icon-btn {
width: 40px;
height: 30px;
min-width: 40px;
min-height: 30px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
margin-right: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: var(--background-primary);
}
.tag-customizations-container {
padding: 10px;
margin-bottom: 20px;
& .map-empty-state {
color: var(--text-muted);
font-style: italic;
text-align: center;
padding: 20px;
}
}
}
.add-tag-modal {
.color-icon-container {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 20px;
}
.color-input {
width: 40px;
height: 40px;
border: none;
border-radius: 4px;
cursor: pointer;
padding: 0;
}
.color-input::-webkit-color-swatch-wrapper {
padding: 0;
height: 100%;
}
.color-input::-webkit-color-swatch {
border: none;
border-radius: 4px;
height: 100%;
}
.icon-container {
display: flex;
gap: 8px;
align-items: center;
flex: 1;
}
.icon-preview {
width: 40px;
height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
}
.icon-btn {
flex: 1;
height: 40px;
}
.button-container {
display: flex;
gap: 10px;
justify-content: flex-end;
}
}
.icon-picker-modal {
.search-container {
margin-bottom: 10px;
input {
width: 100%;
}
}
.icons-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(50px, 1fr));
grid-auto-rows: min-content;
align-content: start;
gap: 8px;
height: 400px;
overflow-y: auto;
padding: 10px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
}
.icon-picker-item {
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
background: var(--background-primary);
&.selected {
border: 2px solid var(--interactive-accent);
}
&:hover {
background: var(--background-modifier-hover);
}
border: 1px solid var(--background-modifier-border);
}
.button-container {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 10px;
}
}
.map-container {
width: 100%;
height: var(--bases-map-height);
position: relative;
}

24
tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

17
version-bump.mjs Normal file
View file

@ -0,0 +1,17 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}