diff --git a/README.md b/README.md index 9e1d123..4e6cd9f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/main.ts b/src/main.ts index 4b98d55..ae41f7a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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() { diff --git a/src/map-renderer.ts b/src/map-renderer.ts index 423e818..7e77658 100644 --- a/src/map-renderer.ts +++ b/src/map-renderer.ts @@ -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, 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, 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, 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; }) => { - // 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(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 { + 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; diff --git a/src/pointutils.ts b/src/pointutils.ts deleted file mode 100644 index 1bc44e3..0000000 --- a/src/pointutils.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/settings/map-settings.ts b/src/settings/map-settings.ts index 334d194..a68cd08 100644 --- a/src/settings/map-settings.ts +++ b/src/settings/map-settings.ts @@ -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') diff --git a/src/thumbnail-cache.ts b/src/thumbnail-cache.ts index 252487c..713bf37 100644 --- a/src/thumbnail-cache.ts +++ b/src/thumbnail-cache.ts @@ -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; diff --git a/src/types/MapPoint.ts b/src/types/MapPoint.ts new file mode 100644 index 0000000..f93cbf2 --- /dev/null +++ b/src/types/MapPoint.ts @@ -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); + } +} + diff --git a/src/types/latlng.ts b/src/types/latlng.ts new file mode 100644 index 0000000..3345699 --- /dev/null +++ b/src/types/latlng.ts @@ -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; + } +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..bb1da40 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,16 @@ + + +export function extractFromFrontmatter(frontmatter: Record, 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]; +} \ No newline at end of file diff --git a/src/views/map-bases-view.ts b/src/views/map-bases-view.ts index 386c7e5..31bae4f 100644 --- a/src/views/map-bases-view.ts +++ b/src/views/map-bases-view.ts @@ -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 | 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 = {}; @@ -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, 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[] { diff --git a/src/views/map-timeline-bases-view.ts b/src/views/map-timeline-bases-view.ts index 1c9729f..4a936c0 100644 --- a/src/views/map-timeline-bases-view.ts +++ b/src/views/map-timeline-bases-view.ts @@ -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(); diff --git a/styles.css b/styles.css index 5fb9e35..ab25bd9 100644 --- a/styles.css +++ b/styles.css @@ -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); } \ No newline at end of file