Add avg lat/lon per H7 cell to map endpoint response

This commit is contained in:
otivm
2026-04-28 05:27:46 +00:00
parent a8d1831fb0
commit 8e57f8c3a3

View File

@@ -12,13 +12,10 @@ const DIST = join(ROOT, 'dist')
const SAVES_DIR = join(ROOT, 'data', 'saves') const SAVES_DIR = join(ROOT, 'data', 'saves')
const DB_PATH = join(ROOT, 'data', 'otivm.sqlite3') const DB_PATH = join(ROOT, 'data', 'otivm.sqlite3')
// Ensure saves directory exists
await mkdir(SAVES_DIR, { recursive: true }) await mkdir(SAVES_DIR, { recursive: true })
// Open TESSERA database — read-only
const db = new Database(DB_PATH, { readonly: true }) const db = new Database(DB_PATH, { readonly: true })
// Prepared statements
const stmtEpoch = db.prepare( const stmtEpoch = db.prepare(
'SELECT sl_offset_cm FROM paleo_epochs WHERE epoch_key = ?' 'SELECT sl_offset_cm FROM paleo_epochs WHERE epoch_key = ?'
) )
@@ -26,6 +23,8 @@ const stmtEpoch = db.prepare(
const stmtH7 = db.prepare(` const stmtH7 = db.prepare(`
SELECT SELECT
h7, h7,
AVG(lat) AS lat,
AVG(lon) AS lon,
SUM(CASE WHEN elev_cm > ? THEN 1 ELSE 0 END) AS h7_land, SUM(CASE WHEN elev_cm > ? THEN 1 ELSE 0 END) AS h7_land,
COUNT(*) AS h9_total COUNT(*) AS h9_total
FROM tessera_cells FROM tessera_cells
@@ -33,38 +32,32 @@ const stmtH7 = db.prepare(`
GROUP BY h7 GROUP BY h7
`) `)
// Convert hex string H3 ID to BigInt integer for DB lookup
function h3HexToInt(hexStr) { function h3HexToInt(hexStr) {
return BigInt('0x' + hexStr) return BigInt('0x' + hexStr)
} }
const fastify = Fastify({ logger: false }) const fastify = Fastify({ logger: false })
// Serve built frontend
await fastify.register(fastifyStatic, { await fastify.register(fastifyStatic, {
root: DIST, root: DIST,
prefix: '/', prefix: '/',
}) })
// --- TESSERA map endpoint --- // GET /api/map/:h5/:epoch
// Returns H7-aggregated land/sea classification for one H5 waypoint at one epoch. // Returns H7-aggregated land/sea classification with real centroids.
// h5param: H3 res-5 cell ID as hex string (e.g. '851e805bfffffff') // h5param: H3 res-5 hex string (e.g. '851e805bfffffff')
// epochKey: named epoch from paleo_epochs (e.g. 'roman_14bce') // 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 }] } // Response: { epoch_key, sl_offset_cm, h5, cells: [{ h7, lat, lon, h7_land, h9_total, is_land }] }
fastify.get('/api/map/:h5/:epoch', async (req, reply) => { fastify.get('/api/map/:h5/:epoch', async (req, reply) => {
const { h5: h5param, epoch: epochKey } = req.params const { h5: h5param, epoch: epochKey } = req.params
// Validate H5 — must be 15-char lowercase hex
if (!/^[0-9a-f]{15}$/.test(h5param)) { if (!/^[0-9a-f]{15}$/.test(h5param)) {
return reply.code(400).send({ error: 'Invalid H5 ID' }) return reply.code(400).send({ error: 'Invalid H5 ID' })
} }
// Validate epoch key — alphanumeric and underscores only
if (!/^[a-z0-9_]+$/.test(epochKey)) { if (!/^[a-z0-9_]+$/.test(epochKey)) {
return reply.code(400).send({ error: 'Invalid epoch key' }) return reply.code(400).send({ error: 'Invalid epoch key' })
} }
// Resolve epoch to sea level offset
const epoch = stmtEpoch.get(epochKey) const epoch = stmtEpoch.get(epochKey)
if (!epoch) { if (!epoch) {
return reply.code(404).send({ error: `Unknown epoch: ${epochKey}` }) return reply.code(404).send({ error: `Unknown epoch: ${epochKey}` })
@@ -72,7 +65,6 @@ fastify.get('/api/map/:h5/:epoch', async (req, reply) => {
const slOffsetCm = epoch.sl_offset_cm const slOffsetCm = epoch.sl_offset_cm
// Convert H5 hex string to integer for DB query
let h5int let h5int
try { try {
h5int = h3HexToInt(h5param) h5int = h3HexToInt(h5param)
@@ -80,16 +72,16 @@ fastify.get('/api/map/:h5/:epoch', async (req, reply) => {
return reply.code(400).send({ error: 'Malformed H5 ID' }) return reply.code(400).send({ error: 'Malformed H5 ID' })
} }
// Query H7 land/sea classification
const rows = stmtH7.all(slOffsetCm, h5int) const rows = stmtH7.all(slOffsetCm, h5int)
if (!rows.length) { if (!rows.length) {
return reply.code(404).send({ error: `No data for H5: ${h5param}` }) 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 => ({ const cells = rows.map(row => ({
h7: row.h7.toString(16), // return as hex string h7: row.h7.toString(16),
lat: row.lat,
lon: row.lon,
h7_land: row.h7_land, h7_land: row.h7_land,
h9_total: row.h9_total, h9_total: row.h9_total,
is_land: row.h7_land * 2 > row.h9_total ? 1 : 0, is_land: row.h7_land * 2 > row.h9_total ? 1 : 0,
@@ -103,7 +95,6 @@ fastify.get('/api/map/:h5/:epoch', async (req, reply) => {
}) })
}) })
// Load save state
fastify.get('/api/save/:token', async (req, reply) => { fastify.get('/api/save/:token', async (req, reply) => {
const { token } = req.params const { token } = req.params
if (!/^[0-9a-f]{8}$/.test(token)) { if (!/^[0-9a-f]{8}$/.test(token)) {
@@ -121,7 +112,6 @@ fastify.get('/api/save/:token', async (req, reply) => {
} }
}) })
// Write save state
fastify.post('/api/save/:token', async (req, reply) => { fastify.post('/api/save/:token', async (req, reply) => {
const { token } = req.params const { token } = req.params
if (!/^[0-9a-f]{8}$/.test(token)) { if (!/^[0-9a-f]{8}$/.test(token)) {
@@ -139,7 +129,6 @@ fastify.post('/api/save/:token', async (req, reply) => {
} }
}) })
// Fallback — serve index.html for client-side routing
fastify.setNotFoundHandler((req, reply) => { fastify.setNotFoundHandler((req, reply) => {
reply.sendFile('index.html') reply.sendFile('index.html')
}) })