Files
TREK/client/src/repo/reverseGeocodeRepo.ts
T
Maurice 782e613506 Tours: day GPX import, wanderer trails and planning over an import
- GPX files drop onto the map or a day; the import lands on that day
- wanderer connection per user in Integrations; the workspace offers the
  user's own trails only, behind an admin switch under the Tours addon
- wanderer difficulty is stored on the SAC scale (T1 to T3), backfilled
- a GPX or wanderer import can be planned as an editable tour, either in
  place (the recorded track is kept and can be restored) or as a new tour
  that remembers the import it came from
- rails, rows and detail dialog follow the plan sidebar's cards and badges
- admin addon shelves with more than one row fold, collapsed by default
- reverse-geocoded waypoint names are cached on the device
2026-10-15 17:44:15 +02:00

125 lines
4.2 KiB
TypeScript

import { mapsApi } from '../api/client';
import { isEffectivelyOffline } from '../sync/networkMode';
/** How many coordinates the cache remembers before it starts over. */
const CACHE_LIMIT = 500;
/** How many answered names outlive a reload; the oldest give way first. */
const STORED_LIMIT = 2000;
/** Where the answered names are kept between visits: a convenience, rebuilt from the geocoder when gone. */
const STORAGE_KEY = 'trek-place-names-v1';
/** How many lookups may be in flight at once, so a long tour does not flood the geocoder. */
const CONCURRENCY = 2;
const cache = new Map<string, Promise<string | null>>();
const queue: Array<() => void> = [];
let running = 0;
/**
* Five decimals are about a metre: a point nudged by less than that is the same
* place, and a point moved further is asked for again.
*/
function keyOf(lat: number, lng: number, lang: string): string {
return `${lat.toFixed(5)},${lng.toFixed(5)}|${lang}`;
}
function runQueued<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const start = () => {
running += 1;
task()
.then(resolve, reject)
.finally(() => {
running -= 1;
queue.shift()?.();
});
};
if (running < CONCURRENCY) start();
else queue.push(start);
});
}
/** The names answered on earlier visits, read once; an unreadable store is an empty one. */
let stored: Map<string, string> | null = null;
function storedNames(): Map<string, string> {
if (stored) return stored;
stored = new Map();
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as unknown;
if (Array.isArray(raw)) {
for (const entry of raw) {
if (Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'string')
stored.set(entry[0], entry[1]);
}
}
} catch {
// Blocked or broken storage: names are simply asked for again.
}
return stored;
}
/** Keeps an answered name for later visits, dropping the oldest past the limit. */
function storeName(key: string, name: string): void {
const names = storedNames();
names.delete(key);
names.set(key, name);
while (names.size > STORED_LIMIT) names.delete(names.keys().next().value as string);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...names]));
} catch {
// Full or blocked storage: the name still serves this visit from memory.
}
}
/** The name a reverse lookup gives, or the first part of its address when it has no name. */
function placeLabel(result: { name: string | null; address: string | null }): string | null {
const name = result.name?.trim();
if (name) return name;
const first = result.address?.split(',')[0]?.trim();
return first || null;
}
export const reverseGeocodeRepo = {
/**
* A short place name for a coordinate, or null when the geocoder knows none.
* Rejects offline and on a failed lookup; a failure is not cached, so the
* same point is asked again the next time it is shown.
*/
placeName(lat: number, lng: number, lang: string): Promise<string | null> {
const key = keyOf(lat, lng, lang);
const known = storedNames().get(key);
if (known !== undefined) return Promise.resolve(known);
if (isEffectivelyOffline()) return Promise.reject(new Error('Offline'));
const cached = cache.get(key);
if (cached !== undefined) return cached;
if (cache.size >= CACHE_LIMIT) cache.clear();
const value = runQueued(() => mapsApi.reverse(lat, lng, lang)).then((result) => {
const label = placeLabel(result);
if (label) storeName(key, label);
return label;
});
cache.set(key, value);
void value.catch(() => {
if (cache.get(key) === value) cache.delete(key);
});
return value;
},
/**
* A name answered before, on this visit or an earlier one, without asking;
* undefined when the coordinate has not been answered with a name yet.
*/
knownName(lat: number, lng: number, lang: string): string | undefined {
return storedNames().get(keyOf(lat, lng, lang));
},
/** Forgets every remembered name, kept ones included; for tests. */
clear() {
cache.clear();
stored = new Map();
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Nothing kept to forget.
}
},
};