mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
106b748551
A place whose image_url already is the place-photo proxy URL skipped the five slot limiter and loaded its marker picture right away. A map showing a big list, such as "All saved" in collections, asked for every picture at once. Those downloads now wait for a slot like the provider lookups do; the entry itself is still there immediately. Refs #2559
172 lines
5.4 KiB
TypeScript
172 lines
5.4 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, under the same limit as the API path:
|
|
// a list of a thousand saved places would otherwise ask for a thousand
|
|
// pictures at once (#2559).
|
|
void acquireRequestSlot()
|
|
.then(() => urlToBase64(photoId))
|
|
.then(thumb => {
|
|
if (thumb) { entry.thumbDataUrl = thumb; notifyThumb(cacheKey, thumb) }
|
|
})
|
|
.finally(releaseRequestSlot)
|
|
return
|
|
}
|
|
|
|
inFlight.add(cacheKey)
|
|
void 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
|
|
}
|