From dfd5befbfea44505993b696f906236f9efc65893 Mon Sep 17 00:00:00 2001 From: ccmdi Date: Wed, 15 Oct 2025 18:34:27 -0400 Subject: [PATCH 01/13] feat: thumbnail cache --- src/main.ts | 15 +++ src/map-renderer.ts | 39 +++++- src/settings/map-settings.ts | 54 ++++++++ src/thumbnail-cache.ts | 250 +++++++++++++++++++++++++++++++++++ src/views/map-bases-view.ts | 5 + 5 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 src/thumbnail-cache.ts diff --git a/src/main.ts b/src/main.ts index d3596c6..a4a354b 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,19 @@ 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(); + const stats = this.thumbnailCache.getCacheStats(); + + if (stats.pending > 0) { + 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..038f717 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; @@ -418,15 +420,17 @@ export async function createMapRenderer(config: MapRendererOptions): Promise { if (thisUpdateId === tooltipUpdateId) { @@ -440,6 +444,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); + } } } diff --git a/src/settings/map-settings.ts b/src/settings/map-settings.ts index f013378..f4b2e9c 100644 --- a/src/settings/map-settings.ts +++ b/src/settings/map-settings.ts @@ -9,6 +9,7 @@ export interface MapPluginSettings { iconFill: boolean; autoCenter: boolean; transitionDuration: number; + enableThumbnailCache: boolean; tagSettings: MapTagSettings; } @@ -19,6 +20,7 @@ export const DEFAULT_SETTINGS: MapPluginSettings = { iconFill: false, autoCenter: true, transitionDuration: 1000, + enableThumbnailCache: false, tagSettings: DEFAULT_MAP_TAG_SETTINGS }; @@ -103,6 +105,58 @@ 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 small thumbnails of location cover images for faster hover performance') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.enableThumbnailCache) + .onChange(async (value) => { + this.plugin.settings.enableThumbnailCache = value; + await this.plugin.saveSettings(); + this.display(); + })); + + if (this.plugin.settings.enableThumbnailCache) { + const stats = this.plugin.thumbnailCache.getCacheStats(); + const sizeKB = (stats.totalSize / 1024).toFixed(1); + + const cacheInfo = new Setting(containerEl) + .setName('Thumbnail cache status') + .setDesc(`${stats.count} thumbnails cached (${sizeKB} KB)`); + + if (stats.pending > 0) { + cacheInfo.setDesc(`${stats.count} thumbnails cached (${sizeKB} KB), ${stats.pending} pending generation`); + + cacheInfo.addButton(button => button + .setButtonText('Generate thumbnails') + .onClick(async () => { + button.setDisabled(true); + button.setButtonText('Generating...'); + + await this.plugin.thumbnailCache.generatePendingThumbnails((current, total) => { + button.setButtonText(`Generating ${current}/${total}...`); + }); + + button.setButtonText('Generate thumbnails'); + button.setDisabled(false); + this.display(); + })); + } + + new Setting(containerEl) + .setName('Clear thumbnail cache') + .setDesc('Delete all cached thumbnails to free up space') + .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..535adec --- /dev/null +++ b/src/thumbnail-cache.ts @@ -0,0 +1,250 @@ +import { App, TFile, normalizePath } from 'obsidian'; +import type MapPlugin from './main'; + +export interface ThumbnailCacheEntry { + dataUrl: string; + sourceModified: number; + size: number; +} + +export interface ThumbnailCacheMetadata { + entries: Record; + pendingGeneration: string[]; +} + +const THUMBNAIL_MAX_SIZE = 25000; // 25kb target +const THUMBNAIL_WIDTH = 300; +const THUMBNAIL_HEIGHT = 200; + +export class ThumbnailCacheManager { + private plugin: MapPlugin; + private app: App; + private cache: ThumbnailCacheMetadata; + private cacheLoaded: boolean = false; + private generationInProgress: boolean = false; + + constructor(plugin: MapPlugin, app: App) { + this.plugin = plugin; + this.app = app; + this.cache = { + entries: {}, + pendingGeneration: [] + }; + } + + async loadCache(): Promise { + if (this.cacheLoaded) return; + + try { + const data = await this.plugin.loadData(); + if (data && data.thumbnailCache) { + this.cache = data.thumbnailCache; + } + this.cacheLoaded = true; + } catch (e) { + console.error('Failed to load thumbnail cache:', e); + this.cache = { + entries: {}, + pendingGeneration: [] + }; + this.cacheLoaded = true; + } + } + + async saveCache(): Promise { + const data = await this.plugin.loadData() || {}; + data.thumbnailCache = this.cache; + await this.plugin.saveData(data); + } + + 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) { + return cached.dataUrl; + } else { + delete this.cache.entries[cacheKey]; + await this.saveCache(); + } + } + + return null; + } + + async generateThumbnail(coverPath: string, sourceFile: TFile): Promise { + const coverFile = this.app.metadataCache.getFirstLinkpathDest(coverPath, sourceFile.path); + if (!coverFile) return null; + + try { + const arrayBuffer = await this.app.vault.readBinary(coverFile); + 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(); + + let quality = 0.7; + let dataUrl = canvas.toDataURL('image/jpeg', quality); + + while (dataUrl.length > THUMBNAIL_MAX_SIZE && quality > 0.1) { + quality -= 0.1; + dataUrl = canvas.toDataURL('image/jpeg', quality); + } + + const cacheKey = coverFile.path; + this.cache.entries[cacheKey] = { + dataUrl, + sourceModified: coverFile.stat.mtime, + size: dataUrl.length + }; + + await this.saveCache(); + return dataUrl; + } catch (e) { + console.error('Failed to generate thumbnail:', e); + return null; + } + } + + 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) { + try { + 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) continue; + + 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(); + + let quality = 0.7; + let dataUrl = canvas.toDataURL('image/jpeg', quality); + + while (dataUrl.length > THUMBNAIL_MAX_SIZE && quality > 0.1) { + quality -= 0.1; + dataUrl = canvas.toDataURL('image/jpeg', quality); + } + + this.cache.entries[imagePath] = { + dataUrl, + sourceModified: file.stat.mtime, + size: dataUrl.length + }; + } catch (e) { + console.error(`Failed to generate thumbnail for ${imagePath}:`, e); + } + } + + 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 clearCache(): Promise { + await this.loadCache(); + 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..feb43d0 100644 --- a/src/views/map-bases-view.ts +++ b/src/views/map-bases-view.ts @@ -228,6 +228,7 @@ export class MapBasesView extends BasesView { app: this.app, settings: settings, tagSettings: tagSettings, + thumbnailCache: this.plugin.thumbnailCache, options: { center: centerToUse, zoom: zoomToUse, @@ -320,6 +321,10 @@ 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); + } } } From 105b762d7b134a28826c6b1e89fc4e302960f7cf Mon Sep 17 00:00:00 2001 From: ccmdi Date: Wed, 15 Oct 2025 18:40:15 -0400 Subject: [PATCH 02/13] refactor: file storage for thumbnail cache --- src/thumbnail-cache.ts | 114 +++++++++++++++++++++++++++++++++++------ 1 file changed, 98 insertions(+), 16 deletions(-) diff --git a/src/thumbnail-cache.ts b/src/thumbnail-cache.ts index 535adec..c73f7e2 100644 --- a/src/thumbnail-cache.ts +++ b/src/thumbnail-cache.ts @@ -2,7 +2,7 @@ import { App, TFile, normalizePath } from 'obsidian'; import type MapPlugin from './main'; export interface ThumbnailCacheEntry { - dataUrl: string; + thumbnailPath: string; sourceModified: number; size: number; } @@ -15,6 +15,7 @@ export interface ThumbnailCacheMetadata { const THUMBNAIL_MAX_SIZE = 25000; // 25kb target const THUMBNAIL_WIDTH = 300; const THUMBNAIL_HEIGHT = 200; +const CACHE_DIR = '.obsidian/plugins/mapplus/.cache'; export class ThumbnailCacheManager { private plugin: MapPlugin; @@ -22,6 +23,7 @@ export class ThumbnailCacheManager { private cache: ThumbnailCacheMetadata; private cacheLoaded: boolean = false; private generationInProgress: boolean = false; + private cacheDir: string; constructor(plugin: MapPlugin, app: App) { this.plugin = plugin; @@ -30,16 +32,23 @@ export class ThumbnailCacheManager { entries: {}, pendingGeneration: [] }; + this.cacheDir = normalizePath(CACHE_DIR); } async loadCache(): Promise { if (this.cacheLoaded) return; try { - const data = await this.plugin.loadData(); - if (data && data.thumbnailCache) { - this.cache = data.thumbnailCache; + 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); @@ -52,9 +61,36 @@ export class ThumbnailCacheManager { } async saveCache(): Promise { - const data = await this.plugin.loadData() || {}; - data.thumbnailCache = this.cache; - await this.plugin.saveData(data); + 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 { @@ -72,11 +108,16 @@ export class ThumbnailCacheManager { if (cached) { if (cached.sourceModified === coverFile.stat.mtime) { - return cached.dataUrl; - } else { - delete this.cache.entries[cacheKey]; - await this.saveCache(); + 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; @@ -119,15 +160,30 @@ export class ThumbnailCacheManager { dataUrl = canvas.toDataURL('image/jpeg', quality); } + const base64Data = dataUrl.split(',')[1]; + const binaryData = atob(base64Data); + const bytes = new Uint8Array(binaryData.length); + for (let i = 0; i < binaryData.length; i++) { + bytes[i] = binaryData.charCodeAt(i); + } + + const thumbnailFilename = this.getThumbnailFilename(coverFile.path); + const thumbnailPath = normalizePath(`${this.cacheDir}/${thumbnailFilename}`); + + await this.ensureCacheDir(); + await this.app.vault.adapter.writeBinary(thumbnailPath, bytes.buffer); + const cacheKey = coverFile.path; this.cache.entries[cacheKey] = { - dataUrl, + thumbnailPath, sourceModified: coverFile.stat.mtime, - size: dataUrl.length + size: bytes.length }; await this.saveCache(); - return dataUrl; + + const resultBlob = new Blob([bytes], { type: 'image/jpeg' }); + return URL.createObjectURL(resultBlob); } catch (e) { console.error('Failed to generate thumbnail:', e); return null; @@ -201,10 +257,23 @@ export class ThumbnailCacheManager { dataUrl = canvas.toDataURL('image/jpeg', quality); } + const base64Data = dataUrl.split(',')[1]; + const binaryData = atob(base64Data); + const bytes = new Uint8Array(binaryData.length); + for (let j = 0; j < binaryData.length; j++) { + bytes[j] = binaryData.charCodeAt(j); + } + + const thumbnailFilename = this.getThumbnailFilename(imagePath); + const thumbnailPath = normalizePath(`${this.cacheDir}/${thumbnailFilename}`); + + await this.ensureCacheDir(); + await this.app.vault.adapter.writeBinary(thumbnailPath, bytes.buffer); + this.cache.entries[imagePath] = { - dataUrl, + thumbnailPath, sourceModified: file.stat.mtime, - size: dataUrl.length + size: bytes.length }; } catch (e) { console.error(`Failed to generate thumbnail for ${imagePath}:`, e); @@ -228,6 +297,19 @@ export class ThumbnailCacheManager { 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: [] From a345796d3ecf3b84ea88084083fb33ccde7d4005 Mon Sep 17 00:00:00 2001 From: ccmdi Date: Wed, 15 Oct 2025 18:47:48 -0400 Subject: [PATCH 03/13] fix: allow cover string as shown property --- src/views/map-bases-view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/map-bases-view.ts b/src/views/map-bases-view.ts index feb43d0..8b2c3ba 100644 --- a/src/views/map-bases-view.ts +++ b/src/views/map-bases-view.ts @@ -331,7 +331,7 @@ export class MapBasesView extends BasesView { 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); From 04cab36ad1cc99c3c00589944bafaab4a42ca56b Mon Sep 17 00:00:00 2001 From: ccmdi Date: Wed, 15 Oct 2025 18:53:55 -0400 Subject: [PATCH 04/13] chore: package.json plugin name + ver --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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", From 640ce208e4c672eb75562afda3f474d71da29478 Mon Sep 17 00:00:00 2001 From: ccmdi Date: Wed, 15 Oct 2025 19:08:37 -0400 Subject: [PATCH 05/13] feat: improved property css; rebuild cache setting --- src/main.ts | 9 +-- src/settings/map-settings.ts | 31 ++++++-- src/thumbnail-cache.ts | 150 ++++++++++++++++++++--------------- styles.css | 26 +++--- 4 files changed, 127 insertions(+), 89 deletions(-) diff --git a/src/main.ts b/src/main.ts index a4a354b..f923f34 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,13 +19,10 @@ export default class MapPlugin extends Plugin { if (this.settings.enableThumbnailCache) { await this.thumbnailCache.loadCache(); - const stats = this.thumbnailCache.getCacheStats(); - if (stats.pending > 0) { - setTimeout(() => { - this.thumbnailCache.generatePendingThumbnails(); - }, 2000); - } + setTimeout(() => { + this.thumbnailCache.generatePendingThumbnails(); + }, 2000); } this.registerBasesView('map', { diff --git a/src/settings/map-settings.ts b/src/settings/map-settings.ts index f4b2e9c..be48a59 100644 --- a/src/settings/map-settings.ts +++ b/src/settings/map-settings.ts @@ -10,6 +10,7 @@ export interface MapPluginSettings { autoCenter: boolean; transitionDuration: number; enableThumbnailCache: boolean; + thumbnailTargetSize: number; tagSettings: MapTagSettings; } @@ -21,6 +22,7 @@ export const DEFAULT_SETTINGS: MapPluginSettings = { autoCenter: true, transitionDuration: 1000, enableThumbnailCache: false, + thumbnailTargetSize: 25, tagSettings: DEFAULT_MAP_TAG_SETTINGS }; @@ -119,27 +121,40 @@ export class MapSettingTab extends PluginSettingTab { })); 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 cacheInfo = new Setting(containerEl) .setName('Thumbnail cache status') .setDesc(`${stats.count} thumbnails cached (${sizeKB} KB)`); - if (stats.pending > 0) { - cacheInfo.setDesc(`${stats.count} thumbnails cached (${sizeKB} KB), ${stats.pending} pending generation`); - + if (stats.pending > 0 || isGenerating) { + cacheInfo.setDesc(`${stats.count} thumbnails cached (${sizeKB} KB), ${stats.pending} pending`); + } else if (stats.count > 0) { cacheInfo.addButton(button => button - .setButtonText('Generate thumbnails') + .setButtonText('Refresh cache') .onClick(async () => { button.setDisabled(true); - button.setButtonText('Generating...'); + button.setButtonText('Refreshing...'); - await this.plugin.thumbnailCache.generatePendingThumbnails((current, total) => { - button.setButtonText(`Generating ${current}/${total}...`); + await this.plugin.thumbnailCache.rebuildCache((current, total) => { + button.setButtonText(`Refreshing ${current}/${total}...`); }); - button.setButtonText('Generate thumbnails'); + button.setButtonText('Refresh cache'); button.setDisabled(false); this.display(); })); diff --git a/src/thumbnail-cache.ts b/src/thumbnail-cache.ts index c73f7e2..9d06f8b 100644 --- a/src/thumbnail-cache.ts +++ b/src/thumbnail-cache.ts @@ -12,7 +12,6 @@ export interface ThumbnailCacheMetadata { pendingGeneration: string[]; } -const THUMBNAIL_MAX_SIZE = 25000; // 25kb target const THUMBNAIL_WIDTH = 300; const THUMBNAIL_HEIGHT = 200; const CACHE_DIR = '.obsidian/plugins/mapplus/.cache'; @@ -123,12 +122,10 @@ export class ThumbnailCacheManager { return null; } - async generateThumbnail(coverPath: string, sourceFile: TFile): Promise { - const coverFile = this.app.metadataCache.getFirstLinkpathDest(coverPath, sourceFile.path); - if (!coverFile) return null; - + private async generateThumbnailFromFile(file: TFile): Promise<{ thumbnailPath: string; size: number } | null> { try { - const arrayBuffer = await this.app.vault.readBinary(coverFile); + 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); @@ -155,7 +152,7 @@ export class ThumbnailCacheManager { let quality = 0.7; let dataUrl = canvas.toDataURL('image/jpeg', quality); - while (dataUrl.length > THUMBNAIL_MAX_SIZE && quality > 0.1) { + while (dataUrl.length > targetSize && quality > 0.1) { quality -= 0.1; dataUrl = canvas.toDataURL('image/jpeg', quality); } @@ -167,29 +164,43 @@ export class ThumbnailCacheManager { bytes[i] = binaryData.charCodeAt(i); } - const thumbnailFilename = this.getThumbnailFilename(coverFile.path); + const thumbnailFilename = this.getThumbnailFilename(file.path); const thumbnailPath = normalizePath(`${this.cacheDir}/${thumbnailFilename}`); await this.ensureCacheDir(); await this.app.vault.adapter.writeBinary(thumbnailPath, bytes.buffer); - const cacheKey = coverFile.path; - this.cache.entries[cacheKey] = { + return { thumbnailPath, - sourceModified: coverFile.stat.mtime, size: bytes.length }; - - await this.saveCache(); - - const resultBlob = new Blob([bytes], { type: 'image/jpeg' }); - return URL.createObjectURL(resultBlob); } 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; @@ -224,59 +235,13 @@ export class ThumbnailCacheManager { const file = this.app.vault.getAbstractFileByPath(imagePath); if (file instanceof TFile) { - try { - 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) continue; - - 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(); - - let quality = 0.7; - let dataUrl = canvas.toDataURL('image/jpeg', quality); - - while (dataUrl.length > THUMBNAIL_MAX_SIZE && quality > 0.1) { - quality -= 0.1; - dataUrl = canvas.toDataURL('image/jpeg', quality); - } - - const base64Data = dataUrl.split(',')[1]; - const binaryData = atob(base64Data); - const bytes = new Uint8Array(binaryData.length); - for (let j = 0; j < binaryData.length; j++) { - bytes[j] = binaryData.charCodeAt(j); - } - - const thumbnailFilename = this.getThumbnailFilename(imagePath); - const thumbnailPath = normalizePath(`${this.cacheDir}/${thumbnailFilename}`); - - await this.ensureCacheDir(); - await this.app.vault.adapter.writeBinary(thumbnailPath, bytes.buffer); - + const result = await this.generateThumbnailFromFile(file); + if (result) { this.cache.entries[imagePath] = { - thumbnailPath, + thumbnailPath: result.thumbnailPath, sourceModified: file.stat.mtime, - size: bytes.length + size: result.size }; - } catch (e) { - console.error(`Failed to generate thumbnail for ${imagePath}:`, e); } } @@ -295,6 +260,59 @@ export class ThumbnailCacheManager { 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 = Object.keys(this.cache.entries); + if (allImagePaths.length === 0) { + return; + } + + this.generationInProgress = true; + + const total = allImagePaths.length; + + for (let i = 0; i < allImagePaths.length; i++) { + const imagePath = allImagePaths[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 + }; + } + } + + await this.saveCache(); + + if (onProgress) { + onProgress(i + 1, total); + } + } + + this.generationInProgress = false; + } + async clearCache(): Promise { await this.loadCache(); 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 { From 151510c1d8de09e8acf0c8254b9f28a311280b72 Mon Sep 17 00:00:00 2001 From: ccmdi Date: Wed, 15 Oct 2025 19:16:32 -0400 Subject: [PATCH 06/13] feat: better targetting for file size for cache --- src/settings/map-settings.ts | 20 +++++++++++++++----- src/thumbnail-cache.ts | 26 +++++++++++++++++--------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/settings/map-settings.ts b/src/settings/map-settings.ts index be48a59..004faa0 100644 --- a/src/settings/map-settings.ts +++ b/src/settings/map-settings.ts @@ -117,6 +117,14 @@ export class MapSettingTab extends PluginSettingTab { .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(); })); @@ -143,18 +151,20 @@ export class MapSettingTab extends PluginSettingTab { if (stats.pending > 0 || isGenerating) { cacheInfo.setDesc(`${stats.count} thumbnails cached (${sizeKB} KB), ${stats.pending} pending`); - } else if (stats.count > 0) { + } + + if (stats.count > 0 || stats.pending > 0) { cacheInfo.addButton(button => button - .setButtonText('Refresh cache') + .setButtonText('Rebuild cache') .onClick(async () => { button.setDisabled(true); - button.setButtonText('Refreshing...'); + button.setButtonText('Rebuilding...'); await this.plugin.thumbnailCache.rebuildCache((current, total) => { - button.setButtonText(`Refreshing ${current}/${total}...`); + button.setButtonText(`Rebuilding ${current}/${total}...`); }); - button.setButtonText('Refresh cache'); + button.setButtonText('Rebuild cache'); button.setDisabled(false); this.display(); })); diff --git a/src/thumbnail-cache.ts b/src/thumbnail-cache.ts index 9d06f8b..6a332dd 100644 --- a/src/thumbnail-cache.ts +++ b/src/thumbnail-cache.ts @@ -149,21 +149,28 @@ export class ThumbnailCacheManager { ctx.drawImage(bitmap, 0, 0, width, height); bitmap.close(); - let quality = 0.7; - let dataUrl = canvas.toDataURL('image/jpeg', quality); - - while (dataUrl.length > targetSize && quality > 0.1) { - quality -= 0.1; - dataUrl = canvas.toDataURL('image/jpeg', quality); - } - + // 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); - const bytes = new Uint8Array(binaryData.length); + 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}`); @@ -317,6 +324,7 @@ export class ThumbnailCacheManager { 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); From 69b8b02358a64ae581c4cfcd9f87066f8887821c Mon Sep 17 00:00:00 2001 From: ccmdi Date: Wed, 15 Oct 2025 19:25:45 -0400 Subject: [PATCH 07/13] feat: live interval for thumbnail cache --- src/settings/map-settings.ts | 37 ++++++++++++++++++++++++++++-------- src/thumbnail-cache.ts | 20 ++++++++++++++----- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/settings/map-settings.ts b/src/settings/map-settings.ts index 004faa0..520fb4b 100644 --- a/src/settings/map-settings.ts +++ b/src/settings/map-settings.ts @@ -145,25 +145,46 @@ export class MapSettingTab extends PluginSettingTab { const sizeKB = (stats.totalSize / 1024).toFixed(1); const isGenerating = this.plugin.thumbnailCache.isGenerating(); - const cacheInfo = new Setting(containerEl) - .setName('Thumbnail cache status') - .setDesc(`${stats.count} thumbnails cached (${sizeKB} KB)`); + 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) { - cacheInfo.setDesc(`${stats.count} thumbnails cached (${sizeKB} KB), ${stats.pending} pending`); - } - - if (stats.count > 0 || stats.pending > 0) { - cacheInfo.addButton(button => button + 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); + await this.plugin.thumbnailCache.rebuildCache((current, total) => { button.setButtonText(`Rebuilding ${current}/${total}...`); }); + clearInterval(progressInterval); button.setButtonText('Rebuild cache'); button.setDisabled(false); this.display(); diff --git a/src/thumbnail-cache.ts b/src/thumbnail-cache.ts index 6a332dd..034dfee 100644 --- a/src/thumbnail-cache.ts +++ b/src/thumbnail-cache.ts @@ -274,17 +274,22 @@ export class ThumbnailCacheManager { await this.loadCache(); - const allImagePaths = Object.keys(this.cache.entries); - if (allImagePaths.length === 0) { + const allImagePaths = new Set([ + ...Object.keys(this.cache.entries), + ...this.cache.pendingGeneration + ]); + + if (allImagePaths.size === 0) { return; } this.generationInProgress = true; - const total = allImagePaths.length; + const imagePaths = Array.from(allImagePaths); + const total = imagePaths.length; - for (let i = 0; i < allImagePaths.length; i++) { - const imagePath = allImagePaths[i]; + for (let i = 0; i < imagePaths.length; i++) { + const imagePath = imagePaths[i]; const file = this.app.vault.getAbstractFileByPath(imagePath); if (file instanceof TFile) { @@ -310,6 +315,11 @@ export class ThumbnailCacheManager { } } + const pendingIdx = this.cache.pendingGeneration.indexOf(imagePath); + if (pendingIdx > -1) { + this.cache.pendingGeneration.splice(pendingIdx, 1); + } + await this.saveCache(); if (onProgress) { From b0b6aa92430df17b56df1befccffd4bcaea5ed4a Mon Sep 17 00:00:00 2001 From: ccmdi Date: Wed, 15 Oct 2025 19:27:34 -0400 Subject: [PATCH 08/13] fix: allow rebuild on thumbnail cache clear --- src/settings/map-settings.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/settings/map-settings.ts b/src/settings/map-settings.ts index 520fb4b..a84aab7 100644 --- a/src/settings/map-settings.ts +++ b/src/settings/map-settings.ts @@ -179,6 +179,10 @@ export class MapSettingTab extends PluginSettingTab { 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}...`); From df46d8637b5808a12958a53ce7c374c43c322b6b Mon Sep 17 00:00:00 2001 From: ccmdi Date: Wed, 15 Oct 2025 19:29:48 -0400 Subject: [PATCH 09/13] chore: description names for thumbnail settings --- src/settings/map-settings.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/settings/map-settings.ts b/src/settings/map-settings.ts index a84aab7..50737e1 100644 --- a/src/settings/map-settings.ts +++ b/src/settings/map-settings.ts @@ -111,7 +111,7 @@ export class MapSettingTab extends PluginSettingTab { const thumbnailToggle = new Setting(containerEl) .setName('Enable thumbnail cache') - .setDesc('Cache small thumbnails of location cover images for faster hover performance') + .setDesc('Cache thumbnails of location cover images - instant tooltips') .addToggle(toggle => toggle .setValue(this.plugin.settings.enableThumbnailCache) .onChange(async (value) => { @@ -197,7 +197,7 @@ export class MapSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Clear thumbnail cache') - .setDesc('Delete all cached thumbnails to free up space') + .setDesc('Delete all cached thumbnails') .addButton(button => button .setButtonText('Clear cache') .setWarning() From a6335f4bded6fe1d1fe9bfc2dedff39b1a49090c Mon Sep 17 00:00:00 2001 From: ccmdi Date: Mon, 20 Oct 2025 12:36:11 -0400 Subject: [PATCH 10/13] fix: always show map, even if coordinates are unset --- src/views/map-bases-view.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/views/map-bases-view.ts b/src/views/map-bases-view.ts index 8b2c3ba..fa2d7f9 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(); From bbed46bcd68f39e05f1d7c2745351205b1d64b12 Mon Sep 17 00:00:00 2001 From: ccmdi Date: Mon, 20 Oct 2025 12:38:52 -0400 Subject: [PATCH 11/13] feat: refresh on lat/lng default change, delays for set --- src/settings/map-settings.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/settings/map-settings.ts b/src/settings/map-settings.ts index 50737e1..5776d0b 100644 --- a/src/settings/map-settings.ts +++ b/src/settings/map-settings.ts @@ -50,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) @@ -61,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) @@ -72,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) From db17f75c13e6e2f0edfef39ef206cdabfe02b3e0 Mon Sep 17 00:00:00 2001 From: ccmdi Date: Mon, 20 Oct 2025 12:55:16 -0400 Subject: [PATCH 12/13] fix: tags + remove deprecated options --- src/map-renderer.ts | 13 ++++++------- src/views/map-bases-view.ts | 1 - 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/map-renderer.ts b/src/map-renderer.ts index 038f717..6172393 100644 --- a/src/map-renderer.ts +++ b/src/map-renderer.ts @@ -62,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; @@ -478,11 +476,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 @@ -500,14 +500,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/views/map-bases-view.ts b/src/views/map-bases-view.ts index fa2d7f9..fa9335a 100644 --- a/src/views/map-bases-view.ts +++ b/src/views/map-bases-view.ts @@ -234,7 +234,6 @@ export class MapBasesView extends BasesView { height: height, markerType: this.markerType, tileLayer: this.tileLayer, - showSearch: false, onTilesLoaded: () => { tilesLoaded = true; hideOverlay(); From 6d7e7604181c787781ca648fa6ee6fe0dbbd64b8 Mon Sep 17 00:00:00 2001 From: ccmdi Date: Mon, 20 Oct 2025 13:02:54 -0400 Subject: [PATCH 13/13] fix: additional check for stale tooltip --- src/map-renderer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/map-renderer.ts b/src/map-renderer.ts index 6172393..6eb3b02 100644 --- a/src/map-renderer.ts +++ b/src/map-renderer.ts @@ -424,6 +424,9 @@ export async function createMapRenderer(config: MapRendererOptions): Promise