This commit is contained in:
103
routes/entities.js
Normal file
103
routes/entities.js
Normal file
@@ -0,0 +1,103 @@
|
||||
const express = require("express");
|
||||
const db = require("../db/polygons");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
const search = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
e.*,
|
||||
COUNT(eg.geometry_id) AS geometry_count
|
||||
FROM entities e
|
||||
LEFT JOIN entity_geometries eg
|
||||
ON eg.entity_id = e.id
|
||||
WHERE e.is_deleted = 0
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
if (search) {
|
||||
sql += " AND (e.name LIKE ? OR e.slug LIKE ?)";
|
||||
const pattern = `%${search}%`;
|
||||
params.push(pattern, pattern);
|
||||
}
|
||||
|
||||
sql += `
|
||||
GROUP BY e.id
|
||||
ORDER BY e.name COLLATE NOCASE ASC
|
||||
`;
|
||||
|
||||
const rows = db.prepare(sql).all(...params);
|
||||
res.json(rows.map(normalizeEntityRow));
|
||||
});
|
||||
|
||||
router.get("/search", (req, res) => {
|
||||
const name = typeof req.query.name === "string" ? req.query.name.trim() : "";
|
||||
const limit = normalizeLimit(req.query.limit, 25, 100);
|
||||
|
||||
if (!name) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const pattern = `%${name}%`;
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
e.*,
|
||||
COUNT(eg.geometry_id) AS geometry_count
|
||||
FROM entities e
|
||||
LEFT JOIN entity_geometries eg
|
||||
ON eg.entity_id = e.id
|
||||
WHERE e.is_deleted = 0
|
||||
AND e.name LIKE ?
|
||||
GROUP BY e.id
|
||||
ORDER BY e.name COLLATE NOCASE ASC
|
||||
LIMIT ?
|
||||
`).all(pattern, limit);
|
||||
|
||||
res.json(rows.map(normalizeEntityRow));
|
||||
});
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
const row = db.prepare(`
|
||||
SELECT
|
||||
e.*,
|
||||
COUNT(eg.geometry_id) AS geometry_count
|
||||
FROM entities e
|
||||
LEFT JOIN entity_geometries eg
|
||||
ON eg.entity_id = e.id
|
||||
WHERE e.id = ?
|
||||
AND e.is_deleted = 0
|
||||
GROUP BY e.id
|
||||
`).get(req.params.id);
|
||||
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: "Entity not found" });
|
||||
}
|
||||
|
||||
res.json(normalizeEntityRow(row));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
function normalizeEntityRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
description: row.description,
|
||||
type_id: row.type_id,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
geometry_count: Number(row.geometry_count || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLimit(value, fallback = 25, max = 100) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
const intValue = Math.trunc(num);
|
||||
if (intValue <= 0) return fallback;
|
||||
return Math.min(intValue, max);
|
||||
}
|
||||
223
routes/geometries.js
Normal file
223
routes/geometries.js
Normal file
@@ -0,0 +1,223 @@
|
||||
const express = require("express");
|
||||
const db = require("../db/polygons");
|
||||
|
||||
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({
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
57
routes/rasterTiles.js
Normal file
57
routes/rasterTiles.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const express = require("express");
|
||||
const Database = require("better-sqlite3");
|
||||
const path = require("path");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const mbtilesPath = path.join(__dirname, "..", "data", "raster.mbtiles");
|
||||
const tileDb = new Database(mbtilesPath, { readonly: true });
|
||||
|
||||
const metadataRows = tileDb.prepare("SELECT name, value FROM metadata").all();
|
||||
const metadata = {};
|
||||
|
||||
for (const row of metadataRows) {
|
||||
metadata[row.name] = row.value;
|
||||
}
|
||||
|
||||
let contentType = "application/octet-stream";
|
||||
if (metadata.format === "png") {
|
||||
contentType = "image/png";
|
||||
} else if (metadata.format === "jpg" || metadata.format === "jpeg") {
|
||||
contentType = "image/jpeg";
|
||||
} else if (metadata.format === "webp") {
|
||||
contentType = "image/webp";
|
||||
}
|
||||
|
||||
router.get("/metadata/info", (req, res) => {
|
||||
res.json(metadata);
|
||||
});
|
||||
|
||||
router.get("/:z/:x/:y", (req, res) => {
|
||||
const z = Number(req.params.z);
|
||||
const x = Number(req.params.x);
|
||||
const y = Number(req.params.y);
|
||||
|
||||
if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) {
|
||||
return res.status(400).json({ error: "Invalid tile coordinates" });
|
||||
}
|
||||
|
||||
const tmsY = (1 << z) - 1 - y;
|
||||
|
||||
const tile = tileDb.prepare(`
|
||||
SELECT tile_data
|
||||
FROM tiles
|
||||
WHERE zoom_level = ?
|
||||
AND tile_column = ?
|
||||
AND tile_row = ?
|
||||
`).get(z, x, tmsY);
|
||||
|
||||
if (!tile) {
|
||||
return res.status(404).json({ error: "Tile not found" });
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.send(tile.tile_data);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
1142
routes/sections.js
Normal file
1142
routes/sections.js
Normal file
File diff suppressed because it is too large
Load Diff
79
routes/tiles.js
Normal file
79
routes/tiles.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const express = require("express");
|
||||
const Database = require("better-sqlite3");
|
||||
const path = require("path");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// =======================
|
||||
// MBTiles DB (READONLY)
|
||||
// =======================
|
||||
const mbtilesPath = path.join(__dirname, "..", "data", "map.mbtiles");
|
||||
const tileDb = new Database(mbtilesPath, { readonly: true });
|
||||
|
||||
// =======================
|
||||
// 📊 METADATA
|
||||
// =======================
|
||||
const metadataRows = tileDb.prepare("SELECT name, value FROM metadata").all();
|
||||
const metadata = {};
|
||||
|
||||
for (const row of metadataRows) {
|
||||
metadata[row.name] = row.value;
|
||||
}
|
||||
|
||||
// decide content-type
|
||||
let contentType = "application/octet-stream";
|
||||
|
||||
if (metadata.format === "pbf") {
|
||||
contentType = "application/x-protobuf";
|
||||
} else if (metadata.format === "png") {
|
||||
contentType = "image/png";
|
||||
} else if (metadata.format === "jpg" || metadata.format === "jpeg") {
|
||||
contentType = "image/jpeg";
|
||||
}
|
||||
|
||||
// =======================
|
||||
// METADATA API
|
||||
// =======================
|
||||
router.get("/metadata/info", (req, res) => {
|
||||
res.json(metadata);
|
||||
});
|
||||
|
||||
// =======================
|
||||
// TILE API (XYZ → TMS)
|
||||
// =======================
|
||||
router.get("/:z/:x/:y", (req, res) => {
|
||||
const z = Number(req.params.z);
|
||||
const x = Number(req.params.x);
|
||||
const y = Number(req.params.y);
|
||||
|
||||
if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) {
|
||||
return res.status(400).json({ error: "Invalid tile coordinates" });
|
||||
}
|
||||
|
||||
// convert XYZ → TMS
|
||||
const tmsY = (1 << z) - 1 - y;
|
||||
|
||||
const stmt = tileDb.prepare(`
|
||||
SELECT tile_data
|
||||
FROM tiles
|
||||
WHERE zoom_level = ?
|
||||
AND tile_column = ?
|
||||
AND tile_row = ?
|
||||
`);
|
||||
|
||||
const tile = stmt.get(z, x, tmsY);
|
||||
|
||||
if (!tile) {
|
||||
return res.status(404).json({ error: "Tile not found" });
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
|
||||
if (metadata.format === "pbf") {
|
||||
res.setHeader("Content-Encoding", "gzip");
|
||||
}
|
||||
|
||||
res.send(tile.tile_data);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user