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
}