mirror of
https://github.com/ccmdi/obsidian-map-plus.git
synced 2026-07-22 06:43:14 +00:00
Merge pull request #16 from ccmdi/feat/image-tilelayer
Image tilelayers
This commit is contained in:
commit
25aa0f6902
4 changed files with 194 additions and 48 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
20
styles.css
20
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;
|
||||
}
|
||||
Loading…
Reference in a new issue