Merge pull request #16 from ccmdi/feat/image-tilelayer

Image tilelayers
This commit is contained in:
ccmdi 2025-11-22 17:45:12 -05:00 committed by GitHub
commit 25aa0f6902
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 194 additions and 48 deletions

View file

@ -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<MapViewType[
const markerColor = options.markerColor || 'var(--color-accent)';
const tileLayer = options.tileLayer || 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
const isLocalImage = !tileLayer.startsWith('http://') && !tileLayer.startsWith('https://');
containerEl.empty();
containerEl.style.setProperty('--bases-map-height', options.height || '100%');
const mapCanvas = containerEl.createEl('canvas', { cls: 'map-canvas' });
// Pre-load background layer
let backgroundLayer;
if (isLocalImage && options.imageBounds) {
try {
const file = app.vault.getAbstractFileByPath(tileLayer);
if (file && file instanceof TFile) {
const resourcePath = app.vault.getResourcePath(file);
const [[minLat, minLng], [maxLat, maxLng]] = options.imageBounds;
backgroundLayer = new BitmapLayer({
id: 'background-image',
image: resourcePath,
bounds: [minLng, minLat, maxLng, maxLat],
pickable: false,
});
} else {
console.error('Image file not found:', tileLayer);
}
} catch (error) {
console.error('Error loading local image:', error);
}
}
if (!backgroundLayer) {
// Use tile layer (default behavior)
backgroundLayer = new TileLayer({
id: 'tile-layer',
data: tileLayer,
minZoom: 0,
maxZoom: 19,
tileSize: 256,
refinementStrategy: 'best-available',
renderSubLayers: (props: TileLayerProps<HTMLImageElement> & {
id: string;
data: HTMLImageElement;
_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;
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<MapViewType[
}
},
layers: (() => {
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<HTMLImageElement> & {
id: string;
data: HTMLImageElement;
_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;
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<TileLayerProps<unknown>> | PolygonLayer | IconLayer | ScatterplotLayer)[] = [tileLayerInstance];
const layers: Layer[] = [backgroundLayer];
if (polygonLayer) layers.push(polygonLayer);
layers.push(markerLayer);

View file

@ -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<string, unknown> = {};
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]]',
},
],
},
];

View file

@ -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) {

View file

@ -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;
}