← 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);
});