From 63eeb29fbff86e9e47005e1ca44d472a75479a7a Mon Sep 17 00:00:00 2001 From: otivm Date: Mon, 27 Apr 2026 10:36:38 +0000 Subject: [PATCH] Add SQLite connection and /api/map/:h5/:epoch endpoint --- server/index.js | 82 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/server/index.js b/server/index.js index 4efaba1..bc32455 100644 --- a/server/index.js +++ b/server/index.js @@ -4,15 +4,40 @@ import { readFile, writeFile, mkdir } from 'fs/promises' import { join, dirname } from 'path' import { fileURLToPath } from 'url' import { existsSync } from 'fs' +import Database from 'better-sqlite3' const __dirname = dirname(fileURLToPath(import.meta.url)) const ROOT = join(__dirname, '..') const DIST = join(ROOT, 'dist') const SAVES_DIR = join(ROOT, 'data', 'saves') +const DB_PATH = join(ROOT, 'data', 'otivm.sqlite3') // Ensure saves directory exists await mkdir(SAVES_DIR, { recursive: true }) +// Open TESSERA database — read-only +const db = new Database(DB_PATH, { readonly: true }) + +// Prepared statements +const stmtEpoch = db.prepare( + 'SELECT sl_offset_cm FROM paleo_epochs WHERE epoch_key = ?' +) + +const stmtH7 = db.prepare(` + SELECT + h7, + SUM(CASE WHEN elev_cm > ? THEN 1 ELSE 0 END) AS h7_land, + COUNT(*) AS h9_total + FROM tessera_cells + WHERE h5 = ? AND status = 2 + GROUP BY h7 +`) + +// Convert hex string H3 ID to BigInt integer for DB lookup +function h3HexToInt(hexStr) { + return BigInt('0x' + hexStr) +} + const fastify = Fastify({ logger: false }) // Serve built frontend @@ -21,6 +46,63 @@ await fastify.register(fastifyStatic, { prefix: '/', }) +// --- TESSERA map endpoint --- +// Returns H7-aggregated land/sea classification for one H5 waypoint at one epoch. +// h5param: H3 res-5 cell ID as hex string (e.g. '851e805bfffffff') +// epochKey: named epoch from paleo_epochs (e.g. 'roman_14bce') +// Response: { epoch_key, sl_offset_cm, h5, cells: [{ h7, h7_land, h9_total, is_land }] } +fastify.get('/api/map/:h5/:epoch', async (req, reply) => { + const { h5: h5param, epoch: epochKey } = req.params + + // Validate H5 — must be 15-char lowercase hex + if (!/^[0-9a-f]{15}$/.test(h5param)) { + return reply.code(400).send({ error: 'Invalid H5 ID' }) + } + + // Validate epoch key — alphanumeric and underscores only + if (!/^[a-z0-9_]+$/.test(epochKey)) { + return reply.code(400).send({ error: 'Invalid epoch key' }) + } + + // Resolve epoch to sea level offset + const epoch = stmtEpoch.get(epochKey) + if (!epoch) { + return reply.code(404).send({ error: `Unknown epoch: ${epochKey}` }) + } + + const slOffsetCm = epoch.sl_offset_cm + + // Convert H5 hex string to integer for DB query + let h5int + try { + h5int = h3HexToInt(h5param) + } catch { + return reply.code(400).send({ error: 'Malformed H5 ID' }) + } + + // Query H7 land/sea classification + const rows = stmtH7.all(slOffsetCm, h5int) + + if (!rows.length) { + return reply.code(404).send({ error: `No data for H5: ${h5param}` }) + } + + // Apply majority rule: H7 cell is land if >50% of H9 children are land + const cells = rows.map(row => ({ + h7: row.h7.toString(16), // return as hex string + h7_land: row.h7_land, + h9_total: row.h9_total, + is_land: row.h7_land * 2 > row.h9_total ? 1 : 0, + })) + + return reply.send({ + epoch_key: epochKey, + sl_offset_cm: slOffsetCm, + h5: h5param, + cells, + }) +}) + // Load save state fastify.get('/api/save/:token', async (req, reply) => { const { token } = req.params