diff --git a/src/map-renderer.ts b/src/map-renderer.ts index 103ad09..423e818 100644 --- a/src/map-renderer.ts +++ b/src/map-renderer.ts @@ -1,5 +1,5 @@ import { App, TFile, setIcon } from 'obsidian'; -import { Deck, PickingInfo, MapViewState, FlyToInterpolator } from '@deck.gl/core'; +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'; import type { TileLayerProps, _Tile2DHeader as Tile2DHeader } from '@deck.gl/geo-layers'; @@ -63,6 +63,7 @@ export interface MapRendererOptions { markerSize?: number; markerColor?: string; tileLayer?: string; + imageBounds?: [[number, number], [number, number]]; autoCenter?: boolean; onMarkerClick?: (point: MapPoint, event: MjolnirEvent) => void; onTilesLoaded?: () => void; @@ -412,11 +413,80 @@ export function createMapRenderer(config: MapRendererOptions): Deck & { + id: string; + data: HTMLImageElement; + _offset: number; + tile: Tile2DHeader; + }) => { + // 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; + return new BitmapLayer({ + ...props, + data: undefined, + image: props.data, + bounds: [west, south, east, north], + }); + }, + getTileData: ({ index: { x, y, z } }: TileIndex) => { + const url = tileLayer + .replace('{s}', ['a', 'b', 'c'][Math.abs(x + y) % 3]) + .replace('{z}', String(z)) + .replace('{x}', String(x)) + .replace('{y}', String(y)) + .replace('{r}', ''); + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = reject; + img.src = url; + }); + }, + }); + } + const tooltip = containerEl.createEl('div', { cls: 'map-tooltip' }); const numPoints = points.length; @@ -622,53 +692,10 @@ export function createMapRenderer(config: MapRendererOptions): Deck { - const tileLayerInstance = new TileLayer({ - id: 'tile-layer', - data: tileLayer, - minZoom: 0, - maxZoom: 19, - tileSize: 256, - refinementStrategy: 'best-available', - // Converts each loaded tile (HTMLImageElement) into a BitmapLayer - // positioned at the tile's geographic bounds for rendering - renderSubLayers: (props: TileLayerProps & { - id: string; - data: HTMLImageElement; - _offset: number; - tile: Tile2DHeader; - }) => { - // 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; - return new BitmapLayer({ - ...props, - data: undefined, - image: props.data, - bounds: [west, south, east, north], - }); - }, - getTileData: ({ index: { x, y, z } }: TileIndex) => { - const url = tileLayer - .replace('{s}', ['a', 'b', 'c'][Math.abs(x + y) % 3]) - .replace('{z}', String(z)) - .replace('{x}', String(x)) - .replace('{y}', String(y)) - .replace('{r}', ''); - return new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => resolve(img); - img.onerror = reject; - img.src = url; - }); - }, - }); - const markerLayer = createMarkerLayer(deckData, markerType, settings, tagSettings, options, app); const polygonLayer = createPolygonLayer(points, tagSettings, markerColor, options, app); - const layers: (TileLayer> | PolygonLayer | IconLayer | ScatterplotLayer)[] = [tileLayerInstance]; + const layers: Layer[] = [backgroundLayer]; if (polygonLayer) layers.push(polygonLayer); layers.push(markerLayer); diff --git a/src/views/map-bases-view.ts b/src/views/map-bases-view.ts index fd57d3a..386c7e5 100644 --- a/src/views/map-bases-view.ts +++ b/src/views/map-bases-view.ts @@ -30,7 +30,7 @@ export class MapBasesView extends BasesView { protected savedViewState: { latitude: number; longitude: number; zoom: number } | null = null; protected lastPoints: MapPoint[] = []; - protected watchProps = ['center', 'defaultZoom']; + protected mapUpdateTimeout?: number; protected lastConfigState: Record = {}; constructor(controller: QueryController, scrollEl: HTMLElement, plugin: MapPlugin) { @@ -126,6 +126,25 @@ export class MapBasesView extends BasesView { return (this.config.get('tileLayer') as string) || 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'; } + private getImageBounds(): [[number, number], [number, number]] | undefined { + const boundsStr = this.config.get('imageBounds'); + if (!boundsStr || typeof boundsStr !== 'string') return undefined; + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const parsed = JSON.parse(boundsStr); + if (Array.isArray(parsed) && parsed.length === 2 && + Array.isArray(parsed[0]) && parsed[0].length === 2 && + Array.isArray(parsed[1]) && parsed[1].length === 2) { + return parsed as [[number, number], [number, number]]; + } + } catch { + // Invalid JSON, ignore + } + + return undefined; + } + private hasConfigMeaningfullyChanged(): boolean { if(this.hasConfigPropertyChanged('center')) { const centerRaw = this.config.get('center'); @@ -150,7 +169,21 @@ export class MapBasesView extends BasesView { return oldValue !== newValue; } + public beforeOnDataUpdated(): void { + if (this.deck) { + if(this.hasConfigPropertyChanged('tileLayer') || this.hasConfigPropertyChanged('imageBounds')) { + if (this.mapUpdateTimeout) { + window.clearTimeout(this.mapUpdateTimeout); + } + this.mapUpdateTimeout = window.setTimeout(() => { + this.refresh(); + }, 250); + } + } + } + public onDataUpdated(): void { + this.beforeOnDataUpdated(); // If map exists, just update points. Otherwise create map. if (this.deck) { this.updateMap(); @@ -250,6 +283,7 @@ export class MapBasesView extends BasesView { height: height, markerType: this.getMarkerType(), tileLayer: this.getTileLayer(), + imageBounds: this.getImageBounds(), onTilesLoaded: () => { tilesLoaded = true; hideOverlay(); @@ -613,11 +647,17 @@ export class MapBasesView extends BasesView { type: 'group', items: [ { - displayName: 'Tile layer URL', + displayName: 'Tile layer URL or image path', type: 'text', key: 'tileLayer', placeholder: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', }, + { + displayName: 'Image bounds', + type: 'text', + key: 'imageBounds', + placeholder: '[[minLat, minLng], [maxLat, maxLng]]', + }, ], }, ]; diff --git a/src/views/map-timeline-bases-view.ts b/src/views/map-timeline-bases-view.ts index 61b762b..1c9729f 100644 --- a/src/views/map-timeline-bases-view.ts +++ b/src/views/map-timeline-bases-view.ts @@ -29,7 +29,6 @@ export class MapTimelineBasesView extends MapBasesView { private playButton: HTMLButtonElement | null = null; // private liveUpdateTimeout?: number; private allTimelineEntries: TimelineMapPoint[] = []; - private mapUpdateTimeout?: number; private dataUpdateTimeout?: number; private isPlaying: boolean = false; @@ -104,6 +103,8 @@ export class MapTimelineBasesView extends MapBasesView { private createSlider(): void { const sliderContainer = this.mapEl.createDiv({ cls: 'bases-timeline-slider' }); + this.makeDraggable(sliderContainer); + const inputContainer = sliderContainer.createDiv({ cls: 'timeline-input-container' }); const dateInputEl = inputContainer.createEl('input', { @@ -315,6 +316,63 @@ 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(); @@ -388,6 +446,7 @@ export class MapTimelineBasesView extends MapBasesView { public onDataUpdated(): void { if (this.getDateProperty()) { + super.beforeOnDataUpdated(); this.updateTimelineData(); if (!this.deck) { diff --git a/styles.css b/styles.css index ac7127c..5fb9e35 100644 --- a/styles.css +++ b/styles.css @@ -546,4 +546,24 @@ color: var(--text-normal); font-size: 13px; cursor: pointer; +} + +.map-draggable { + cursor: move; + & .dragging { + position: absolute; + z-index: 1000; + } + & .move { + bottom: auto; + right: auto; + } +} + +/* should be specific enough */ +.input-row:has(input[placeholder="[[minLat, minLng], [maxLat, maxLng]]"]) .input-row-label::after { + content: " (local images only)"; + color: var(--text-muted); + font-style: italic; + font-size: 0.9em; } \ No newline at end of file