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/.
This commit is contained in:
jubnl
2026-04-18 01:28:37 +02:00
parent 9a31fcac7b
commit 6a718fccea
45 changed files with 22471 additions and 285 deletions
+52 -27
View File
@@ -12,6 +12,29 @@ 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)
@@ -99,37 +122,39 @@ export function fetchPhoto(
}
inFlight.add(cacheKey)
mapsApi.placePhoto(photoId, lat, lng, name)
.then(async (data: { photoUrl?: string }) => {
const photoUrl = data.photoUrl || null
if (!photoUrl) {
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)
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) })
})
.finally(() => { inFlight.delete(cacheKey); releaseRequestSlot() })
)
}
export function getAllThumbs(): Record<string, string> {
+25
View File
@@ -0,0 +1,25 @@
import { weatherApi } from '../api/client'
const MAX_CONCURRENT = 3
let active = 0
const queue: Array<() => void> = []
function acquire(): Promise<void> {
if (active < MAX_CONCURRENT) { active++; return Promise.resolve() }
return new Promise(resolve => queue.push(resolve))
}
function release(): void {
const next = queue.shift()
if (next) next()
else active--
}
export async function fetchWeather(lat: number, lng: number, date: string) {
await acquire()
try {
return await weatherApi.get(lat, lng, date)
} finally {
release()
}
}