- MUST have
postcss.config.mjswith@tailwindcss/postcssplugin — without it,@import "tailwindcss"produces ZERO utility classes (no error, just silently broken) - MUST pin
tailwindcss@4.0.7and@tailwindcss/postcss@4.0.7— v4.1.18+ crashes Turbopack - Next.js 16 is Turbopack-only (no
--no-turbopackflag exists) - If Tailwind classes aren't working, check postcss.config.mjs first
- 3-column flexbox layout: left panel (220px) | circular globe viewport | right panel (280px)
- Center column is
flex-col: globe viewport on top, TimelineSlider below - CesiumJS globe loaded via
next/dynamicwithssr: false— Cesium cannot run server-side - State: Zustand store at
stores/worldview-store.ts - 9 data layers: flights, satellites, disasters, asteroids, weather, cameras, livestreams, news, militaryActions
- 3 expandable with sub-filters: flights (Regular/ISS), disasters (6 types), militaryActions (5 types)
- View modes (EO/FLIR/CRT/NV): CSS filters in
components/effects/ViewModeFilter.tsx - SVG filter
<defs>(e.g. FLIR) must be rendered outsideoverflow:hiddencontainers — useFlirFilterDefsexport at page level - Globe is clipped to circle via
.scope-viewport { border-radius: 50%; overflow: hidden }in globals.css
- Timeline slider beneath the globe viewport:
components/hud/TimelineSlider.tsx+CalendarPicker.tsx - Simulation state in Zustand:
simulationDate,simulationHour,simulationMinute,isLive- Setters (
setSimulationDate,setSimulationTime) sync CesiumJS clock viaCesium.JulianDate.fromDate() resetToLive()snaps back to current time and resumes CesiumJS clock animation
- Setters (
- TimelineSlider uses
mountedstate to suppress hydration mismatch fromDate.now()drift between SSR and client - Data hooks (
useNews,useDisasters) checkisLive:- Live mode →
/api/news(graph + RSS + Reddit),/api/disasters - Historical mode →
/api/news/historical?date=YYYYMMDD&hour=HH,/api/disasters?date=YYYYMMDD - Both hooks use
AbortControllerto cancel in-flight requests on cleanup
- Live mode →
- Flights, cameras, livestreams, militaryActions are always live (no historical data available)
docker-compose.yml— Cassandra 4.1.7 + JanusGraph 1.0.0 (ports configurable via env vars)- JanusGraph 1.0.0 requires
JANUS_PROPS_TEMPLATE=cql(NOTcassandra)
- JanusGraph 1.0.0 requires
lib/graph/client.ts— Gremlin client with 3s connection timeout and 1min backoff on failure- Uses
mimeType: "application/vnd.gremlin-v3.0+json"(GraphSON) — GraphBinary fails with JanusGraph custom types - Uses
gremlin.process.traversal().with_(conn)(v3.8 API) — NOTgraph.traversal().withRemote(conn)(v3.6) - If JanusGraph isn't running, graph queries fail fast and don't retry for 60s — app still works with RSS/Reddit only
- Uses
lib/graph/schema.ts— Graph schema: article, person, organization, location, theme vertices + relationship edgesscripts/init-graph-schema.ts— Idempotent schema init (npm run init-schema)lib/graph/queries.ts— Query functions:getArticlesByDateRange,getArticlesByLocation,getPersonNetwork,getThemeTrendstypes/gremlin.d.ts— TypeScript declarations for thegremlinpackage (no@types/gremlinavailable)Graphlives ingremlin.structure, NOTgremlin.driver
lib/graph/gkg-parser.ts— Parses GDELT GKG 2.0 27-column TSV files (handles\r\nline endings)lib/graph/gkg-downloader.ts— Downloads + extracts ZIP archives viaadm-zip(NOT gzip — GDELT uses standard ZIP format)lib/graph/gkg-loader.ts— Batch upserts into JanusGraph with get-or-create (upsert) patternscripts/ingest-gkg.ts— CLI:--daily,--latest,--backfill --from YYYYMMDD --to YYYYMMDD- GDELT URLs use HTTPS (configured in
lib/constants.ts) - Cache/cursor paths configurable via
GKG_CACHE_DIRandGKG_CURSOR_PATHenv vars
/api/news— Primary endpoint: JanusGraph (last 4h) + RSS + Reddit. Does NOT call GDELT Doc API (removed due to chronic timeouts)/api/news/realtime— Same sources as/api/news, plus DDG as supplemental (often rate-limited)/api/news/historical— JanusGraph only, queries ±30min window around requested timelib/api/duckduckgo.ts— DDG results haveNaNlat/lon (no geo data); callers must filter accordingly- RSS feeds: BBC Middle East, BBC World, Al Jazeera, Jerusalem Post, France 24 ME, PressTV Iran
- Tehran Times removed (307 redirect loop), replaced with PressTV
- Arab News removed (Cloudflare blocked), replaced with France 24 Middle East
/api/military— GDELT GEO API withtheme:MILITARY+ conflict keyword querieshooks/useMilitary.ts— Polls every 5 min, stores in ZustandmilitaryActionscomponents/layers/MilitaryLayer.tsx— Red/orange dots on globe, click for details- Sub-filters in sidebar: Airstrikes, Missile Strikes, Ground Ops, Naval Ops, Other
- GDELT GEO API returns geocoded locations with mention counts, no auth required
- Category classification uses keyword matching on article titles
lib/agents/— Agent swarm infrastructure for AI-powered intelligence gatheringlib/agents/ollama-client.ts— Ollama connection with 3s timeout, 1min backoff (same pattern as JanusGraph)lib/agents/agents.ts— Agent definitions: News Scout, Military Analyst, Disaster Monitor, GEOINT Analystlib/agents/swarm.ts— Legacy orchestrator (runs all agents sequentially with big context)/api/agents— GET for status, POST{ "action": "run" }to trigger legacy swarm- Preferred models:
sam860/lfm2:8b(5.9GB, fits 12GB VRAM) >lfm2:24b>lfm2:24b-a2b- Ollama stores model names lowercase —
sam860/lfm2:8bnotsam860/LFM2:8b - Avoid qwen3 on CPU — thinking mode makes even tiny prompts take 60s+
- Avoid
qwen3.5:27bfor tool calling — broken renderer in Ollama (issue #14493)
- Ollama stores model names lowercase —
- Graceful degradation: app works fully without Ollama, agents just return empty results
- Full-viewport modal (
components/mission-control/) for geographic agent deployment - Pipeline: Pin-drop → web search → micro-agent LLM extraction → streaming results
lib/agents/mission.ts— Mission executor with pause/resume/cancel per agentlib/agents/micro-agents.ts— Per-item LLM extraction (~400 char input, 128 token output, 20s timeout)- LLM speed probe (15s) auto-detects GPU vs CPU at mission start
- GPU path: LLM extraction at ~150 tok/s (~0.8s per item, ~20s for 24 items)
- CPU fallback: direct extraction from search data (instant, lower confidence)
lib/agents/geo-context.ts— Web search (DDG) + RSS/USGS fallback for context gathering- DDG rate-limits aggressively — RSS fallback (BBC, Al Jazeera, USGS) kicks in automatically
- Reverse geocodes deployment coords via Nominatim to build location-targeted queries
lib/agents/mission-emitter.ts— EventEmitter singleton (globalThis pattern) bridging executor → SSE- Emits:
log,agent_status,phase,chat_token,chat_actionevent types
- Emits:
app/api/agents/mission/route.ts— REST: deploy, abort, pause, resume, cancel, skip, set_promptapp/api/agents/mission/stream/route.ts— SSE endpoint for live log/status/chat streaminghooks/useMissionControl.ts— Client hook: SSE connection, API actions, agent state management- SSE is a module-level singleton — multiple components call
useMissionControl()but only one EventSource is created (ref-counted). Without this, tokens stream N times (once per hook instance)
- SSE is a module-level singleton — multiple components call
components/layers/DeploymentLayer.tsx— Globe pin + radius circle (CesiumJS entities) + drag/resize- globalThis singleton pattern required for
mission-emitter.ts,mission.ts, andspecialist-chat.ts— without it, Turbopack creates separate module instances and SSE events never reach the client - Zustand
missionControlslice: deploymentMode, deploymentArea, agentStates, missionLogs, missionResults, chatMessages, chatActive, chatGenerating, repositionModeaddMissionLogandaddChatMessagededuplicate by ID (SSE reconnects can replay events)
lib/agents/specialist-chat.ts— Server-side orchestrator with ReAct-lite action parsing- globalThis singleton for conversation history (survives Turbopack HMR)
- System prompt describes 4 available agents + deployment area context + action block syntax
- LLM emits
<<ACTION:dispatch|agent=news-scout>>markers → orchestrator detects, pauses stream, dispatches agent pipeline, injects results, resumes generation for synthesis - Fallback: if model ignores action syntax, keyword-based intent detection auto-dispatches agents
- Context budget: ~3000 tokens (20 message history + system prompt + agent results) fits lfm2:8b's 8192 window
lib/agents/specialist-types.ts—ChatMessage,ChatActiontypesapp/api/agents/mission/chat/route.ts— POST (message or abort), GET (history), DELETE (clear)components/mission-control/ChatPanel.tsx— Chat UI with auto-scroll, SEND/STOP/CLEAR buttonscomponents/mission-control/ChatMessage.tsx— Message renderer withreact-markdownfor HUD-styled markdown- Agent result cards show clickable source URLs, category badges, confidence, coordinates
- Results default to expanded so links are immediately visible
- Toggle: SPECIALIST / LOG VIEW button in
MissionHeader.tsxswitches right panel between chat and log+results - Chat requires Ollama online — shows "SPECIALIST OFFLINE" when unavailable
components/layers/DeploymentLayer.tsx— Full drag/resize system usingScreenSpaceEventHandler- Move: drag center pin (entity tagged with
_deploymentRole = "center-pin") - Resize: drag circle edge (haversine distance ≈ radius, ±15% tolerance, min 20km)
- Scroll wheel: adjust radius while hovering over zone (0.9x/1.1x per tick, clamped 10-5000km)
- Hover feedback: cursor changes (grab/ew-resize) + entity highlighting (brighter green, larger pin)
- Camera controls disabled during drag (
screenSpaceCameraController.enableRotate = falseetc.) - Stage-then-confirm: all adjustments update a local
pendingAreaRef(visual entity updates only). ZustandsetDeploymentArea()only called when user clicks CONFIRM POSITION - Escape cancels active drag or exits reposition mode
- Move: drag center pin (entity tagged with
- REPOSITION button in
MissionHeader.tsx(configuring phase only) → collapses modal to bottom bar MissionControlModal.tsxcollapsed bar: shows coordinates, radius controls, CONFIRM/CANCEL, drag instructions- CONFIRM commits
pendingAreaRefto Zustand and restores full modal
- HTTPS fails from Node.js for
data.gdeltproject.organdapi.gdeltproject.orgwith TLS cert mismatch (hosts redirect to Google Cloud Storage which serves*.storage.googleapis.comcerts) - All GDELT URLs must use HTTP from server-side code —
curlworks with HTTPS but Node.jsfetchdoes not - This affects:
lib/constants.ts(GKG download URLs),app/api/military/route.ts,lib/agents/swarm.ts
lib/constants.ts— All configurable values: polling intervals, cache TTLs, GDELT URLs, entity caps, query defaults, dedup thresholds- When adding new configurable values, add them here instead of using inline magic numbers
npm run dev— starts dev server (check output for actual port, often not 3000)npm run build— production build, use to verify compilationnpm run init-schema— initialize JanusGraph schema (requiresdocker compose up -d)npm run ingest-gkg— daily GKG ingestion (last 24h or since cursor)npm run ingest-gkg-backfill— historical backfill (add-- --from YYYYMMDD --to YYYYMMDD)- If dev server won't start:
rm -rf .nextto clear cache and stale lock files
- Camera data:
lib/api/cameras.ts— aggregates 3,000+ cameras from 10+ cities via live APIs getAllCameras()usesPromise.allSettled— one source failure won't block others- Two reusable generic fetchers:
fetchArcGISCameras()— for ArcGIS FeatureServer endpoints (WSDOT, IDOT/Travel Midwest)fetchIteris511Cameras()— for Iteris 511 DataTables endpoints (NV Roads, FL511)latLng.geographycan be a string OR object{ wellKnownText: "POINT (...)" }— code handles both
- City sources: Caltrans (CA), Austin, Houston TranStar, WSDOT (Seattle), IDOT (Chicago), NV Roads (Las Vegas), FL511 (Orlando), NYC DOT, UK Highways, HK Transport
- Houston TranStar is the only hardcoded fallback (no JSON API, only HTML scraping)
- Images proxied through
/api/cameras/feed?id={id}to avoid CORS — never load external camera URLs directly in<img>tags - Feed proxy passes through actual Content-Type (some sources return PNG, not JPEG)
- Detection overlay (canvas) should only render when the feed image has loaded (
onLoad→imgLoadedstate)
lib/camera-clusters.ts—buildCityClusters()groups by city, merges within 80km (Caltrans granularnearbyPlace→ super-clusters)- Three tiers by altitude in
components/layers/CameraLayer.tsx:- GLOBAL (>2,000km): One cluster dot per city with count label (~25-35 entities)
- REGIONAL (200km–2,000km): Individual camera dots for cities in viewport only
- LOCAL (<200km): Camera dots + feed preview billboards with 60s refresh
- Entities are lazily created/destroyed per viewport — not all 3,000+ at once
- Cluster click →
flyTo(center, 500km)→ seamless tier transition - Shared camera state in Zustand store (
cameras/setCameras) — CameraLayer fetches, CameraList reads
Follow the 6-step pattern (all existing layers follow this):
- Add types to
types/index.ts(LayerKey, LayerState, data interface, optional category type) - Create data hook in
hooks/(fetch + poll + AbortController cleanup) - Update Zustand store (
stores/worldview-store.ts) — data array + setter + optional sub-filters - Create layer component in
components/layers/(CustomDataSource + click handler + entities) - Add to
components/hud/Sidebar.tsx(layer toggle + optional expandable sub-filters) - Add to
components/Globe.tsx(conditional render) +components/hud/DataFeed.tsx(right panel)
Most US state DOTs use one of two platforms. Check which one before writing any code:
1. ArcGIS FeatureServer (used by WSDOT, IDOT, many others)
- Browse
https://services2.arcgis.com/or the state DOT's GIS portal for a traffic cameras layer - Test the query endpoint in a browser — append
?where=1=1&outFields=*&f=json&outSR=4326&returnGeometry=true&resultRecordCount=5 - Identify the field names for camera title and snapshot URL (varies per DOT)
- Add a wrapper that calls
fetchArcGISCameras()with the endpoint URL, a bounding box, and the field names:
async function fetchDenverCameras(): Promise<Camera[]> {
return fetchArcGISCameras({
url: "https://example.arcgis.com/.../FeatureServer/0/query",
bbox: [-105.1, 39.6, -104.8, 39.9], // [minLon, minLat, maxLon, maxLat]
idPrefix: "den",
city: "Denver",
nameField: "CameraName", // inspect the JSON to find these
imageField: "SnapshotURL",
limit: 500,
});
}2. Iteris 511 platform (used by NV Roads, FL511, many state 511 sites)
- If the state's 511 site URL looks like
xx511.comorxxroads.com, it's likely Iteris - Test:
https://{domain}/List/GetData/Cameras?query={"start":0,"length":5,"search":{"value":""}}&lang=en-US - Add a wrapper that calls
fetchIteris511Cameras()with the base URL and a search term (city name, county, or region):
async function fetchPhoenixCameras(): Promise<Camera[]> {
return fetchIteris511Cameras({
baseUrl: "https://az511.com",
searchTerm: "Maricopa", // county name works well for filtering
idPrefix: "phx",
city: "Phoenix",
limit: 200,
});
}3. Custom API (Caltrans, Austin Socrata, etc.)
- Write a dedicated fetcher following the existing patterns in
lib/api/cameras.ts - Must return
Camera[]with valid lat/lon and a direct image URL (JPEG or PNG)
After adding the fetcher:
- Add it to the
Promise.allSettledingetAllCameras()(uses allSettled — one failure won't block others) - Run
npm run build— zero errors - The clustering system handles everything else automatically (no changes to CameraLayer needed)
- Green-on-black HUD/military aesthetic — use
#000a00bg,#00ff41/ green-400/500 text - Custom CSS classes in
globals.css(.panel-section,.panel-label,.scope-*,.hud-glow,.timeline-*,.calendar-*) alongside Tailwind utilities - Font: monospace throughout (
font-mono) - Text sizes: 8-11px for HUD elements, tracking-wide/wider