/
githubmirror
/
obsidian-maps
Обзор
Документация
Войти
/
githubmirror
/
obsidian-maps
Код
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
src/map-view.ts
744 строки
23 KB
Steph Ango
Review fixes (#80)
01 авг 2026, 17:54
Не верифицирован
01 авг 2026, 17:54
98bcc14
Код
Авторство
О чём код?
import { BasesView, debounce, Menu, Platform, QueryController, Value, StringValue, NullValue, BasesAllOptions, } from 'obsidian'; import { Map, setRTLTextPlugin } from 'maplibre-gl'; import type ObsidianMapsPlugin from './main'; import { DEFAULT_MAP_HEIGHT, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from './map/constants'; import { CustomZoomControl } from './map/controls/zoom-control'; import { CustomGeolocateControl } from './map/controls/geolocate-control'; import { BackgroundSwitcherControl } from './map/controls/background-switcher'; import { StyleManager } from './map/style'; import { PopupManager } from './map/popup'; import { MarkerManager } from './map/markers'; import { MapConfig } from './map/types'; import { hasOwnProperty, coordinateFromValue, toLngLat, roundCoordinate, formatCoordinates, sameCoordinates } from './map/utils'; import { rtlPluginCode } from './map/rtl-plugin-code'; export const MapViewType = 'map'; /** Raw config values watched for changes. Values stay unknown; only equality matters. */ interface ConfigSnapshot { center: unknown; defaultZoom: unknown; minZoom: unknown; maxZoom: unknown; mapHeight: unknown; mapTiles: unknown; mapTilesDark: unknown; } export class MapView extends BasesView { type = MapViewType; scrollEl: HTMLElement; containerEl: HTMLElement; mapEl: HTMLElement; plugin: ObsidianMapsPlugin; // Internal rendering data private map: Map | null = null; private mapConfig: MapConfig | null = null; private pendingMapState: { center?: { lng: number, lat: number }, zoom?: number } | null = null; private isFirstLoad = true; private lastConfigSnapshot: ConfigSnapshot | null = null; private lastEvaluatedCenter: [number, number] | null = null; // Managers private styleManager: StyleManager; private popupManager: PopupManager; private markerManager: MarkerManager; // Static flag to track RTL plugin initialization private static rtlPluginInitialized = false; constructor(controller: QueryController, scrollEl: HTMLElement, plugin: ObsidianMapsPlugin) { super(controller); this.scrollEl = scrollEl; this.plugin = plugin; this.containerEl = scrollEl.createDiv({ cls: 'bases-map-container is-loading', attr: { tabIndex: 0 } }); this.mapEl = this.containerEl.createDiv('bases-map'); // Initialize managers this.styleManager = new StyleManager(this.app); this.popupManager = new PopupManager(this.containerEl, this.app); this.markerManager = new MarkerManager( this.app, this.mapEl, this.popupManager, (path, newLeaf) => void this.app.workspace.openLinkText(path, '', newLeaf), () => this.data, () => this.mapConfig, (prop) => this.config.getDisplayName(prop) ); } onload(): void { // Listen for theme changes to update map tiles this.registerEvent(this.app.workspace.on('css-change', this.onThemeChange, this)); // Add context menu to map this.registerDomEvent(this.mapEl, 'contextmenu', (evt) => { evt.preventDefault(); this.showMapContextMenu(evt); }); } onunload() { this.destroyMap(); } /** Reduce flashing due to map re-rendering by debouncing while resizes are still ocurring. */ private onResizeDebounce = debounce( () => { if (this.map) this.map.resize() }, 100, true); onResize(): void { this.onResizeDebounce(); } public focus(): void { this.containerEl.focus({ preventScroll: true }); } private onThemeChange = (): void => { if (this.map) { void this.updateMapStyle(); } }; private async updateMapStyle(): Promise<void> { if (!this.map || !this.mapConfig) return; const newStyle = await this.styleManager.getMapStyle(this.mapConfig.mapTiles, this.mapConfig.mapTilesDark); this.map.setStyle(newStyle); this.markerManager.clearLoadedIcons(); // Re-add markers after style change since setStyle removes all runtime layers void this.map.once('styledata', () => { void this.markerManager.updateMarkers(this.data); }); } private async switchToTileSet(tileSetId: string): Promise<void> { const tileSet = this.plugin.settings.tileSets.find(ts => ts.id === tileSetId); if (!tileSet || !this.mapConfig) return; this.mapConfig.currentTileSetId = tileSetId; // Update the current tiles this.mapConfig.mapTiles = tileSet.lightTiles ? [tileSet.lightTiles] : []; this.mapConfig.mapTilesDark = tileSet.darkTiles ? [tileSet.darkTiles] : (tileSet.lightTiles ? [tileSet.lightTiles] : []); // Update the map style await this.updateMapStyle(); } private async initializeMap(): Promise<void> { if (this.map) return; // Initialize RTL text plugin once if (!MapView.rtlPluginInitialized) { try { // Create a blob URL from the bundled RTL plugin code // The plugin needs to run in a worker context const blob = new Blob([rtlPluginCode], { type: 'application/javascript' }); const blobURL = URL.createObjectURL(blob); // Set lazy loading to false - plugin is initialized since code is already bundled void setRTLTextPlugin(blobURL, false); MapView.rtlPluginInitialized = true; } catch (error) { console.warn('Failed to initialize RTL text plugin:', error); } } // Load config first const currentTileSetId = this.mapConfig?.currentTileSetId || null; this.mapConfig = this.loadConfig(currentTileSetId); // Set initial map height based on context this.applyMapHeight(); // Get the map style (may involve fetching remote style JSON) const mapStyle = await this.styleManager.getMapStyle(this.mapConfig.mapTiles, this.mapConfig.mapTilesDark); // Determine initial position: prefer ephemeral state if available, otherwise use config. // An unconfigured center starts at the default and is corrected to the marker // bounds once the style has loaded. let initialCenter = toLngLat(this.mapConfig.center ?? DEFAULT_MAP_CENTER); let initialZoom = this.mapConfig.defaultZoom; // Capture if we are starting with a pending state restoration const isRestoringState = this.pendingMapState !== null; const pendingCenter = this.pendingMapState?.center; if (pendingCenter) initialCenter = [pendingCenter.lng, pendingCenter.lat]; initialZoom = this.pendingMapState?.zoom ?? initialZoom; // Initialize MapLibre GL JS map with configured tiles or default style this.map = new Map({ container: this.mapEl, style: mapStyle, center: initialCenter, zoom: initialZoom, minZoom: this.mapConfig.minZoom, maxZoom: this.mapConfig.maxZoom, }); // Set map reference in managers this.popupManager.setMap(this.map); this.markerManager.setMap(this.map); this.map.addControl(new CustomZoomControl(), 'top-right'); // Desktop Electron grants the geolocation permission but has no location // provider behind it, so requests hang until they time out if (Platform.isMobileApp) { this.map.addControl(new CustomGeolocateControl(), 'top-right'); } // Add background switcher if multiple tile sets are available if (this.plugin.settings.tileSets.length > 1) { const currentId = this.mapConfig.currentTileSetId || this.plugin.settings.tileSets[0]?.id || ''; if (currentId) { this.map.addControl( new BackgroundSwitcherControl( this.plugin.settings.tileSets, currentId, (tileSetId) => void this.switchToTileSet(tileSetId) ), 'top-right' ); } } this.map.on('error', (e) => { console.warn('Map error:', e); }); // Ensure the center and zoom are set after map loads (in case the style loading overrides it) this.map.on('load', () => { if (!this.map || !this.mapConfig) return; // If we were restoring state, do not reset to defaults if (isRestoringState || this.pendingMapState) return; const configuredCenter = this.mapConfig.center; // Set center based on configuration if (configuredCenter) { this.map.setCenter(toLngLat(configuredCenter)); } else { const bounds = this.markerManager.getBounds(); if (bounds) { this.map.setCenter(bounds.getCenter()); // Center on markers } } // Set zoom based on configuration if (this.hasConfiguredZoom()) { this.map.setZoom(this.mapConfig.defaultZoom); // Use configured zoom } else { const bounds = this.markerManager.getBounds(); if (bounds) { this.map.fitBounds(bounds, { padding: 20 }); // Fit all markers } } }); // Hide tooltip on the map element. this.mapEl.querySelector('canvas')?.setCssProps({ '--no-tooltip': 'true' }); } private destroyMap(): void { this.popupManager.destroy(); if (this.map) { this.map.remove(); this.map = null; } this.markerManager.setMap(null); } public onDataUpdated(): void { this.containerEl.removeClass('is-loading'); const configSnapshot = this.getConfigSnapshot(); const configChanged = JSON.stringify(this.lastConfigSnapshot) !== JSON.stringify(configSnapshot); const currentTileSetId = this.mapConfig?.currentTileSetId || null; this.mapConfig = this.loadConfig(currentTileSetId); // Check if the evaluated center coordinates have changed const centerChanged = !sameCoordinates(this.mapConfig.center, this.lastEvaluatedCenter); void this.initializeMap().then(async () => { // Apply config to map on first load or when config changes if (configChanged) { await this.applyConfigToMap(this.lastConfigSnapshot, configSnapshot); this.lastConfigSnapshot = configSnapshot; this.isFirstLoad = false; } // Update center when the evaluated center coordinates change // (e.g., due to formula re-evaluation when active file changes) // But skip if we're restoring ephemeral state else if (this.map && !this.isFirstLoad && centerChanged && this.pendingMapState === null) { this.updateCenter(); } if (this.map && this.data) { await this.markerManager.updateMarkers(this.data); // Apply pending map state if available (for restoring ephemeral state) if (this.pendingMapState && this.map) { const { center, zoom } = this.pendingMapState; if (center) { this.map.setCenter(center); } if (zoom !== null && zoom !== undefined) { this.map.setZoom(zoom); } this.pendingMapState = null; } } // Track state for next comparison if (this.mapConfig) { const center = this.mapConfig.center; this.lastEvaluatedCenter = center && [center[0], center[1]]; } }); } /** A zoom of 0 is valid, so this tests for presence rather than truthiness. */ private hasConfiguredZoom(): boolean { return Number.isNumber(this.config.get('defaultZoom')); } private updateZoom(): void { if (!this.map || !this.mapConfig) return; if (this.hasConfiguredZoom()) { this.map.setZoom(this.mapConfig.defaultZoom); } } private updateCenter(): void { if (!this.map || !this.mapConfig) return; const configuredCenter = this.mapConfig.center; if (configuredCenter) { // Only recenter if the evaluated coordinates actually changed const currentCenter = this.map.getCenter(); if (!currentCenter) return; // Map not fully initialized yet const targetCenter = toLngLat(configuredCenter); const centerActuallyChanged = Math.abs(currentCenter.lng - targetCenter[0]) > 0.00001 || Math.abs(currentCenter.lat - targetCenter[1]) > 0.00001; if (centerActuallyChanged) { this.map.setCenter(targetCenter); } } } private async applyConfigToMap(oldConfig: ConfigSnapshot | null, newConfig: ConfigSnapshot): Promise<void> { if (!this.map || !this.mapConfig) return; // Detect what changed const centerConfigChanged = oldConfig?.center !== newConfig.center; const zoomConfigChanged = oldConfig?.defaultZoom !== newConfig.defaultZoom; const tilesChanged = JSON.stringify(oldConfig?.mapTiles) !== JSON.stringify(newConfig.mapTiles) || JSON.stringify(oldConfig?.mapTilesDark) !== JSON.stringify(newConfig.mapTilesDark); const heightChanged = oldConfig?.mapHeight !== newConfig.mapHeight; // Update map constraints this.map.setMinZoom(this.mapConfig.minZoom); this.map.setMaxZoom(this.mapConfig.maxZoom); // Clamp current zoom to new min/max bounds const currentZoom = this.map.getZoom(); if (currentZoom < this.mapConfig.minZoom) { this.map.setZoom(this.mapConfig.minZoom); } else if (currentZoom > this.mapConfig.maxZoom) { this.map.setZoom(this.mapConfig.maxZoom); } // Skip updating zoom/center if we have pending ephemeral state to restore // (e.g., when navigating back in history to restore the user's last pan/zoom) const hasEphemeralState = this.pendingMapState !== null; // Only update zoom on first load or when zoom config explicitly changed // But skip if we're restoring ephemeral state if (!hasEphemeralState && (this.isFirstLoad || zoomConfigChanged)) { this.updateZoom(); } // Update center on first load or when center config changed // But skip if we're restoring ephemeral state if (!hasEphemeralState && (this.isFirstLoad || centerConfigChanged)) { this.updateCenter(); } // Update map style if tiles configuration changed if (this.isFirstLoad || tilesChanged) { const newStyle = await this.styleManager.getMapStyle(this.mapConfig.mapTiles, this.mapConfig.mapTilesDark); const currentStyle = this.map.getStyle(); if (JSON.stringify(newStyle) !== JSON.stringify(currentStyle)) { this.map.setStyle(newStyle); this.markerManager.clearLoadedIcons(); } } // Update map height for embedded views if height changed if (this.isFirstLoad || heightChanged) { this.applyMapHeight(); // Resize map after height changes this.map.resize(); } } isEmbedded(): boolean { // Check if this map view is embedded in a markdown file rather than opened directly // If the scrollEl has a parent with 'bases-embed' class, it's embedded return this.scrollEl.parentElement?.matchParent('.bases-embed, .block-language-base') != null; } /** Embedded views size to the configured height; direct views let CSS drive it. */ private applyMapHeight(): void { this.mapEl.style.height = this.isEmbedded() && this.mapConfig ? this.mapConfig.mapHeight + 'px' : ''; } private loadConfig(currentTileSetId: string | null): MapConfig { // Load property configurations const coordinatesProp = this.config.getAsPropertyId('coordinates'); const markerIconProp = this.config.getAsPropertyId('markerIcon'); const markerColorProp = this.config.getAsPropertyId('markerColor'); // Load numeric configurations with validation const minZoom = this.getNumericConfig('minZoom', 0, 0, 24); const maxZoom = this.getNumericConfig('maxZoom', 18, 0, 24); const defaultZoom = this.getNumericConfig('defaultZoom', DEFAULT_MAP_ZOOM, minZoom, maxZoom); // Load center coordinates const center = this.getCenterFromConfig(); // Load map height for embedded views const mapHeight = this.isEmbedded() ? this.getNumericConfig('mapHeight', DEFAULT_MAP_HEIGHT, 100, 2000) : DEFAULT_MAP_HEIGHT; // Load map tiles configurations // Use view-specific tiles if configured, otherwise fall back to plugin defaults const viewSpecificTiles = this.getArrayConfig('mapTiles'); const viewSpecificTilesDark = this.getArrayConfig('mapTilesDark'); let mapTiles: string[]; let mapTilesDark: string[]; let selectedTileSetId: string | null; if (viewSpecificTiles.length > 0) { // View has specific tiles configured mapTiles = viewSpecificTiles; mapTilesDark = viewSpecificTilesDark; selectedTileSetId = null; } else if (this.plugin.settings.tileSets.length > 0) { // Use first tile set from plugin settings (or previously selected one) const tileSet = currentTileSetId ? this.plugin.settings.tileSets.find(ts => ts.id === currentTileSetId) : null; const selectedTileSet = tileSet || this.plugin.settings.tileSets[0]; selectedTileSetId = selectedTileSet.id; mapTiles = selectedTileSet.lightTiles ? [selectedTileSet.lightTiles] : []; mapTilesDark = selectedTileSet.darkTiles ? [selectedTileSet.darkTiles] : (selectedTileSet.lightTiles ? [selectedTileSet.lightTiles] : []); } else { // No tiles configured, will fall back to default style mapTiles = []; mapTilesDark = []; selectedTileSetId = null; } return { coordinatesProp, markerIconProp, markerColorProp, mapHeight, defaultZoom, center, maxZoom, minZoom, mapTiles, mapTilesDark, currentTileSetId: selectedTileSetId, }; } private getNumericConfig(key: string, defaultValue: number, min: number, max: number): number { const value = this.config.get(key); if (value == null || typeof value !== 'number') return defaultValue; return Math.min(max, Math.max(min, value)); } private getArrayConfig(key: string): string[] { const value = this.config.get(key); if (!value) return []; // Handle array values if (Array.isArray(value)) { return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0); } // Handle single string value if (typeof value === 'string' && value.trim().length > 0) { return [value.trim()]; } return []; } /** Returns null when no usable center is configured, so [0, 0] stays a real center. */ private getCenterFromConfig(): [number, number] | null { let centerConfig: Value | null = null; try { centerConfig = this.config.getEvaluatedFormula(this, 'center'); } catch { // Formula evaluation failed (e.g., this.file is null when no active file) } // Fall back to the raw config value, which also supports the legacy string format if (!centerConfig || Value.equals(centerConfig, NullValue.value)) { const centerConfigStr = this.config.get('center'); if (!String.isString(centerConfigStr)) return null; centerConfig = new StringValue(centerConfigStr); } return coordinateFromValue(centerConfig); } private getConfigSnapshot(): ConfigSnapshot { // Create a snapshot of config values that affect map display return { center: this.config.get('center'), defaultZoom: this.config.get('defaultZoom'), minZoom: this.config.get('minZoom'), maxZoom: this.config.get('maxZoom'), mapHeight: this.config.get('mapHeight'), mapTiles: this.config.get('mapTiles'), mapTilesDark: this.config.get('mapTilesDark'), }; } private showMapContextMenu(evt: MouseEvent): void { if (!this.map || !this.mapConfig) return; const currentZoom = Math.round(this.map.getZoom() * 10) / 10; // Round to 1 decimal place // Get coordinates from the location of the right-click event, not the map center const clickPoint: [number, number] = [evt.offsetX, evt.offsetY]; const clickedCoords = this.map.unproject(clickPoint); const currentLat = roundCoordinate(clickedCoords.lat); const currentLng = roundCoordinate(clickedCoords.lng); const menu = Menu.forEvent(evt); menu.addItem(item => item .setTitle('New note') .setSection('action') .setIcon('square-pen') .onClick(() => { void this.createFileForView('', (frontmatter: Record<string, unknown>) => { // Pre-fill coordinates if a coordinates property is configured if (this.mapConfig?.coordinatesProp) { // Remove 'note.' prefix if present const propertyKey = this.mapConfig.coordinatesProp.startsWith('note.') ? this.mapConfig.coordinatesProp.slice(5) : this.mapConfig.coordinatesProp; frontmatter[propertyKey] = [currentLat.toString(), currentLng.toString()]; } }); }) ); menu.addItem(item => item .setTitle('Copy coordinates') .setSection('action') .setIcon('copy') .onClick(() => { void navigator.clipboard.writeText(formatCoordinates(currentLat, currentLng)); }) ); menu.addItem(item => item .setTitle('Set default center point') .setSection('action') .setIcon('map-pin') .onClick(() => { // Set the current center as the default coordinates const coordListStr = `[${currentLat}, ${currentLng}]`; // 1. Update the component's internal state immediately. // This ensures that if a re-render is triggered, its logic will use the // new coordinates and prevent the map from recentering on markers. if (this.mapConfig) { this.mapConfig.center = [currentLat, currentLng]; } // 2. Set the config value, which will be saved. this.config.set('center', coordListStr); // 3. Immediately move the map for instant user feedback. this.map?.setCenter(toLngLat([currentLat, currentLng])); }) ); menu.addItem(item => item .setTitle(`Set default zoom (${currentZoom})`) .setSection('action') .setIcon('crosshair') .onClick(() => { this.config.set('defaultZoom', currentZoom); }) ); } public setEphemeralState(state: unknown): void { if (!state) { this.pendingMapState = null; return; } this.pendingMapState = {}; if (hasOwnProperty(state, 'center') && hasOwnProperty(state.center, 'lng') && hasOwnProperty(state.center, 'lat')) { const lng = state.center.lng; const lat = state.center.lat; if (typeof lng === 'number' && typeof lat === 'number') { this.pendingMapState.center = { lng, lat }; } } if (hasOwnProperty(state, 'zoom') && typeof state.zoom === 'number') { this.pendingMapState.zoom = state.zoom; } } public getEphemeralState(): unknown { if (!this.map) return {}; const center = this.map.getCenter(); return { center: { lng: center.lng, lat: center.lat }, zoom: this.map.getZoom(), }; } static getViewOptions(): BasesAllOptions[] { return [ { displayName: 'Embedded height', type: 'slider', key: 'mapHeight', min: 200, max: 800, step: 20, default: DEFAULT_MAP_HEIGHT, }, { displayName: 'Display', type: 'group', items: [ { displayName: 'Center coordinates', type: 'formula', key: 'center', placeholder: '[latitude, longitude]', }, { displayName: 'Default zoom', type: 'slider', key: 'defaultZoom', min: 1, max: 18, step: 1, default: DEFAULT_MAP_ZOOM, }, { displayName: 'Minimum zoom', type: 'slider', key: 'minZoom', min: 0, max: 24, step: 1, default: 0, }, { displayName: 'Maximum zoom', type: 'slider', key: 'maxZoom', min: 0, max: 24, step: 1, default: 18, }, ] }, { displayName: 'Markers', type: 'group', items: [ { displayName: 'Marker coordinates', type: 'property', key: 'coordinates', filter: prop => !prop.startsWith('file.'), placeholder: 'Property', }, { displayName: 'Marker icon', type: 'property', key: 'markerIcon', filter: prop => !prop.startsWith('file.'), placeholder: 'Property', }, { displayName: 'Marker color', type: 'property', key: 'markerColor', filter: prop => !prop.startsWith('file.'), placeholder: 'Property', }, ] }, { displayName: 'Background', type: 'group', items: [ { displayName: 'Map tiles', type: 'multitext', key: 'mapTiles', }, { displayName: 'Map tiles in dark mode', type: 'multitext', key: 'mapTilesDark', }, ] }, ]; } }