mirror of
https://github.com/ccmdi/obsidian-map-plus.git
synced 2026-07-22 06:43:14 +00:00
commit
0e54ca1281
12 changed files with 811 additions and 294 deletions
33
README.md
33
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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
83
src/types/MapPoint.ts
Normal 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
167
src/types/latlng.ts
Normal 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
16
src/utils.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
64
styles.css
64
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);
|
||||
}
|
||||
Loading…
Reference in a new issue