Files
TREK/client/src/services/photoService.ts
T
jubnl 6a718fccea feat(import): selective GPX/KML element import and performance improvements
Add type-selector UI in the file import modal letting users choose which
GPX elements (waypoints, routes, tracks) or KML/KMZ elements (points,
paths) to import. KML LineString placemarks are now imported as path
places with route_geometry.

Performance improvements:
- Extract MemoPlaceRow with React.memo and contentVisibility:auto to cut
  unnecessary re-renders in PlacesSidebar
- Add weatherQueue to cap concurrent weather fetches at 3
- Replace sequential per-place deletes with a single bulkDelete API call
  (new DELETE /places/bulk endpoint + deletePlacesMany service)
- Memoize atlas/photo/weather service calls to avoid redundant requests
- Add multi-select mode to PlacesSidebar for bulk operations

Add large GPX/KML/KMZ fixtures for integration/perf testing and two
profiler analysis scripts under scripts/.
2026-04-18 01:28:37 +02:00

167 lines
5.1 KiB
TypeScript

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>>()
// Concurrency limiter — at most N photo API requests in flight at once.
// Prevents flooding the server (and external APIs it calls) when many places appear at once.
const MAX_CONCURRENT = 5
let activeRequests = 0
const requestQueue: Array<() => void> = []
function acquireRequestSlot(): Promise<void> {
if (activeRequests < MAX_CONCURRENT) {
activeRequests++
return Promise.resolve()
}
return new Promise(resolve => requestQueue.push(resolve))
}
function releaseRequestSlot(): void {
const next = requestQueue.shift()
if (next) {
next()
} else {
activeRequests--
}
}
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)
export 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
}
// If photoId is already our stable proxy URL, use it directly — no API round-trip needed
if (photoId && photoId.startsWith('/api/maps/place-photo/')) {
const entry: PhotoEntry = { photoUrl: photoId, thumbDataUrl: null }
cache.set(cacheKey, entry)
callback?.(entry)
notify(cacheKey, entry)
// Generate base64 thumb in background
urlToBase64(photoId).then(thumb => {
if (thumb) { entry.thumbDataUrl = thumb; notifyThumb(cacheKey, thumb) }
})
return
}
inFlight.add(cacheKey)
acquireRequestSlot().then(() =>
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); releaseRequestSlot() })
)
}
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
}