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
+50 -1
View File
@@ -264,6 +264,54 @@ function getPlacesForTrips(tripIds: number[]): Place[] {
return db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[];
}
// ── Country resolution (batch DB cache + sync fallback + background geocoding) ──
function resolvePlaceCountries(places: Place[]): Map<number, string> {
const out = new Map<number, string>();
const geoPlaces = places.filter(p => p.lat && p.lng);
const placeIds = geoPlaces.map(p => p.id);
const cached = placeIds.length > 0
? (db.prepare(
`SELECT place_id, country_code FROM place_regions WHERE place_id IN (${placeIds.map(() => '?').join(',')})`
).all(...placeIds) as { place_id: number; country_code: string }[])
: [];
const cachedMap = new Map(cached.map(r => [r.place_id, r.country_code]));
const uncachedForGeocode: Place[] = [];
for (const p of places) {
const fromDb = cachedMap.get(p.id);
if (fromDb) { out.set(p.id, fromDb); continue; }
const sync = resolveCountryCodeSync(p);
if (sync) { out.set(p.id, sync); continue; }
if (p.lat && p.lng && !geocodingInFlight.has(p.id)) {
uncachedForGeocode.push(p);
}
}
if (uncachedForGeocode.length > 0) {
const insertStmt = db.prepare(
'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)'
);
for (const p of uncachedForGeocode) geocodingInFlight.add(p.id);
void (async () => {
try {
for (const place of uncachedForGeocode) {
try {
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
} catch { /* continue */ }
finally { geocodingInFlight.delete(place.id); }
}
} catch {
for (const p of uncachedForGeocode) geocodingInFlight.delete(p.id);
}
})();
}
return out;
}
// ── getStats ────────────────────────────────────────────────────────────────
export async function getStats(userId: number) {
@@ -279,9 +327,10 @@ export async function getStats(userId: number) {
const places = getPlacesForTrips(tripIds);
interface CountryEntry { code: string; places: { id: number; name: string; lat: number | null; lng: number | null }[]; tripIds: Set<number> }
const placeCountries = resolvePlaceCountries(places);
const countrySet = new Map<string, CountryEntry>();
for (const place of places) {
const code = await resolveCountryCode(place);
const code = placeCountries.get(place.id);
if (code) {
if (!countrySet.has(code)) {
countrySet.set(code, { code, places: [], tripIds: new Set() });
+36 -3
View File
@@ -6,6 +6,7 @@ export interface ParsedKmlPlacemark {
lat: number | null;
lng: number | null;
folderName: string | null;
routeGeometry: string | null;
}
export interface KmlPlacemarkNode {
@@ -97,6 +98,26 @@ export function sanitizeKmlDescription(value: unknown): string | null {
return decoded || null;
}
export function parseKmlLineStringCoordinates(value: unknown): Array<{ lat: number; lng: number; ele: number | null }> | null {
const coordinates = asTrimmedString(value);
if (!coordinates) return null;
const points = coordinates
.trim()
.split(/\s+/)
.map(coord => {
const parts = coord.split(',');
const lng = Number.parseFloat(parts[0] ?? '');
const lat = Number.parseFloat(parts[1] ?? '');
const eleRaw = parts[2] != null ? Number.parseFloat(parts[2]) : NaN;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return { lat, lng, ele: Number.isFinite(eleRaw) ? eleRaw : null };
})
.filter((p): p is { lat: number; lng: number; ele: number | null } => p !== null);
return points.length >= 2 ? points : null;
}
export function parseKmlPointCoordinates(value: unknown): { lat: number; lng: number } | null {
const coordinates = asTrimmedString(value);
if (!coordinates) return null;
@@ -167,13 +188,25 @@ export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] {
}
export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark {
const coordinates = parseKmlPointCoordinates(node.placemark?.Point?.coordinates);
const pointCoords = parseKmlPointCoordinates(node.placemark?.Point?.coordinates);
let routeGeometry: string | null = null;
let pathFirstPt: { lat: number; lng: number } | null = null;
if (!pointCoords) {
const linePts = parseKmlLineStringCoordinates(node.placemark?.LineString?.coordinates);
if (linePts) {
pathFirstPt = { lat: linePts[0].lat, lng: linePts[0].lng };
const hasAllEle = linePts.every(p => p.ele !== null);
routeGeometry = JSON.stringify(linePts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]));
}
}
return {
name: asTrimmedString(node.placemark?.name),
description: sanitizeKmlDescription(node.placemark?.description),
lat: coordinates?.lat ?? null,
lng: coordinates?.lng ?? null,
lat: pointCoords?.lat ?? pathFirstPt?.lat ?? null,
lng: pointCoords?.lng ?? pathFirstPt?.lng ?? null,
folderName: node.folderName,
routeGeometry,
};
}
+29
View File
@@ -71,6 +71,30 @@ const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import * as placePhotoCache from './placePhotoCache';
// ── Concurrency limiter for outbound photo fetches ───────────────────────────
// Caps simultaneous Wikimedia/Google photo requests so a bulk import of hundreds
// of places cannot monopolise the event loop or trigger external API rate limits.
const MAX_CONCURRENT_PHOTO_FETCHES = 5;
let photoFetchActive = 0;
const photoFetchQueue: Array<() => void> = [];
function acquirePhotoFetchSlot(): Promise<void> {
if (photoFetchActive < MAX_CONCURRENT_PHOTO_FETCHES) {
photoFetchActive++;
return Promise.resolve();
}
return new Promise(resolve => photoFetchQueue.push(resolve));
}
function releasePhotoFetchSlot(): void {
const next = photoFetchQueue.shift();
if (next) {
next();
} else {
photoFetchActive--;
}
}
// ── API key retrieval ────────────────────────────────────────────────────────
export function getMapsKey(userId: number): string | null {
@@ -597,6 +621,8 @@ export async function getPlacePhoto(
}
const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => {
await acquirePhotoFetchSlot();
try {
const apiKey = getMapsKey(userId);
const isCoordLookup = placeId.startsWith('coords:');
@@ -676,6 +702,9 @@ export async function getPlacePhoto(
}
return { filePath: cached.filePath, attribution };
} finally {
releasePhotoFetchSlot();
}
})();
placePhotoCache.setInFlight(placeId, fetchPromise);
+24 -11
View File
@@ -10,11 +10,14 @@ const ERROR_TTL = 5 * 60 * 1000;
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
function ensureDir(): void {
if (!fs.existsSync(GOOGLE_PHOTO_DIR)) {
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
}
}
// In-memory set of placeIds whose file is confirmed on disk this session.
// Avoids a synchronous fs.existsSync() call on every cache hit after the first verification.
const knownOnDisk = new Set<string>();
// Ensure upload dir exists once at startup — avoids sync FS calls inside put() on every write.
try {
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
} catch { /* already exists */ }
function filePath(placeId: string): string {
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
@@ -41,10 +44,15 @@ export function get(placeId: string): CachedPhoto | null {
if (!row) return null;
const fp = filePath(placeId);
if (!fs.existsSync(fp)) {
// File missing (e.g. volume wiped) — clear row so it refetches
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
return null;
if (!knownOnDisk.has(placeId)) {
// First time this placeId is checked this session — verify the file exists on disk.
// (Guards against volume wipes or manual deletion between server restarts.)
if (!fs.existsSync(fp)) {
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
return null;
}
knownOnDisk.add(placeId);
}
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution: row.attribution };
@@ -60,19 +68,21 @@ export function getErrored(placeId: string): boolean {
}
export function markError(placeId: string): void {
knownOnDisk.delete(placeId);
db.prepare(
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
).run(placeId, Date.now(), Date.now());
}
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
ensureDir();
const fp = filePath(placeId);
const tmp = fp + '.tmp';
await fsPromises.writeFile(tmp, bytes);
await fsPromises.rename(tmp, fp);
knownOnDisk.add(placeId);
db.prepare(
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
).run(placeId, attribution, Date.now());
@@ -90,6 +100,9 @@ export function setInFlight(placeId: string, promise: Promise<{ filePath: string
}
export function serveFilePath(placeId: string): string | null {
if (knownOnDisk.has(placeId)) return filePath(placeId);
const fp = filePath(placeId);
return fs.existsSync(fp) ? fp : null;
if (!fs.existsSync(fp)) return null;
knownOnDisk.add(placeId);
return fp;
}
+87 -38
View File
@@ -240,6 +240,22 @@ export function deletePlace(tripId: string, placeId: string): boolean {
return true;
}
export function deletePlacesMany(tripId: string, ids: number[]): number[] {
if (ids.length === 0) return [];
const selectStmt = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?');
const deleteStmt = db.prepare('DELETE FROM places WHERE id = ?');
const deleted: number[] = [];
const run = db.transaction((list: number[]) => {
for (const id of list) {
if (!selectStmt.get(id, tripId)) continue;
deleteStmt.run(id);
deleted.push(id);
}
});
run(ids);
return deleted;
}
// ---------------------------------------------------------------------------
// Import GPX
// ---------------------------------------------------------------------------
@@ -326,7 +342,20 @@ function trackInsertedInDedupSet(
}
}
export function importGpx(tripId: string, fileBuffer: Buffer) {
export interface GpxImportOptions {
importWaypoints?: boolean;
importRoutes?: boolean;
importTracks?: boolean;
}
export interface KmlImportOptions {
importPoints?: boolean;
importPaths?: boolean;
}
export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}) {
const { importWaypoints = true, importRoutes = true, importTracks = true } = opts;
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
const gpx = parsed?.gpx;
if (!gpx) return null;
@@ -338,41 +367,46 @@ export function importGpx(tripId: string, fileBuffer: Buffer) {
const waypoints: WaypointEntry[] = [];
// 1) Parse <wpt> elements (named waypoints / POIs)
for (const wpt of gpx.wpt ?? []) {
const lat = num(wpt['@_lat']);
const lng = num(wpt['@_lon']);
if (lat === null || lng === null) continue;
waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) });
if (importWaypoints) {
for (const wpt of gpx.wpt ?? []) {
const lat = num(wpt['@_lat']);
const lng = num(wpt['@_lon']);
if (lat === null || lng === null) continue;
waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) });
}
}
// 2) If no <wpt>, try <rte> route points as individual places
if (waypoints.length === 0) {
// 2) Parse <rte> routes as polyline-places (one place per route with route_geometry)
if (importRoutes) {
for (const rte of gpx.rte ?? []) {
for (const rtept of rte.rtept ?? []) {
const lat = num(rtept['@_lat']);
const lng = num(rtept['@_lon']);
if (lat === null || lng === null) continue;
waypoints.push({ lat, lng, name: str(rtept.name) || `Route Point ${waypoints.length + 1}`, description: str(rtept.desc) });
}
const pts = (rte.rtept ?? [])
.map((pt: Record<string, unknown>) => ({ lat: num(pt['@_lat']), lng: num(pt['@_lon']), ele: num(pt['ele']) }))
.filter((p: { lat: number | null; lng: number | null; ele: number | null }) => p.lat !== null && p.lng !== null) as Array<{ lat: number; lng: number; ele: number | null }>;
if (pts.length === 0) continue;
const hasAllEle = pts.every(p => p.ele !== null);
const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: str(rte.name) || 'GPX Route', description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) });
}
}
// 3) Extract full track geometry from <trk> (always, even if <wpt> were found)
for (const trk of gpx.trk ?? []) {
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
for (const seg of trk.trkseg ?? []) {
for (const pt of seg.trkpt ?? []) {
const lat = num(pt['@_lat']);
const lng = num(pt['@_lon']);
if (lat === null || lng === null) continue;
trackPoints.push({ lat, lng, ele: num(pt.ele) });
// 3) Extract full track geometry from <trk>
if (importTracks) {
for (const trk of gpx.trk ?? []) {
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
for (const seg of trk.trkseg ?? []) {
for (const pt of seg.trkpt ?? []) {
const lat = num(pt['@_lat']);
const lng = num(pt['@_lon']);
if (lat === null || lng === null) continue;
trackPoints.push({ lat, lng, ele: num(pt.ele) });
}
}
if (trackPoints.length === 0) continue;
const start = trackPoints[0];
const hasAllEle = trackPoints.every(p => p.ele !== null);
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
}
if (trackPoints.length === 0) continue;
const start = trackPoints[0];
const hasAllEle = trackPoints.every(p => p.ele !== null);
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
}
if (waypoints.length === 0) return null;
@@ -401,7 +435,8 @@ export function importGpx(tripId: string, fileBuffer: Buffer) {
return { places: created, count: created.length, skipped };
}
export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult {
export function importKmlPlaces(tripId: string, fileBuffer: Buffer, opts: KmlImportOptions = {}): PlaceImportResult {
const { importPoints = true, importPaths = true } = opts;
const decoded = decodeUtf8WithWarning(fileBuffer);
const validationResult = XMLValidator.validate(decoded.text);
@@ -430,19 +465,32 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
let dupCount = 0;
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, 'walking')
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode, route_geometry)
VALUES (?, ?, ?, ?, ?, ?, 'walking', ?)
`);
const insertAll = db.transaction(() => {
let fallbackIndex = 1;
for (const node of placemarkNodes) {
const parsedPlacemark = parsePlacemarkNode(node);
const isPath = parsedPlacemark.routeGeometry !== null;
// KML geometry support is intentionally limited to <Placemark><Point> coordinates.
// Unsupported geometry type (polygon, multi-geometry, no geometry, etc.)
if (parsedPlacemark.lat === null || parsedPlacemark.lng === null) {
summary.skippedCount += 1;
summary.errors.push(`Skipped Placemark ${fallbackIndex}: missing Point coordinates.`);
summary.errors.push(`Skipped Placemark ${fallbackIndex}: unsupported geometry type.`);
fallbackIndex += 1;
continue;
}
// Type filtering: respect importPoints / importPaths opts
if (isPath && !importPaths) {
summary.skippedCount += 1;
fallbackIndex += 1;
continue;
}
if (!isPath && !importPoints) {
summary.skippedCount += 1;
fallbackIndex += 1;
continue;
}
@@ -466,6 +514,7 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
parsedPlacemark.lat,
parsedPlacemark.lng,
categoryId,
parsedPlacemark.routeGeometry,
);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
@@ -514,15 +563,15 @@ export async function unpackKmzToKml(
return preferredEntry.buffer();
}
export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer): Promise<PlaceImportResult> {
export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
const kmlBuffer = await unpackKmzToKml(kmzBuffer);
return importKmlPlaces(tripId, kmlBuffer);
return importKmlPlaces(tripId, kmlBuffer, opts);
}
export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string): Promise<PlaceImportResult> {
export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
const ext = filename.toLowerCase().split('.').pop();
if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer);
if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer);
if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer, opts);
if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer, opts);
throw new Error(`Unsupported map file format: .${ext}. Please upload a .kml or .kmz file.`);
}
+39 -2
View File
@@ -95,6 +95,7 @@ const WMO_DESCRIPTION_EN: Record<number, string> = {
// ── Cache management ────────────────────────────────────────────────────
const weatherCache = new Map<string, { data: WeatherResult; expiresAt: number }>();
const inFlight = new Map<string, Promise<WeatherResult>>();
const CACHE_MAX_ENTRIES = 1000;
const CACHE_PRUNE_TARGET = 500;
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
@@ -146,7 +147,7 @@ export function estimateCondition(tempAvg: number, precipMm: number): string {
// ── getWeather ──────────────────────────────────────────────────────────
export async function getWeather(
async function _getWeatherImpl(
lat: string,
lng: string,
date: string | undefined,
@@ -281,9 +282,27 @@ export async function getWeather(
return result;
}
export async function getWeather(
lat: string,
lng: string,
date: string | undefined,
lang: string,
): Promise<WeatherResult> {
const ck = cacheKey(lat, lng, date);
const cached = getCached(ck);
if (cached) return cached;
const inFlightKey = `${ck}:${lang}`;
const existing = inFlight.get(inFlightKey);
if (existing) return existing;
const promise = _getWeatherImpl(lat, lng, date, lang);
inFlight.set(inFlightKey, promise);
try { return await promise; } finally { inFlight.delete(inFlightKey); }
}
// ── getDetailedWeather ──────────────────────────────────────────────────
export async function getDetailedWeather(
async function _getDetailedWeatherImpl(
lat: string,
lng: string,
date: string,
@@ -434,6 +453,24 @@ export async function getDetailedWeather(
return result;
}
export async function getDetailedWeather(
lat: string,
lng: string,
date: string,
lang: string,
): Promise<WeatherResult> {
const ck = `detailed_${cacheKey(lat, lng, date)}`;
const cached = getCached(ck);
if (cached) return cached;
const inFlightKey = `${ck}:${lang}`;
const existing = inFlight.get(inFlightKey);
if (existing) return existing;
const promise = _getDetailedWeatherImpl(lat, lng, date, lang);
inFlight.set(inFlightKey, promise);
try { return await promise; } finally { inFlight.delete(inFlightKey); }
}
// ── ApiError ────────────────────────────────────────────────────────────
export class ApiError extends Error {