Merge pull request #17 from ccmdi/feat/context-menu

Context menu
This commit is contained in:
ccmdi 2025-11-24 20:13:13 -05:00 committed by GitHub
commit 0e54ca1281
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 811 additions and 294 deletions

View file

@ -45,9 +45,40 @@ Higher tags take precedence in locations with multiple tags.
The timeline view type filters map markers by date.
- **Date property**: Property containing dates (supports `YYYY-MM-DD`, `YYYY-MM`, `YYYY`, and negative years like `-1000-01-01` for BC)
- **Group by property**: Deduplicate markers with the same value - for tracking the same object at different times
- **Grouping uniqueness mode**: Show all entries, only the most recent, or only the least recent per group (per date property)
The slider and text box filter markers up to a specific date. You can manually type in the text box the exact date you want to jump to. The granularity level is determined by the dropdown.
### Using search and geocoding
Enable "Search geocoding" in plugin settings to add a search bar to your map views.
The search bar supports:
- **Natural language search**: Type location names and select from search results
- **Coordinate paste**: Paste coordinates in various formats:
- Decimal degrees: `40.7128, -74.0060`
- Degrees/minutes/seconds formats
- Google Maps links
- Other common coordinate formats
Select a result from the dropdown to navigate to that location on the map.
### Customizing map appearance
In plugin settings under "Map":
- **Stroke width for icons**: Adjust the outline thickness of map markers (0.5-5)
- **Fill icons**: Toggle whether icons are filled or outlined
- **Auto-center on update**: Automatically zoom and center the map when data changes
- **Transition duration**: Control the animation speed when the map view changes (0-2000ms)
### Performance optimization
**Thumbnail cache**: Enable in settings under "Performance" to cache downsized versions of location cover images for instant tooltips.
- **Thumbnail target size**: Set the target file size for cached thumbnails (10-50 KB)
- **Cache status**: View number of cached thumbnails and total cache size
- **Rebuild cache**: Regenerate all thumbnails if images have changed
- **Clear cache**: Delete all cached thumbnails

View file

@ -41,8 +41,7 @@ export default class MapPlugin extends Plugin {
}
async loadSettings() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as MapPluginSettings);
}
async saveSettings() {

View file

@ -1,4 +1,4 @@
import { App, TFile, setIcon } from 'obsidian';
import { App, TFile, setIcon, Menu } from 'obsidian';
import { Deck, PickingInfo, MapViewState, FlyToInterpolator, Layer } from '@deck.gl/core';
import { BitmapLayer, IconLayer, ScatterplotLayer, PolygonLayer } from '@deck.gl/layers';
import { TileLayer } from '@deck.gl/geo-layers';
@ -9,30 +9,14 @@ import { MapView as MapViewType } from '@deck.gl/core';
import { MapTagSettings } from './settings/map-tag-settings';
import type MapPlugin from './main';
import type { ThumbnailCacheManager } from './thumbnail-cache';
import { LatLng } from './types/LatLng';
import { MapPoint } from './types/MapPoint';
function easeCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
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;
tags?: string[];
properties?: MapProperty[];
polygon?: [number, number][];
}
export let currentViewState: MapViewState | null = null;
interface TileIndex {
index: {
@ -67,6 +51,8 @@ export interface MapRendererOptions {
autoCenter?: boolean;
onMarkerClick?: (point: MapPoint, event: MjolnirEvent) => void;
onTilesLoaded?: () => void;
onSetMapCenter?: (lat: number, lng: number) => void;
onSetDefaultZoom?: (zoom: number) => void;
};
}
@ -162,13 +148,18 @@ function getIconSVG(iconName: string, strokeWidth: number, fill: boolean): strin
function handlePointClick(info: PickingInfo<DeckDataPoint>, event: MjolnirEvent, options: MapRendererOptions['options'], app: App) {
if (!info.object) return;
// Ignore right-clicks (button === 2)
const srcEvent = event?.srcEvent;
if (srcEvent && 'button' in srcEvent && srcEvent.button === 2) {
return;
}
if (options.onMarkerClick) {
options.onMarkerClick(info.object.point, event);
return;
}
if (info.object.point.file) {
const srcEvent = event?.srcEvent;
const newTab =
(srcEvent && 'button' in srcEvent && srcEvent.button === 1) ||
srcEvent?.ctrlKey ||
@ -182,10 +173,10 @@ function calculateBounds(points: MapPoint[], containerEl: HTMLElement): { latitu
let minLng = Infinity, maxLng = -Infinity;
for (const p of points) {
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;
if (p.location.lat < minLat) minLat = p.location.lat;
if (p.location.lat > maxLat) maxLat = p.location.lat;
if (p.location.lng < minLng) minLng = p.location.lng;
if (p.location.lng > maxLng) maxLng = p.location.lng;
}
const centerLat = (maxLat + minLat) / 2;
@ -238,8 +229,8 @@ function createPolygonLayer(
for (const point of points) {
if (point.polygon && point.polygon.length > 0) {
// Convert lat,lng to lng,lat for deck.gl
const polygon: [number, number][] = point.polygon.map(([lat, lng]) => [lng, lat]);
// Convert LatLng.Verified to [lng, lat] for deck.gl
const polygon: [number, number][] = point.polygon.map(coord => LatLng.toArrayLngLat(coord));
polygonData.push({
polygon,
@ -346,7 +337,7 @@ export function updateMapPoints(deck: Deck<MapViewType[]>, points: MapPoint[], c
const autoCenter = options.autoCenter !== false; // Default to true
const deckData: DeckDataPoint[] = points.map(point => ({
position: [point.lng, point.lat] as [number, number],
position: LatLng.toArrayLngLat(point.location),
color: parseColor(getPointColor(point, tagSettings, defaultColor)),
radius: point.size || markerSize,
point: point,
@ -371,7 +362,7 @@ export function updateMapPoints(deck: Deck<MapViewType[]>, points: MapPoint[], c
const hasConfiguredCenter = options.center && (options.center[0] !== 0 || options.center[1] !== 0);
if (autoCenter && hasConfiguredCenter && options.zoom) {
if (hasConfiguredCenter && options.zoom) {
if (!options.center) return;
shouldTransition = true;
targetViewState = {
@ -458,10 +449,9 @@ export function createMapRenderer(config: MapRendererOptions): Deck<MapViewType[
_offset: number;
tile: Tile2DHeader<HTMLImageElement>;
}) => {
// eslint-disable-next-line @typescript-eslint/no-deprecated
const bbox = props.tile.bbox;
if (!('west' in bbox)) return null;
const { west, south, east, north } = bbox;
const boundingBox = props.tile.boundingBox;
if (!boundingBox || boundingBox.length !== 2) return null;
const [[west, south], [east, north]] = boundingBox;
return new BitmapLayer({
...props,
data: undefined,
@ -490,13 +480,12 @@ export function createMapRenderer(config: MapRendererOptions): Deck<MapViewType[
const tooltip = containerEl.createEl('div', { cls: 'map-tooltip' });
const numPoints = points.length;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const deckData: DeckDataPoint[] = new Array(numPoints);
const deckData: DeckDataPoint[] = new Array<DeckDataPoint>(numPoints);
for (let i = 0; i < numPoints; i++) {
const point = points[i];
deckData[i] = {
position: [point.lng, point.lat] as [number, number],
position: LatLng.toArrayLngLat(point.location),
color: parseColor(getPointColor(point, tagSettings, markerColor)),
radius: point.size || markerSize,
point: point,
@ -701,6 +690,69 @@ export function createMapRenderer(config: MapRendererOptions): Deck<MapViewType[
return layers;
})(),
onViewStateChange: ({ viewState: newViewState }) => {
currentViewState = newViewState;
}
});
mapCanvas.addEventListener('contextmenu', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const rect = mapCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const viewState = currentViewState;
if (viewState) {
const viewport = deck.getViewports()[0];
const [lng, lat] = viewport?.unproject([x, y]) || [0, 0];
const menu = new Menu();
menu.addItem((item) => {
item
.setTitle('Copy coordinates')
.setIcon('copy')
.onClick(async () => {
await navigator.clipboard.writeText(`${lat.toFixed(6)}, ${lng.toFixed(6)}`);
});
});
if (options.onSetMapCenter) {
menu.addItem((item) => {
item
.setTitle('Set as map center')
.setIcon('map-pin')
.onClick(() => {
options.onSetMapCenter?.(lat, lng);
});
});
}
if (options.onSetDefaultZoom) {
menu.addItem((item) => {
const currentZoom = viewState.zoom;
item
.setTitle('Set default zoom')
.setIcon('zoom-in')
.onClick(() => {
options.onSetDefaultZoom?.(currentZoom);
});
});
}
menu.addItem((item) => {
item
.setTitle('Open in web')
.setIcon('globe')
.onClick(() => {
window.open(`https://www.google.com/maps?q=${lat.toFixed(6)},${lng.toFixed(6)}`, '_blank');
});
});
menu.showAtMouseEvent(e);
}
});
return deck;

View file

@ -1,54 +0,0 @@
import { MapPoint } from "./map-renderer";
export function haveLocationsChanged(points1: MapPoint[], points2: MapPoint[]): boolean {
// Check if count changed
if (points1.length !== points2.length) return true;
// Check if any location (lat/lng) changed
for (let i = 0; i < points1.length; i++) {
if (points1[i].lat !== points2[i].lat || points1[i].lng !== points2[i].lng) {
return true;
}
}
return false;
}
export function arePointsEqual(points1: MapPoint[], points2: MapPoint[]): boolean {
if (points1.length !== points2.length) return false;
for (let i = 0; i < points1.length; i++) {
const p1 = points1[i];
const p2 = points2[i];
if (p1.lat !== p2.lat ||
p1.lng !== p2.lng ||
p1.title !== p2.title ||
p1.color !== p2.color ||
p1.size !== p2.size ||
p1.cover !== p2.cover ||
p1.file?.path !== p2.file?.path) {
return false;
}
const tags1 = p1.tags || [];
const tags2 = p2.tags || [];
if (tags1.length !== tags2.length || !tags1.every((tag, idx) => tag === tags2[idx])) {
return false;
}
const props1 = p1.properties || [];
const props2 = p2.properties || [];
if (props1.length !== props2.length) {
return false;
}
for (let j = 0; j < props1.length; j++) {
if (props1[j].name !== props2[j].name || props1[j].value !== props2[j].value) {
return false;
}
}
}
return true;
}

View file

@ -13,6 +13,7 @@ export interface MapPluginSettings {
thumbnailTargetSize: number;
tagSettings: MapTagSettings;
enableTimelineView: boolean;
enableSearchGeocoding: boolean;
}
export const DEFAULT_SETTINGS: MapPluginSettings = {
@ -25,7 +26,8 @@ export const DEFAULT_SETTINGS: MapPluginSettings = {
enableThumbnailCache: false,
thumbnailTargetSize: 25,
tagSettings: DEFAULT_MAP_TAG_SETTINGS,
enableTimelineView: false
enableTimelineView: false,
enableSearchGeocoding: false
};
export class MapSettingTab extends PluginSettingTab {
@ -120,6 +122,16 @@ export class MapSettingTab extends PluginSettingTab {
this.plugin.settings.transitionDuration = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Enable search geocoding')
.setDesc('Enable a search bar to go to locations via natural language or coordinate paste')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.enableSearchGeocoding)
.onChange(async (value) => {
this.plugin.settings.enableSearchGeocoding = value;
this.plugin.refreshAllMapViews();
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Performance')

View file

@ -44,8 +44,7 @@ export class ThumbnailCacheManager {
if (exists) {
const content = await this.app.vault.adapter.read(metadataPath);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.cache = JSON.parse(content);
this.cache = JSON.parse(content) as ThumbnailCacheMetadata;
}
this.cacheLoaded = true;

83
src/types/MapPoint.ts Normal file
View file

@ -0,0 +1,83 @@
import { TFile } from "obsidian";
import { LatLng } from "./LatLng";
type PropertyValue = string | number | boolean | string[] | null;
interface MapProperty {
name: string;
value: PropertyValue;
}
export interface MapPoint {
location: LatLng.Verified;
title: string;
color?: string;
size?: number;
cover?: string;
file?: TFile;
tags?: string[];
properties?: MapProperty[];
polygon?: LatLng.Verified[];
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace MapPoint {
export function areSamePlace(point1: MapPoint, point2: MapPoint): boolean {
return point1.location.lat === point2.location.lat && point1.location.lng === point2.location.lng;
}
export function areEqual(point1: MapPoint[], point2: MapPoint[]): boolean;
export function areEqual(point1: MapPoint, point2: MapPoint): boolean;
export function areEqual(
point1: MapPoint | MapPoint[],
point2: MapPoint | MapPoint[]
): boolean {
// Handle array case
if (Array.isArray(point1) && Array.isArray(point2)) {
if (point1.length !== point2.length) return false;
for (let i = 0; i < point1.length; i++) {
if (!areEqual(point1[i], point2[i])) return false;
}
return true;
}
// Handle single MapPoint case
if (!Array.isArray(point1) && !Array.isArray(point2)) {
if (!LatLng.equals(point1.location, point2.location) ||
point1.title !== point2.title ||
point1.color !== point2.color ||
point1.size !== point2.size ||
point1.cover !== point2.cover ||
point1.file?.path !== point2.file?.path) {
return false;
}
const tags1 = point1.tags || [];
const tags2 = point2.tags || [];
if (tags1.length !== tags2.length || !tags1.every((tag, idx) => tag === tags2[idx])) {
return false;
}
const props1 = point1.properties || [];
const props2 = point2.properties || [];
if (props1.length !== props2.length) {
return false;
}
for (let j = 0; j < props1.length; j++) {
if (props1[j].name !== props2[j].name || props1[j].value !== props2[j].value) {
return false;
}
}
return true;
}
return false;
}
export function haveLocationsChanged(points1: MapPoint[], points2: MapPoint[]): boolean {
return !areEqual(points1, points2);
}
}

167
src/types/latlng.ts Normal file
View file

@ -0,0 +1,167 @@
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace LatLng {
// Unverified input - accepts various formats
export type Like =
| { lat: number; lng: number }
| { latitude: number; longitude: number }
| [number, number] // [lat, lng]
| string; // "lat,lng" format
// Verified type using a brand
export type Verified = {
readonly lat: number;
readonly lng: number;
readonly __brand: 'LatLng';
};
// Main parse function - handles Obsidian-specific types (ListValue, StringValue, arrays, strings)
export function parse(value: unknown): Verified | null {
// Handle ListValue from frontmatter: [40.7128, -74.0060]
if (value && typeof value === 'object' && 'length' in value && typeof value.length === 'function') {
const listValue = value as { length: () => number; get: (index: number) => unknown };
if (listValue.length() >= 2) {
const lat = parseCoordinate(listValue.get(0));
const lng = parseCoordinate(listValue.get(1));
if (lat !== null && lng !== null) {
return from(lat, lng);
}
}
}
// Handle plain JavaScript array from JSON.parse: [40.7128, -74.0060]
if (Array.isArray(value) && value.length >= 2) {
const lat = parseCoordinate(value[0]);
const lng = parseCoordinate(value[1]);
if (lat !== null && lng !== null) {
return from(lat, lng);
}
}
// Handle string: "40.7128, -74.0060" or StringValue wrapper
if (value && typeof value === 'object' && 'toString' in value) {
const str = (value as { toString: () => string }).toString();
const parts = str.split(',').map(p => parseFloat(p.trim()));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
return from(parts[0], parts[1]);
}
} else if (typeof value === 'string') {
const parts = value.split(',').map(p => parseFloat(p.trim()));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
return from(parts[0], parts[1]);
}
}
return null;
}
// Parse or return default value
export function parseOrDefault(input: Like, defaultValue: Verified): Verified {
return parse(input) ?? defaultValue;
}
// Parse or return [0, 0]
export function parseOrZero(input: Like): Verified {
return parse(input) ?? ({ lat: 0, lng: 0, __brand: 'LatLng' } as Verified);
}
// Helper to check if already verified
export function isVerified(input: Like | Verified): input is Verified {
return typeof input === 'object' && !Array.isArray(input) && '__brand' in input;
}
// Convert to array [lat, lng]
export function toArray(point: Verified): [number, number] {
return [point.lat, point.lng];
}
// Convert to array [lng, lat] (for deck.gl)
export function toArrayLngLat(point: Verified): [number, number] {
return [point.lng, point.lat];
}
// Convert to object {lat, lng}
export function toObject(point: Verified): { lat: number; lng: number } {
return { lat: point.lat, lng: point.lng };
}
// Convert to string "lat, lng"
export function toString(point: Verified, precision = 6): string {
return `${point.lat.toFixed(precision)}, ${point.lng.toFixed(precision)}`;
}
// Distance calculation (simple Euclidean for now)
export function distance(point1: Verified, point2: Verified): number {
return Math.sqrt(Math.pow(point1.lat - point2.lat, 2) + Math.pow(point1.lng - point2.lng, 2));
}
// Haversine distance in kilometers
export function distanceHaversine(point1: Verified, point2: Verified): number {
const R = 6371; // Earth's radius in km
const dLat = toRadians(point2.lat - point1.lat);
const dLng = toRadians(point2.lng - point1.lng);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(point1.lat)) * Math.cos(toRadians(point2.lat)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// Helper for radians conversion
function toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}
// Check if two points are equal
export function equals(point1: Verified, point2: Verified): boolean {
return point1.lat === point2.lat && point1.lng === point2.lng;
}
// Check if coordinates are valid (without parsing)
export function isValid(lat: number, lng: number): boolean {
return (
typeof lat === 'number' &&
typeof lng === 'number' &&
isFinite(lat) &&
isFinite(lng) &&
lat >= -90 &&
lat <= 90 &&
lng >= -180 &&
lng <= 180
);
}
// Create from lat/lng numbers (with validation)
export function from(lat: number, lng: number): Verified | null {
if (!isValid(lat, lng)) return null;
return { lat, lng, __brand: 'LatLng' } as Verified;
}
// Create from lat/lng numbers (unsafe, no validation)
export function fromUnsafe(lat: number, lng: number): Verified {
return { lat, lng, __brand: 'LatLng' } as Verified;
}
// Parse coordinate value from various sources (Obsidian-specific)
// Handles NumberValue, StringValue, and primitive types
export function parseCoordinate(value: unknown): number | null {
// Handle null/undefined
if (value === null || value === undefined) {
return null;
}
// Handle Obsidian's NumberValue/StringValue types
if (typeof value === 'object' && 'toString' in value) {
const strValue = (value as { toString: () => string }).toString();
const num = parseFloat(strValue);
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;
}
}

16
src/utils.ts Normal file
View file

@ -0,0 +1,16 @@
export function 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;
}
return frontmatter[key];
}

View file

@ -3,18 +3,23 @@ import {
BasesPropertyId,
BasesView,
ListValue,
NumberValue,
QueryController,
StringValue,
ViewOption,
requestUrl,
} from 'obsidian';
import { Deck } from '@deck.gl/core';
import { Deck, FlyToInterpolator, MapViewState } from '@deck.gl/core';
import { MapView as MapViewType } from '@deck.gl/core';
import { createMapRenderer, MapPoint, updateMapPoints } from '../map-renderer';
import { createMapRenderer, updateMapPoints } from '../map-renderer';
import MapPlugin from '../main';
import { arePointsEqual, haveLocationsChanged } from '../pointutils';
import { currentViewState } from '../map-renderer';
import { LatLng } from '../types/LatLng';
import { extractFromFrontmatter } from '../utils';
import { MapPoint } from '../types/MapPoint';
export const MapBasesViewType = 'map';
export const SEARCH_DEBOUNCE_TIME = 300;
export const SEARCH_TRANSITION_DURATION = 1600;
const DEFAULT_MAP_HEIGHT = 400;
const DEFAULT_MAP_ZOOM = 4;
@ -28,7 +33,7 @@ export class MapBasesView extends BasesView {
protected deck: Deck<MapViewType[]> | null = null;
protected savedViewState: { latitude: number; longitude: number; zoom: number } | null = null;
protected savedViewState: MapViewState | null = null;
protected lastPoints: MapPoint[] = [];
protected mapUpdateTimeout?: number;
protected lastConfigState: Record<string, unknown> = {};
@ -74,11 +79,8 @@ export class MapBasesView extends BasesView {
private destroyMap(): void {
if (this.deck) {
try {
// @ts-expect-error - accessing protected viewState
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const viewState = this.deck.viewState?.MapView;
const viewState = currentViewState;
if (viewState) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.savedViewState = viewState;
}
this.deck.finalize();
@ -110,8 +112,11 @@ export class MapBasesView extends BasesView {
return (this.config.get('defaultZoom') as number) || DEFAULT_MAP_ZOOM;
}
private getCenter(): [number, number] {
return this.parseLatLngOrZero(this.config.get('center'));
private getCenter(): LatLng.Verified {
const centerRaw = this.config.get('center');
if (!centerRaw) return LatLng.fromUnsafe(0, 0);
const parsed = LatLng.parse(centerRaw);
return parsed ?? LatLng.fromUnsafe(0, 0);
}
private getMarkerType(): 'pins' | 'dots' {
@ -131,8 +136,7 @@ export class MapBasesView extends BasesView {
if (!boundsStr || typeof boundsStr !== 'string') return undefined;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsed = JSON.parse(boundsStr);
const parsed = JSON.parse(boundsStr) as unknown[];
if (Array.isArray(parsed) && parsed.length === 2 &&
Array.isArray(parsed[0]) && parsed[0].length === 2 &&
Array.isArray(parsed[1]) && parsed[1].length === 2) {
@ -149,14 +153,14 @@ export class MapBasesView extends BasesView {
if(this.hasConfigPropertyChanged('center')) {
const centerRaw = this.config.get('center');
// Meaningful if it's empty OR if it's valid
if (!centerRaw || this.parseLatLng(centerRaw) !== null) {
if (!centerRaw || LatLng.parse(centerRaw) !== null) {
return true;
}
}
if (this.hasConfigPropertyChanged('defaultZoom')) {
const centerRaw = this.parseLatLng(this.config.get('center'));
const center = LatLng.parse(this.config.get('center'));
// Only meaningful if center is valid AND non-empty (non-zero)
if (centerRaw !== null && (centerRaw[0] !== 0 || centerRaw[1] !== 0)) {
if (center !== null && (center.lat !== 0 || center.lng !== 0)) {
return true;
}
}
@ -255,18 +259,18 @@ export class MapBasesView extends BasesView {
};
const center = this.getCenter();
const hasConfiguredCenter = center[0] !== 0 || center[1] !== 0;
const hasConfiguredCenter = center.lat !== 0 || center.lng !== 0;
let centerToUse: [number, number];
let zoomToUse: number;
if (hasConfiguredCenter) {
centerToUse = center;
centerToUse = LatLng.toArray(center);
zoomToUse = this.getDefaultZoom();
} else if (this.savedViewState) {
centerToUse = [this.savedViewState.latitude, this.savedViewState.longitude];
zoomToUse = this.savedViewState.zoom;
} else {
centerToUse = center;
centerToUse = LatLng.toArray(center);
zoomToUse = this.getDefaultZoom();
}
@ -287,14 +291,25 @@ export class MapBasesView extends BasesView {
onTilesLoaded: () => {
tilesLoaded = true;
hideOverlay();
},
onSetMapCenter: (lat: number, lng: number) => {
this.config.set('center', `${lat}, ${lng}`);
},
onSetDefaultZoom: (zoom: number) => {
this.config.set('defaultZoom', zoom);
}
}
});
this.lastPoints = points;
this.lastConfigState = { ...this.config.data };
this.containerEl.removeClass('is-loading');
if (this.plugin.settings.enableSearchGeocoding) {
this.createSearchBox();
}
setTimeout(() => {
if (!tilesLoaded) {
hideOverlay();
@ -318,11 +333,6 @@ export class MapBasesView extends BasesView {
const points = this.extractPointsFromData();
if (arePointsEqual(points, this.lastPoints)) {
console.warn('onDataUpdated triggered but points are unchanged - skipping update');
return;
}
this.updateRenderedPoints(points);
}
@ -330,16 +340,16 @@ export class MapBasesView extends BasesView {
if (!this.deck) return;
const center = this.getCenter();
const hasConfiguredCenter = center[0] !== 0 || center[1] !== 0;
const locationsChanged = haveLocationsChanged(points, this.lastPoints);
const hasConfiguredCenter = center.lat !== 0 || center.lng !== 0;
const locationsChanged = MapPoint.haveLocationsChanged(points, this.lastPoints);
const configChanged = this.hasConfigMeaningfullyChanged();
this.lastConfigState = { ...this.config.data };
const shouldAutoCenter = this.plugin.settings.autoCenter && (autofit === true || (autofit === undefined && (locationsChanged || configChanged)));
this.lastPoints = points;
const shouldAutoCenter = this.plugin.settings.autoCenter && !hasConfiguredCenter && (autofit === true || (autofit === undefined && (locationsChanged || configChanged)));
const shouldUseConfiguredCenter = hasConfiguredCenter && (autofit !== false); //TODO: slight hack
updateMapPoints(this.deck, points, {
containerEl: this.mapEl,
app: this.app,
@ -347,8 +357,8 @@ export class MapBasesView extends BasesView {
tagSettings: this.plugin.tagSettings,
options: {
markerType: this.getMarkerType(),
center: hasConfiguredCenter ? center : undefined,
zoom: hasConfiguredCenter ? this.getDefaultZoom() : undefined,
center: shouldUseConfiguredCenter ? LatLng.toArray(center) : undefined,
zoom: shouldUseConfiguredCenter ? this.getDefaultZoom() : undefined,
autoCenter: shouldAutoCenter
}
});
@ -367,17 +377,14 @@ export class MapBasesView extends BasesView {
if (!coordinates) continue;
let point: MapPoint = {
lat: coordinates[0],
lng: coordinates[1],
location: coordinates,
title: entry.file.basename,
file: entry.file,
};
const fileCache = this.app.metadataCache.getFileCache(entry.file);
if (fileCache?.frontmatter?.tags) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const tags = fileCache.frontmatter.tags;
point.tags = Array.isArray(tags) ? tags : [tags];
point.tags = fileCache.frontmatter.tags as string[];
}
if (coverProp) {
@ -418,7 +425,7 @@ export class MapBasesView extends BasesView {
const polygonVal = entry.getValue(polygonProp);
if (polygonVal) {
const polygonCoords = this.extractPolygonCoordinates(polygonVal);
if (polygonCoords) {
if (polygonCoords && polygonCoords.length > 0) {
point.polygon = polygonCoords;
}
}
@ -432,11 +439,11 @@ export class MapBasesView extends BasesView {
return points;
}
protected extractCoordinates(entry: BasesEntry, coordinatesProp: BasesPropertyId | null): [number, number] | null {
protected extractCoordinates(entry: BasesEntry, coordinatesProp: BasesPropertyId | null): LatLng.Verified | null {
if (coordinatesProp) {
try {
const value = entry.getValue(coordinatesProp);
const coords = this.parseLatLng(value);
const coords = LatLng.parse(value);
if (coords) return coords;
} catch (error) {
console.error(`Error extracting coordinates for ${entry.file.name}:`, error);
@ -446,18 +453,13 @@ export class MapBasesView extends BasesView {
// Fallback to global settings if coordinates property not set or failed
if (this.plugin.settings.latKey && this.plugin.settings.lngKey) {
try {
//TODO: use fm cache - location[0] and location[1] not accessible via entry.getValue?
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);
const latValue = extractFromFrontmatter(fileCache.frontmatter, this.plugin.settings.latKey);
const lngValue = 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];
}
}
return LatLng.parse([latValue, lngValue]);
}
} catch (error) {
console.error(`Error extracting coordinates from frontmatter for ${entry.file.name}:`, error);
@ -467,110 +469,313 @@ export class MapBasesView extends BasesView {
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;
}
private parseLatLng(value: unknown): [number, number] | null {
// Handle ListValue from frontmatter: [40.7128, -74.0060]
if (value instanceof ListValue && 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 plain JavaScript array from JSON.parse: [40.7128, -74.0060]
if (Array.isArray(value) && value.length >= 2) {
const lat = this.parseCoordinate(value[0]);
const lng = this.parseCoordinate(value[1]);
if (lat !== null && lng !== null) {
return [lat, lng];
}
}
// Handle string: "40.7128, -74.0060" or StringValue wrapper
if (value instanceof StringValue || typeof value === 'string') {
const str = value instanceof StringValue ? value.toString() : value;
const parts = str.split(',').map(p => parseFloat(p.trim()));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
return [parts[0], parts[1]];
}
}
return null;
}
private parseLatLngOrZero(value: unknown): [number, number] {
return this.parseLatLng(value) ?? [0, 0];
}
private extractPolygonCoordinates(value: unknown): [number, number][] | null {
private extractPolygonCoordinates(value: unknown): LatLng.Verified[] | null {
try {
let items: unknown[] = [];
// Handle ListValue (array from frontmatter)
if (value instanceof ListValue) {
const coords: [number, number][] = [];
for (let i = 0; i < value.length(); i++) {
const coord = this.parseLatLng(value.get(i));
if (coord) coords.push(coord);
items.push(value.get(i));
}
return coords.length > 0 ? coords : null;
}
// Handle string value as JSON array
else if (value instanceof StringValue) {
const parsed = JSON.parse(value.toString().trim()) as unknown[];
if (Array.isArray(parsed)) {
items = parsed;
}
}
// Handle plain array
else if (Array.isArray(value)) {
items = value;
}
// Handle string value as JSON array
if (value instanceof StringValue) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsed = JSON.parse(value.toString().trim());
if (Array.isArray(parsed)) {
const coords: [number, number][] = [];
for (const item of parsed) {
const coord = this.parseLatLng(item);
if (coord) coords.push(coord);
}
return coords.length > 0 ? coords : null;
}
} catch {
// Not JSON, ignore
}
// Parse each item as a coordinate
const coords: LatLng.Verified[] = [];
for (const item of items) {
const coord = LatLng.parse(item);
if (coord) coords.push(coord);
}
return coords.length > 0 ? coords : null;
} catch (error) {
console.error('Error extracting polygon coordinates:', error);
return null;
}
}
private createSearchBox(): void {
interface PhotonFeature {
geometry: {
coordinates: [number, number];
};
properties: {
name?: string;
street?: string;
city?: string;
country?: string;
};
}
return null;
interface PhotonResponse {
features: PhotonFeature[];
}
const searchContainer = this.mapEl.createDiv({ cls: 'bases-map-search-container' });
this.makeDraggable(searchContainer);
const searchInput = searchContainer.createEl('input', {
type: 'text',
cls: 'map-search-input',
placeholder: 'Search location...',
});
const resultsContainer = searchContainer.createDiv({ cls: 'map-search-results' });
let searchTimeout: number;
let currentResults: Array<{ lat: number; lng: number; name: string; display_name: string }> = [];
let selectedResultIndex = -1;
const performSearch = async (query: string) => {
try {
const response = await requestUrl(`https://photon.komoot.io/api/?q=${encodeURIComponent(query)}&limit=5`);
const data = response.json as PhotonResponse;
currentResults = data.features.map((feature: PhotonFeature) => ({
lat: feature.geometry.coordinates[1],
lng: feature.geometry.coordinates[0],
name: feature.properties.name ?? feature.properties.street ?? '',
display_name: [
feature.properties.name,
feature.properties.city,
feature.properties.country
].filter(Boolean).join(', ')
}));
resultsContainer.empty();
selectedResultIndex = -1;
if (currentResults.length > 0) {
currentResults.forEach((result, index) => {
const resultItem = resultsContainer.createDiv({ cls: 'map-search-result-item' });
resultItem.textContent = result.display_name;
const selectResult = () => {
if (this.deck) {
this.deck.setProps({
initialViewState: {
MapView: {
latitude: result.lat,
longitude: result.lng,
zoom: 12,
transitionDuration: SEARCH_TRANSITION_DURATION,
transitionInterpolator: new FlyToInterpolator(),
}
}
});
}
searchInput.value = result.name || result.display_name;
resultsContainer.empty();
resultsContainer.removeClass('visible');
selectedResultIndex = -1;
};
resultItem.addEventListener('click', selectResult);
resultItem.addEventListener('mouseenter', () => {
updateSelectedResult(index);
});
});
resultsContainer.addClass('visible');
} else {
resultsContainer.removeClass('visible');
}
} catch (error) {
console.error('Search error:', error);
resultsContainer.empty();
resultsContainer.removeClass('visible');
}
};
const updateSelectedResult = (index: number) => {
selectedResultIndex = index;
const resultItems = resultsContainer.querySelectorAll('.map-search-result-item');
resultItems.forEach((item, i) => {
if (i === index) {
item.addClass('selected');
} else {
item.removeClass('selected');
}
});
};
const handleCoordinatePaste = (query: string): boolean => {
const parsed = LatLng.parse(query);
if (parsed && this.deck) {
this.deck.setProps({
initialViewState: {
MapView: {
latitude: parsed.lat,
longitude: parsed.lng,
zoom: 12,
transitionDuration: SEARCH_TRANSITION_DURATION,
transitionInterpolator: new FlyToInterpolator(),
}
}
});
searchInput.value = LatLng.toString(parsed);
resultsContainer.empty();
resultsContainer.removeClass('visible');
selectedResultIndex = -1;
return true;
}
return false;
};
searchInput.addEventListener('paste', (e) => {
e.preventDefault();
const pastedText = e.clipboardData?.getData('text')?.trim() || '';
if (handleCoordinatePaste(pastedText)) {
return;
}
searchInput.value = pastedText;
searchInput.dispatchEvent(new Event('input'));
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (currentResults.length > 0) {
const newIndex = selectedResultIndex < currentResults.length - 1 ? selectedResultIndex + 1 : selectedResultIndex;
updateSelectedResult(newIndex);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (currentResults.length > 0 && selectedResultIndex > 0) {
updateSelectedResult(selectedResultIndex - 1);
}
} else if (e.key === 'Enter') {
e.preventDefault();
const query = searchInput.value.trim();
if (handleCoordinatePaste(query)) {
return;
}
// If a result is selected, navigate to it
if (selectedResultIndex >= 0 && selectedResultIndex < currentResults.length) {
const result = currentResults[selectedResultIndex];
if (this.deck) {
this.deck.setProps({
initialViewState: {
MapView: {
latitude: result.lat,
longitude: result.lng,
zoom: 12,
transitionDuration: SEARCH_TRANSITION_DURATION,
transitionInterpolator: new FlyToInterpolator(),
}
}
});
}
searchInput.value = result.name || result.display_name;
resultsContainer.empty();
resultsContainer.removeClass('visible');
selectedResultIndex = -1;
}
} else if (e.key === 'Escape') {
resultsContainer.empty();
resultsContainer.removeClass('visible');
selectedResultIndex = -1;
}
});
searchInput.addEventListener('input', () => {
const query = searchInput.value.trim();
if (searchTimeout) {
window.clearTimeout(searchTimeout);
}
if (query.length < 3) {
resultsContainer.empty();
resultsContainer.removeClass('visible');
return;
}
// Don't perform geocoding search if it's a valid LatLng
if (LatLng.parse(query)) {
resultsContainer.empty();
resultsContainer.removeClass('visible');
return;
}
searchTimeout = window.setTimeout(() => {
void performSearch(query);
}, SEARCH_DEBOUNCE_TIME);
});
// Close results when clicking outside
document.addEventListener('click', (e) => {
if (!searchContainer.contains(e.target as Node)) {
resultsContainer.empty();
resultsContainer.removeClass('visible');
selectedResultIndex = -1;
}
});
}
protected makeDraggable(element: HTMLElement): void {
let isDragging = false;
let offsetX = 0;
let offsetY = 0;
element.addClass('map-draggable');
const onMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'SELECT' || target.tagName === 'BUTTON') {
return;
}
isDragging = true;
const rect = element.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
element.addClass('dragging');
e.preventDefault();
};
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const containerRect = this.mapEl.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
let x = e.clientX - containerRect.left - offsetX;
let y = e.clientY - containerRect.top - offsetY;
// Constrain to container bounds
x = Math.max(0, Math.min(x, containerRect.width - elementRect.width));
y = Math.max(0, Math.min(y, containerRect.height - elementRect.height));
element.setCssProps({'left': `${x}px`});
element.setCssProps({'top': `${y}px`});
element.addClass('move');
};
const onMouseUp = () => {
isDragging = false;
};
element.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
static getViewOptions(): ViewOption[] {

View file

@ -5,7 +5,7 @@ import {
ViewOption,
setIcon,
} from 'obsidian';
import { MapPoint } from '../map-renderer';
import { MapPoint } from '../types/MapPoint';
import MapPlugin from '../main';
import { MapBasesView } from './map-bases-view';
@ -316,63 +316,6 @@ export class MapTimelineBasesView extends MapBasesView {
}
}
private makeDraggable(element: HTMLElement): void {
let isDragging = false;
let offsetX = 0;
let offsetY = 0;
element.addClass('map-draggable');
const onMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'SELECT' || target.tagName === 'BUTTON') {
return;
}
isDragging = true;
const rect = element.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
element.addClass('dragging');
e.preventDefault();
};
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const containerRect = this.mapEl.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
let x = e.clientX - containerRect.left - offsetX;
let y = e.clientY - containerRect.top - offsetY;
// Constrain to container bounds
x = Math.max(0, Math.min(x, containerRect.width - elementRect.width));
y = Math.max(0, Math.min(y, containerRect.height - elementRect.height));
element.setCssProps({'left': `${x}px`});
element.setCssProps({'top': `${y}px`});
element.addClass('move');
};
const onMouseUp = () => {
isDragging = false;
};
element.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
// Clean up on destroy
this.register(() => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
});
}
private togglePlayback(): void {
if (this.isPlaying) {
this.stopPlayback();

View file

@ -566,4 +566,68 @@
color: var(--text-muted);
font-style: italic;
font-size: 0.9em;
}
/* Map search box */
.bases-map-search-container {
position: absolute;
top: 8px;
left: 8px;
width: 280px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
padding: 8px;
}
.map-search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
background: var(--background-secondary);
color: var(--text-normal);
font-size: 13px;
}
.map-search-input:focus {
outline: none;
border-color: var(--interactive-accent);
}
.map-search-results {
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
border-radius: 6px;
background: var(--background-secondary);
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.map-search-results.visible {
opacity: 1;
transform: translateY(0);
pointer-events: all;
}
.map-search-result-item {
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
color: var(--text-normal);
border-bottom: 1px solid var(--background-modifier-border);
}
.map-search-result-item:last-child {
border-bottom: none;
}
.map-search-result-item:hover,
.map-search-result-item.selected {
background: var(--background-modifier-hover);
}