← back to Firecities
server.js
175 lines
#!/usr/bin/env node
// firecities server.js — California Fire Communities DINS viewer
// Local only — no deploy. Serves on a free port between 9970–9975.
const express = require('express');
const fs = require('fs');
const path = require('path');
const net = require('net');
const DATA_FILE = path.join(__dirname, 'data', 'dins.json');
const PUBLIC_DIR = path.join(__dirname, 'public');
// ── Port discovery ───────────────────────────────────────────────────────────
function portFree(p) {
return new Promise(resolve => {
const s = net.createServer();
s.listen(p, () => { s.close(() => resolve(true)); });
s.on('error', () => resolve(false));
});
}
async function findPort(start = 9970, end = 9979) {
for (let p = start; p <= end; p++) {
if (await portFree(p)) return p;
}
throw new Error(`No free port found between ${start}–${end}`);
}
// ── Data cache ───────────────────────────────────────────────────────────────
let DINS = null;
function loadData() {
if (DINS) return DINS;
if (!fs.existsSync(DATA_FILE)) return null;
try {
DINS = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
return DINS;
} catch (e) {
console.error('Failed to parse', DATA_FILE, e.message);
return null;
}
}
// ── Express app ──────────────────────────────────────────────────────────────
const app = express();
app.use(express.static(PUBLIC_DIR));
// /api/status — health check + data availability
app.get('/api/status', (req, res) => {
const d = loadData();
if (!d) {
return res.json({
ok: false,
message: 'DINS data not loaded. Run: node scripts/fetch-dins.js --limit=5000',
dataFile: DATA_FILE
});
}
res.json({
ok: true,
fetchedAt: d.fetchedAt,
totalRecords: d.totalRecords,
totalCities: d.totalCities,
destroyedCount: d.destroyedCount,
source: d.source
});
});
// /api/communities — aggregated stats per city/community
// Query params: sort, damage, county, q (search), limit, offset
app.get('/api/communities', (req, res) => {
const d = loadData();
if (!d) return res.status(503).json({ error: 'Data not available. Run fetch-dins.js first.' });
let communities = d.communities.slice();
// Filter: text search on city name
if (req.query.q) {
const q = req.query.q.toLowerCase();
communities = communities.filter(c => c.city.toLowerCase().includes(q));
}
// Filter: county
if (req.query.county) {
const county = req.query.county.toLowerCase();
communities = communities.filter(c => (c.county || '').toLowerCase().includes(county));
}
// Filter: must have at least one structure of this damage level
if (req.query.damage) {
const dmg = req.query.damage;
communities = communities.filter(c => (c.counts[dmg] || 0) > 0);
}
// Sort
const sort = req.query.sort || 'destroyed';
if (sort === 'destroyed') {
communities.sort((a, b) => (b.counts['Destroyed (>50%)'] || 0) - (a.counts['Destroyed (>50%)'] || 0));
} else if (sort === 'total') {
communities.sort((a, b) => b.total - a.total);
} else if (sort === 'name') {
communities.sort((a, b) => a.city.localeCompare(b.city));
} else if (sort === 'county') {
communities.sort((a, b) => (a.county || '').localeCompare(b.county || '') || a.city.localeCompare(b.city));
} else if (sort === 'date') {
communities.sort((a, b) => (b.lastDate || '').localeCompare(a.lastDate || ''));
}
const limit = Math.min(parseInt(req.query.limit, 10) || 500, 1000);
const offset = parseInt(req.query.offset, 10) || 0;
const page = communities.slice(offset, offset + limit);
res.json({
total: communities.length,
offset,
limit,
communities: page
});
});
// /api/structures — individual structure records for a city
app.get('/api/structures', (req, res) => {
const d = loadData();
if (!d) return res.status(503).json({ error: 'Data not available. Run fetch-dins.js first.' });
const city = (req.query.city || '').trim();
if (!city) return res.status(400).json({ error: 'city param required' });
const records = (d.records || []).filter(r => (r.city || r.community || '').trim() === city);
const limit = Math.min(parseInt(req.query.limit, 10) || 500, 5000);
const offset = parseInt(req.query.offset, 10) || 0;
// Optional damage filter
let filtered = records;
if (req.query.damage) {
filtered = records.filter(r => r.damage === req.query.damage);
}
res.json({
city,
total: filtered.length,
offset,
limit,
structures: filtered.slice(offset, offset + limit)
});
});
// /api/counties — list distinct counties
app.get('/api/counties', (req, res) => {
const d = loadData();
if (!d) return res.status(503).json({ error: 'Data not available.' });
const counties = [...new Set(d.communities.map(c => c.county).filter(Boolean))].sort();
res.json({ counties });
});
// ── Start ────────────────────────────────────────────────────────────────────
findPort().then(port => {
app.listen(port, () => {
console.log(`\n🔥 CA Fire Communities DINS Viewer`);
console.log(` http://localhost:${port}`);
console.log(` API: http://localhost:${port}/api/status`);
const d = loadData();
if (d) {
console.log(` Data: ${d.totalRecords?.toLocaleString()} structures / ${d.totalCities} communities`);
console.log(` Destroyed (>50%): ${d.destroyedCount?.toLocaleString()}`);
} else {
console.log(` ⚠ No data yet. Run: node scripts/fetch-dins.js --limit=5000`);
}
// Write port to a file for easy discovery
fs.writeFileSync(path.join(__dirname, '.port'), String(port));
});
}).catch(e => {
console.error('Failed to start:', e.message);
process.exit(1);
});