# Project export: Redhue

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: Fire moves fast. Move faster.
- Devpost: https://devpost.com/software/redhue
- GitHub: https://github.com/micah872/Redhue
- Video: https://www.youtube.com/embed/06D2E9V5wGQ?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — micah872 (1 commits)

## Devpost submission (written by the team)

### Inspiration

In 2024 alone, wildfires decimated over 8.1 million acres across the United States. California's Park Fire scorched over 428,000 acres in a single incident, becoming the state's fourth largest wildfire in recorded history. The 2018 Camp Fire in Paradise, CA killed 85 people and brought 18,804 structures to ruins in under 4 hours. Unfortunately, these deaths were preventable due to most of them being attributed to delayed evacuation decisions. Yet the Incident Commanders responsible for making those life or death decisions are still working with radio dispatches, large influxes of unreadable data, and situation reports that are 30-60 minutes stale by the time they arrive. NIFC's own after-action report consistently cite "inadequate real-time situational awareness" as a contributing factor in firefighter fatalities. Meanwhile, the data just sits their motionless. NASA's VIIRS and GOES-16/18 satellites detect fires every 10-15 minutes. Open-Meteo serves readily available hyperlocal weather data, updated hourly. ArcGIS provides real time fire perimeter display from NIFC WFIGS. However, all of this data is useless if it can't reach the person who has the power to act. The gap isn't data availability, but data delivery. Nowadays, wildfires cost the U.S. an estimated $394-$893 billion annually when factoring in property loss, health impacts, and suppression costs. Fire seasons are 78 days longer than they were in 1970. The firefighter workforce is also declining, with a reported 45% attrition rate in 2023 due to working conditions and unfair compensation. I built Redhue because the brave heroes that protect our communities from the fastest-growing natural disaster in the country deserve a tool that's as modern and dangerous as the threat they're facing.

### What it does

Redhue is a real-time wildfire intelligence dashboard built for Incident Commanders. It fuses 8 live data sources into a single mobile-first map interface: Satellite fire detection from NASA FIRMS (VIIRS + GOES-16/18) refreshing every 2.5 minutes Real-time weather with wind speed, humidity, temperature, and a scrollable 24-hour forecast with danger flagging AI-powered fire danger scoring (0-100) mapped to NIMS incident types (Type 1-5) Predictive fire spread modeling using a Rothermel elliptical model that projects burn area at 30, 60, and 120 minutes Infrastructure threat awareness: power lines, buildings, and roads overlaid on the map with counts of structures in the fire's projected path Tactical AI alerts in radio-callout style: short headlines an IC can act on in seconds, not paragraphs LCES safety assessment-Lookouts, Communications, Escape Routes, Safety Zones, and Hazards generated for the IC's specific location CAL FIRE incident data and NIFC fire perimeter polygons for statewide situational awareness A device compass that rotates with the phone's magnetometer so the wind arrow always points true An IC opens Redhue on their phone, and within seconds they have a full operational picture without manual input required

### How we built it

Next.js 16 with App Router and Turbopack for the framework React 19 + TypeScript for type-safe UI components Leaflet + react-leaflet for the interactive map with 10+ layered data overlays Tailwind CSS 4 for a dark-themed, mobile-first design Claude API (Anthropic) for fire danger scoring, tactical alert generation, and LCES assessment NASA FIRMS API for real-time VIIRS and GOES satellite fire detections Open-Meteo API for weather data and hourly forecasts OpenStreetMap Overpass API for infrastructure queries (power lines, buildings, roads) ArcGIS FeatureServer endpoints for CAL FIRE incidents, NIFC fire perimeters, and MTBS/NIFC historical data Client-side Rothermel model-the fire spread prediction runs entirely in the browser with zero latency, using fuel model lookup tables and Anderson's length-to-breadth ratio formula DeviceOrientationEvent API for phone magnetometer integration The architecture uses independent polling loops per data source so stale satellite data doesn't block fresh weather updates, and AI alerts only regenerate when conditions materially change (wind shifts >20°, new fire within 10km, humidity crosses critical thresholds). Challenges I ran into Overpass API timeouts: Our initial infrastructure query combined power lines, buildings, and roads into a single Overpass call. It consistently timed out on anything larger than a few square kilometers. I solved it by splitting into two parallel queries: power lines + roads . Compass spinning at the 0°/360° boundary: When the user rotated their phone past north, the wind arrow would spin 359° the wrong way instead of moving 1° smoothly. I fixed this with a cumulative angle tracker using shortest-path delta rotation. GeoJSON coordinate flipping: Every GIS API returns coordinates in [longitude, latitude] order. Leaflet expects [latitude, longitude]. I lost hours to invisible polygons before catching this. Balancing AI call frequency: Claude produces excellent tactical analysis, but calling it on every 2.5-minute satellite refresh would be wasteful and slow. I built a smart diffing system that only triggers AI re-analysis when the situation materially changes. Fire spread model calibration: The Rothermel model is well-documented in forestry literature but translating chains-per-hour and length-to-breadth ratios into accurate Leaflet polygon overlays required careful coordinate math and fan polygon generation. Accomplishments that I am proud of 8 data sources, one screen, zero manual input. GPS does the rest. The fire spread prediction runs entirely client-side. Adjust wind speed in the demo controls and watch the spread cones reshape in real time. Radio-style tactical alerts. We spent significant time making the AI output concise enough to be read at a glance from a truck. Headlines under 8 words, details under 15 words. The demo mode simulates a realistic Paradise, CA wildfire scenario (the site of the 2018 Camp Fire) with full interactivity: adjustable wind, humidity, temperature, and fire placement with scenario presets (Calm Day, Moderate, Red Flag, Diablo Event). The wind compass actually works with the phone's magnetometer. Point your phone and the arrow shows true wind direction relative to where you're facing. What I learned Fire behavior is governed by surprisingly few variables: wind, humidity, fuel, and slope account for nearly all of it. ICs don't want more data. They want less, presented better. Every design decision came down to "can you read this from 3 feet away in bright sunlight?" Satellite fire detection has a real gap: VIIRS passes every 12 hours, GOES refreshes every 10-15 minutes but at lower resolution. Building for emergency responders means designing for the worst possible conditions: low connectivity, bright sunlight, one-handed use, high stress.

### What's next

Crew tracking-blue-force GPS tracking so ICs can see where every engine company and hand crew is positioned in real time. Evacuation route modeling-road capacity analysis and traffic prediction during mass evacuations. Terrain-aware spread modeling-incorporate digital elevation models for slope/aspect effects on fire behavior.

## README (from the GitHub repository)

TREEHACKS 2026 LETS GOOOOOO!!!!!! 
Fire moves fast. Move faster.


## Detected evidence (automated analysis)

Indexed codebase: 37 recognized source files, 273 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- React (technology) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository; commit authorship or trailers
- AI coding agent: Codex — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (42 of 42)

```
.gitignore
Agent Files/AGENTS.md
Agent Files/CLAUDE.md
Agent Files/code_patterns.md
Agent Files/product_requirements.md
Agent Files/project_brief.md
Agent Files/tech_stack.md
Agent Files/testing.md
eslint.config.mjs
next.config.ts
package.json
postcss.config.mjs
PRD-Redhue-MVP.md
README.md
scripts/seed-2000.ts
scripts/seed-fires.ts
src/app/api/calfire-incidents/route.ts
src/app/api/fire-danger/route.ts
src/app/api/fire-perimeters/route.ts
src/app/api/historical-fires/route.ts
src/app/api/infrastructure/route.ts
src/app/api/nearby-fires/route.ts
src/app/api/suggest/route.ts
src/app/api/weather/route.ts
src/app/globals.css
src/app/layout.tsx
src/app/page.tsx
src/components/BottomDrawer.tsx
src/components/DashboardView.tsx
src/components/DemoControlPanel.tsx
src/components/MapView.tsx
src/components/MapViewClient.tsx
src/components/ProcessingView.tsx
src/components/StatusBar.tsx
src/lib/anthropic.ts
src/lib/demo-data.ts
src/lib/fire-spread.ts
src/lib/polling.ts
src/lib/types.ts
src/lib/useDeviceHeading.ts
TechDesign-Redhue-MVP.md
tsconfig.json
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.74.0, @supabase/supabase-js@^2.95.3, @tailwindcss/postcss@^4, @types/leaflet@^1.9.21, @types/node@^20, @types/react@^19, @types/react-dom@^19, csv-parse@^6.1.0, dotenv@^17.3.1, eslint@^9, eslint-config-next@16.1.6, leaflet@^1.9.4, next@16.1.6, openai@^6.22.0, openmeteo@^1.2.3, react@19.2.3, react-dom@19.2.3, react-leaflet@^5.0.0, tailwindcss@^4, typescript@^5

### Recent commits (newest first)

- Create README.md
- Redhue: Real-time wildfire intelligence dashboard for Incident Commanders
- Rebuild Redhue as camera-first firefighter assistant
- Initial commit from Create Next App

## Key source files (fetched from GitHub, selected and truncated for size)

### PRD-Redhue-MVP.md

```markdown
# Product Requirements Document: Redhue MVP

## Product Overview

**App Name:** Redhue
**Tagline:** The wisdom of 10,000 past fires — in your ear, in 2 seconds.
**Launch Goal:** Win the Stanford TreeHacks Sustainability Track
**Target Launch:** 36-hour hackathon build (February 2026)
**Builder:** Solo vibe-coder on Windows Surface with VSCode
**Coding Assistants:** Cursor (Composer mode), Claude API credits (hackathon-provided)

---

## Who It's For

### Primary User: Wildfire Incident Commander (IC)

A pragmatic, experienced field leader responsible for life-or-death decisions in the first 0–3 hours of a wildfire. They are comfortable with apps and tablets but have zero patience for complexity. They wear heavy gloves, work in smoke and noise, and need answers — not dashboards.

**Their Current Pain:**

- Juggling 5+ fragmented tools (Tablet Command, FIRIS, Watch Duty, radio, Windy.com) with no integration
- Drowning in raw data streams when they need distilled, decision-relevant information
- No tool bridges historical fire intelligence with real-time tactical recommendations
- Relying on personal "slide files" (mental memories of past fires) that retire when the firefighter does

**What They Need:**

- Hands-free input (they're wearing gloves and managing a scene)
- Instant historical context ("fires like this one went bad when...")
- Explainable, defensible recommendations — not black-box predictions
- A tool that works in 3 seconds of attention, not 3 minutes of navigation

### Secondary User: On-Site Firefighters

Crew members who need to quickly process tactical information relayed from the IC. They benefit from the same clarity and speed requirements.

### Example User Story

"Meet Captain Martinez, a CAL FIRE IC who just arrived at a wind-driven brush fire threatening a residential ridge. He's got engines arriving, dispatch on the radio, and residents asking about evacuations — all at once. He pulls out his phone, opens Redhue, and hits the big mic button. He says: 'Wind-driven fire in heavy brush, spotting 200 yards, structures threatened on the north ridge.' Within seconds, Redhue extracts the key details and surfaces 3 analogous past fires — including one where direct attack failed and dozer lines saved the day. Martinez taps Analyze, speaks his plan: 'Going direct with engines on the south flank.' Redhue returns a yellow 62% alignment score with a warning: 'In similar conditions, direct attack failed 60% of the time. Consider indirect dozer lines.' Martinez adjusts his plan with confidence, backed by the lessons of thousands of past fires."

---

## The Problem We're Solving

In the first 0–3 hours of a wildfire — the initial attack period — the decisions made by the Incident Commander often determine whether a fire is contained or becomes a catastrophic disaster. ICs have more data than ever (satellite imagery, drone feeds, weather sensors, AI cameras), but more data does not mean better decisions. In fact, raw data streams overwhelm
[truncated — 17645 more characters]
```

### Agent Files/project_brief.md

```markdown
# Project Brief (Persistent) — Redhue

## Product Vision
Redhue is a voice-first AI decision support tool that gives wildfire incident commanders the wisdom of thousands of past California wildfires — in 2 seconds, hands-free — during the critical initial attack period (0–3 hours).

## Who This Is For
- **Primary:** Any wildfire IC in California
- **Secondary:** On-site firefighters processing tactical information
- **Key traits:** Pragmatic, skeptical of black boxes, wearing heavy gloves, under extreme stress, need answers in 3 seconds

## Coding Conventions
- **Framework:** Next.js 14 App Router (`src/app/` directory)
- **Styling:** Tailwind CSS utility classes only — no custom CSS files beyond globals.css
- **Components:** Functional React components with hooks. `'use client'` directive for interactive components.
- **State:** React Context (AppContext) for shared state across tabs. `useState` for local component state.
- **API Routes:** Next.js Route Handlers in `src/app/api/[name]/route.js`
- **No TypeScript for hackathon:** Use plain JavaScript (.js/.jsx) to move faster. TypeScript can be added post-hackathon.
- **No testing framework for hackathon:** Manual testing on iPhone is the verification method.
- **No auth:** No login, no user accounts, no sessions. The app is open for the demo.

## Quality Gates
- Every feature must work on iPhone Safari before moving to the next feature
- Every API route must handle errors gracefully (try/catch, user-friendly error messages)
- The demo fallback system must be built and tested before Phase 3 begins
- Dark mode must look correct before light mode is polished
- All buttons ≥60px height, all text ≥16px

## Key Commands
```bash
npm run dev          # Start dev server
npm run build        # Production build (test before deploy)
npm run lint         # Lint check
npx vercel           # Manual deploy
```

## When to Update This Brief
- After each phase is completed
- When a new convention is established
- When a technical decision changes (e.g., switching from context stuffing to pgvector)

## Critical Hackathon Rules
1. **Working > Pretty.** A functioning demo beats a beautiful broken one.
2. **30-Minute Rule.** If stuck on something for 30 minutes, find a workaround or hardcode it.
3. **Demo Fallback.** Always have the 3 hardcoded scenarios ready as backup.
4. **Sleep.** Take at least one 1-2 hour nap. A rested brain solves problems faster.
5. **Test on Real Phone.** Browser DevTools mobile view lies. Test on actual iPhone.
6. **Warm Up Vercel.** Load the app 2 minutes before the demo to avoid cold-start delays.

```

### package.json

```
{
  "name": "redhue",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.74.0",
    "@supabase/supabase-js": "^2.95.3",
    "@types/leaflet": "^1.9.21",
    "csv-parse": "^6.1.0",
    "dotenv": "^17.3.1",
    "leaflet": "^1.9.4",
    "next": "16.1.6",
    "openai": "^6.22.0",
    "openmeteo": "^1.2.3",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "react-leaflet": "^5.0.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.1.6",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### src/app/layout.tsx

```typescript
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Redhue — AI Wildfire Assistant",
  description: "Real-time wildfire situational awareness for Incident Commanders",
};

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  maximumScale: 1,
  userScalable: false,
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

```

### src/app/page.tsx

```typescript
'use client'

import { useState, useCallback, useEffect, useRef } from 'react'
import ProcessingView from '@/components/ProcessingView'
import DashboardView from '@/components/DashboardView'
import DemoControlPanel from '@/components/DemoControlPanel'
import { calculateSpreadCones } from '@/lib/fire-spread'
import { useDeviceHeading } from '@/lib/useDeviceHeading'
import { POLL_FIRES, POLL_WEATHER, shouldRefreshAI } from '@/lib/polling'
import {
  DEMO_LOCATION,
  DEMO_WEATHER,
  DEMO_BIOME,
  DEMO_ACTIVE_FIRES,
  DEMO_HISTORICAL_FIRES,
  DEMO_ALERTS,
  DEMO_LCES,
  DEMO_FIRE_DANGER,
  DEMO_INFRASTRUCTURE,
  DEMO_CALFIRE_INCIDENTS,
  DEMO_FIRE_PERIMETERS,
  buildDemoHourlyForecast,
} from '@/lib/demo-data'
import type {
  FullAnalysisResult,
  LocationData,
  WeatherData,
  BiomeInfo,
  ActiveFire,
  HistoricalFire,
  TacticalAlert,
  LCESOutput,
  FireDangerScore,
  HourlyForecast,
  InfrastructureData,
  SpreadCone,
  CalFireIncident,
  FirePerimeter,
} from '@/lib/types'

type AppPhase = 'locating' | 'processing' | 'dashboard' | 'error'

function useIsDemo(): boolean {
  const [isDemo, setIsDemo] = useState(false)
  useEffect(() => {
    setIsDemo(new URLSearchParams(window.location.search).has('demo'))
  }, [])
  return isDemo
}

export default function Home() {
  const isDemo = useIsDemo()
  const { heading: deviceHeading, permissionNeeded, requestPermission } = useDeviceHeading()
  const [phase, setPhase] = useState<AppPhase>('locating')
  const [location, setLocation] = useState<LocationData | null>(null)
  const [processingStep, setProcessingStep] = useState('Detecting location...')
  const [errorMsg, setErrorMsg] = useState('')
  const started = useRef(false)

  // Per-source data + freshness
  const [weather, setWeather] = useState<WeatherData | null>(null)
  const [biome, setBiome] = useState<BiomeInfo | null>(null)
  const [hourlyForecast, setHourlyForecast] = useState<HourlyForecast[]>([])
  const [activeFires, setActiveFires] = useState<ActiveFire[]>([])
  const [historicalFires, setHistoricalFires] = useState<HistoricalFire[]>([])
  const [alerts, setAlerts] = useState<TacticalAlert[]>([])
  const [lces, setLces] = useState<LCESOutput | undefined>()
  const [fireDanger, setFireDanger] = useState<FireDangerScore | undefined>()
  const [infrastructure, setInfrastructure] = useState<InfrastructureData | undefined>()
  const [spreadCones, setSpreadCones] = useState<SpreadCone[]>([])
  const [calFireIncidents, setCalFireIncidents] = useState<CalFireIncident[]>([])
  const [firePerimeters, setFirePerimeters] = useState<FirePerimeter[]>([])

  const [firesUpdatedAt, setFiresUpdatedAt] = useState(0)
  const [weatherUpdatedAt, setWeatherUpdatedAt] = useState(0)

  // Track previous AI inputs for diffing
  const prevAIInputs = useRef<{ weather: WeatherData; fires: ActiveFire[] } | null>(null)

  // ── Demo mode initialization ──

  useEffect(() => {
    if (!isDemo || started.current) return
    started.current = true

    const loc = DEMO_LOCATION
    setLocation(loc)
    setWeather(DEMO_WEATHER)
    setBiome(DEMO_BIOME)
    setActiveFires(DEMO_ACTIVE_FIRES)
    setHistoricalFires(DEMO_HISTORICAL_FIRES)
    setAlerts(DEMO_ALERTS)
    setLces(DEMO_LCES)
    setFireDanger(DEMO_FIRE_DANGER)
    setHourlyForecast(buildDemoHourlyForecast())

    const now = Date.now()
    setFiresUpdatedAt(now)
    setWeatherUpdatedAt(now)

    // Calculate initial spread cones
    const cones = calculateSpreadCones(
      DEMO_ACTIVE_FIRES,
      DEMO_WEATHER,
      DEMO_BIOME.fuel_model,
      loc.latitude,
      loc.longitude
    )
    setSpreadCones(cones)

    // Use static demo data (no network dependency)
    setInfrastructure(DEMO_INFRASTRUCTURE)
    setCalFireIncidents(DEMO_CALFIRE_INCIDENTS)
    setFirePerimeters(DEMO_FIRE_PERIMETERS)

    setPhase('dashboard')
  }, [isDemo])

  // ── Demo: recalculate spread cones when demo weather/fires change ──

  const handleDemoWeatherChange = useCallback((newWeather: WeatherData) => {
    setWeather(newWeather)
    setWeatherUpdatedAt(Date.now())
  }, [])

  const handleDemoFiresChange = useCallback((newFires: ActiveFire[]) => {
    setActiveFires(newFires)
    setFiresUpdatedAt(Date.now())
  }, [])

  // ── Fetch functions ──

  const fetchWeather = useCallback(async (loc: LocationData) => {
    try {
      const res = await fetch('/api/weather', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ latitude: loc.latitude, longitude: loc.longitude }),
      })
      if (!res.ok) return
      const data = await res.json()
      setWeather(data.weather)
      setBiome(data.biome)
      setHourlyForecast(data.hourlyForecast || [])
      setWeatherUpdatedAt(Date.now())
    } catch (e) {
      console.error('[POLL] Weather error:', e)
    }
  }, [])

  const fetchFires = useCallback(async (loc: LocationData) => {
    try {
      const res = await fetch('/api/nearby-fires', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ latitude: loc.latitude, longitude: loc.longitude }),
      })
      if (!res.ok) return
      const data = await res.json()
      setActiveFires(data.activeFires || [])
      setFiresUpdatedAt(Date.now())
    } catch (e) {
      console.error('[POLL] Fires error:', e)
    }
  }, [])

  const fetchFireDanger = useCallback(async (wx: WeatherData, bio: BiomeInfo, fires: ActiveFire[], hist: HistoricalFire[]) => {
    try {
      const res = await fetch('/api/fire-danger', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ weather: wx, biome: bio, activeFires: fires, historicalFires: hist }),
      })
      if (!res.ok) return
      const data = await res.json()
      setFireDanger(data.fireDanger)
    } catch (e) {
      console.error('[POLL] Fire danger error:', e)
    }
  }, [])

  const fetchAlerts = useCallback(async (wx: WeatherData, bio: BiomeInfo, fires
[truncated — 11919 more characters]
```

### src/app/api/fire-perimeters/route.ts

```typescript
import { NextResponse } from 'next/server'
import type { FirePerimeter } from '@/lib/types'

export async function POST() {
  try {
    // NIFC Active Fire Perimeters — nationwide but we filter to California bbox
    // This provides actual fire boundary polygons for active incidents
    const url = new URL(
      'https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/WFIGS_Interagency_Perimeters/FeatureServer/0/query'
    )
    // California bounding box envelope
    url.searchParams.set('geometry', '-124.5,32.5,-114.1,42.0')
    url.searchParams.set('geometryType', 'esriGeometryEnvelope')
    url.searchParams.set('spatialRel', 'esriSpatialRelIntersects')
    url.searchParams.set('outFields', 'poly_IncidentName,poly_GISAcres,irwin_IrwinID')
    url.searchParams.set('returnGeometry', 'true')
    url.searchParams.set('outSR', '4326')
    url.searchParams.set('resultRecordCount', '50')
    url.searchParams.set('orderByFields', 'poly_GISAcres DESC')
    url.searchParams.set('f', 'geojson')

    const res = await fetch(url.toString(), { signal: AbortSignal.timeout(15000) })
    if (!res.ok) {
      return NextResponse.json({ firePerimeters: [] })
    }

    const data = await res.json()
    if (!data.features) {
      return NextResponse.json({ firePerimeters: [] })
    }

    const perimeters: FirePerimeter[] = data.features
      .map((f: Record<string, unknown>) => {
        const props = f.properties as Record<string, unknown>
        const geo = f.geometry as { type: string; coordinates: number[][][][] } | null
        if (!geo || (geo.type !== 'Polygon' && geo.type !== 'MultiPolygon')) return null

        // Normalize to array of polygon rings (each ring is [lat,lon][])
        let rings: [number, number][][] = []
        const coords = geo.coordinates as unknown
        if (geo.type === 'Polygon') {
          for (const ring of coords as number[][][]) {
            rings.push(ring.map(coord => [coord[1], coord[0]] as [number, number]))
          }
        } else {
          // MultiPolygon — flatten to list of rings
          for (const polygon of coords as number[][][][]) {
            for (const ring of polygon) {
              rings.push(ring.map(coord => [coord[1], coord[0]] as [number, number]))
            }
          }
        }

        return {
          incident_name: (props.poly_IncidentName as string) || 'Unknown',
          gis_acres: Math.round((props.poly_GISAcres as number) || 0),
          coordinates: rings,
          irwin_id: (props.irwin_IrwinID as string) || undefined,
        }
      })
      .filter((p: FirePerimeter | null): p is FirePerimeter => p !== null && p.coordinates.length > 0)

    return NextResponse.json({ firePerimeters: perimeters })
  } catch (error) {
    console.error('[FIRE-PERIMETERS] Error:', (error as Error).message)
    return NextResponse.json({ firePerimeters: [] })
  }
}

```

### src/app/api/calfire-incidents/route.ts

```typescript
import { NextResponse } from 'next/server'
import type { CalFireIncident } from '@/lib/types'

function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
  const R = 6371
  const dLat = ((lat2 - lat1) * Math.PI) / 180
  const dLon = ((lon2 - lon1) * Math.PI) / 180
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos((lat1 * Math.PI) / 180) *
      Math.cos((lat2 * Math.PI) / 180) *
      Math.sin(dLon / 2) ** 2
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}

export async function POST(request: Request) {
  try {
    const { latitude, longitude } = await request.json()

    if (latitude == null || longitude == null) {
      return NextResponse.json(
        { error: 'latitude and longitude are required' },
        { status: 400 }
      )
    }

    // CAL FIRE active incidents — California-specific ArcGIS FeatureServer
    // This covers all active and recent California wildfires with detailed incident info
    const url = new URL(
      'https://services1.arcgis.com/jUJYIo9tSA7EHvfZ/arcgis/rest/services/CAL_FIRE_Incidents/FeatureServer/0/query'
    )
    url.searchParams.set('where', '1=1') // All incidents statewide
    url.searchParams.set(
      'outFields',
      'incident_name,incident_latitude,incident_longitude,incident_acres_burned,incident_containment,incident_date_created,incident_county,incident_administrative_unit,is_active'
    )
    url.searchParams.set('resultRecordCount', '200')
    url.searchParams.set('orderByFields', 'incident_acres_burned DESC')
    url.searchParams.set('f', 'json')

    const res = await fetch(url.toString(), { signal: AbortSignal.timeout(12000) })
    if (!res.ok) {
      return NextResponse.json({ calFireIncidents: [] })
    }

    const data = await res.json()
    if (!data.features) {
      return NextResponse.json({ calFireIncidents: [] })
    }

    const incidents: CalFireIncident[] = data.features
      .map((f: Record<string, unknown>) => {
        const attrs = f.attributes as Record<string, unknown>
        const lat = (attrs.incident_latitude as number) || 0
        const lon = (attrs.incident_longitude as number) || 0
        if (!lat || !lon) return null

        const startMs = attrs.incident_date_created as number | null
        const startDate = startMs ? new Date(startMs) : null

        return {
          incident_name: (attrs.incident_name as string) || 'Unknown',
          latitude: lat,
          longitude: lon,
          acres: Math.round((attrs.incident_acres_burned as number) || 0),
          containment: Math.round((attrs.incident_containment as number) || 0),
          start_date: startDate ? startDate.toISOString().split('T')[0] : 'Unknown',
          county: (attrs.incident_county as string) || 'Unknown',
          admin_unit: (attrs.incident_administrative_unit as string) || '',
          is_active: Boolean(attrs.is_active),
          distance_km: Math.round(haversineKm(latitude, longitude, lat, lon) * 10) / 10,
        }
      })
      .filter((f: CalFireIncident | null): f is CalFireIncident => f !== null)
      .sort((a: CalFireIncident, b: CalFireIncident) => a.distance_km - b.distance_km)

    return NextResponse.json({ calFireIncidents: incidents })
  } catch (error) {
    console.error('[CALFIRE] Error:', (error as Error).message)
    return NextResponse.json({ calFireIncidents: [] })
  }
}

```

### src/app/api/nearby-fires/route.ts

```typescript
import { NextResponse } from 'next/server'
import type { ActiveFire } from '@/lib/types'

function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
  const R = 6371
  const dLat = ((lat2 - lat1) * Math.PI) / 180
  const dLon = ((lon2 - lon1) * Math.PI) / 180
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos((lat1 * Math.PI) / 180) *
      Math.cos((lat2 * Math.PI) / 180) *
      Math.sin(dLon / 2) ** 2
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}

function parseCSV(csv: string): Record<string, string>[] {
  const lines = csv.trim().split('\n')
  if (lines.length < 2) return []
  const headers = lines[0].split(',').map((h) => h.trim())
  return lines.slice(1).map((line) => {
    const values = line.split(',').map((v) => v.trim())
    const obj: Record<string, string> = {}
    headers.forEach((h, i) => {
      obj[h] = values[i] || ''
    })
    return obj
  })
}

function parseFirmsRows(
  rows: Record<string, string>[],
  userLat: number,
  userLon: number,
  defaultSatellite: string
): ActiveFire[] {
  return rows
    .map((row) => ({
      latitude: parseFloat(row.latitude || row.Latitude || '0'),
      longitude: parseFloat(row.longitude || row.Longitude || '0'),
      brightness: parseFloat(row.bright_ti4 || row.brightness || row.Temp_BB || '0'),
      frp: parseFloat(row.frp || row.FRP || '0'),
      confidence: row.confidence || row.Confidence || 'unknown',
      satellite: row.satellite || row.Satellite || defaultSatellite,
      acq_date: row.acq_date || row.Acq_Date || '',
      acq_time: row.acq_time || row.Acq_Time || '',
      distance_km: 0,
    }))
    .filter((f) => f.latitude !== 0 && f.longitude !== 0)
    .map((f) => ({
      ...f,
      distance_km: Math.round(haversineKm(userLat, userLon, f.latitude, f.longitude) * 10) / 10,
    }))
}

export async function POST(request: Request) {
  try {
    const { latitude, longitude } = await request.json()

    if (latitude == null || longitude == null) {
      return NextResponse.json(
        { error: 'latitude and longitude are required' },
        { status: 400 }
      )
    }

    const mapKey = process.env.NASA_FIRMS_MAP_KEY
    if (!mapKey) {
      return NextResponse.json(
        { error: 'NASA_FIRMS_MAP_KEY not configured' },
        { status: 500 }
      )
    }

    // California-wide bounding box (lat 32.5-42.0, lon -124.5 to -114.1)
    const CA_BBOX = '-124.5,32.5,-114.1,42.0'

    // Query VIIRS (polar-orbit, high-res ~375m) and GOES (geostationary, ~2km, ~10-15min refresh) in parallel
    // Covering all of California for statewide awareness
    const [viirsRes, goesRes] = await Promise.all([
      fetch(
        `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${mapKey}/VIIRS_SNPP_NRT/${CA_BBOX}/5`,
        { signal: AbortSignal.timeout(15000) }
      ).catch(() => null),
      fetch(
        `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${mapKey}/GOES_NRT/${CA_BBOX}/2`,
        { signal: AbortSignal.timeout(15000) }
      ).catch(() => null),
    ])

    let allFires: ActiveFire[] = []

    // Parse VIIRS detections (past 5 days, higher spatial resolution)
    if (viirsRes?.ok) {
      const csvText = await viirsRes.text()
      const rows = parseCSV(csvText)
      allFires.push(...parseFirmsRows(rows, latitude, longitude, 'VIIRS'))
    }

    // Parse GOES detections (past 2 days, near-real-time ~10-15 min refresh)
    if (goesRes?.ok) {
      const csvText = await goesRes.text()
      const rows = parseCSV(csvText)
      allFires.push(...parseFirmsRows(rows, latitude, longitude, 'GOES'))
    }

    // Sort by distance from user
    const activeFires = allFires
      .sort((a, b) => a.distance_km - b.distance_km)

    return NextResponse.json({ activeFires })
  } catch (error) {
    console.error('[NEARBY-FIRES] Error:', (error as Error).message)
    return NextResponse.json(
      { error: 'Nearby fires lookup failed', details: (error as Error).message },
      { status: 500 }
    )
  }
}

```

### src/app/api/infrastructure/route.ts

```typescript
import { NextResponse } from 'next/server'
import type { PowerLine, Structure, Road, InfrastructureData } from '@/lib/types'

export async function POST(request: Request) {
  try {
    const { south, west, north, east } = await request.json()

    if (south == null || west == null || north == null || east == null) {
      return NextResponse.json({ error: 'Bounding box (south, west, north, east) required' }, { status: 400 })
    }

    // Split into two queries: (1) power lines + roads (lightweight) and (2) buildings (heavier, smaller bbox)
    // This prevents Overpass from timing out on large building queries
    const infraQuery = `
[out:json][timeout:25];
(
  way["power"="line"](${south},${west},${north},${east});
  way["power"="minor_line"](${south},${west},${north},${east});
  way["highway"~"motorway|trunk|primary|secondary|tertiary"](${south},${west},${north},${east});
);
out geom;
`
    // Smaller bbox for buildings (±0.03° ~3.3km) to avoid timeout
    const midLat = (south + north) / 2
    const midLon = (west + east) / 2
    const bldgOffset = 0.03
    const buildingQuery = `
[out:json][timeout:15];
(
  way["building"](${midLat - bldgOffset},${midLon - bldgOffset},${midLat + bldgOffset},${midLon + bldgOffset});
);
out center;
`

    const [infraRes, bldgRes] = await Promise.all([
      fetch('https://overpass-api.de/api/interpreter', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: `data=${encodeURIComponent(infraQuery)}`,
        signal: AbortSignal.timeout(30000),
      }).catch(() => null),
      fetch('https://overpass-api.de/api/interpreter', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: `data=${encodeURIComponent(buildingQuery)}`,
        signal: AbortSignal.timeout(20000),
      }).catch(() => null),
    ])

    const res = infraRes

    if (!res?.ok) {
      console.error('[INFRA] Overpass returned', res?.status || 'no response')
      return NextResponse.json(emptyInfra(), { status: 200 })
    }

    const json = await res.json()
    const elements = [...(json.elements || [])]

    // Merge building results if available
    if (bldgRes?.ok) {
      const bldgJson = await bldgRes.json()
      elements.push(...(bldgJson.elements || []))
    }

    const powerLines: PowerLine[] = []
    const structures: Structure[] = []
    const roads: Road[] = []

    for (const el of elements) {
      const tags = el.tags || {}

      if (tags.power === 'line' || tags.power === 'minor_line') {
        if (el.geometry) {
          powerLines.push({
            id: el.id,
            voltage: tags.voltage,
            coordinates: el.geometry.map((g: { lat: number; lon: number }) => [g.lat, g.lon] as [number, number]),
            operator: tags.operator,
          })
        }
      } else if (tags.building) {
        const lat = el.center?.lat ?? el.lat
        const lon = el.center?.lon ?? el.lon
        if (lat && lon) {
          structures.push({
            id: el.id,
            center: [lat, lon],
            type: tags.building === 'yes' ? 'unknown' : tags.building,
          })
        }
      } else if (tags.highway) {
        if (el.geometry) {
          roads.push({
            id: el.id,
            name: tags.name,
            type: tags.highway,
            coordinates: el.geometry.map((g: { lat: number; lon: number }) => [g.lat, g.lon] as [number, number]),
          })
        }
      }
    }

    const result: InfrastructureData = {
      powerLines,
      structures: structures.slice(0, 200), // cap to prevent performance issues
      roads,
      structuresInPath: 0,
      powerLinesInPath: 0,
    }

    return NextResponse.json(result)
  } catch (error) {
    console.error('[INFRA] Error:', (error as Error).message)
    // Graceful degradation — return empty data, don't fail the dashboard
    return NextResponse.json(emptyInfra(), { status: 200 })
  }
}

function emptyInfra(): InfrastructureData {
  return { powerLines: [], structures: [], roads: [], structuresInPath: 0, powerLinesInPath: 0 }
}

```

### src/app/api/fire-danger/route.ts

```typescript
import { NextResponse } from 'next/server'
import type { WeatherData, BiomeInfo, ActiveFire, HistoricalFire, FireDangerScore } from '@/lib/types'

// FBFM40 fuel labels → danger score
const HEAVY_FUEL_LABELS = new Set([
  'Very Heavy Dry Brush', 'Very Heavy Green Brush', 'Very Heavy Timber-Brush',
  'Very Heavy Slash', 'Heavy Slash',
])
const MODERATE_FUEL_LABELS = new Set([
  'Heavy Dry Brush', 'Heavy Green Brush', 'Heavy Dry Grass',
  'Moderate Timber-Shrub', 'Heavy Pine Litter', 'Moderate Slash',
  'Heavy Humid Grass-Shrub', 'Tall Coarse Humid Grass',
])
const LIGHT_FUEL_LABELS = new Set([
  'Moderate Dry Brush', 'Moderate Green Brush', 'Moderate Dry Grass',
  'Light Timber-Brush Mix', 'Moderate Pine Litter', 'Light Slash',
  'Moderate Grass-Shrub', 'Dry Grass-Shrub Mix',
])

function scoreFuel(fuelModel: string): number {
  if (HEAVY_FUEL_LABELS.has(fuelModel)) return 25
  if (MODERATE_FUEL_LABELS.has(fuelModel)) return 15
  if (LIGHT_FUEL_LABELS.has(fuelModel)) return 10
  // Non-burnable (Urban, Water, Barren, Snow, Agriculture)
  if (['Urban / Developed', 'Water', 'Barren', 'Snow / Ice', 'Agriculture'].includes(fuelModel)) return 0
  return 5
}

export async function POST(request: Request) {
  try {
    const { weather, biome, activeFires = [], historicalFires = [] } = (await request.json()) as {
      weather: WeatherData
      biome: BiomeInfo
      activeFires: ActiveFire[]
      historicalFires: HistoricalFire[]
    }

    if (!weather || !biome) {
      return NextResponse.json({ error: 'weather and biome required' }, { status: 400 })
    }

    const factors = { weather: 0, fuel: 0, activeFires: 0, historical: 0 }

    // Weather (0-30)
    if (weather.temperature_f > 95) factors.weather += 5
    else if (weather.temperature_f > 86) factors.weather += 3
    if (weather.relative_humidity < 15) factors.weather += 10
    else if (weather.relative_humidity < 25) factors.weather += 5
    if (weather.wind_speed_mph > 25) factors.weather += 10
    else if (weather.wind_speed_mph > 15) factors.weather += 5
    if (weather.wind_gusts_mph > 35) factors.weather += 5

    // Fuel (0-25)
    factors.fuel = scoreFuel(biome.fuel_model)

    // Active fires (0-25)
    const within5 = activeFires.filter((f) => f.distance_km <= 5)
    const within25 = activeFires.filter((f) => f.distance_km <= 25)
    if (within5.length > 0) factors.activeFires += 15
    if (within25.some((f) => f.frp > 100)) factors.activeFires += 10
    if (within25.length >= 10) factors.activeFires += 10
    else if (within25.length >= 5) factors.activeFires += 5
    else if (within25.length >= 1) factors.activeFires += 3
    factors.activeFires = Math.min(25, factors.activeFires)

    // Historical (0-20)
    if (historicalFires.some((f) => f.acres > 100000 && f.distance_km <= 50)) factors.historical += 10
    if (historicalFires.some((f) => f.acres > 10000 && f.distance_km <= 25)) factors.historical += 5
    if (historicalFires.some((f) => f.fire_behavior?.toLowerCase().includes('extreme') || f.fire_behavior?.toLowerCase().includes('erratic')))
      factors.historical += 5

    const score = Math.min(100, factors.weather + factors.fuel + factors.activeFires + factors.historical)

    let rating: FireDangerScore['rating']
    let incidentType: FireDangerScore['incidentType']

    if (score <= 20) { rating = 'Low'; incidentType = 'Type 5' }
    else if (score <= 40) { rating = 'Moderate'; incidentType = 'Type 4' }
    else if (score <= 60) { rating = 'High'; incidentType = 'Type 3' }
    else if (score <= 80) { rating = 'Very High'; incidentType = 'Type 2' }
    else { rating = 'Extreme'; incidentType = 'Type 1' }

    const fireDanger: FireDangerScore = {
      score,
      rating,
      incidentType,
      factors,
      reasoning: `Weather ${factors.weather}/30 | Fuel ${factors.fuel}/25 | Active fires ${factors.activeFires}/25 | Historical ${factors.historical}/20`,
    }

    return NextResponse.json({ fireDanger })
  } catch (error) {
    console.error('[FIRE-DANGER] Error:', (error as Error).message)
    return NextResponse.json(
      { error: 'Fire danger scoring failed', details: (error as Error).message },
      { status: 500 }
    )
  }
}

```

[27 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]