mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
perf: major trip planner performance overhaul (#218)
Store & re-render optimization: - TripPlannerPage uses selective Zustand selectors instead of full store - placesSlice only updates affected days on place update/delete - Route calculation only reacts to selected day's assignments - DayPlanSidebar uses stable action refs instead of full store Map marker performance: - Shared photoService for PlaceAvatar and MapView (single cache, no duplicate requests) - Client-side base64 thumbnail generation via canvas (CORS-safe for Wikimedia) - Map markers use base64 data URL <img> tags for smooth zoom (no external image decode) - Sidebar uses same base64 thumbnails with IntersectionObserver for visible-first loading - Icon cache prevents duplicate L.divIcon creation - MarkerClusterGroup with animate:false and optimized chunk settings - Photo fetch deduplication and batched state updates Server optimizations: - Wikimedia image size reduced to 400px (from 600px) - Photo cache: 5min TTL for errors (was 12h), prevents stale 404 caching - Removed unused image-proxy endpoint UX improvements: - Splash screen with plane animation during initial photo preload - Markdown rendering in DayPlanSidebar place descriptions - Missing i18n keys added, all 12 languages synced to 1376 keys
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { mapsApi } from '../api/client'
|
||||
|
||||
// Shared photo cache — used by PlaceAvatar (sidebar) and MapView (map markers)
|
||||
interface PhotoEntry {
|
||||
photoUrl: string | null
|
||||
thumbDataUrl: string | null
|
||||
}
|
||||
|
||||
const cache = new Map<string, PhotoEntry>()
|
||||
const inFlight = new Set<string>()
|
||||
const listeners = new Map<string, Set<(entry: PhotoEntry) => void>>()
|
||||
// Separate thumb listeners — called when thumbDataUrl becomes available after initial load
|
||||
const thumbListeners = new Map<string, Set<(thumb: string) => void>>()
|
||||
|
||||
function notify(key: string, entry: PhotoEntry) {
|
||||
listeners.get(key)?.forEach(fn => fn(entry))
|
||||
listeners.delete(key)
|
||||
}
|
||||
|
||||
function notifyThumb(key: string, thumb: string) {
|
||||
thumbListeners.get(key)?.forEach(fn => fn(thumb))
|
||||
thumbListeners.delete(key)
|
||||
}
|
||||
|
||||
export function onPhotoLoaded(key: string, fn: (entry: PhotoEntry) => void): () => void {
|
||||
if (!listeners.has(key)) listeners.set(key, new Set())
|
||||
listeners.get(key)!.add(fn)
|
||||
return () => { listeners.get(key)?.delete(fn) }
|
||||
}
|
||||
|
||||
// Subscribe to thumb availability — called when base64 thumb is ready (may be after photoUrl)
|
||||
export function onThumbReady(key: string, fn: (thumb: string) => void): () => void {
|
||||
if (!thumbListeners.has(key)) thumbListeners.set(key, new Set())
|
||||
thumbListeners.get(key)!.add(fn)
|
||||
return () => { thumbListeners.get(key)?.delete(fn) }
|
||||
}
|
||||
|
||||
export function getCached(key: string): PhotoEntry | undefined {
|
||||
return cache.get(key)
|
||||
}
|
||||
|
||||
export function isLoading(key: string): boolean {
|
||||
return inFlight.has(key)
|
||||
}
|
||||
|
||||
// Convert image URL to base64 via canvas (CORS required — Wikimedia supports it)
|
||||
function urlToBase64(url: string, size: number = 48): Promise<string | null> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const s = Math.min(img.naturalWidth, img.naturalHeight)
|
||||
const sx = (img.naturalWidth - s) / 2
|
||||
const sy = (img.naturalHeight - s) / 2
|
||||
ctx.beginPath()
|
||||
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2)
|
||||
ctx.clip()
|
||||
ctx.drawImage(img, sx, sy, s, s, 0, 0, size, size)
|
||||
resolve(canvas.toDataURL('image/webp', 0.6))
|
||||
} catch { resolve(null) }
|
||||
}
|
||||
img.onerror = () => resolve(null)
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchPhoto(
|
||||
cacheKey: string,
|
||||
photoId: string,
|
||||
lat?: number,
|
||||
lng?: number,
|
||||
name?: string,
|
||||
callback?: (entry: PhotoEntry) => void
|
||||
) {
|
||||
const cached = cache.get(cacheKey)
|
||||
if (cached) { callback?.(cached); return }
|
||||
|
||||
if (inFlight.has(cacheKey)) {
|
||||
if (callback) onPhotoLoaded(cacheKey, callback)
|
||||
return
|
||||
}
|
||||
|
||||
inFlight.add(cacheKey)
|
||||
mapsApi.placePhoto(photoId, lat, lng, name)
|
||||
.then(async (data: { photoUrl?: string }) => {
|
||||
const photoUrl = data.photoUrl || null
|
||||
if (!photoUrl) {
|
||||
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
return
|
||||
}
|
||||
|
||||
// Store URL first — sidebar can show immediately
|
||||
const entry: PhotoEntry = { photoUrl, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
|
||||
// Generate base64 thumb in background
|
||||
const thumb = await urlToBase64(photoUrl)
|
||||
if (thumb) {
|
||||
entry.thumbDataUrl = thumb
|
||||
notifyThumb(cacheKey, thumb)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
})
|
||||
.finally(() => { inFlight.delete(cacheKey) })
|
||||
}
|
||||
|
||||
export function getAllThumbs(): Record<string, string> {
|
||||
const r: Record<string, string> = {}
|
||||
for (const [k, v] of cache.entries()) {
|
||||
if (v.thumbDataUrl) r[k] = v.thumbDataUrl
|
||||
}
|
||||
return r
|
||||
}
|
||||
Reference in New Issue
Block a user