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:
Maurice
2026-04-01 14:56:01 +02:00
parent 7d0ae631b8
commit 95cb81b0e5
20 changed files with 456 additions and 212 deletions
+36 -42
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import { mapsApi } from '../../api/client'
import React, { useState, useEffect, useRef } from 'react'
import { getCategoryIcon } from './categoryIcons'
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
import type { Place } from '../../types'
interface Category {
@@ -14,57 +14,52 @@ interface PlaceAvatarProps {
category?: Category | null
}
const photoCache = new Map<string, string | null>()
const photoInFlight = new Set<string>()
// Event-based notification instead of polling intervals
const photoListeners = new Map<string, Set<(url: string | null) => void>>()
function notifyListeners(key: string, url: string | null) {
const listeners = photoListeners.get(key)
if (listeners) {
listeners.forEach(fn => fn(url))
photoListeners.delete(key)
}
}
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
const [visible, setVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null)
// Observe visibility — fetch photo only when avatar enters viewport
useEffect(() => {
if (place.image_url) { setVisible(true); return }
const el = ref.current
if (!el) return
// Check if already cached — show immediately without waiting for intersection
const photoId = place.google_place_id || place.osm_id
const cacheKey = photoId || `${place.lat},${place.lng}`
if (cacheKey && getCached(cacheKey)) { setVisible(true); return }
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' })
io.observe(el)
return () => io.disconnect()
}, [place.id])
useEffect(() => {
if (!visible) return
if (place.image_url) { setPhotoSrc(place.image_url); return }
const photoId = place.google_place_id || place.osm_id
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
const cacheKey = photoId || `${place.lat},${place.lng}`
if (photoCache.has(cacheKey)) {
const cached = photoCache.get(cacheKey)
if (cached) setPhotoSrc(cached)
const cached = getCached(cacheKey)
if (cached) {
setPhotoSrc(cached.thumbDataUrl || cached.photoUrl)
if (!cached.thumbDataUrl && cached.photoUrl) {
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}
return
}
if (photoInFlight.has(cacheKey)) {
// Subscribe to notification instead of polling
if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set())
const handler = (url: string | null) => { if (url) setPhotoSrc(url) }
photoListeners.get(cacheKey)!.add(handler)
return () => { photoListeners.get(cacheKey)?.delete(handler) }
if (isLoading(cacheKey)) {
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}
photoInFlight.add(cacheKey)
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
.then((data: { photoUrl?: string }) => {
const url = data.photoUrl || null
photoCache.set(cacheKey, url)
if (url) setPhotoSrc(url)
notifyListeners(cacheKey, url)
photoInFlight.delete(cacheKey)
})
.catch(() => {
photoCache.set(cacheKey, null)
notifyListeners(cacheKey, null)
photoInFlight.delete(cacheKey)
})
}, [place.id, place.image_url, place.google_place_id, place.osm_id])
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name,
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
)
return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb))
}, [visible, place.id, place.image_url, place.google_place_id, place.osm_id])
const bgColor = category?.color || '#6366f1'
const IconComp = getCategoryIcon(category?.icon)
@@ -81,11 +76,10 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
if (photoSrc) {
return (
<div style={containerStyle}>
<div ref={ref} style={containerStyle}>
<img
src={photoSrc}
alt={place.name}
loading="lazy"
decoding="async"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)}
@@ -95,7 +89,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
}
return (
<div style={containerStyle}>
<div ref={ref} style={containerStyle}>
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
</div>
)