mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat(outlines): voice outlines without Overpass — bundled US places and neighborhoods, guarded Nominatim, OpenFreeMap streets and buildings
This commit is contained in:
1 parent
e7707d9a0f
commit
9559e7a282
333 files changed
+13203
-271
No files matched your search
@@ -0,0 +1,171 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** Entry files this cache writes: a 40-hex-digit key hash. */
|
||||
const CACHE_FILE_RE = /^[0-9a-f]{40}\.json$/;
|
||||
|
||||
/** How long an answer is kept, by what it was. */
|
||||
export const NOMINATIM_CACHE_TTL_MS = Object.freeze({
|
||||
outline: 90 * DAY_MS,
|
||||
place: 30 * DAY_MS,
|
||||
notFound: DAY_MS,
|
||||
});
|
||||
|
||||
/**
|
||||
* Comparable form of a place name: Unicode-normalized (NFKC), accents
|
||||
* removed, case folded, punctuation and symbols collapsed to single spaces,
|
||||
* and a leading article dropped. Non-Latin scripts are kept as they are:
|
||||
* "東京" stays "東京", while "São Paulo", "sao paulo" and "SÃO PAULO!" meet.
|
||||
*
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizePlaceName(value) {
|
||||
return String(value ?? '')
|
||||
.normalize('NFKD')
|
||||
.replace(/\p{M}+/gu, '')
|
||||
.normalize('NFKC')
|
||||
.toLocaleLowerCase('en-US')
|
||||
.replace(/[\p{P}\p{S}\p{Z}\s]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/^(?:the) /, '')
|
||||
.slice(0, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory + disk cache for Nominatim answers.
|
||||
*
|
||||
* Every entry carries its own expiry. Memory is an LRU bounded by entries;
|
||||
* disk is bounded by entries and bytes, with expired files removed during a
|
||||
* periodic sweep. A null `dir` keeps everything in memory.
|
||||
*/
|
||||
export function createNominatimCache({
|
||||
dir = null,
|
||||
maxMemoryEntries = 256,
|
||||
maxDiskEntries = 4000,
|
||||
maxDiskBytes = 64 * 1024 * 1024,
|
||||
maxEntryBytes = 2 * 1024 * 1024,
|
||||
sweepEvery = 50,
|
||||
now = Date.now,
|
||||
} = {}) {
|
||||
const memory = new Map();
|
||||
let writesSinceSweep = 0;
|
||||
let sweeping = null;
|
||||
|
||||
const fileFor = (key) =>
|
||||
path.join(
|
||||
dir,
|
||||
`${crypto.createHash('sha256').update(key).digest('hex').slice(0, 40)}.json`,
|
||||
);
|
||||
|
||||
function remember(key, entry) {
|
||||
memory.delete(key);
|
||||
memory.set(key, entry);
|
||||
while (memory.size > maxMemoryEntries)
|
||||
memory.delete(memory.keys().next().value);
|
||||
}
|
||||
|
||||
async function get(key) {
|
||||
const hit = memory.get(key);
|
||||
if (hit) {
|
||||
if (hit.expiresAt > now()) {
|
||||
remember(key, hit);
|
||||
return hit;
|
||||
}
|
||||
memory.delete(key);
|
||||
}
|
||||
if (!dir) return null;
|
||||
try {
|
||||
const entry = JSON.parse(await fs.readFile(fileFor(key), 'utf8'));
|
||||
if (entry?.key !== key || !(entry.expiresAt > now())) return null;
|
||||
remember(key, entry);
|
||||
return entry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function set(key, payload, ttlMs) {
|
||||
const entry = { key, payload, storedAt: now(), expiresAt: now() + ttlMs };
|
||||
remember(key, entry);
|
||||
if (!dir) return;
|
||||
const body = JSON.stringify(entry);
|
||||
if (Buffer.byteLength(body) > maxEntryBytes) return;
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const file = fileFor(key);
|
||||
const temp = `${file}.${process.pid}.tmp`;
|
||||
await fs.writeFile(temp, body);
|
||||
await fs.rename(temp, file);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
writesSinceSweep += 1;
|
||||
if (writesSinceSweep >= sweepEvery) {
|
||||
writesSinceSweep = 0;
|
||||
await sweep();
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove expired files, then the oldest until both disk bounds hold. */
|
||||
async function sweep() {
|
||||
if (!dir) return;
|
||||
if (sweeping) return sweeping;
|
||||
sweeping = (async () => {
|
||||
let names;
|
||||
try {
|
||||
names = await fs.readdir(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const files = [];
|
||||
for (const name of names) {
|
||||
// Only this cache's own entry files; anything else in the directory
|
||||
// is left alone.
|
||||
if (!CACHE_FILE_RE.test(name)) continue;
|
||||
const file = path.join(dir, name);
|
||||
let stat;
|
||||
let text;
|
||||
try {
|
||||
[stat, text] = await Promise.all([
|
||||
fs.stat(file),
|
||||
fs.readFile(file, 'utf8'),
|
||||
]);
|
||||
} catch {
|
||||
// Unreadable right now (gone, busy, too many open files): an error
|
||||
// reading is not evidence the entry is bad, so it stays.
|
||||
continue;
|
||||
}
|
||||
let expiresAt;
|
||||
try {
|
||||
expiresAt = JSON.parse(text)?.expiresAt;
|
||||
} catch {
|
||||
expiresAt = null; // corrupt: removed below
|
||||
}
|
||||
if (!(expiresAt > now())) {
|
||||
await fs.rm(file, { force: true }).catch(() => {});
|
||||
continue;
|
||||
}
|
||||
files.push({ file, size: stat.size, at: stat.mtimeMs });
|
||||
}
|
||||
files.sort((a, b) => a.at - b.at);
|
||||
let bytes = files.reduce((sum, entry) => sum + entry.size, 0);
|
||||
while (
|
||||
files.length &&
|
||||
(files.length > maxDiskEntries || bytes > maxDiskBytes)
|
||||
) {
|
||||
const oldest = files.shift();
|
||||
bytes -= oldest.size;
|
||||
await fs.rm(oldest.file, { force: true }).catch(() => {});
|
||||
}
|
||||
})().finally(() => {
|
||||
sweeping = null;
|
||||
});
|
||||
return sweeping;
|
||||
}
|
||||
|
||||
return { get, set, sweep, memorySize: () => memory.size };
|
||||
}
|
||||
Reference in new issue
Block a user