Spaces:
Sleeping
Sleeping
File size: 5,251 Bytes
cd99321 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | 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
}
|