gallery_server_client_s190js/helpers/activitySpatialIndex.js
2026-10-26 11:00:14 +01:00

228 lines
4.4 KiB
JavaScript

"use strict";
const db = require("../db/knex");
let RBushClass = null;
let tree = null;
let dirty = true;
let buildPromise = null;
let generation = 0;
function numberOrNull(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function makeIndexItem(row) {
const minLat = numberOrNull(row.min_latitude);
const maxLat = numberOrNull(row.max_latitude);
const minLon = numberOrNull(row.min_longitude);
const maxLon = numberOrNull(row.max_longitude);
// Usa il bounding box quando tutti e quattro i limiti sono presenti.
if (
minLat !== null &&
maxLat !== null &&
minLon !== null &&
maxLon !== null &&
minLat >= -90 &&
maxLat <= 90 &&
minLat <= maxLat &&
minLon >= -180 &&
maxLon <= 180 &&
minLon <= maxLon
) {
return {
minX: minLon,
minY: minLat,
maxX: maxLon,
maxY: maxLat,
id: String(row.id),
};
}
// Fallback per attività prive di bounding box completo.
const lat = numberOrNull(row.center_latitude);
const lon = numberOrNull(row.center_longitude);
if (
lat === null ||
lon === null ||
lat < -90 ||
lat > 90 ||
lon < -180 ||
lon > 180
) {
return null;
}
return {
minX: lon,
minY: lat,
maxX: lon,
maxY: lat,
id: String(row.id),
};
}
async function loadRBush() {
if (!RBushClass) {
const module = await import("rbush");
RBushClass = module.default;
}
return RBushClass;
}
async function ensureIndex() {
if (!dirty && tree) {
return tree;
}
if (buildPromise) {
await buildPromise;
if (!dirty && tree) {
return tree;
}
}
const buildGeneration = generation;
buildPromise = (async () => {
const RBush = await loadRBush();
const rows = await db("activities").select(
"id",
"min_latitude",
"max_latitude",
"min_longitude",
"max_longitude",
"center_latitude",
"center_longitude"
);
const items = [];
for (const row of rows) {
const item = makeIndexItem(row);
if (item) items.push(item);
}
const nextTree = new RBush();
nextTree.load(items);
// Non pubblicare un indice costruito mentre era in corso
// un'invalidazione dovuta a una modifica dei dati.
if (buildGeneration === generation) {
tree = nextTree;
dirty = false;
}
})();
try {
await buildPromise;
} finally {
buildPromise = null;
}
if (dirty || !tree) {
return ensureIndex();
}
return tree;
}
function invalidateActivitySpatialIndex() {
generation += 1;
dirty = true;
}
function searchBoxes(treeInstance, west, east, south, north) {
const boxes =
west <= east
? [{ minX: west, maxX: east, minY: south, maxY: north }]
: [
{ minX: west, maxX: 180, minY: south, maxY: north },
{ minX: -180, maxX: east, minY: south, maxY: north },
];
const found = new Map();
for (const box of boxes) {
for (const item of treeInstance.search(box)) {
found.set(item.id, item);
}
}
return [...found.keys()];
}
async function searchActivitiesInBounds({
west,
east,
south,
north,
readableSources,
includeDeleted = false,
limit = 1500,
}) {
const index = await ensureIndex();
const ids = searchBoxes(index, west, east, south, north);
if (!ids.length || !readableSources.length) {
return { items: [], truncated: false };
}
const rowsById = new Map();
const batchSize = 400;
// Blocchi piccoli per evitare di superare il limite dei parametri SQLite.
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
let query = db("activities")
.whereIn("id", batch)
.whereIn("user", readableSources);
if (!includeDeleted) {
query = query.whereNull("deleted_at");
}
const rows = await query.select("*");
for (const row of rows) {
rowsById.set(String(row.id), row);
}
}
// Mantiene un ordine deterministico rispetto ai candidati RBush.
const matching = [];
for (const id of ids) {
const row = rowsById.get(id);
if (row) {
matching.push(row);
if (matching.length > limit) {
break;
}
}
}
return {
items: matching.slice(0, limit),
truncated: matching.length > limit,
};
}
module.exports = {
searchActivitiesInBounds,
invalidateActivitySpatialIndex,
};