Files
otivm/server/index.js

143 lines
3.9 KiB
JavaScript

import Fastify from 'fastify'
import fastifyStatic from '@fastify/static'
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')
await mkdir(SAVES_DIR, { recursive: true })
const db = new Database(DB_PATH, { readonly: true })
const stmtEpoch = db.prepare(
'SELECT sl_offset_cm FROM paleo_epochs WHERE epoch_key = ?'
)
const stmtH7 = db.prepare(`
SELECT
h7,
AVG(lat) AS lat,
AVG(lon) AS lon,
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
`)
function h3HexToInt(hexStr) {
return BigInt('0x' + hexStr)
}
const fastify = Fastify({ logger: false })
await fastify.register(fastifyStatic, {
root: DIST,
prefix: '/',
})
// GET /api/map/:h5/:epoch
// Returns H7-aggregated land/sea classification with real centroids.
// h5param: H3 res-5 hex string (e.g. '851e805bfffffff')
// epochKey: named epoch from paleo_epochs (e.g. 'roman_14bce')
// 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) => {
const { h5: h5param, epoch: epochKey } = req.params
if (!/^[0-9a-f]{15}$/.test(h5param)) {
return reply.code(400).send({ error: 'Invalid H5 ID' })
}
if (!/^[a-z0-9_]+$/.test(epochKey)) {
return reply.code(400).send({ error: 'Invalid epoch key' })
}
const epoch = stmtEpoch.get(epochKey)
if (!epoch) {
return reply.code(404).send({ error: `Unknown epoch: ${epochKey}` })
}
const slOffsetCm = epoch.sl_offset_cm
let h5int
try {
h5int = h3HexToInt(h5param)
} catch {
return reply.code(400).send({ error: 'Malformed H5 ID' })
}
const rows = stmtH7.all(slOffsetCm, h5int)
if (!rows.length) {
return reply.code(404).send({ error: `No data for H5: ${h5param}` })
}
const cells = rows.map(row => ({
h7: row.h7.toString(16),
lat: row.lat,
lon: row.lon,
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,
})
})
fastify.get('/api/save/:token', async (req, reply) => {
const { token } = req.params
if (!/^[0-9a-f]{8}$/.test(token)) {
return reply.code(400).send({ error: 'Invalid token' })
}
const path = join(SAVES_DIR, `${token}.json`)
if (!existsSync(path)) {
return reply.code(404).send({ error: 'Save not found' })
}
try {
const raw = await readFile(path, 'utf8')
return reply.send(JSON.parse(raw))
} catch {
return reply.code(500).send({ error: 'Failed to read save' })
}
})
fastify.post('/api/save/:token', async (req, reply) => {
const { token } = req.params
if (!/^[0-9a-f]{8}$/.test(token)) {
return reply.code(400).send({ error: 'Invalid token' })
}
if (!req.body || typeof req.body !== 'object') {
return reply.code(400).send({ error: 'Invalid body' })
}
const path = join(SAVES_DIR, `${token}.json`)
try {
await writeFile(path, JSON.stringify(req.body, null, 2), 'utf8')
return reply.send({ ok: true })
} catch {
return reply.code(500).send({ error: 'Failed to write save' })
}
})
fastify.setNotFoundHandler((req, reply) => {
reply.sendFile('index.html')
})
try {
await fastify.listen({ port: 3000, host: '0.0.0.0' })
console.log('OTIVM server running on port 3000')
} catch (err) {
console.error(err)
process.exit(1)
}