diff --git a/package.json b/package.json index 0f8eccb..cf0a5a9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "obsidian-sample-plugin", - "version": "1.0.0", - "description": "This is a sample plugin for Obsidian (https://obsidian.md)", + "name": "mapplus", + "version": "1.0.2", + "description": "A performant and customizable map inside Bases", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", diff --git a/src/main.ts b/src/main.ts index d3596c6..f923f34 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,9 +2,11 @@ import { Plugin } from 'obsidian'; import { MapBasesView } from './views/map-bases-view'; import { MapTagSettings, DEFAULT_MAP_TAG_SETTINGS } from './settings/map-tag-settings'; import { MapSettingTab, MapPluginSettings, DEFAULT_SETTINGS } from './settings/map-settings'; +import { ThumbnailCacheManager } from './thumbnail-cache'; export default class MapPlugin extends Plugin { settings: MapPluginSettings; + thumbnailCache: ThumbnailCacheManager; get tagSettings(): MapTagSettings { return this.settings.tagSettings; @@ -13,6 +15,16 @@ export default class MapPlugin extends Plugin { async onload() { await this.loadSettings(); + this.thumbnailCache = new ThumbnailCacheManager(this, this.app); + + if (this.settings.enableThumbnailCache) { + await this.thumbnailCache.loadCache(); + + setTimeout(() => { + this.thumbnailCache.generatePendingThumbnails(); + }, 2000); + } + this.registerBasesView('map', { name: 'Map', icon: 'lucide-map', diff --git a/src/map-renderer.ts b/src/map-renderer.ts index 4dfd729..6eb3b02 100644 --- a/src/map-renderer.ts +++ b/src/map-renderer.ts @@ -8,6 +8,7 @@ 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'; function easeCubic(t: number): number { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; @@ -52,6 +53,7 @@ export interface MapRendererOptions { app: App; settings: MapPlugin['settings']; tagSettings?: MapTagSettings; + thumbnailCache?: ThumbnailCacheManager; options: { center?: [number, number]; zoom?: number; @@ -60,8 +62,6 @@ export interface MapRendererOptions { markerSize?: number; markerColor?: string; tileLayer?: string; - showSearch?: boolean; - showTags?: boolean; autoCenter?: boolean; onMarkerClick?: (point: MapPoint, event: MjolnirEvent) => void; onTilesLoaded?: () => void; @@ -418,15 +418,20 @@ export async function createMapRenderer(config: MapRendererOptions): Promise { if (thisUpdateId === tooltipUpdateId) { @@ -440,6 +445,29 @@ export async function createMapRenderer(config: MapRendererOptions): Promise { + if (thisUpdateId === tooltipUpdateId) { + tooltip.insertBefore(img, tooltip.firstChild); + } + }) + .catch(() => { + if (thisUpdateId === tooltipUpdateId) { + tooltip.insertBefore(img, tooltip.firstChild); + } + }); + + renderPromises.push(imagePromise); + } } } @@ -451,11 +479,13 @@ export async function createMapRenderer(config: MapRendererOptions): Promise { + if (prop.name.toLowerCase() === 'tags') return; + const propEl = propsContainer.createEl('div', { cls: 'map-tooltip-property' }); - + const labelEl = propEl.createEl('span', { cls: 'map-tooltip-property-label' }); labelEl.textContent = prop.name + ':'; - + const valueEl = propEl.createEl('span', { cls: 'map-tooltip-property-value' }); // render value @@ -473,14 +503,13 @@ export async function createMapRenderer(config: MapRendererOptions): Promise 0) { + // show tags + if (point.tags && point.tags.length > 0) { const tagsEl = tooltip.createEl('div', { cls: 'map-tooltip-tags' }); point.tags.forEach((tag) => { const tagEl = tagsEl.createEl('a', { cls: 'tag' }); tagEl.textContent = `#${tag}`; }); - tooltip.appendChild(tagsEl); } // Wait for all images to load before showing tooltip diff --git a/src/settings/map-settings.ts b/src/settings/map-settings.ts index f013378..5776d0b 100644 --- a/src/settings/map-settings.ts +++ b/src/settings/map-settings.ts @@ -9,6 +9,8 @@ export interface MapPluginSettings { iconFill: boolean; autoCenter: boolean; transitionDuration: number; + enableThumbnailCache: boolean; + thumbnailTargetSize: number; tagSettings: MapTagSettings; } @@ -19,6 +21,8 @@ export const DEFAULT_SETTINGS: MapPluginSettings = { iconFill: false, autoCenter: true, transitionDuration: 1000, + enableThumbnailCache: false, + thumbnailTargetSize: 25, tagSettings: DEFAULT_MAP_TAG_SETTINGS }; @@ -46,6 +50,9 @@ export class MapSettingTab extends PluginSettingTab { .onChange(async (value) => { this.plugin.settings.latKey = value; await this.plugin.saveSettings(); + setTimeout(() => { + this.plugin.refreshAllMapViews(); + }, 1000); })); new Setting(containerEl) @@ -57,6 +64,9 @@ export class MapSettingTab extends PluginSettingTab { .onChange(async (value) => { this.plugin.settings.lngKey = value; await this.plugin.saveSettings(); + setTimeout(() => { + this.plugin.refreshAllMapViews(); + }, 1000); })); new Setting(containerEl) @@ -68,7 +78,9 @@ export class MapSettingTab extends PluginSettingTab { .onChange(async (value) => { this.plugin.settings.strokeWidth = value; await this.plugin.saveSettings(); - this.plugin.refreshAllMapViews(); + setTimeout(() => { + this.plugin.refreshAllMapViews(); + }, 250); })); new Setting(containerEl) @@ -103,6 +115,106 @@ export class MapSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); + containerEl.createEl('h3', { text: 'Performance' }); + + const thumbnailToggle = new Setting(containerEl) + .setName('Enable thumbnail cache') + .setDesc('Cache thumbnails of location cover images - instant tooltips') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableThumbnailCache) + .onChange(async (value) => { + this.plugin.settings.enableThumbnailCache = value; + await this.plugin.saveSettings(); + + if (value) { + await this.plugin.thumbnailCache.loadCache(); + setTimeout(() => { + this.plugin.thumbnailCache.generatePendingThumbnails(); + }, 500); + } + + this.display(); + })); + + if (this.plugin.settings.enableThumbnailCache) { + new Setting(containerEl) + .setName('Thumbnail target size') + .setDesc('Target file size for cached thumbnails (in KB)') + .addSlider(slider => slider + .setLimits(10, 50, 5) + .setValue(this.plugin.settings.thumbnailTargetSize) + .setDynamicTooltip() + .onChange(async (value) => { + this.plugin.settings.thumbnailTargetSize = value; + await this.plugin.saveSettings(); + })); + + const stats = this.plugin.thumbnailCache.getCacheStats(); + const sizeKB = (stats.totalSize / 1024).toFixed(1); + const isGenerating = this.plugin.thumbnailCache.isGenerating(); + + const statusSetting = new Setting(containerEl) + .setName('Thumbnail cache status'); + + const updateStatus = () => { + const currentStats = this.plugin.thumbnailCache.getCacheStats(); + const currentSizeKB = (currentStats.totalSize / 1024).toFixed(1); + + if (currentStats.pending > 0) { + statusSetting.setDesc(`${currentStats.count} thumbnails cached (${currentSizeKB} KB), ${currentStats.pending} pending`); + } else { + statusSetting.setDesc(`${currentStats.count} thumbnails cached (${currentSizeKB} KB)`); + } + }; + + updateStatus(); + + if (stats.pending > 0 || isGenerating) { + const refreshInterval = window.setInterval(() => { + updateStatus(); + if (this.plugin.thumbnailCache.getCacheStats().pending === 0 && !this.plugin.thumbnailCache.isGenerating()) { + clearInterval(refreshInterval); + this.display(); + } + }, 500); + } else { + statusSetting.addButton(button => button + .setButtonText('Rebuild cache') + .onClick(async () => { + button.setDisabled(true); + button.setButtonText('Rebuilding...'); + + const progressInterval = window.setInterval(() => { + updateStatus(); + }, 500); + + // force refresh markers for cover context + this.plugin.refreshAllMapViews(); + await new Promise(resolve => setTimeout(resolve, 1000)); + + await this.plugin.thumbnailCache.rebuildCache((current, total) => { + button.setButtonText(`Rebuilding ${current}/${total}...`); + }); + + clearInterval(progressInterval); + button.setButtonText('Rebuild cache'); + button.setDisabled(false); + this.display(); + })); + } + + new Setting(containerEl) + .setName('Clear thumbnail cache') + .setDesc('Delete all cached thumbnails') + .addButton(button => button + .setButtonText('Clear cache') + .setWarning() + .onClick(async () => { + await this.plugin.thumbnailCache.clearCache(); + this.display(); + })); + } + renderTagCustomizations(containerEl, this.app, this.plugin); } } \ No newline at end of file diff --git a/src/thumbnail-cache.ts b/src/thumbnail-cache.ts new file mode 100644 index 0000000..034dfee --- /dev/null +++ b/src/thumbnail-cache.ts @@ -0,0 +1,368 @@ +import { App, TFile, normalizePath } from 'obsidian'; +import type MapPlugin from './main'; + +export interface ThumbnailCacheEntry { + thumbnailPath: string; + sourceModified: number; + size: number; +} + +export interface ThumbnailCacheMetadata { + entries: Record; + pendingGeneration: string[]; +} + +const THUMBNAIL_WIDTH = 300; +const THUMBNAIL_HEIGHT = 200; +const CACHE_DIR = '.obsidian/plugins/mapplus/.cache'; + +export class ThumbnailCacheManager { + private plugin: MapPlugin; + private app: App; + private cache: ThumbnailCacheMetadata; + private cacheLoaded: boolean = false; + private generationInProgress: boolean = false; + private cacheDir: string; + + constructor(plugin: MapPlugin, app: App) { + this.plugin = plugin; + this.app = app; + this.cache = { + entries: {}, + pendingGeneration: [] + }; + this.cacheDir = normalizePath(CACHE_DIR); + } + + async loadCache(): Promise { + if (this.cacheLoaded) return; + + try { + await this.ensureCacheDir(); + + const metadataPath = normalizePath(`${this.cacheDir}/metadata.json`); + const exists = await this.app.vault.adapter.exists(metadataPath); + + if (exists) { + const content = await this.app.vault.adapter.read(metadataPath); + this.cache = JSON.parse(content); + } + + this.cacheLoaded = true; + } catch (e) { + console.error('Failed to load thumbnail cache:', e); + this.cache = { + entries: {}, + pendingGeneration: [] + }; + this.cacheLoaded = true; + } + } + + async saveCache(): Promise { + try { + await this.ensureCacheDir(); + + const metadataPath = normalizePath(`${this.cacheDir}/metadata.json`); + await this.app.vault.adapter.write(metadataPath, JSON.stringify(this.cache, null, 2)); + } catch (e) { + console.error('Failed to save thumbnail cache:', e); + } + } + + private async ensureCacheDir(): Promise { + const exists = await this.app.vault.adapter.exists(this.cacheDir); + if (!exists) { + await this.app.vault.adapter.mkdir(this.cacheDir); + } + } + + private getThumbnailFilename(sourcePath: string): string { + const hash = this.hashString(sourcePath); + return `thumb_${hash}.jpg`; + } + + private hashString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return Math.abs(hash).toString(36); + } + + async getThumbnail(coverPath: string, sourceFile: TFile): Promise { + if (!this.plugin.settings.enableThumbnailCache) { + return null; + } + + await this.loadCache(); + + const coverFile = this.app.metadataCache.getFirstLinkpathDest(coverPath, sourceFile.path); + if (!coverFile) return null; + + const cacheKey = coverFile.path; + const cached = this.cache.entries[cacheKey]; + + if (cached) { + if (cached.sourceModified === coverFile.stat.mtime) { + const thumbnailExists = await this.app.vault.adapter.exists(cached.thumbnailPath); + if (thumbnailExists) { + const arrayBuffer = await this.app.vault.adapter.readBinary(cached.thumbnailPath); + const blob = new Blob([arrayBuffer], { type: 'image/jpeg' }); + return URL.createObjectURL(blob); + } + } + + delete this.cache.entries[cacheKey]; + await this.saveCache(); + } + + return null; + } + + private async generateThumbnailFromFile(file: TFile): Promise<{ thumbnailPath: string; size: number } | null> { + try { + const targetSize = this.plugin.settings.thumbnailTargetSize * 1000; + const arrayBuffer = await this.app.vault.readBinary(file); + const blob = new Blob([arrayBuffer]); + const bitmap = await createImageBitmap(blob); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + const aspectRatio = bitmap.width / bitmap.height; + let width = THUMBNAIL_WIDTH; + let height = THUMBNAIL_HEIGHT; + + if (aspectRatio > width / height) { + height = width / aspectRatio; + } else { + width = height * aspectRatio; + } + + canvas.width = width; + canvas.height = height; + + ctx.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + + // Start with max quality and only reduce if needed + let quality = 1.0; + const dataUrl = canvas.toDataURL('image/jpeg', quality); + const base64Data = dataUrl.split(',')[1]; + const binaryData = atob(base64Data); + let bytes = new Uint8Array(binaryData.length); + for (let i = 0; i < binaryData.length; i++) { + bytes[i] = binaryData.charCodeAt(i); + } + + // Only reduce quality if we exceed the target + while (bytes.length > targetSize && quality > 0.1) { + quality -= 0.05; + const dataUrl = canvas.toDataURL('image/jpeg', quality); + const base64Data = dataUrl.split(',')[1]; + const binaryData = atob(base64Data); + bytes = new Uint8Array(binaryData.length); + for (let i = 0; i < binaryData.length; i++) { + bytes[i] = binaryData.charCodeAt(i); + } + } + + const thumbnailFilename = this.getThumbnailFilename(file.path); + const thumbnailPath = normalizePath(`${this.cacheDir}/${thumbnailFilename}`); + + await this.ensureCacheDir(); + await this.app.vault.adapter.writeBinary(thumbnailPath, bytes.buffer); + + return { + thumbnailPath, + size: bytes.length + }; + } catch (e) { + console.error('Failed to generate thumbnail:', e); + return null; + } + } + + async generateThumbnail(coverPath: string, sourceFile: TFile): Promise { + const coverFile = this.app.metadataCache.getFirstLinkpathDest(coverPath, sourceFile.path); + if (!coverFile) return null; + + const result = await this.generateThumbnailFromFile(coverFile); + if (!result) return null; + + const cacheKey = coverFile.path; + this.cache.entries[cacheKey] = { + thumbnailPath: result.thumbnailPath, + sourceModified: coverFile.stat.mtime, + size: result.size + }; + + await this.saveCache(); + + const arrayBuffer = await this.app.vault.adapter.readBinary(result.thumbnailPath); + const blob = new Blob([arrayBuffer], { type: 'image/jpeg' }); + return URL.createObjectURL(blob); + } + + async markForGeneration(coverPath: string, sourceFile: TFile): Promise { + const coverFile = this.app.metadataCache.getFirstLinkpathDest(coverPath, sourceFile.path); + if (!coverFile) return; + + await this.loadCache(); + + const cacheKey = coverFile.path; + if (!this.cache.pendingGeneration.includes(cacheKey) && !this.cache.entries[cacheKey]) { + this.cache.pendingGeneration.push(cacheKey); + await this.saveCache(); + } + } + + async generatePendingThumbnails(onProgress?: (current: number, total: number) => void): Promise { + if (this.generationInProgress || !this.plugin.settings.enableThumbnailCache) { + return; + } + + await this.loadCache(); + + if (this.cache.pendingGeneration.length === 0) { + return; + } + + this.generationInProgress = true; + + const pending = [...this.cache.pendingGeneration]; + const total = pending.length; + + for (let i = 0; i < pending.length; i++) { + const imagePath = pending[i]; + + const file = this.app.vault.getAbstractFileByPath(imagePath); + if (file instanceof TFile) { + const result = await this.generateThumbnailFromFile(file); + if (result) { + this.cache.entries[imagePath] = { + thumbnailPath: result.thumbnailPath, + sourceModified: file.stat.mtime, + size: result.size + }; + } + } + + const idx = this.cache.pendingGeneration.indexOf(imagePath); + if (idx > -1) { + this.cache.pendingGeneration.splice(idx, 1); + } + + await this.saveCache(); + + if (onProgress) { + onProgress(i + 1, total); + } + } + + this.generationInProgress = false; + } + + async rebuildCache(onProgress?: (current: number, total: number) => void): Promise { + if (this.generationInProgress || !this.plugin.settings.enableThumbnailCache) { + return; + } + + await this.loadCache(); + + const allImagePaths = new Set([ + ...Object.keys(this.cache.entries), + ...this.cache.pendingGeneration + ]); + + if (allImagePaths.size === 0) { + return; + } + + this.generationInProgress = true; + + const imagePaths = Array.from(allImagePaths); + const total = imagePaths.length; + + for (let i = 0; i < imagePaths.length; i++) { + const imagePath = imagePaths[i]; + + const file = this.app.vault.getAbstractFileByPath(imagePath); + if (file instanceof TFile) { + const oldEntry = this.cache.entries[imagePath]; + if (oldEntry) { + try { + const exists = await this.app.vault.adapter.exists(oldEntry.thumbnailPath); + if (exists) { + await this.app.vault.adapter.remove(oldEntry.thumbnailPath); + } + } catch (e) { + console.error(`Failed to remove old thumbnail ${oldEntry.thumbnailPath}:`, e); + } + } + + const result = await this.generateThumbnailFromFile(file); + if (result) { + this.cache.entries[imagePath] = { + thumbnailPath: result.thumbnailPath, + sourceModified: file.stat.mtime, + size: result.size + }; + } + } + + const pendingIdx = this.cache.pendingGeneration.indexOf(imagePath); + if (pendingIdx > -1) { + this.cache.pendingGeneration.splice(pendingIdx, 1); + } + + await this.saveCache(); + + if (onProgress) { + onProgress(i + 1, total); + } + } + + this.generationInProgress = false; + } + + async clearCache(): Promise { + await this.loadCache(); + + const thumbnailPaths = Object.values(this.cache.entries).map(entry => entry.thumbnailPath); + + for (const path of thumbnailPaths) { + try { + const exists = await this.app.vault.adapter.exists(path); + if (exists) { + await this.app.vault.adapter.remove(path); + } + } catch (e) { + console.error(`Failed to remove thumbnail ${path}:`, e); + } + } + + this.cache = { + entries: {}, + pendingGeneration: [] + }; + await this.saveCache(); + } + + getCacheStats(): { count: number; totalSize: number; pending: number } { + const entries = Object.values(this.cache.entries); + return { + count: entries.length, + totalSize: entries.reduce((sum, entry) => sum + entry.size, 0), + pending: this.cache.pendingGeneration.length + }; + } + + isGenerating(): boolean { + return this.generationInProgress; + } +} diff --git a/src/views/map-bases-view.ts b/src/views/map-bases-view.ts index f0089e4..fa9335a 100644 --- a/src/views/map-bases-view.ts +++ b/src/views/map-bases-view.ts @@ -146,12 +146,11 @@ export class MapBasesView extends BasesView { } private async renderMap(): Promise { - // Need either coordinates property OR global lat/lng keys - if (!this.data || (!this.coordinatesProp && (!this.plugin.settings.latKey || !this.plugin.settings.lngKey))) { + if (!this.data) { this.containerEl.removeClass('is-loading'); return; } - + const loadingOverlay = this.containerEl.createDiv({ cls: 'map-loading-overlay' }); const spinner = loadingOverlay.createDiv(); @@ -228,13 +227,13 @@ export class MapBasesView extends BasesView { app: this.app, settings: settings, tagSettings: tagSettings, + thumbnailCache: this.plugin.thumbnailCache, options: { center: centerToUse, zoom: zoomToUse, height: height, markerType: this.markerType, tileLayer: this.tileLayer, - showSearch: false, onTilesLoaded: () => { tilesLoaded = true; hideOverlay(); @@ -320,13 +319,17 @@ export class MapBasesView extends BasesView { const coverVal = entry.getValue(this.coverProp); if (coverVal) { point.cover = coverVal.toString(); + + if (this.plugin.settings.enableThumbnailCache) { + this.plugin.thumbnailCache.markForGeneration(point.cover, entry.file); + } } } const properties: Array<{ name: string; value: string }> = []; if (this.data.properties) { for (const prop of this.data.properties.slice(0, 20)) { - if (prop === this.coordinatesProp || prop === this.coverProp) continue; + if (prop === this.coordinatesProp) continue; try { const value = entry.getValue(prop); diff --git a/styles.css b/styles.css index 62be9d6..e49ee71 100644 --- a/styles.css +++ b/styles.css @@ -69,22 +69,25 @@ position: absolute; left: var(--tooltip-x, 0); top: var(--tooltip-y, 0); - padding: 8px 12px; + padding: 10px 14px; background: var(--background-primary); color: var(--text-normal); - border-radius: 4px; + border-radius: 6px; pointer-events: none; opacity: 0; z-index: 1000; - font-size: 14px; - box-shadow: 0 2px 8px rgba(0,0,0,0.3); - max-width: 250px; + font-size: 13px; + box-shadow: 0 2px 12px rgba(0,0,0,0.4); + width: max-content; + max-width: 400px; transition: opacity 0.15s ease; .map-tooltip-property { - display: flex; + display: grid; + grid-template-columns: auto 1fr; gap: 8px; margin-top: 4px; + align-items: baseline; } .map-tooltip-tags { @@ -97,12 +100,15 @@ .map-tooltip-property-label { color: var(--text-muted); font-size: 12px; + white-space: nowrap; } .map-tooltip-property-value { font-size: 12px; - word-break: break-word; - overflow-wrap: break-word; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } .map-tooltip-cover-image { @@ -115,7 +121,9 @@ } .map-tooltip-title { - font-weight: bold; + font-weight: 600; + font-size: 14px; + margin-bottom: 2px; } .map-tooltip-property-container {