const express = require("express"); const db = require("../db/polygons"); const { normalizeFeatureCollectionContract, normalizeFeatureContract } = require("../types/contracts"); const router = express.Router(); // ======================= // Query by bbox/time(/entity) // ======================= router.get("/", (req, res) => { const minLng = Number(req.query.minLng); const minLat = Number(req.query.minLat); const maxLng = Number(req.query.maxLng); const maxLat = Number(req.query.maxLat); if ( !Number.isFinite(minLng) || !Number.isFinite(minLat) || !Number.isFinite(maxLng) || !Number.isFinite(maxLat) ) { return res.status(400).json({ error: "Missing/invalid bbox" }); } let t = null; if (req.query.time !== undefined && req.query.time !== "") { const parsed = Number(req.query.time); if (!Number.isFinite(parsed)) { return res.status(400).json({ error: "Invalid time" }); } t = Math.trunc(parsed); } let entityId = null; try { entityId = normalizeEntityId(req.query.entity_id); } catch (err) { return res.status(err.status || 400).json({ error: err.message }); } const rows = db.prepare(` SELECT g.* FROM geometries g WHERE g.is_deleted = 0 AND g.bbox_max_lng >= ? AND g.bbox_min_lng <= ? AND g.bbox_max_lat >= ? AND g.bbox_min_lat <= ? AND ( ? IS NULL OR ( (g.time_start IS NULL OR g.time_start <= ?) AND (g.time_end IS NULL OR g.time_end >= ?) ) ) AND ( ? IS NULL OR EXISTS ( SELECT 1 FROM entity_geometries eg JOIN entities e ON e.id = eg.entity_id AND e.is_deleted = 0 WHERE eg.geometry_id = g.id AND eg.entity_id = ? ) ) `).all(minLng, maxLng, minLat, maxLat, t, t, t, entityId, entityId); const geometryIds = rows.map((row) => String(row.id)); const linksByGeometryId = loadGeometryLinksByGeometryId(geometryIds); res.json(normalizeFeatureCollectionContract({ type: "FeatureCollection", features: rows.map((row) => { const linkedEntities = linksByGeometryId.get(String(row.id)) || []; return buildFeatureFromRow(row, linkedEntities); }), })); }); module.exports = router; function buildFeatureFromRow(row, linkedEntities = []) { const entityIds = linkedEntities.map((entity) => entity.entity_id); const primaryEntityId = entityIds[0] || null; const orderedEntityIds = entityIds; const orderedLinkedEntities = linkedEntities; const primaryEntity = orderedLinkedEntities.find((entity) => entity.entity_id === primaryEntityId) || orderedLinkedEntities[0] || null; const storedGeometryType = normalizeGeometryType(row.type); const semanticType = storedGeometryType && !isLegacyLineModeToken(storedGeometryType) ? storedGeometryType : normalizeGeometryType(primaryEntity?.entity_type_id); return normalizeFeatureContract({ type: "Feature", properties: { id: row.id, type: semanticType || null, time_start: row.time_start, time_end: row.time_end, binding: parseBindingIds(row.binding), entity_id: primaryEntityId, entity_ids: orderedEntityIds, entity_name: primaryEntity?.entity_name || null, entity_names: orderedLinkedEntities .map((entity) => entity.entity_name) .filter((name) => typeof name === "string" && name.length > 0), entity_type_id: primaryEntity?.entity_type_id || null, }, geometry: JSON.parse(row.draw_geometry), }); } function normalizeGeometryType(value) { if (value === undefined || value === null || value === "") return null; const normalized = String(value).trim().toLowerCase(); return normalized.length ? normalized : null; } function isLegacyLineModeToken(value) { return value === "line" || value === "path"; } function normalizeEntityId(value) { if (value === undefined || value === null || value === "") return null; const normalized = String(value).trim(); return normalized || null; } function normalizeBindingIds(rawBinding) { if (rawBinding === undefined || rawBinding === null || rawBinding === "") { return []; } if (!Array.isArray(rawBinding)) { throw createValidationError("binding must be an array"); } const deduped = []; const seen = new Set(); for (const rawId of rawBinding) { if (typeof rawId !== "string" && typeof rawId !== "number") { throw createValidationError("binding must contain geometry ids as string/number"); } const normalized = String(rawId).trim(); if (!normalized || seen.has(normalized)) continue; seen.add(normalized); deduped.push(normalized); } return deduped; } function parseBindingIds(rawBinding) { if (rawBinding === undefined || rawBinding === null || rawBinding === "") { return []; } if (Array.isArray(rawBinding)) { try { return normalizeBindingIds(rawBinding); } catch (_err) { return []; } } if (typeof rawBinding === "string") { try { const parsed = JSON.parse(rawBinding); return normalizeBindingIds(parsed); } catch (_err) { const plain = rawBinding.trim(); return plain.length ? [plain] : []; } } return []; } function loadGeometryLinksByGeometryId(geometryIds) { const map = new Map(); if (!geometryIds.length) return map; const placeholders = geometryIds.map(() => "?").join(","); const rows = db.prepare(` SELECT eg.geometry_id, eg.entity_id, e.name AS entity_name, e.type_id AS entity_type_id FROM entity_geometries eg JOIN entities e ON e.id = eg.entity_id AND e.is_deleted = 0 WHERE eg.geometry_id IN (${placeholders}) ORDER BY eg.rowid ASC `).all(...geometryIds); for (const row of rows) { const geometryId = String(row.geometry_id); const current = map.get(geometryId) || []; current.push({ entity_id: row.entity_id, entity_name: row.entity_name || null, entity_type_id: row.entity_type_id || null, }); map.set(geometryId, current); } return map; } function createValidationError(message, status = 400) { const err = new Error(message); err.status = status; return err; }