diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..5cb02570a9bc151a954c48087557a2065edd1899
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+.git
+venv
+.venv
+__pycache__/
+*.pyc
+node_modules/
+web/node_modules/
+web/.vite/
+data/
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..b93357628915feb2cdee18f839985dab84fa25da
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+# Python
+__pycache__/
+*.pyc
+
+# Env
+.env
+venv/
+.venv/
+
+# Data
+data/**
+!data/.gitkeep
+
+# Node
+web/node_modules/
+web/dist/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..78cf8c2a4175595a7e47f3f3f623faa7ca532ad6
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,37 @@
+# ---------- Stage 1: build the React app ----------
+FROM node:20-alpine AS webbuilder
+WORKDIR /web
+COPY web/package*.json ./
+RUN npm ci
+COPY web/ .
+RUN npm run build
+
+# ---------- Stage 2: Python runtime ----------
+FROM python:3.11-slim
+ENV PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ PORT=7860 \
+ DATA_DIR=/data
+WORKDIR /app
+
+# (optional) if you hit build issues with some libs
+RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*
+
+# Copy backend and install deps
+COPY backend ./backend
+# Use a simple requirements file for predictability
+COPY requirements.txt ./
+RUN pip install --upgrade pip && pip install -r requirements.txt
+
+# Copy built frontend into /app/web/dist so FastAPI can serve it
+COPY --from=webbuilder /web/dist ./web/dist
+
+# Prepare data dir for sqlite + uploads
+RUN mkdir -p ${DATA_DIR}/uploads
+VOLUME ["/data"]
+
+# Spaces require a single port—expose default; they’ll pass $PORT
+EXPOSE 7860
+
+# Start FastAPI bound to Spaces' $PORT
+CMD ["bash","-lc","uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT}"]
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/backend/app/agents/classifier.py b/backend/app/agents/classifier.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a5890b2bd277c43813c3733ce2b89a246cac679
--- /dev/null
+++ b/backend/app/agents/classifier.py
@@ -0,0 +1,53 @@
+# same content as your current classifier.py, but model name from settings
+from __future__ import annotations
+from typing import Optional
+from pydantic import BaseModel, Field
+from langchain_openai import ChatOpenAI
+from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate
+from ..config.settings import settings
+
+class ReportClassification(BaseModel):
+ category: str = Field(..., description="taxonomy id like 'crime.gunshot'")
+ label: str = Field(..., description="short human title")
+ description: Optional[str] = Field(None, description="one sentence, no emojis")
+ severity: Optional[str] = None
+ confidence: float = Field(..., ge=0, le=1)
+
+CATEGORY_TO_ICON = {
+ "crime.gunshot": "3d-gun",
+ "crime.robbery": "3d-robbery",
+ "crime.sex_offender": "3d-sex",
+ "crime.suspicious": "3d-alert",
+ "incident.missing_person": "3d-user_search",
+ "incident.lost_item": "3d-search",
+ "incident.medical": "3d-ambulance",
+ "incident.car_accident": "3d-car",
+ "road.flood": "3d-flood",
+ "road.blocked": "3d-traffic",
+ "road.construction": "3d-construction",
+ "help.general": "3d-help",
+ "help.ride": "3d-ride",
+ "other.unknown": "3d-info",
+}
+
+SYSTEM = ("You classify short community reports into a strict taxonomy. "
+ "Return ONLY the schema fields. If unclear, choose other.unknown.")
+
+EXAMPLES = [
+ {"input": "I heard gunshots near 5th and Pine!",
+ "output_json": '{"category":"crime.gunshot","label":"Gunshots reported","description":"Multiple shots heard near 5th and Pine.","severity":"high","confidence":0.9}'},
+ {"input": "Car crash blocking the left lane on I-66",
+ "output_json": '{"category":"incident.car_accident","label":"Car accident","description":"Crash reported blocking the left lane on I-66.","severity":"medium","confidence":0.85}'},
+]
+
+example_block = ChatPromptTemplate.from_messages([("human", "{input}"), ("ai", "{output_json}")])
+prompt = ChatPromptTemplate.from_messages([
+ ("system", SYSTEM),
+ FewShotChatMessagePromptTemplate(example_prompt=example_block, examples=EXAMPLES),
+ ("human", "{text}"),
+])
+
+_model = ChatOpenAI(model=settings.OPENAI_MODEL_CLASSIFIER, temperature=0).with_structured_output(ReportClassification)
+
+def classify_report_text(text: str) -> ReportClassification:
+ return (prompt | _model).invoke({"text": text})
diff --git a/backend/app/agents/graph.py b/backend/app/agents/graph.py
new file mode 100644
index 0000000000000000000000000000000000000000..fce3f44bf3e6cee42037816fbd33a4a5044f7d8e
--- /dev/null
+++ b/backend/app/agents/graph.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+from typing import Annotated, Dict, List, Optional, TypedDict
+from langgraph.graph import StateGraph, START, END
+from langgraph.prebuilt import ToolNode
+from langgraph.graph.message import add_messages
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, BaseMessage, ToolMessage
+from langgraph.checkpoint.sqlite.aio import SqliteSaver
+import sqlite3
+
+from .tools import TOOLS
+from ..config.settings import settings
+
+SYSTEM_PROMPT = """
+You are PulseMap Agent — a calm, friendly assistant inside a live community map.
+You help people add reports and discover what’s happening around them.
+
+### What to do
+- If the user reports an incident (e.g. "flooded underpass here"), call `add_report(lat, lon, text, photo_url?)`.
+- If the user asks about nearby updates (e.g. "what’s near me?", "any reports here?"), call `find_reports_near(lat, lon, radius_km=?, limit=?)`.
+ • Default radius = 25 miles (~40 km). Default limit = 10.
+- If no coordinates in the message but `user_location` is provided, use that.
+- If a photo URL is available, pass it through.
+
+### How to answer
+- Speak like a helpful neighbor, not a robot.
+- Use plain text only. No **bold**, no numbered lists, no markdown tables.
+- After a tool call, start with a quick recap then list items newest first using hyphen bullets.
+ *“I checked within 25 miles of your location and found 3 updates.”*
+For each item, one line like:
+ - 🔫 Gunshot — Severity: High; Confidence: 0.9; Time: 2h ago; Source: User; Photo: yes
+- If nothing found:
+ - “I didn’t find anything within 25 miles in the last 48 hours. Want me to widen the search?”
+
+### Safety
+- Keep a supportive tone. Do not dramatize.
+- End with situational advice when it makes sense (e.g. “Avoid driving through floodwater”).
+- Only mention calling 911 if the report itself clearly describes an urgent danger.
+- Never invent reports — summarize only what tools/feed data provide.
+"""
+
+# Long-lived sessions DB (same filename as before)
+conn = sqlite3.connect(str(settings.SESSIONS_DB), check_same_thread=False)
+
+model = ChatOpenAI(
+ model=settings.OPENAI_MODEL_AGENT,
+ temperature=0.2,
+ openai_api_key=settings.OPENAI_API_KEY,
+ streaming=True,
+).bind_tools(TOOLS)
+
+class AgentState(TypedDict):
+ messages: Annotated[List[BaseMessage], add_messages]
+ user_location: Optional[Dict[str, float]]
+ photo_url: Optional[str]
+
+def model_call(state: AgentState, config=None) -> AgentState:
+ loc = state.get("user_location")
+ loc_hint = f"User location (fallback): lat={loc['lat']}, lon={loc['lon']}" if (loc and 'lat' in loc and 'lon' in loc) else "User location: unknown"
+ photo = state.get("photo_url") or ""
+ photo_hint = f"Photo URL available: {photo}" if photo else "No photo URL in context."
+ system = SystemMessage(content=SYSTEM_PROMPT + "\n" + loc_hint + "\n" + photo_hint + "\nOnly call another tool if the user asks for more.")
+ msgs = [system, *state["messages"]]
+ ai_msg: AIMessage = model.invoke(msgs)
+ return {"messages": [ai_msg]}
+
+def should_continue(state: AgentState) -> str:
+ last = state["messages"][-1]
+ if getattr(last, "tool_calls", None):
+ return "continue"
+ return "end"
+
+graph = StateGraph(AgentState)
+graph.add_node("agent", model_call)
+graph.add_node("tools", ToolNode(tools=TOOLS))
+graph.add_edge(START, "agent")
+graph.add_conditional_edges("agent", should_continue, {"continue": "tools", "end": END})
+graph.add_edge("tools", "agent")
+
+checkpointer = SqliteSaver(conn)
+APP = graph.compile(checkpointer=checkpointer)
diff --git a/backend/app/agents/tools.py b/backend/app/agents/tools.py
new file mode 100644
index 0000000000000000000000000000000000000000..5aca4496b6e6fb573d29e1402cb07b50b81ac097
--- /dev/null
+++ b/backend/app/agents/tools.py
@@ -0,0 +1,40 @@
+import json
+from datetime import datetime, timezone
+from typing import Optional
+from langchain.tools import tool
+from .classifier import classify_report_text, CATEGORY_TO_ICON
+from ..services.reports import add_report, find_reports_near
+
+@tool("add_report")
+def add_report_tool(lat: float, lon: float, text: str = "User report", photo_url: Optional[str] = None) -> str:
+ """
+ Add a user report as a map point (GeoJSON Feature).
+ Returns a JSON string: {"ok": true, "feature": ...}
+ """
+ cls = classify_report_text(text or "User report")
+ icon_name = CATEGORY_TO_ICON.get(cls.category, "3d-info")
+ props = {
+ "title": cls.label,
+ "text": cls.description or (text.strip() if text else "User report"),
+ "category": cls.category,
+ "emoji": icon_name,
+ "severity": cls.severity,
+ "confidence": cls.confidence,
+ "source": "user",
+ "reported_at": datetime.now(timezone.utc).isoformat(),
+ }
+ if photo_url:
+ props["photo_url"] = photo_url
+ feat = add_report(float(lat), float(lon), text or cls.label, props=props)
+ return json.dumps({"ok": True, "feature": feat})
+
+@tool("find_reports_near")
+def find_reports_near_tool(lat: float, lon: float, radius_km: float = 10.0, limit: int = 20) -> str:
+ """
+ Find user reports near a location.
+ Returns a JSON string: {"ok": true, "count": N, "results": [Feature,...]}
+ """
+ res = find_reports_near(float(lat), float(lon), float(radius_km), int(limit))
+ return json.dumps({"ok": True, "count": len(res), "results": res})
+
+TOOLS = [add_report_tool, find_reports_near_tool]
diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..efa498b4afce317a0911dd19632dfe1d669d2480
--- /dev/null
+++ b/backend/app/config/settings.py
@@ -0,0 +1,38 @@
+from pydantic_settings import BaseSettings, SettingsConfigDict
+from pathlib import Path
+from pydantic import Field
+
+class Settings(BaseSettings):
+
+ model_config = SettingsConfigDict(
+ env_file=".env",
+ extra="ignore",
+ case_sensitive=False,
+ populate_by_name=True,
+ )
+
+ FRONTEND_DIST: Path = Path("web") / "dist"
+
+ # Models
+ OPENAI_API_KEY: str | None = None
+ OPENAI_MODEL_AGENT: str = "gpt-4o"
+ OPENAI_MODEL_CLASSIFIER: str = "gpt-4o-mini"
+
+ # Data paths
+ DATA_DIR: Path = Path("data")
+ REPORTS_DB: Path = DATA_DIR / "pulsemaps_reports.db"
+ SESSIONS_DB: Path = DATA_DIR / "pulsemap_sessions.db"
+ UPLOADS_DIR: Path = DATA_DIR / "uploads"
+
+ # Defaults
+ DEFAULT_RADIUS_KM: float = 40.0 # ~25 miles
+ DEFAULT_LIMIT: int = 10
+ MAX_AGE_HOURS: int = 48
+
+ firms_map_key: str | None = Field(default=None, alias="FIRMS_MAP_KEY")
+ gdacs_rss_url: str | None = Field(default="https://www.gdacs.org/xml/rss.xml", alias="GDACS_RSS_URL")
+ nvidia_api_key: str | None = Field(default=None, alias="NVIDIA_API_KEY")
+
+settings = Settings()
+settings.DATA_DIR.mkdir(exist_ok=True)
+settings.UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
\ No newline at end of file
diff --git a/backend/app/data/db.py b/backend/app/data/db.py
new file mode 100644
index 0000000000000000000000000000000000000000..153085bf11e0a0f0c8f1f8b734342e24e51299bb
--- /dev/null
+++ b/backend/app/data/db.py
@@ -0,0 +1,7 @@
+import sqlite3
+from . import store # ensure tables are created on import (store does CREATE TABLE)
+
+def get_reports_conn() -> sqlite3.Connection:
+ # store.py already keeps a module-level connection; this is a placeholder
+ from .store import _CONN as REPORTS_CONN # type: ignore
+ return REPORTS_CONN
diff --git a/backend/app/data/geo.py b/backend/app/data/geo.py
new file mode 100644
index 0000000000000000000000000000000000000000..2c4df61a4b4beb6729c410c3478d838276b1468d
--- /dev/null
+++ b/backend/app/data/geo.py
@@ -0,0 +1,13 @@
+from math import radians, sin, cos, asin, sqrt
+from typing import Tuple
+
+def haversine_km(a: Tuple[float, float], b: Tuple[float, float]) -> float:
+ """Distance in km between (lat,lon) points a, b."""
+ lat1, lon1 = a
+ lat2, lon2 = b
+ R = 6371.0
+ dlat = radians(lat2 - lat1)
+ dlon = radians(lon2 - lon1)
+ lat1r, lat2r = radians(lat1), radians(lat2)
+ h = sin(dlat/2)**2 + cos(lat1r)*cos(lat2r)*sin(dlon/2)**2
+ return 2 * R * asin(sqrt(h))
diff --git a/backend/app/data/store.py b/backend/app/data/store.py
new file mode 100644
index 0000000000000000000000000000000000000000..19cfb558bca59402a7225959a3b384af0a0df694
--- /dev/null
+++ b/backend/app/data/store.py
@@ -0,0 +1,69 @@
+# same content as your current store.py, just moved here
+from __future__ import annotations
+import json, sqlite3
+from datetime import datetime, timezone, timedelta
+from typing import Dict, Any, List, Optional
+from pathlib import Path
+from ..data.geo import haversine_km
+
+Path("data").mkdir(exist_ok=True)
+_CONN = sqlite3.connect("data/pulsemaps_reports.db", check_same_thread=False)
+_CONN.execute("""
+CREATE TABLE IF NOT EXISTS reports (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ lat REAL NOT NULL,
+ lon REAL NOT NULL,
+ text TEXT NOT NULL,
+ props_json TEXT,
+ created_at TEXT NOT NULL
+)
+""")
+_CONN.commit()
+
+def _row_to_feature(row: tuple) -> Dict[str, Any]:
+ _id, lat, lon, text, props_json, created_at = row
+ props = {"type": "user_report", "text": text, "reported_at": created_at}
+ if props_json:
+ try: props.update(json.loads(props_json))
+ except Exception: props["raw_props"] = props_json
+ return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [lon, lat]}, "properties": props}
+
+def add_report(lat: float, lon: float, text: str = "User report", props: dict | None = None):
+ created_at = datetime.now(timezone.utc).isoformat()
+ props_json = json.dumps(props or {})
+ _CONN.execute("INSERT INTO reports (lat, lon, text, props_json, created_at) VALUES (?,?,?,?,?)",
+ (float(lat), float(lon), text, props_json, created_at))
+ _CONN.commit()
+ return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [float(lon), float(lat)]},
+ "properties": {"type": "user_report", "text": text, "reported_at": created_at, **(props or {})}}
+
+def get_feature_collection() -> Dict[str, Any]:
+ cur = _CONN.execute("SELECT id, lat, lon, text, props_json, created_at FROM reports ORDER BY id DESC")
+ feats = [_row_to_feature(r) for r in cur.fetchall()]
+ return {"type": "FeatureCollection", "features": feats}
+
+def find_reports_near(lat: float, lon: float, radius_km: float = 10.0, limit: int = 20, max_age_hours: Optional[int] = None) -> List[Dict[str, Any]]:
+ params: list[Any] = []
+ sql = "SELECT id, lat, lon, text, props_json, created_at FROM reports"
+ if max_age_hours is not None:
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=int(max_age_hours))
+ sql += " WHERE datetime(created_at) >= datetime(?)"
+ params.append(cutoff.isoformat())
+ sql += " ORDER BY id DESC LIMIT 2000"
+ cur = _CONN.execute(sql, params)
+
+ center = (lat, lon)
+ cand = []
+ for r in cur.fetchall():
+ _, lat2, lon2, *_ = r
+ d = haversine_km(center, (lat2, lon2))
+ if d <= radius_km:
+ cand.append((d, r))
+ cand.sort(key=lambda x: x[0])
+ out = [_row_to_feature(r) for _, r in cand[:max(1, limit)]]
+ return out
+
+def clear_reports() -> dict[str, any]:
+ _CONN.execute("DELETE FROM reports")
+ _CONN.commit()
+ return {"ok": True, "message": "All reports cleared."}
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..5d89feddc7ddb7317659e10f8d7b19a24a3ddf72
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,33 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+from pathlib import Path
+
+from .config.settings import settings
+
+app = FastAPI(title="PulseMap Agent – API", version="0.2.0")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
+)
+
+# Static uploads
+app.mount("/uploads", StaticFiles(directory=str(settings.UPLOADS_DIR)), name="uploads")
+
+# Routers
+from .routers import chat, reports, feeds, uploads # noqa
+from .routers.feeds import updates as updates_router
+app.include_router(chat.router)
+app.include_router(reports.router)
+app.include_router(feeds.router)
+app.include_router(updates_router)
+app.include_router(uploads.router)
+
+if settings.FRONTEND_DIST.exists():
+ app.mount("/", StaticFiles(directory=str(settings.FRONTEND_DIST), html=True), name="spa")
+
+@app.get("/health")
+def health():
+ from datetime import datetime, timezone
+ return {"ok": True, "time": datetime.now(timezone.utc).isoformat()}
diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6128571808a9d840f7c111af1a1fab2f0e5102e
--- /dev/null
+++ b/backend/app/routers/chat.py
@@ -0,0 +1,29 @@
+from fastapi import APIRouter, Body
+from typing import Dict, Any, Optional
+
+from ..services.chat_agent import run_chat
+
+router = APIRouter(prefix="/chat", tags=["chat"])
+
+@router.post("")
+def chat(payload: Dict[str, Any] = Body(...)):
+ """
+ Body: { "message": str, "user_location": {lat,lon}?, "session_id"?: str, "photo_url"?: str }
+ """
+ msg = payload.get("message", "")
+ if not isinstance(msg, str) or not msg.strip():
+ return {"reply": "Please type something.", "tool_used": None}
+ return run_chat(
+ message=msg.strip(),
+ user_location=payload.get("user_location"),
+ session_id=payload.get("session_id"),
+ photo_url=payload.get("photo_url"),
+ )
+
+@router.post("/reset")
+def reset_chat(payload: Dict[str, Any] = Body(...)):
+ sid = payload.get("session_id")
+ if not sid:
+ return {"ok": False, "error": "session_id required"}
+ # Same guidance as before—client can rotate session_id for SqliteSaver threads.
+ return {"ok": True}
diff --git a/backend/app/routers/feeds.py b/backend/app/routers/feeds.py
new file mode 100644
index 0000000000000000000000000000000000000000..792e591db3156b7dea3ab04fb97c2a2a3dd17c5e
--- /dev/null
+++ b/backend/app/routers/feeds.py
@@ -0,0 +1,40 @@
+from fastapi import APIRouter, HTTPException
+from typing import Any, Dict, Optional
+from ..services.feeds import (
+ fetch_usgs_quakes_geojson, fetch_nws_alerts_geojson,
+ eonet_geojson_points, firms_geojson_points, # <-- use normalized point outputs
+ local_updates as _local_updates, global_updates as _global_updates
+)
+
+router = APIRouter(prefix="/feeds", tags=["feeds"])
+
+@router.get("/usgs")
+async def usgs():
+ return {"data": await fetch_usgs_quakes_geojson()}
+
+@router.get("/nws")
+async def nws():
+ return {"data": await fetch_nws_alerts_geojson()}
+
+@router.get("/eonet")
+async def eonet():
+ return {"data": await eonet_geojson_points()}
+
+@router.get("/firms")
+async def firms():
+ # Return pointified features for map markers
+ return {"data": await firms_geojson_points()}
+
+# Convenience endpoints parallel to your previous design
+updates = APIRouter(prefix="/updates", tags=["updates"])
+
+@updates.get("/local")
+async def local_updates(lat: float, lon: float, radius_miles: float = 25.0,
+ max_age_hours: int = 48, limit: int = 100):
+ return await _local_updates(lat, lon, radius_miles, max_age_hours, limit)
+
+@updates.get("/global")
+async def global_updates(limit: int = 200, max_age_hours: Optional[int] = None):
+ return await _global_updates(limit, max_age_hours)
+
+router.include_router(updates)
diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py
new file mode 100644
index 0000000000000000000000000000000000000000..40d0ce09105790bfe9b2f22866aa07c8e3b6cd9b
--- /dev/null
+++ b/backend/app/routers/reports.py
@@ -0,0 +1,12 @@
+from fastapi import APIRouter
+from ..data.store import get_feature_collection, clear_reports
+
+router = APIRouter(prefix="/reports", tags=["reports"])
+
+@router.get("")
+def reports():
+ return get_feature_collection()
+
+@router.post("/clear")
+def clear_reports_api():
+ return clear_reports()
diff --git a/backend/app/routers/uploads.py b/backend/app/routers/uploads.py
new file mode 100644
index 0000000000000000000000000000000000000000..d07a6db5a7b02bbf01ed18d5f1dc6ddc294f5085
--- /dev/null
+++ b/backend/app/routers/uploads.py
@@ -0,0 +1,25 @@
+from fastapi import APIRouter, UploadFile, File, Request, HTTPException
+import os
+from uuid import uuid4
+from ..config.settings import settings
+
+router = APIRouter(prefix="/upload", tags=["uploads"])
+
+@router.post("/photo")
+async def upload_photo(request: Request, file: UploadFile = File(...)):
+ if not file.content_type or not file.content_type.startswith("image/"):
+ raise HTTPException(status_code=400, detail="Only image files are allowed.")
+ ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg"
+ if ext not in [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"]:
+ ext = ".jpg"
+
+ data = await file.read()
+ if len(data) > 5 * 1024 * 1024:
+ raise HTTPException(status_code=413, detail="Image too large (max 5MB).")
+
+ name = f"{uuid4().hex}{ext}"
+ (settings.UPLOADS_DIR / name).write_bytes(data)
+
+ base = str(request.base_url).rstrip("/")
+ url = f"{base}/uploads/{name}"
+ return {"ok": True, "url": url, "path": f"/uploads/{name}"}
diff --git a/backend/app/services/chat_agent.py b/backend/app/services/chat_agent.py
new file mode 100644
index 0000000000000000000000000000000000000000..39df7111208cb0d1b46d642da94ec2378e58b253
--- /dev/null
+++ b/backend/app/services/chat_agent.py
@@ -0,0 +1,26 @@
+from typing import Dict, Any, Optional
+from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
+from ..agents.graph import APP
+
+def run_chat(message: str,
+ user_location: Optional[Dict[str, float]] = None,
+ session_id: Optional[str] = None,
+ photo_url: Optional[str] = None) -> Dict[str, Any]:
+ from uuid import uuid4
+ sid = session_id or str(uuid4())
+ init = {"messages": [HumanMessage(content=message)], "user_location": user_location, "photo_url": photo_url}
+ cfg = {"configurable": {"thread_id": sid}}
+ final = APP.invoke(init, config=cfg)
+
+ reply, tool_used, tool_result = "", None, None
+ for m in final["messages"]:
+ if isinstance(m, AIMessage):
+ reply = m.content or reply
+ elif isinstance(m, ToolMessage) and getattr(m, "name", None) in {"add_report", "find_reports_near"}:
+ import json
+ try:
+ tool_used = m.name
+ tool_result = json.loads(m.content) if isinstance(m.content, str) else m.content
+ except Exception:
+ tool_result = {"raw": m.content}
+ return {"reply": reply, "tool_used": tool_used, "tool_result": tool_result, "session_id": sid}
diff --git a/backend/app/services/feeds.py b/backend/app/services/feeds.py
new file mode 100644
index 0000000000000000000000000000000000000000..acdf4ed56ab8cc04b3bc0a93acea24472b2d19d5
--- /dev/null
+++ b/backend/app/services/feeds.py
@@ -0,0 +1,241 @@
+import asyncio
+from datetime import datetime, timezone
+from typing import Any, Dict, Optional, List, Iterable, Tuple
+from dateutil import parser as dtparser
+
+from ..data.geo import haversine_km
+from .fetchers import (
+ fetch_usgs_quakes_geojson, fetch_nws_alerts_geojson,
+ fetch_eonet_events_geojson, fetch_firms_hotspots_geojson
+)
+
+def _flatten_lonlats(coords: Any) -> List[Tuple[float, float]]:
+ """Collect (lon, lat) pairs from nested coordinate arrays."""
+ out: List[Tuple[float, float]] = []
+ if not isinstance(coords, (list, tuple)):
+ return out
+ if len(coords) >= 2 and isinstance(coords[0], (int, float)) and isinstance(coords[1], (int, float)):
+ # Single coordinate pair [lon, lat, ...]
+ out.append((float(coords[0]), float(coords[1])))
+ else:
+ for c in coords:
+ out.extend(_flatten_lonlats(c))
+ return out
+
+def _centroid_from_geom(geom: Dict[str, Any]) -> Optional[Tuple[float, float]]:
+ """Return (lon, lat) for any geometry by taking a simple average of all coords."""
+ if not geom or "type" not in geom:
+ return None
+ gtype = geom.get("type")
+ coords = geom.get("coordinates")
+
+ # Fast path for Point
+ if gtype == "Point" and isinstance(coords, (list, tuple)) and len(coords) >= 2:
+ return (float(coords[0]), float(coords[1]))
+
+ # Generic centroid for Polygon/MultiPolygon/LineString/etc.
+ pts = _flatten_lonlats(coords)
+ if not pts:
+ return None
+ xs = [p[0] for p in pts]
+ ys = [p[1] for p in pts]
+ return (sum(xs) / len(xs), sum(ys) / len(ys))
+
+def _mk_point_feature(lon: float, lat: float, props: Dict[str, Any]) -> Dict[str, Any]:
+ return {
+ "type": "Feature",
+ "geometry": {"type": "Point", "coordinates": [float(lon), float(lat)]},
+ "properties": props or {},
+ }
+
+def _report_to_update(f: Dict[str, Any]) -> Dict[str, Any]:
+ p = f.get("properties", {}) or {}
+ lat = f["geometry"]["coordinates"][1]
+ lon = f["geometry"]["coordinates"][0]
+ return {
+ "kind": "report",
+ "title": p.get("title") or p.get("text") or "User report",
+ "emoji": p.get("emoji") or "📝",
+ "time": p.get("reported_at"),
+ "lat": float(lat), "lon": float(lon),
+ "severity": p.get("severity"),
+ "sourceUrl": None,
+ "raw": p,
+ }
+
+def _quake_to_update(f: Dict[str, Any]) -> Dict[str, Any] | None:
+ p = f.get("properties", {}) or {}
+ g = f.get("geometry", {}) or {}
+ if g.get("type") != "Point": return None
+ lon, lat = g["coordinates"][:2]
+ title = p.get("place") or p.get("title") or "Earthquake"
+ mag = p.get("mag") or p.get("Magnitude") or p.get("m")
+ ts = p.get("time")
+ if isinstance(ts, (int, float)):
+ time_iso = datetime.fromtimestamp(ts/1000, tz=timezone.utc).isoformat()
+ else:
+ time_iso = p.get("updated") if isinstance(p.get("updated"), str) else datetime.now(timezone.utc).isoformat()
+ return {"kind": "quake", "title": title, "emoji": "💥", "time": time_iso,
+ "lat": float(lat), "lon": float(lon), "severity": f"M{mag}" if mag is not None else None,
+ "sourceUrl": p.get("url") or p.get("detail"), "raw": p}
+
+def _eonet_to_update(f: Dict[str, Any]) -> Dict[str, Any] | None:
+ p = f.get("properties", {}) or {}
+ g = f.get("geometry", {}) or {}
+ if g.get("type") != "Point": return None
+ lon, lat = g["coordinates"][:2]
+ title = p.get("title") or p.get("category") or "Event"
+ cat = (p.get("category") or (p.get("categories") or [{}])[0].get("title") or "").lower()
+ if "wildfire" in cat: emoji = "🔥"
+ elif "volcano" in cat: emoji = "🌋"
+ elif "earthquake" in cat or "seismic" in cat: emoji = "💥"
+ elif any(k in cat for k in ["storm","cyclone","hurricane","typhoon"]): emoji = "🌀"
+ elif "flood" in cat: emoji = "🌊"
+ elif "landslide" in cat: emoji = "🏔️"
+ elif any(k in cat for k in ["ice","snow","blizzard"]): emoji = "❄️"
+ elif any(k in cat for k in ["dust","smoke","haze"]): emoji = "🌫️"
+ else: emoji = "⚠️"
+ time_iso = p.get("time") or p.get("updated") or datetime.now(timezone.utc).isoformat()
+ return {"kind": "eonet", "title": title, "emoji": emoji, "time": time_iso,
+ "lat": float(lat), "lon": float(lon), "sourceUrl": p.get("link") or p.get("url"), "raw": p}
+
+def _firms_to_update(f: Dict[str, Any]) -> Dict[str, Any] | None:
+ p = f.get("properties", {}) or {}
+ g = f.get("geometry", {}) or {}
+ if g.get("type") != "Point": return None
+ lon, lat = g["coordinates"][:2]
+ time_iso = p.get("acq_datetime") or p.get("acq_date") or datetime.now(timezone.utc).isoformat()
+ sev = p.get("confidence") or p.get("brightness") or p.get("frp")
+ return {"kind": "fire", "title": "Fire hotspot", "emoji": "🔥", "time": time_iso,
+ "lat": float(lat), "lon": float(lon), "severity": sev, "sourceUrl": None, "raw": p}
+
+def _within(lat: float, lon: float, u: Dict[str, Any], radius_km: float) -> bool:
+ return haversine_km((lat, lon), (u["lat"], u["lon"])) <= radius_km
+
+def _is_recent(iso: str | None, max_age_hours: int) -> bool:
+ if not iso: return False
+ try:
+ t = dtparser.isoparse(iso)
+ if not t.tzinfo: t = t.replace(tzinfo=timezone.utc)
+ except Exception:
+ return False
+ return (datetime.now(timezone.utc) - t).total_seconds() <= max_age_hours * 3600
+
+async def _gather_feeds():
+ results = await asyncio.gather(
+ fetch_usgs_quakes_geojson(), fetch_nws_alerts_geojson(),
+ fetch_eonet_events_geojson(), fetch_firms_hotspots_geojson(),
+ return_exceptions=True
+ )
+ def ok(x): return {"features": []} if isinstance(x, Exception) or not x else x
+ return {"usgs": ok(results[0]), "nws": ok(results[1]), "eonet": ok(results[2]), "firms": ok(results[3])}
+
+async def local_updates(lat: float, lon: float, radius_miles: float, max_age_hours: int, limit: int):
+ from ..data.store import find_reports_near
+ km = float(radius_miles) * 1.609344
+ near_reports = find_reports_near(lat, lon, radius_km=km, limit=limit, max_age_hours=max_age_hours)
+ updates: List[Dict[str, Any]] = [_report_to_update(f) for f in near_reports]
+ feeds = await _gather_feeds()
+
+ for f in (feeds["usgs"].get("features") or []):
+ u = _quake_to_update(f)
+ if u and _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km):
+ updates.append(u)
+ for u in _nws_to_updates(feeds["nws"]):
+ if _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km):
+ updates.append(u)
+ for f in (feeds["eonet"].get("features") or []):
+ u = _eonet_to_update(f)
+ if u and _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km):
+ updates.append(u)
+ for f in (feeds["firms"].get("features") or []):
+ u = _firms_to_update(f)
+ if u and _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km):
+ updates.append(u)
+
+ updates.sort(key=lambda x: x["time"] or "", reverse=True)
+ return {"count": min(len(updates), limit), "updates": updates[:limit]}
+
+def _nws_to_updates(fc: Dict[str, Any]) -> list[Dict[str, Any]]:
+ out: list[Dict[str, Any]] = []
+ for f in (fc.get("features") or []):
+ p = f.get("properties", {}) or {}
+ g = f.get("geometry", {}) or {}
+ coords = None
+ if g.get("type") == "Polygon":
+ poly = g["coordinates"][0]
+ if poly:
+ lats = [c[1] for c in poly]; lons = [c[0] for c in poly]
+ coords = (sum(lats)/len(lats), sum(lons)/len(lons))
+ elif g.get("type") == "Point":
+ coords = (g["coordinates"][1], g["coordinates"][0])
+ if not coords:
+ continue
+ sev = p.get("severity") or "Unknown"
+ issued = p.get("effective") or p.get("onset") or p.get("sent") or datetime.now(timezone.utc).isoformat()
+ out.append({"kind": "nws", "title": p.get("event") or "NWS Alert", "emoji": "⚠️",
+ "time": issued, "lat": float(coords[0]), "lon": float(coords[1]),
+ "severity": sev, "sourceUrl": p.get("@id") or p.get("id"), "raw": p})
+ return out
+
+async def global_updates(limit: int, max_age_hours: Optional[int]):
+ from ..data.store import get_feature_collection
+ fc = get_feature_collection()
+ reports = fc.get("features") or []
+ rep_updates = [_report_to_update(f) for f in reports]
+ feeds = await _gather_feeds()
+ nws_updates = _nws_to_updates(feeds["nws"])
+ quake_updates = [_ for f in (feeds["usgs"].get("features") or []) if (_ := _quake_to_update(f))]
+ eonet_updates = [_ for f in (feeds["eonet"].get("features") or []) if (_ := _eonet_to_update(f))]
+ firms_updates = [_ for f in (feeds["firms"].get("features") or []) if (_ := _firms_to_update(f))]
+
+ updates = rep_updates + nws_updates + quake_updates + eonet_updates + firms_updates
+ if max_age_hours is not None:
+ updates = [u for u in updates if _is_recent(u["time"], max_age_hours)]
+ updates.sort(key=lambda x: x["time"] or "", reverse=True)
+ return {"count": min(len(updates), limit), "updates": updates[:limit]}
+
+async def eonet_geojson_points() -> Dict[str, Any]:
+ """Always return Point features for EONET (polygon events -> centroid)."""
+ fc = await fetch_eonet_events_geojson() or {}
+ features = []
+ for f in (fc.get("features") or []):
+ g = f.get("geometry") or {}
+ p = f.get("properties") or {}
+ cen = _centroid_from_geom(g)
+ if not cen:
+ continue
+ lon, lat = cen
+ # Keep a stable, small prop set the map can style
+ props = {
+ "source": "eonet",
+ "title": p.get("title") or p.get("category") or "Event",
+ "emoji": "⚠️", # the map can replace based on category if it wants
+ "raw": p,
+ }
+ features.append(_mk_point_feature(lon, lat, props))
+ return {"type": "FeatureCollection", "features": features}
+
+async def firms_geojson_points() -> Dict[str, Any]:
+ """Always return Point features for FIRMS (skip invalid rows)."""
+ fc = await fetch_firms_hotspots_geojson() or {}
+ features = []
+ for f in (fc.get("features") or []):
+ g = f.get("geometry") or {}
+ p = f.get("properties") or {}
+ cen = _centroid_from_geom(g)
+ if not cen:
+ # Some rows can be malformed; skip them
+ continue
+ lon, lat = cen
+ props = {
+ "source": "firms",
+ "title": "Fire hotspot",
+ "emoji": "🔥",
+ "confidence": p.get("confidence"),
+ "brightness": p.get("brightness"),
+ "time": p.get("acq_datetime") or p.get("acq_date"),
+ "raw": p,
+ }
+ features.append(_mk_point_feature(lon, lat, props))
+ return {"type": "FeatureCollection", "features": features}
diff --git a/backend/app/services/fetchers.py b/backend/app/services/fetchers.py
new file mode 100644
index 0000000000000000000000000000000000000000..c70d14d5cd2f6fcc1aefda9cf276df2d1be6dd14
--- /dev/null
+++ b/backend/app/services/fetchers.py
@@ -0,0 +1,154 @@
+import httpx
+import os, io, csv
+import asyncio
+import random
+import httpx
+
+
+# Keep URLs simple & stable; you can lift to config/env later.
+USGS_ALL_HOUR = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson"
+NWS_ALERTS_ACTIVE = "https://api.weather.gov/alerts/active"
+EONET_EVENTS_GEOJSON = "https://eonet.gsfc.nasa.gov/api/v3/events/geojson?status=open&days=7"
+DATASETS = ["VIIRS_NOAA20_NRT", "VIIRS_SNPP_NRT"]
+
+
+import httpx
+
+def _in_usa(lat: float, lon: float) -> bool:
+ # CONUS
+ if 24.5 <= lat <= 49.5 and -125.0 <= lon <= -66.0:
+ return True
+ # Alaska (rough)
+ if 51.0 <= lat <= 71.0 and -170.0 <= lon <= -129.0:
+ return True
+ # Hawaii
+ if 18.5 <= lat <= 22.5 and -161.0 <= lon <= -154.0:
+ return True
+ return False
+
+async def fetch_json_once(
+ url: str,
+ headers: dict,
+ *,
+ connect_timeout: float = 3,
+ read_timeout: float = 12,
+):
+ """
+ Single attempt fetch; no retries, no delay.
+ """
+ timeout = httpx.Timeout(
+ connect=connect_timeout,
+ read=read_timeout,
+ write=read_timeout,
+ pool=connect_timeout,
+ )
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
+ r = await client.get(url, headers=headers)
+ r.raise_for_status()
+ return r.json()
+
+async def fetch_usgs_quakes_geojson():
+ async with httpx.AsyncClient(timeout=10) as client:
+ r = await client.get(USGS_ALL_HOUR, headers={"Accept":"application/geo+json"})
+ r.raise_for_status()
+ return r.json()
+
+async def fetch_nws_alerts_geojson():
+ async with httpx.AsyncClient(timeout=10) as client:
+ r = await client.get(NWS_ALERTS_ACTIVE, headers={"Accept":"application/geo+json"})
+ r.raise_for_status()
+ return r.json()
+
+async def fetch_eonet_events_geojson():
+ return await fetch_json_once(
+ EONET_EVENTS_GEOJSON,
+ headers={"Accept": "application/geo+json"},
+ connect_timeout=3,
+ read_timeout=12,
+ )
+
+def _get_num(d: dict, *keys):
+ for k in keys:
+ if k in d and d[k] not in (None, ""):
+ try:
+ return float(d[k])
+ except Exception:
+ pass
+ raise KeyError("no numeric value")
+
+async def _fetch_firms_csv_rows(key: str, dataset: str, hours: int = 1) -> list[dict]:
+ url = f"https://firms.modaps.eosdis.nasa.gov/api/area/csv/{key}/{dataset}/world/{hours}"
+ async with httpx.AsyncClient(timeout=20) as client:
+ r = await client.get(url, headers={"Accept": "text/csv", "User-Agent": "PulseMap/1.0"})
+ text = r.text or ""
+
+ # Some FIRMS edges return text/plain or octet-stream; parse anyway
+ # Strip BOM if present
+ if text and text[:1] == "\ufeff":
+ text = text[1:]
+
+ # Try CSV parse
+ try:
+ reader = csv.DictReader(io.StringIO(text))
+ rows = [row for row in reader]
+ except Exception:
+ rows = []
+
+ # If we got nothing, surface first 200 chars to the caller for logging
+ if not rows:
+ return [{"__error__": (text[:200] if text else "empty response")}]
+
+ return rows
+
+async def fetch_firms_hotspots_geojson():
+ """
+ NASA FIRMS: returns GeoJSON FeatureCollection (Points).
+ Requires env FIRMS_MAP_KEY. Tries NOAA-20 first, then SNPP. World, last 24h (1 day segment).
+ """
+ key = "95fa2dac8d20024aa6a17229dbf5ce74"
+ if not key:
+ return {"type": "FeatureCollection", "features": [], "_note": "Set FIRMS_MAP_KEY to enable."}
+
+ errors = []
+ for dataset in DATASETS:
+ rows = await _fetch_firms_csv_rows(key, dataset, hours=1)
+ if rows and "__error__" in rows[0]:
+ errors.append(f"{dataset}: {rows[0]['__error__']}")
+ continue
+
+ feats = []
+ for i, row in enumerate(rows):
+ if i >= 1500:
+ break
+ try:
+ lat = _get_num(row, "latitude", "LATITUDE", "lat", "LAT")
+ lon = _get_num(row, "longitude", "LONGITUDE", "lon", "LON")
+ except Exception:
+ continue
+
+ props = {
+ "source": "FIRMS",
+ "dataset": dataset,
+ "acq_date": row.get("acq_date") or row.get("ACQ_DATE"),
+ "acq_time": row.get("acq_time") or row.get("ACQ_TIME"),
+ "instrument": row.get("instrument") or row.get("INSTRUMENT"),
+ "confidence": row.get("confidence") or row.get("CONFIDENCE"),
+ "frp": row.get("frp") or row.get("FRP"),
+ "daynight": row.get("daynight") or row.get("DAYNIGHT"),
+ }
+ feats.append({
+ "type": "Feature",
+ "geometry": {"type": "Point", "coordinates": [lon, lat]},
+ "properties": props,
+ })
+
+ feats = [f for f in feats
+ if _in_usa(f["geometry"]["coordinates"][1], f["geometry"]["coordinates"][0])]
+ if feats:
+ return {"type": "FeatureCollection", "features": feats, "_note": f"{dataset} ok, {len(feats)} points (USA only)"}
+
+ # Try next dataset if this one returned 0 points
+ errors.append(f"{dataset}: 0 rows or no valid coordinates")
+
+ # If we got here, nothing worked
+ return {"type": "FeatureCollection", "features": [], "_note": f"FIRMS empty. Details: {' | '.join(errors[:2])}"}
\ No newline at end of file
diff --git a/backend/app/services/reports.py b/backend/app/services/reports.py
new file mode 100644
index 0000000000000000000000000000000000000000..ae8e42d4a5f235aa559a1f38f908943346c26a57
--- /dev/null
+++ b/backend/app/services/reports.py
@@ -0,0 +1,9 @@
+from typing import Dict, Any, List, Optional
+from ..data.store import add_report as _add, find_reports_near as _find
+
+def add_report(lat: float, lon: float, text: str, props: dict | None = None) -> Dict[str, Any]:
+ return _add(lat, lon, text, props)
+
+def find_reports_near(lat: float, lon: float, radius_km: float, limit: int,
+ max_age_hours: Optional[int] = None) -> List[Dict[str, Any]]:
+ return _find(lat, lon, radius_km, limit, max_age_hours=max_age_hours)
diff --git a/backend/app/types/models.py b/backend/app/types/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..517e8a3a3a5d4ae3331df3e88b585e41369eccc7
--- /dev/null
+++ b/backend/app/types/models.py
@@ -0,0 +1,27 @@
+from pydantic import BaseModel, Field
+from typing import Optional, List, Any
+
+class UserLocation(BaseModel):
+ lat: float
+ lon: float
+
+class ChatRequest(BaseModel):
+ message: str
+ user_location: Optional[UserLocation] = None
+ session_id: Optional[str] = None
+ photo_url: Optional[str] = None
+
+class Update(BaseModel):
+ kind: str
+ title: str
+ emoji: str
+ time: Optional[str]
+ lat: float
+ lon: float
+ severity: Optional[str] = None
+ sourceUrl: Optional[str] = None
+ raw: Any
+
+class UpdatesResponse(BaseModel):
+ count: int
+ updates: List[Update]
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..335430d8a29d2cb52cbde7523362312ba4339ad9
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,19 @@
+[project]
+name = "pulsemaps-backend"
+version = "0.2.0"
+requires-python = ">=3.10"
+dependencies = [
+ "fastapi",
+ "uvicorn[standard]",
+ "pydantic",
+ "pydantic-settings",
+ "python-dateutil",
+ "httpx",
+ "langchain",
+ "langchain-openai",
+ "langgraph",
+]
+
+[tool.ruff]
+line-length = 100
+select = ["E","F","I","UP"]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e5a4cc00368bb031ac719be28d543f8b75d2974f
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,14 @@
+fastapi==0.115.4
+uvicorn[standard]==0.31.0
+pydantic==2.9.2
+pydantic-settings==2.5.2
+python-multipart==0.0.9
+python-dateutil==2.9.0.post0
+httpx==0.27.2
+
+# LangChain stack
+langchain==0.2.16
+langchain-openai==0.1.26
+openai==1.40.6
+langgraph==0.2.34
+aiosqlite==0.20.0
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..7959ce4269342e8ede1d06bded69ec800d8503d9
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,69 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+
+```js
+export default tseslint.config([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+
+ // Remove tseslint.configs.recommended and replace with this
+ ...tseslint.configs.recommendedTypeChecked,
+ // Alternatively, use this for stricter rules
+ ...tseslint.configs.strictTypeChecked,
+ // Optionally, add this for stylistic rules
+ ...tseslint.configs.stylisticTypeChecked,
+
+ // Other configs...
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
+
+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+
+```js
+// eslint.config.js
+import reactX from 'eslint-plugin-react-x'
+import reactDom from 'eslint-plugin-react-dom'
+
+export default tseslint.config([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+ // Enable lint rules for React
+ reactX.configs['recommended-typescript'],
+ // Enable lint rules for React DOM
+ reactDom.configs.recommended,
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
diff --git a/web/eslint.config.js b/web/eslint.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..d94e7deb72c90f03254225baf04f31b621b4877c
--- /dev/null
+++ b/web/eslint.config.js
@@ -0,0 +1,23 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { globalIgnores } from 'eslint/config'
+
+export default tseslint.config([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs['recommended-latest'],
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ },
+])
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..33d519fc5e11b18253f2af486e43415174cbce02
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ PulseMap Agent
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000000000000000000000000000000000000..92515c5ad3f298a54bd7789ad52c202acdb6a517
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1731 @@
+{
+ "name": "cta-web",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "cta-web",
+ "version": "0.0.1",
+ "dependencies": {
+ "@vis.gl/react-google-maps": "^1.5.5",
+ "leaflet": "^1.9.4",
+ "lucide-react": "^0.542.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-leaflet": "^4.2.1"
+ },
+ "devDependencies": {
+ "@types/leaflet": "^1.9.20",
+ "@types/react": "^18.3.24",
+ "@types/react-dom": "^18.3.7",
+ "@vitejs/plugin-react": "^5.0.1",
+ "typescript": "^5.9.2",
+ "vite": "^5.4.19"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
+ "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
+ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.2.0",
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.3",
+ "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-module-transforms": "^7.28.3",
+ "@babel/helpers": "^7.28.3",
+ "@babel/parser": "^7.28.3",
+ "@babel/template": "^7.27.2",
+ "@babel/traverse": "^7.28.3",
+ "@babel/types": "^7.28.2",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
+ "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.3",
+ "@babel/types": "^7.28.2",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
+ "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.27.2",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
+ "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/traverse": "^7.28.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
+ "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz",
+ "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
+ "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+ "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/parser": "^7.27.2",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
+ "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.3",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.3",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.2",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.28.2",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
+ "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.30",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
+ "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@react-leaflet/core": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
+ "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
+ "license": "Hippocratic-2.1",
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.32",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz",
+ "integrity": "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.48.0.tgz",
+ "integrity": "sha512-aVzKH922ogVAWkKiyKXorjYymz2084zrhrZRXtLrA5eEx5SO8Dj0c/4FpCHZyn7MKzhW2pW4tK28vVr+5oQ2xw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.48.0.tgz",
+ "integrity": "sha512-diOdQuw43xTa1RddAFbhIA8toirSzFMcnIg8kvlzRbK26xqEnKJ/vqQnghTAajy2Dcy42v+GMPMo6jq67od+Dw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.48.0.tgz",
+ "integrity": "sha512-QhR2KA18fPlJWFefySJPDYZELaVqIUVnYgAOdtJ+B/uH96CFg2l1TQpX19XpUMWUqMyIiyY45wje8K6F4w4/CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.48.0.tgz",
+ "integrity": "sha512-Q9RMXnQVJ5S1SYpNSTwXDpoQLgJ/fbInWOyjbCnnqTElEyeNvLAB3QvG5xmMQMhFN74bB5ZZJYkKaFPcOG8sGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.48.0.tgz",
+ "integrity": "sha512-3jzOhHWM8O8PSfyft+ghXZfBkZawQA0PUGtadKYxFqpcYlOYjTi06WsnYBsbMHLawr+4uWirLlbhcYLHDXR16w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.48.0.tgz",
+ "integrity": "sha512-NcD5uVUmE73C/TPJqf78hInZmiSBsDpz3iD5MF/BuB+qzm4ooF2S1HfeTChj5K4AV3y19FFPgxonsxiEpy8v/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.48.0.tgz",
+ "integrity": "sha512-JWnrj8qZgLWRNHr7NbpdnrQ8kcg09EBBq8jVOjmtlB3c8C6IrynAJSMhMVGME4YfTJzIkJqvSUSVJRqkDnu/aA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.48.0.tgz",
+ "integrity": "sha512-9xu92F0TxuMH0tD6tG3+GtngwdgSf8Bnz+YcsPG91/r5Vgh5LNofO48jV55priA95p3c92FLmPM7CvsVlnSbGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.48.0.tgz",
+ "integrity": "sha512-NLtvJB5YpWn7jlp1rJiY0s+G1Z1IVmkDuiywiqUhh96MIraC0n7XQc2SZ1CZz14shqkM+XN2UrfIo7JB6UufOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.48.0.tgz",
+ "integrity": "sha512-QJ4hCOnz2SXgCh+HmpvZkM+0NSGcZACyYS8DGbWn2PbmA0e5xUk4bIP8eqJyNXLtyB4gZ3/XyvKtQ1IFH671vQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.48.0.tgz",
+ "integrity": "sha512-Pk0qlGJnhILdIC5zSKQnprFjrGmjfDM7TPZ0FKJxRkoo+kgMRAg4ps1VlTZf8u2vohSicLg7NP+cA5qE96PaFg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.48.0.tgz",
+ "integrity": "sha512-/dNFc6rTpoOzgp5GKoYjT6uLo8okR/Chi2ECOmCZiS4oqh3mc95pThWma7Bgyk6/WTEvjDINpiBCuecPLOgBLQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.48.0.tgz",
+ "integrity": "sha512-YBwXsvsFI8CVA4ej+bJF2d9uAeIiSkqKSPQNn0Wyh4eMDY4wxuSp71BauPjQNCKK2tD2/ksJ7uhJ8X/PVY9bHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.48.0.tgz",
+ "integrity": "sha512-FI3Rr2aGAtl1aHzbkBIamsQyuauYtTF9SDUJ8n2wMXuuxwchC3QkumZa1TEXYIv/1AUp1a25Kwy6ONArvnyeVQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.48.0.tgz",
+ "integrity": "sha512-Dx7qH0/rvNNFmCcIRe1pyQ9/H0XO4v/f0SDoafwRYwc2J7bJZ5N4CHL/cdjamISZ5Cgnon6iazAVRFlxSoHQnQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.48.0.tgz",
+ "integrity": "sha512-GUdZKTeKBq9WmEBzvFYuC88yk26vT66lQV8D5+9TgkfbewhLaTHRNATyzpQwwbHIfJvDJ3N9WJ90wK/uR3cy3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.48.0.tgz",
+ "integrity": "sha512-ao58Adz/v14MWpQgYAb4a4h3fdw73DrDGtaiF7Opds5wNyEQwtO6M9dBh89nke0yoZzzaegq6J/EXs7eBebG8A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.48.0.tgz",
+ "integrity": "sha512-kpFno46bHtjZVdRIOxqaGeiABiToo2J+st7Yce+aiAoo1H0xPi2keyQIP04n2JjDVuxBN6bSz9R6RdTK5hIppw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.48.0.tgz",
+ "integrity": "sha512-rFYrk4lLk9YUTIeihnQMiwMr6gDhGGSbWThPEDfBoU/HdAtOzPXeexKi7yU8jO+LWRKnmqPN9NviHQf6GDwBcQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.48.0.tgz",
+ "integrity": "sha512-sq0hHLTgdtwOPDB5SJOuaoHyiP1qSwg+71TQWk8iDS04bW1wIE0oQ6otPiRj2ZvLYNASLMaTp8QRGUVZ+5OL5A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/google.maps": {
+ "version": "3.58.1",
+ "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
+ "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/leaflet": {
+ "version": "1.9.20",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz",
+ "integrity": "sha512-rooalPMlk61LCaLOvBF2VIf9M47HgMQqi5xQ9QRi7c8PkdIe0WrIi5IxXUXQjAdL0c+vcQ01mYWbthzmp9GHWw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.24",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
+ "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@vis.gl/react-google-maps": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.5.5.tgz",
+ "integrity": "sha512-LgHtK1AtE2/BN4dPoK05oWu0jWmeDdyX0Ffqi+mZc+M4apaHn2sUxxKXAxhPF90O9vcsiou/ntm6/XBWX+gpqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/google.maps": "^3.54.10",
+ "fast-deep-equal": "^3.1.3"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.1.tgz",
+ "integrity": "sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.3",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.32",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.25.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz",
+ "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "caniuse-lite": "^1.0.30001735",
+ "electron-to-chromium": "^1.5.204",
+ "node-releases": "^2.0.19",
+ "update-browserslist-db": "^1.1.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001737",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz",
+ "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.208",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.208.tgz",
+ "integrity": "sha512-ozZyibehoe7tOhNaf16lKmljVf+3npZcJIEbJRVftVsmAg5TeA1mGS9dVCZzOwr2xT7xK15V0p7+GZqSPgkuPg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.542.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz",
+ "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.19",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
+ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-leaflet": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
+ "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
+ "license": "Hippocratic-2.1",
+ "dependencies": {
+ "@react-leaflet/core": "^2.1.0"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.48.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.0.tgz",
+ "integrity": "sha512-BXHRqK1vyt9XVSEHZ9y7xdYtuYbwVod2mLwOMFP7t/Eqoc1pHRlG/WdV2qNeNvZHRQdLedaFycljaYYM96RqJQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.48.0",
+ "@rollup/rollup-android-arm64": "4.48.0",
+ "@rollup/rollup-darwin-arm64": "4.48.0",
+ "@rollup/rollup-darwin-x64": "4.48.0",
+ "@rollup/rollup-freebsd-arm64": "4.48.0",
+ "@rollup/rollup-freebsd-x64": "4.48.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.48.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.48.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.48.0",
+ "@rollup/rollup-linux-arm64-musl": "4.48.0",
+ "@rollup/rollup-linux-loongarch64-gnu": "4.48.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.48.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.48.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.48.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.48.0",
+ "@rollup/rollup-linux-x64-gnu": "4.48.0",
+ "@rollup/rollup-linux-x64-musl": "4.48.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.48.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.48.0",
+ "@rollup/rollup-win32-x64-msvc": "4.48.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+ "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.19",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
+ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..65b696e11e067a8dd604f41b8328a69f4fc465cb
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "cta-web",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview --port 5174"
+ },
+ "dependencies": {
+ "@vis.gl/react-google-maps": "^1.5.5",
+ "leaflet": "^1.9.4",
+ "lucide-react": "^0.542.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-leaflet": "^4.2.1"
+ },
+ "devDependencies": {
+ "@types/leaflet": "^1.9.20",
+ "@types/react": "^18.3.24",
+ "@types/react-dom": "^18.3.7",
+ "@vitejs/plugin-react": "^5.0.1",
+ "typescript": "^5.9.2",
+ "vite": "^5.4.19"
+ }
+}
diff --git a/web/public/icons/3d/3d-alert.png b/web/public/icons/3d/3d-alert.png
new file mode 100644
index 0000000000000000000000000000000000000000..9a5ec71147bc070889143610f96c69c63838fcbe
Binary files /dev/null and b/web/public/icons/3d/3d-alert.png differ
diff --git a/web/public/icons/3d/3d-ambulance.png b/web/public/icons/3d/3d-ambulance.png
new file mode 100644
index 0000000000000000000000000000000000000000..fdac8e707ce7404ae419aa62bc7aeb6d80c429b6
Binary files /dev/null and b/web/public/icons/3d/3d-ambulance.png differ
diff --git a/web/public/icons/3d/3d-car.png b/web/public/icons/3d/3d-car.png
new file mode 100644
index 0000000000000000000000000000000000000000..f34c5ca1451dd444e720c328ace2859e1ee25afc
Binary files /dev/null and b/web/public/icons/3d/3d-car.png differ
diff --git a/web/public/icons/3d/3d-construction.png b/web/public/icons/3d/3d-construction.png
new file mode 100644
index 0000000000000000000000000000000000000000..79872a124d32ef95b7767c193bc7077961a93970
Binary files /dev/null and b/web/public/icons/3d/3d-construction.png differ
diff --git a/web/public/icons/3d/3d-flood.png b/web/public/icons/3d/3d-flood.png
new file mode 100644
index 0000000000000000000000000000000000000000..107087a40c3da0e2d2bd1f27c884b2cae986eae5
Binary files /dev/null and b/web/public/icons/3d/3d-flood.png differ
diff --git a/web/public/icons/3d/3d-gun.png b/web/public/icons/3d/3d-gun.png
new file mode 100644
index 0000000000000000000000000000000000000000..c4981624b6ed7920656c6ef998e8e193968eda9e
Binary files /dev/null and b/web/public/icons/3d/3d-gun.png differ
diff --git a/web/public/icons/3d/3d-help.png b/web/public/icons/3d/3d-help.png
new file mode 100644
index 0000000000000000000000000000000000000000..7ff10554e27c9bf8fc562131f37acb8b13731bcf
Binary files /dev/null and b/web/public/icons/3d/3d-help.png differ
diff --git a/web/public/icons/3d/3d-info.png b/web/public/icons/3d/3d-info.png
new file mode 100644
index 0000000000000000000000000000000000000000..2939cb57a44cca41e45ac45fcffe36e05fd8af48
Binary files /dev/null and b/web/public/icons/3d/3d-info.png differ
diff --git a/web/public/icons/3d/3d-ride.png b/web/public/icons/3d/3d-ride.png
new file mode 100644
index 0000000000000000000000000000000000000000..f1e539c4029c808e44bd3e7b5e07a5ff32db7f7c
Binary files /dev/null and b/web/public/icons/3d/3d-ride.png differ
diff --git a/web/public/icons/3d/3d-robbery.png b/web/public/icons/3d/3d-robbery.png
new file mode 100644
index 0000000000000000000000000000000000000000..71e615ad147e17e68b595a82a1f077866912f3ab
Binary files /dev/null and b/web/public/icons/3d/3d-robbery.png differ
diff --git a/web/public/icons/3d/3d-search.png b/web/public/icons/3d/3d-search.png
new file mode 100644
index 0000000000000000000000000000000000000000..ce9eb0c657a42e896a63a69f1684c6bada7874ba
Binary files /dev/null and b/web/public/icons/3d/3d-search.png differ
diff --git a/web/public/icons/3d/3d-sex.png b/web/public/icons/3d/3d-sex.png
new file mode 100644
index 0000000000000000000000000000000000000000..6e556f479cc828b4c66ad67442781ade6d0a41ed
Binary files /dev/null and b/web/public/icons/3d/3d-sex.png differ
diff --git a/web/public/icons/3d/3d-traffic.png b/web/public/icons/3d/3d-traffic.png
new file mode 100644
index 0000000000000000000000000000000000000000..f2349176bb80a99bf8619ba11ff1d90c8a4b01bb
Binary files /dev/null and b/web/public/icons/3d/3d-traffic.png differ
diff --git a/web/public/icons/3d/3d-user_search.png b/web/public/icons/3d/3d-user_search.png
new file mode 100644
index 0000000000000000000000000000000000000000..2ff7496d91d85a7a2b6b77d3c5ed1586f3007074
Binary files /dev/null and b/web/public/icons/3d/3d-user_search.png differ
diff --git a/web/public/vite.svg b/web/public/vite.svg
new file mode 100644
index 0000000000000000000000000000000000000000..e7b8dfb1b2a60bd50538bec9f876511b9cac21e3
--- /dev/null
+++ b/web/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/src/App.css b/web/src/App.css
new file mode 100644
index 0000000000000000000000000000000000000000..25481688365e2dfc3734d893af44cc2c9c6182bc
--- /dev/null
+++ b/web/src/App.css
@@ -0,0 +1,48 @@
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
+
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6cbffbc1930fcd2b1238a51f8241942eb582777e
--- /dev/null
+++ b/web/src/App.tsx
@@ -0,0 +1,159 @@
+import React from "react";
+import "./style.css";
+import type { FC, SelectMeta } from "./lib/types";
+import { REPORTS_URL } from "./lib/constants";
+import { useFeeds } from "./hooks/useFeeds";
+import { useSessionId } from "./hooks/useSessionId";
+import { useUpdates } from "./hooks/useUpdates";
+import { useChat } from "./hooks/useChat";
+import MapCanvas from "./components/map/MapCanvas";
+import SelectedLocationCard from "./components/sidebar/SelectedLocationCard";
+import UpdatesPanel from "./components/sidebar/UpdatesPanel";
+import ChatPanel from "./components/chat/ChatPanel";
+
+export default function App() {
+ const [selectedLL, setSelectedLL] = React.useState<[number, number] | null>(
+ null
+ );
+ const [selectedMeta, setSelectedMeta] = React.useState(
+ null
+ );
+
+ const [reports, setReports] = React.useState({
+ type: "FeatureCollection",
+ features: [],
+ });
+ const { nws, quakes, eonet, firms } = useFeeds();
+
+ const sessionId = useSessionId();
+ const {
+ activeTab,
+ setActiveTab,
+ localUpdates,
+ globalUpdates,
+ loadingLocal,
+ loadingGlobal,
+ } = useUpdates(selectedLL);
+
+ const {
+ messages,
+ draft,
+ setDraft,
+ isStreaming,
+ hasFirstToken,
+ chatBodyRef,
+ send,
+ pendingPhotoUrl,
+ setPendingPhotoUrl,
+ isUploading,
+ onFileChosen,
+ } = useChat(sessionId, selectedLL);
+
+ const fileInputRef = React.useRef(null);
+
+ const loadReports = React.useCallback(async () => {
+ const fc = await fetch(REPORTS_URL)
+ .then((r) => r.json())
+ .catch(() => ({ type: "FeatureCollection", features: [] }));
+ setReports(fc);
+ }, []);
+
+ React.useEffect(() => {
+ loadReports();
+ }, [loadReports]);
+
+ const selectPoint = React.useCallback(
+ (ll: [number, number], meta: SelectMeta) => {
+ setSelectedLL(ll);
+ setSelectedMeta(meta);
+ },
+ []
+ );
+
+ const pickPhoto = React.useCallback(() => fileInputRef.current?.click(), []);
+ const onSend = React.useCallback(async () => {
+ const res = await send();
+ if (res?.tool_used === "add_report") await loadReports();
+ }, [send, loadReports]);
+
+ return (
+
+
+
+
+
+
+ setPendingPhotoUrl(null)}
+ isUploading={isUploading}
+ />
+
+ {/* hidden file input lives here */}
+ {
+ const f = e.target.files?.[0];
+ if (f) onFileChosen(f);
+ }}
+ />
+
+
+ );
+}
diff --git a/web/src/assets/react.svg b/web/src/assets/react.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6c87de9bb3358469122cc991d5cf578927246184
--- /dev/null
+++ b/web/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/src/components/ReportIcon.tsx b/web/src/components/ReportIcon.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..251597e38a537d3ac3f28b2031ded744a9dfaed5
--- /dev/null
+++ b/web/src/components/ReportIcon.tsx
@@ -0,0 +1,34 @@
+// components/ReportIcon.tsx
+import * as Lucide from "lucide-react";
+
+const pascal = (s: string) =>
+ s.replace(/(^\w|-\w)/g, (m) => m.replace("-", "").toUpperCase());
+
+export function ReportIcon({
+ name = "info",
+ size = 32,
+}: {
+ name?: string;
+ size?: number;
+}) {
+ const key = (name || "info").toLowerCase();
+
+ // 3D local PNGs: name like "3d:police-light"
+ if (key.startsWith("3d-")) {
+ const file = key; // "police-light"
+ return (
+
+ );
+ }
+
+ // fallback to your existing Lucide logic
+ const Comp = (Lucide as any)[pascal(key)] ?? (Lucide as any).Info;
+ return ;
+}
diff --git a/web/src/components/chat/ChatPanel.tsx b/web/src/components/chat/ChatPanel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8b735c3419b8a3ea68fc0e2dc5a8683e18e39ca6
--- /dev/null
+++ b/web/src/components/chat/ChatPanel.tsx
@@ -0,0 +1,110 @@
+import TypingDots from "./TypingDots";
+import type { Message } from "../../lib/types";
+
+export default function ChatPanel({
+ messages,
+ draft,
+ setDraft,
+ isStreaming,
+ hasFirstToken,
+ chatBodyRef,
+ onSend,
+ pendingThumb,
+ onAttachClick,
+ onClearAttach,
+ isUploading,
+}: {
+ messages: Message[];
+ draft: string;
+ setDraft: (s: string) => void;
+ isStreaming: boolean;
+ hasFirstToken: boolean;
+ chatBodyRef: React.RefObject;
+ onSend: () => void;
+ pendingThumb?: string | null;
+ onAttachClick: () => void;
+ onClearAttach: () => void;
+ isUploading: boolean;
+}) {
+ return (
+
+ Assistant
+
+ {messages.length === 0 ? (
+
+ Try: “Flooded underpass here”, or “List reports near me”.
+
+ ) : (
+ messages.map((m, idx) => (
+
+ {isStreaming &&
+ !hasFirstToken &&
+ idx === messages.length - 1 &&
+ m.role === "assistant" ? (
+
+
+
+ ) : (
+ <>
+ {m.text}
+ {m.image && (
+
+

+
+ )}
+ >
+ )}
+
+ ))
+ )}
+
+
+
+
setDraft(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && onSend()}
+ />
+
+ {pendingThumb && (
+
+

+
+
+ )}
+
+
+
+ );
+}
diff --git a/web/src/components/chat/TypingDots.tsx b/web/src/components/chat/TypingDots.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..918a3dd384e689a92d47fec085bd38bbe65a5870
--- /dev/null
+++ b/web/src/components/chat/TypingDots.tsx
@@ -0,0 +1,10 @@
+export default function TypingDots() {
+ return (
+
+ …
+
+
+
+
+ );
+}
diff --git a/web/src/components/map/MapCanvas.tsx b/web/src/components/map/MapCanvas.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fc8afac2ef8f709aa2d61356d03700157394709a
--- /dev/null
+++ b/web/src/components/map/MapCanvas.tsx
@@ -0,0 +1,179 @@
+import {
+ APIProvider,
+ Map,
+ AdvancedMarker,
+ useMap,
+} from "@vis.gl/react-google-maps";
+import { useEffect } from "react";
+import SearchControl from "./controls/SearchControl";
+import MyLocationControl from "./controls/MyLocationControl";
+import SingleSelect from "./controls/SingleSelect";
+import NWSDataLayer from "./overlays/NWSDataLayer";
+import EmojiMarker from "./overlays/EmojiMarker";
+import { GMAPS_KEY, MAP_ID } from "../../lib/constants";
+import { eonetEmoji } from "../../lib/utils";
+import type { FC, SelectMeta } from "../../lib/types";
+import { ReportIcon } from "../../components/ReportIcon";
+import FirmsLayer from "./overlays/FirmsLayer";
+
+function PanOnSelect({ ll }: { ll: [number, number] | null }) {
+ const map = useMap();
+ useEffect(() => {
+ if (!map || !ll) return;
+ map.panTo({ lat: ll[0], lng: ll[1] });
+ const z = map.getZoom?.() ?? 0;
+ if (z < 14) map.setZoom(14); // tweak 14–16 as you like
+ }, [map, ll]);
+ return null;
+}
+
+export default function MapCanvas({
+ selectedLL,
+ selectedMeta,
+ setSelected,
+ nws,
+ quakes,
+ eonet,
+ firms,
+ reports,
+}: {
+ selectedLL: [number, number] | null;
+ selectedMeta: SelectMeta | null;
+ setSelected: (ll: [number, number], meta: SelectMeta) => void;
+ nws: FC | null;
+ quakes: FC | null;
+ eonet: FC | null;
+ firms: FC | null;
+ reports: FC;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/web/src/components/map/controls/MyLocationControl.tsx b/web/src/components/map/controls/MyLocationControl.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9285b2dfdf373960975b76b15c319da94836639b
--- /dev/null
+++ b/web/src/components/map/controls/MyLocationControl.tsx
@@ -0,0 +1,49 @@
+import { useEffect } from "react";
+import { useMap } from "@vis.gl/react-google-maps";
+import type { SelectMeta } from "../../../lib/types";
+
+export default function MyLocationControl({
+ onLocated,
+}: {
+ onLocated: (ll: [number, number], meta: SelectMeta) => void;
+}) {
+ const map = useMap();
+ useEffect(() => {
+ if (!map) return;
+ const btn = document.createElement("div");
+ btn.style.margin = "10px";
+ btn.innerHTML = ``;
+ map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(btn);
+
+ const click = () => {
+ if (!navigator.geolocation) return;
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ const ll: [number, number] = [
+ pos.coords.latitude,
+ pos.coords.longitude,
+ ];
+ map.setCenter({ lat: ll[0], lng: ll[1] });
+ map.setZoom(13);
+ onLocated(ll, { kind: "mylocation", title: "My location" });
+ },
+ undefined,
+ { enableHighAccuracy: true }
+ );
+ };
+ btn.addEventListener("click", click);
+
+ return () => {
+ btn.removeEventListener("click", click);
+ const arr = map.controls[google.maps.ControlPosition.RIGHT_BOTTOM];
+ for (let i = 0; i < arr.getLength(); i++) {
+ if (arr.getAt(i) === (btn as any)) {
+ arr.removeAt(i);
+ break;
+ }
+ }
+ };
+ }, [map, onLocated]);
+
+ return null;
+}
diff --git a/web/src/components/map/controls/SearchControl.tsx b/web/src/components/map/controls/SearchControl.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..100528edfff893f40c4cc96e5f4e2b27f20c216b
--- /dev/null
+++ b/web/src/components/map/controls/SearchControl.tsx
@@ -0,0 +1,75 @@
+import React from "react";
+import { useMap } from "@vis.gl/react-google-maps";
+import type { SelectMeta } from "../../../lib/types";
+
+export default function SearchControl({
+ onPlace,
+}: {
+ onPlace: (ll: [number, number], meta: SelectMeta) => void;
+}) {
+ const map = useMap();
+ const onPlaceRef = React.useRef(onPlace);
+ React.useEffect(() => {
+ onPlaceRef.current = onPlace;
+ }, [onPlace]);
+
+ React.useEffect(() => {
+ if (!map || !window.google) return;
+ const container = document.createElement("div");
+ Object.assign(container.style, {
+ background: "#fff",
+ borderRadius: "8px",
+ boxShadow: "0 1px 4px rgba(0,0,0,.3)",
+ margin: "10px",
+ padding: "4px",
+ });
+
+ const input = document.createElement("input");
+ input.type = "text";
+ input.placeholder = "Search places…";
+ input.setAttribute("aria-label", "Search places");
+ Object.assign(input.style, {
+ border: "0",
+ outline: "0",
+ padding: "10px 12px",
+ width: "260px",
+ borderRadius: "6px",
+ } as CSSStyleDeclaration);
+
+ container.appendChild(input);
+ map.controls[google.maps.ControlPosition.TOP_LEFT].push(container);
+
+ const ac = new google.maps.places.Autocomplete(input, {
+ fields: ["geometry", "name", "formatted_address"],
+ types: ["geocode"],
+ });
+ const listener = ac.addListener("place_changed", () => {
+ const place = ac.getPlace();
+ const loc = place?.geometry?.location;
+ if (loc) {
+ const ll: [number, number] = [loc.lat(), loc.lng()];
+ map.setCenter({ lat: ll[0], lng: ll[1] });
+ map.setZoom(12);
+ onPlaceRef.current(ll, {
+ kind: "search",
+ title: place.name || "Search result",
+ subtitle: place.formatted_address,
+ raw: place,
+ });
+ }
+ });
+
+ return () => {
+ google.maps.event.removeListener(listener);
+ const arr = map.controls[google.maps.ControlPosition.TOP_LEFT];
+ for (let i = 0; i < arr.getLength(); i++) {
+ if (arr.getAt(i) === (container as any)) {
+ arr.removeAt(i);
+ break;
+ }
+ }
+ };
+ }, [map]);
+
+ return null;
+}
diff --git a/web/src/components/map/controls/SingleSelect.tsx b/web/src/components/map/controls/SingleSelect.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e7a000ad01a308fb01435d41f92c5e7985e18d62
--- /dev/null
+++ b/web/src/components/map/controls/SingleSelect.tsx
@@ -0,0 +1,38 @@
+import { useEffect } from "react";
+import { useMap } from "@vis.gl/react-google-maps";
+import type { SelectMeta } from "../../../lib/types";
+
+export default function SingleSelect({
+ onPick,
+}: {
+ onPick: (ll: [number, number], meta: SelectMeta) => void;
+}) {
+ const map = useMap();
+ useEffect(() => {
+ if (!map) return;
+ map.setOptions({ disableDoubleClickZoom: true });
+ const onClick = map.addListener("click", (e: google.maps.MapMouseEvent) => {
+ if (!e.latLng) return;
+ onPick([e.latLng.lat(), e.latLng.lng()], {
+ kind: "click",
+ title: "Selected point",
+ });
+ });
+ const onDbl = map.addListener(
+ "dblclick",
+ (e: google.maps.MapMouseEvent) => {
+ if (!e.latLng) return;
+ onPick([e.latLng.lat(), e.latLng.lng()], {
+ kind: "click",
+ title: "Selected point",
+ });
+ }
+ );
+ return () => {
+ google.maps.event.removeListener(onClick);
+ google.maps.event.removeListener(onDbl);
+ };
+ }, [map, onPick]);
+
+ return null;
+}
diff --git a/web/src/components/map/overlays/EmojiMarker.tsx b/web/src/components/map/overlays/EmojiMarker.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0851c525cb6cf07a2ee24867d6e11f7d4e096e71
--- /dev/null
+++ b/web/src/components/map/overlays/EmojiMarker.tsx
@@ -0,0 +1,63 @@
+import { AdvancedMarker, Marker } from "@vis.gl/react-google-maps";
+
+export default function EmojiMarker(props: {
+ position: google.maps.LatLngLiteral;
+ emoji: string;
+ title?: string;
+ draggable?: boolean;
+ onDragEnd?: (ll: [number, number]) => void;
+ onClick?: () => void;
+}) {
+ const {
+ position,
+ emoji,
+ title,
+ draggable = false,
+ onDragEnd,
+ onClick,
+ } = props;
+ const hasAdvanced =
+ typeof window !== "undefined" &&
+ !!(window as any).google?.maps?.marker?.AdvancedMarkerElement;
+
+ if (hasAdvanced) {
+ return (
+ {
+ if (onDragEnd && e.latLng)
+ onDragEnd([e.latLng.lat(), e.latLng.lng()]);
+ }}
+ onClick={onClick}
+ zIndex={100}
+ >
+
+ {emoji}
+
+
+ );
+ }
+
+ return (
+ {
+ if (onDragEnd && e.latLng) onDragEnd([e.latLng.lat(), e.latLng.lng()]);
+ }}
+ onClick={onClick}
+ title={title}
+ />
+ );
+}
diff --git a/web/src/components/map/overlays/FirmsLayer.tsx b/web/src/components/map/overlays/FirmsLayer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..805be1a37c7a283981241034a2826b875852213a
--- /dev/null
+++ b/web/src/components/map/overlays/FirmsLayer.tsx
@@ -0,0 +1,53 @@
+// components/map/overlays/FirmsLayer.tsx
+import React from "react";
+import type { FC, SelectMeta } from "../../../lib/types";
+import EmojiMarker from "./EmojiMarker";
+
+export default function FirmsLayer({
+ firms,
+ onSelect,
+}: {
+ firms: FC | null;
+ onSelect: (ll: [number, number], meta: SelectMeta) => void;
+}) {
+ if (!firms?.features?.length) return null;
+
+ const push = (lat: number, lon: number, p: any, k: string) => (
+
+ onSelect([lat, lon], {
+ kind: "fire",
+ title: "Fire hotspot",
+ severity: p.confidence ?? p.brightness ?? p.frp,
+ confidence: 1,
+ emoji: "🔥",
+ raw: p,
+ })
+ }
+ />
+ );
+
+ const out: React.ReactNode[] = [];
+ firms.features.forEach((f: any, i: number) => {
+ const g = f?.geometry,
+ p = f?.properties || {};
+ if (!g) return;
+ if (g.type === "Point" && Array.isArray(g.coordinates)) {
+ const [lon, lat] = g.coordinates;
+ if (Number.isFinite(lat) && Number.isFinite(lon))
+ out.push(push(lat, lon, p, `fi-${i}`));
+ } else if (g.type === "MultiPoint" && Array.isArray(g.coordinates)) {
+ g.coordinates.forEach((c: any, j: number) => {
+ const [lon, lat] = c || [];
+ if (Number.isFinite(lat) && Number.isFinite(lon))
+ out.push(push(lat, lon, p, `fi-${i}-${j}`));
+ });
+ }
+ });
+
+ return <>{out}>;
+}
diff --git a/web/src/components/map/overlays/NWSDataLayer.tsx b/web/src/components/map/overlays/NWSDataLayer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5cfebbaa3484f7c0b5a9d9b932bfbc6785399020
--- /dev/null
+++ b/web/src/components/map/overlays/NWSDataLayer.tsx
@@ -0,0 +1,63 @@
+import { useEffect } from "react";
+import { useMap } from "@vis.gl/react-google-maps";
+import type { FC, SelectMeta } from "../../../lib/types";
+import { sevColor } from "../../../lib/utils";
+
+export default function NWSDataLayer({
+ nws,
+ onSelect,
+}: {
+ nws: FC | null;
+ onSelect: (ll: [number, number], meta: SelectMeta) => void;
+}) {
+ const map = useMap();
+ useEffect(() => {
+ if (!map) return;
+ map.data.forEach((f) => map.data.remove(f));
+
+ if (nws?.features?.length) {
+ map.data.addGeoJson(nws as any);
+ map.data.setStyle((f) => {
+ const sev = (f.getProperty("severity") || "Unknown") as string;
+ const color = sevColor(sev);
+ return {
+ strokeColor: color,
+ strokeWeight: 1.2,
+ fillColor: color,
+ fillOpacity: 0.18,
+ };
+ });
+
+ const clickListener = map.data.addListener(
+ "click",
+ (e: google.maps.Data.MouseEvent) => {
+ const p: any = e.feature;
+ const title =
+ (p.getProperty && p.getProperty("event")) || "NWS Alert";
+ const sev = (p.getProperty && p.getProperty("severity")) || "Unknown";
+ const src =
+ (p.getProperty && (p.getProperty("@id") || p.getProperty("id"))) ||
+ "";
+ if (e.latLng) {
+ onSelect([e.latLng.lat(), e.latLng.lng()], {
+ kind: "nws",
+ title,
+ severity: sev,
+ sourceUrl: src || undefined,
+ confidence: 1,
+ emoji: "⚠️",
+ raw: p?.g ?? p,
+ });
+ }
+ }
+ );
+
+ return () => {
+ google.maps.event.removeListener(clickListener);
+ map.data.forEach((f) => map.data.remove(f));
+ };
+ }
+ }, [map, nws, onSelect]);
+
+ return null;
+}
diff --git a/web/src/components/sidebar/SelectedLocationCard.tsx b/web/src/components/sidebar/SelectedLocationCard.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dbb29956bccff3c94bf5000d9fd56f6ed354ef86
--- /dev/null
+++ b/web/src/components/sidebar/SelectedLocationCard.tsx
@@ -0,0 +1,147 @@
+import type { SelectMeta } from "../../lib/types";
+
+const isEmoji = (s: string) => !!s && /\p{Extended_Pictographic}/u.test(s);
+const cleanTitle = (t?: string) => {
+ if (!t) return t;
+ return t
+ .replace(/^(?:[a-z0-9]+-){1,3}(?=[A-Z])/i, "") // slug stuck to title (no space)
+ .replace(/^(?:[a-z0-9]+-){1,3}\s+/i, ""); // slug + space
+};
+
+export default function SelectedLocationCard({
+ selectedLL,
+ selectedMeta,
+ onClear,
+}: {
+ selectedLL: [number, number] | null;
+ selectedMeta: SelectMeta | null;
+ onClear: () => void;
+}) {
+ const photoSrc =
+ selectedMeta?.raw?.photo_url ??
+ selectedMeta?.raw?.photoUrl ??
+ selectedMeta?.raw?.image_url ??
+ selectedMeta?.raw?.imageUrl ??
+ (selectedMeta as any)?.photo_url ??
+ (selectedMeta as any)?.photoUrl ??
+ null;
+
+ const showEmoji =
+ selectedMeta?.emoji && isEmoji(String(selectedMeta.emoji))
+ ? String(selectedMeta.emoji)
+ : null;
+
+ const displayTitle = cleanTitle(selectedMeta?.title) || "Selected";
+
+ return (
+
+
+
+ {selectedLL ? (
+ <>
+
+ {showEmoji ? (
+
+ {showEmoji}
+
+ ) : null}
+ {displayTitle}
+
+
+ {selectedMeta?.subtitle && (
+
{selectedMeta.subtitle}
+ )}
+
+
+ {selectedLL[0].toFixed(4)}, {selectedLL[1].toFixed(4)}
+
+
+
+ {(selectedMeta?.category || selectedMeta?.raw?.category) && (
+
+ Category:{" "}
+ {selectedMeta.category || selectedMeta.raw?.category}
+
+ )}
+ {(selectedMeta?.severity !== undefined ||
+ selectedMeta?.raw?.severity !== undefined) && (
+
+ Severity/Mag:{" "}
+ {String(
+ selectedMeta?.severity ?? selectedMeta?.raw?.severity
+ )}
+
+ )}
+
+ Confidence:{" "}
+ {(() => {
+ const k = selectedMeta?.kind;
+ const fromMeta =
+ selectedMeta?.confidence ?? selectedMeta?.raw?.confidence;
+ const official =
+ k && ["nws", "quake", "eonet", "fire"].includes(k);
+ const val = fromMeta ?? (official ? 1 : undefined);
+ return val !== undefined ? String(val) : "—";
+ })()}
+
+ {selectedMeta?.raw?.source ? (
+
+ Source: {selectedMeta.raw.source}
+
+ ) : selectedMeta?.kind &&
+ ["nws", "quake", "eonet", "fire"].includes(
+ selectedMeta.kind
+ ) ? (
+
+ Source: {selectedMeta.kind.toUpperCase()}
+
+ ) : null}
+ {selectedMeta?.sourceUrl && (
+
+ )}
+
+ >
+ ) : (
+
Use search, 📍, or click the map.
+ )}
+
+ {photoSrc && (
+
+

+
+ )}
+
+
+ Only one point is active. Drag 📍 to fine-tune; chat uses this point.
+
+
+ {selectedLL && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/sidebar/UpdatesPanel.tsx b/web/src/components/sidebar/UpdatesPanel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..01db739f0579a5a3a1d1c7ffadb2097c9cbcad35
--- /dev/null
+++ b/web/src/components/sidebar/UpdatesPanel.tsx
@@ -0,0 +1,133 @@
+import type { UpdateItem } from "../../lib/types";
+import { formatAgo } from "../../lib/utils";
+
+const isEmoji = (s: string) => !!s && /\p{Extended_Pictographic}/u.test(s);
+
+// Same slug cleaner used above
+const cleanTitle = (t?: string) => {
+ if (!t) return t;
+ return t
+ .replace(/^(?:[a-z0-9]+-){1,3}(?=[A-Z])/i, "")
+ .replace(/^(?:[a-z0-9]+-){1,3}\s+/i, "");
+};
+
+export default function UpdatesPanel({
+ activeTab,
+ setActiveTab,
+ localUpdates,
+ globalUpdates,
+ loadingLocal,
+ loadingGlobal,
+ selectedLL,
+ onView,
+}: {
+ activeTab: "local" | "global";
+ setActiveTab: (t: "local" | "global") => void;
+ localUpdates: UpdateItem[];
+ globalUpdates: UpdateItem[];
+ loadingLocal: boolean;
+ loadingGlobal: boolean;
+ selectedLL: [number, number] | null;
+ onView: (u: UpdateItem) => void;
+}) {
+ const renderList = (
+ list: UpdateItem[],
+ loading: boolean,
+ emptyMsg: string
+ ) => (
+ <>
+ {loading && Loading…
}
+ {!loading && list.length === 0 && {emptyMsg}
}
+ {!loading &&
+ list.map((u, i) => {
+ // DEBUG: inspect each item
+ // eslint-disable-next-line no-console
+ console.debug("[UpdatesPanel] item:", u);
+
+ const showEmoji =
+ u.emoji && isEmoji(String(u.emoji)) ? u.emoji : null;
+ const title = cleanTitle(u.title) || u.title || "Update";
+
+ return (
+
+
+ {showEmoji ?
{showEmoji}
: null}
+
+
{title}
+
+ {formatAgo(u.time)} · {u.kind}
+ {u.severity ? <> · {String(u.severity)}> : null}
+
+ {u.sourceUrl && (
+
+ )}
+
+
+
+
+ );
+ })}
+ >
+ );
+
+ return (
+
+
+
+
+
+
e.stopPropagation()}
+ >
+ {activeTab === "local" ? (
+ selectedLL ? (
+ renderList(localUpdates, loadingLocal, "No recent updates here.")
+ ) : (
+
+ Pick a point (search/📍/click) to load local updates within 25
+ miles (last 48h).
+
+ )
+ ) : (
+ renderList(
+ globalUpdates,
+ loadingGlobal,
+ "No global updates right now."
+ )
+ )}
+
+
+ );
+}
diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e63bf7553f199fc54423c1d50ec5b3250d5879a7
--- /dev/null
+++ b/web/src/hooks/useChat.ts
@@ -0,0 +1,127 @@
+import { useCallback, useRef, useState } from "react";
+import { CHAT_URL, UPLOAD_URL } from "../lib/constants";
+import type { Message } from "../lib/types";
+
+export function useChat(
+ sessionId: string,
+ selectedLL: [number, number] | null
+) {
+ const [messages, setMessages] = useState([]);
+ const [draft, setDraft] = useState("");
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [hasFirstToken, setHasFirstToken] = useState(false);
+ const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null);
+ const [isUploading, setIsUploading] = useState(false);
+ const chatBodyRef = useRef(null);
+
+ const scrollToBottom = useCallback(() => {
+ const el = chatBodyRef.current;
+ if (!el) return;
+ el.scrollTop = el.scrollHeight;
+ }, []);
+
+ const typeOut = useCallback(
+ async (fullText: string) => {
+ const step = fullText.length > 1200 ? 6 : fullText.length > 400 ? 3 : 1;
+ const delayMs =
+ fullText.length > 1200 ? 4 : fullText.length > 400 ? 8 : 15;
+ let firstTokenSet = false;
+ for (let i = 0; i < fullText.length; i += step) {
+ const acc = fullText.slice(0, i + step);
+ setMessages((m) => {
+ const out = [...m];
+ for (let j = out.length - 1; j >= 0; j--) {
+ if (out[j].role === "assistant") {
+ out[j] = { ...out[j], text: acc };
+ break;
+ }
+ }
+ return out;
+ });
+ if (!firstTokenSet && acc.length > 0) {
+ setHasFirstToken(true);
+ firstTokenSet = true;
+ }
+ scrollToBottom();
+ await new Promise((r) => setTimeout(r, delayMs));
+ }
+ setIsStreaming(false);
+ setHasFirstToken(true);
+ scrollToBottom();
+ },
+ [scrollToBottom]
+ );
+
+ const onFileChosen = useCallback(async (file: File) => {
+ setIsUploading(true);
+ try {
+ const fd = new FormData();
+ fd.append("file", file);
+ const res = await fetch(UPLOAD_URL, { method: "POST", body: fd }).then(
+ (r) => r.json()
+ );
+ const url =
+ res?.url ||
+ (res?.path
+ ? (import.meta.env.VITE_API_BASE || "http://localhost:8000") +
+ res.path
+ : "");
+ if (url) setPendingPhotoUrl(url);
+ } finally {
+ setIsUploading(false);
+ }
+ }, []);
+
+ const send = useCallback(async () => {
+ const text = draft.trim();
+ if (!text) return;
+
+ const attached = pendingPhotoUrl; // capture now
+ setPendingPhotoUrl(null); // clear immediately
+ setMessages((m) => [
+ ...m,
+ { role: "user", text, image: attached || undefined },
+ ]);
+ setDraft("");
+ setTimeout(scrollToBottom, 0);
+
+ setIsStreaming(true);
+ setHasFirstToken(false);
+ setMessages((m) => [...m, { role: "assistant", text: "" }]);
+ setTimeout(scrollToBottom, 0);
+
+ let finalText = text;
+ if (selectedLL)
+ finalText += `\n\n[COORDS lat=${selectedLL[0]} lon=${selectedLL[1]}]`;
+
+ const payload: any = { message: finalText, session_id: sessionId };
+ if (selectedLL)
+ payload.user_location = { lat: selectedLL[0], lon: selectedLL[1] };
+ if (attached) payload.photo_url = attached;
+
+ const res = await fetch(CHAT_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ })
+ .then((r) => r.json())
+ .catch(() => ({ reply: "Something went wrong." }));
+
+ await typeOut(res.reply || "(no reply)");
+ return res; // caller can react to tool_used (e.g., reload reports)
+ }, [draft, pendingPhotoUrl, selectedLL, sessionId, scrollToBottom, typeOut]);
+
+ return {
+ messages,
+ draft,
+ setDraft,
+ isStreaming,
+ hasFirstToken,
+ chatBodyRef,
+ send,
+ pendingPhotoUrl,
+ setPendingPhotoUrl,
+ isUploading,
+ onFileChosen,
+ };
+}
diff --git a/web/src/hooks/useFeeds.ts b/web/src/hooks/useFeeds.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2b3ac3a2ccb204ce54b3f8c5c64cd90aea350fca
--- /dev/null
+++ b/web/src/hooks/useFeeds.ts
@@ -0,0 +1,99 @@
+import { useEffect, useState } from "react";
+import type { FC } from "../lib/types";
+import { NWS_URL, USGS_URL, EONET_URL, FIRMS_URL } from "../lib/constants";
+
+// normalize FIRMS (same as you had)
+function normalizeFirms(
+ j: any
+): { type: "FeatureCollection"; features: any[] } | null {
+ if (!j) return null;
+ if (j.type === "FeatureCollection" && Array.isArray(j.features)) return j;
+ if (j.data?.type === "FeatureCollection" && Array.isArray(j.data.features))
+ return j.data;
+ if (Array.isArray(j.features))
+ return { type: "FeatureCollection", features: j.features };
+ const rows = Array.isArray(j?.rows)
+ ? j.rows
+ : Array.isArray(j?.items)
+ ? j.items
+ : Array.isArray(j)
+ ? j
+ : null;
+ if (rows) {
+ const features = rows
+ .map((r: any) => {
+ const lat = Number(r.lat ?? r.latitude ?? r.LAT ?? r.LATITUDE);
+ const lon = Number(r.lon ?? r.longitude ?? r.LON ?? r.LONGITUDE);
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
+ return {
+ type: "Feature",
+ geometry: { type: "Point", coordinates: [lon, lat] },
+ properties: r,
+ };
+ })
+ .filter(Boolean);
+ return { type: "FeatureCollection", features };
+ }
+ return null;
+}
+
+// small helper: fetch with timeout; return null on any failure
+async function fetchJSON(url: string, timeoutMs = 8000): Promise {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
+ try {
+ const r = await fetch(url, { signal: ctrl.signal });
+ if (!r.ok) return null;
+ return await r.json();
+ } catch {
+ return null;
+ } finally {
+ clearTimeout(t);
+ }
+}
+
+export function useFeeds() {
+ const [nws, setNws] = useState(null);
+ const [quakes, setQuakes] = useState(null);
+ const [eonet, setEonet] = useState(null);
+ const [firms, setFirms] = useState(null);
+
+ useEffect(() => {
+ let mounted = true;
+
+ (async () => {
+ const [nwsRes, usgsRes, eonetRes, firmsRes] = await Promise.allSettled([
+ fetchJSON(NWS_URL, 8000),
+ fetchJSON(USGS_URL, 8000),
+ fetchJSON(EONET_URL, 5000), // shorter timeout since it’s flaky right now
+ fetchJSON(FIRMS_URL, 8000),
+ ]);
+
+ if (!mounted) return;
+
+ const val = (x: PromiseSettledResult) =>
+ x.status === "fulfilled" ? x.value : null;
+
+ const a = val(nwsRes),
+ b = val(usgsRes),
+ c = val(eonetRes),
+ d = val(firmsRes);
+
+ setNws(a?.data || a || null);
+ setQuakes(b?.data || b || null);
+ setEonet(c?.data || c || null);
+
+ const firmsFC = normalizeFirms(d);
+ setFirms(firmsFC);
+
+ console.log("FIRMS note:", (d && d._note) || firmsFC?._note || null);
+ console.log("FIRMS normalized:", firmsFC?.features?.length);
+ })();
+
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ return { nws, quakes, eonet, firms };
+}
diff --git a/web/src/hooks/useSessionId.ts b/web/src/hooks/useSessionId.ts
new file mode 100644
index 0000000000000000000000000000000000000000..38c010c787e7d000ff47dd71ad161e0619f440db
--- /dev/null
+++ b/web/src/hooks/useSessionId.ts
@@ -0,0 +1,11 @@
+import { useState } from "react";
+export const useSessionId = () => {
+ const [sessionId] = useState(() => {
+ const existing = localStorage.getItem("pulsemaps_session");
+ if (existing) return existing;
+ const fresh = crypto.randomUUID();
+ localStorage.setItem("pulsemaps_session", fresh);
+ return fresh;
+ });
+ return sessionId;
+};
diff --git a/web/src/hooks/useUpdates.ts b/web/src/hooks/useUpdates.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8dc63b1cd86f8e81f5b6a53a343312286188ec21
--- /dev/null
+++ b/web/src/hooks/useUpdates.ts
@@ -0,0 +1,63 @@
+import { useCallback, useEffect, useState } from "react";
+import type { UpdateItem } from "../lib/types";
+import { UPDATES_LOCAL_URL, UPDATES_GLOBAL_URL } from "../lib/constants";
+import { toQuery } from "../lib/utils";
+
+export function useUpdates(selectedLL: [number, number] | null) {
+ const [activeTab, setActiveTab] = useState<"local" | "global">("local");
+ const [localUpdates, setLocal] = useState([]);
+ const [globalUpdates, setGlobal] = useState([]);
+ const [loadingLocal, setLLoad] = useState(false);
+ const [loadingGlobal, setGLoad] = useState(false);
+
+ const loadLocal = useCallback(async (ll: [number, number]) => {
+ setLLoad(true);
+ try {
+ const url =
+ UPDATES_LOCAL_URL +
+ toQuery({
+ lat: ll[0],
+ lon: ll[1],
+ radius_miles: 25,
+ max_age_hours: 48,
+ limit: 100,
+ });
+ const j = await fetch(url).then((r) => r.json());
+ setLocal(j.updates || []);
+ } catch {
+ setLocal([]);
+ } finally {
+ setLLoad(false);
+ }
+ }, []);
+
+ const loadGlobal = useCallback(async () => {
+ setGLoad(true);
+ try {
+ const j = await fetch(UPDATES_GLOBAL_URL + "?limit=200").then((r) =>
+ r.json()
+ );
+ setGlobal(j.updates || []);
+ } catch {
+ setGlobal([]);
+ } finally {
+ setGLoad(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadGlobal();
+ }, [loadGlobal]);
+ useEffect(() => {
+ if (selectedLL) loadLocal(selectedLL);
+ }, [selectedLL, loadLocal]);
+
+ return {
+ activeTab,
+ setActiveTab,
+ localUpdates,
+ globalUpdates,
+ loadingLocal,
+ loadingGlobal,
+ };
+}
diff --git a/web/src/index.css b/web/src/index.css
new file mode 100644
index 0000000000000000000000000000000000000000..08a3ac9e1e5c44ce374f782d7c4fa3aa70e4c1ff
--- /dev/null
+++ b/web/src/index.css
@@ -0,0 +1,68 @@
+:root {
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b18e5c92082f3cb84b3209c304f4098c320578c2
--- /dev/null
+++ b/web/src/lib/constants.ts
@@ -0,0 +1,14 @@
+export const GMAPS_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
+export const MAP_ID = import.meta.env.VITE_GOOGLE_MAPS_MAP_ID;
+
+const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000";
+export const REPORTS_URL = `${API_BASE}/reports`;
+export const CHAT_URL = `${API_BASE}/chat`;
+export const NWS_URL = `${API_BASE}/feeds/nws`;
+export const USGS_URL = `${API_BASE}/feeds/usgs`;
+export const EONET_URL = `${API_BASE}/feeds/eonet`;
+export const FIRMS_URL = `${API_BASE}/feeds/firms`;
+export const UPLOAD_URL = `${API_BASE}/upload/photo`;
+
+export const UPDATES_LOCAL_URL = `${API_BASE}/updates/local`;
+export const UPDATES_GLOBAL_URL = `${API_BASE}/updates/global`;
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4eb6a78b529356d7893e0cd56658064757b90c04
--- /dev/null
+++ b/web/src/lib/types.ts
@@ -0,0 +1,43 @@
+export type Feature = {
+ type: "Feature";
+ geometry: { type: "Point" | "Polygon" | "MultiPolygon"; coordinates: any };
+ properties: Record;
+};
+export type FC = { type: "FeatureCollection"; features: Feature[] };
+
+export type SelectMeta = {
+ kind:
+ | "search"
+ | "mylocation"
+ | "click"
+ | "quake"
+ | "fire"
+ | "eonet"
+ | "report"
+ | "nws";
+ title?: string;
+ subtitle?: string;
+ severity?: string | number;
+ sourceUrl?: string;
+ confidence?: number;
+ emoji?: string;
+ category?: string;
+ raw?: any;
+};
+
+export type Message = {
+ role: "user" | "assistant";
+ text: string;
+ image?: string;
+};
+
+export type UpdateItem = {
+ kind: "report" | "quake" | "nws" | "eonet" | "fire";
+ title: string;
+ emoji: string;
+ time: string;
+ lat: number;
+ lon: number;
+ severity?: string | number;
+ sourceUrl?: string;
+};
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cc2b046516c7e593da9b5fa7d46f3f6a2da5ba71
--- /dev/null
+++ b/web/src/lib/utils.ts
@@ -0,0 +1,59 @@
+export const sevColor = (sev?: string): string => {
+ switch ((sev || "").toLowerCase()) {
+ case "extreme":
+ return "#6f00ff";
+ case "severe":
+ return "#d7191c";
+ case "moderate":
+ return "#fdae61";
+ case "minor":
+ return "#ffff99";
+ default:
+ return "#9e9e9e";
+ }
+};
+
+export const eonetEmoji = (p: any) => {
+ const s = (
+ p?.category ||
+ p?.categories?.[0]?.title ||
+ p?.title ||
+ ""
+ ).toLowerCase();
+ if (s.includes("wildfire")) return "🔥";
+ if (s.includes("volcano")) return "🌋";
+ if (s.includes("earthquake") || s.includes("seismic")) return "💥";
+ if (
+ s.includes("storm") ||
+ s.includes("cyclone") ||
+ s.includes("hurricane") ||
+ s.includes("typhoon")
+ )
+ return "🌀";
+ if (s.includes("flood")) return "🌊";
+ if (s.includes("landslide")) return "🏔️";
+ if (s.includes("drought")) return "🌵";
+ if (s.includes("ice") || s.includes("snow") || s.includes("blizzard"))
+ return "❄️";
+ if (s.includes("dust") || s.includes("smoke") || s.includes("haze"))
+ return "🌫️";
+ return "⚠️";
+};
+
+export const toQuery = (o: Record) =>
+ "?" +
+ Object.entries(o)
+ .map(
+ ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`
+ )
+ .join("&");
+
+export const formatAgo = (iso?: string) => {
+ if (!iso) return "";
+ const t = new Date(iso);
+ const s = Math.max(0, (Date.now() - t.getTime()) / 1000);
+ if (s < 60) return `${Math.floor(s)}s ago`;
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
+ return `${Math.floor(s / 86400)}d ago`;
+};
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d192394a3471f138de744687147b6c305f168a5b
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,11 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App.tsx";
+import "leaflet/dist/leaflet.css";
+import "./style.css";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/web/src/style.css b/web/src/style.css
new file mode 100644
index 0000000000000000000000000000000000000000..840fbf672817d60fa63860d029e33470a24d328c
--- /dev/null
+++ b/web/src/style.css
@@ -0,0 +1,347 @@
+/* THEME */
+:root {
+ --primary: #006266; /* main brand */
+ --accent: #009432;
+ --accent-2: #a3cb38;
+ --accent-3: #c4e538;
+ --bg: #121212;
+ --panel: #ffffff;
+ --muted: #667085;
+ --border: #e6e8eb;
+}
+
+/* BASE */
+html,
+body,
+#root {
+ height: 100%;
+ margin: 0;
+ background: var(--bg);
+ font: 14px/1.3 system-ui, -apple-system, "Segoe UI", Roboto, Arial;
+}
+body {
+ overflow: hidden;
+} /* panes handle their own scroll */
+
+/* LAYOUT */
+.shell {
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+}
+
+/* SIDEBAR */
+.sidebar {
+ flex: 0 0 360px; /* wider: was ~320px */
+ max-width: 420px;
+ min-width: 300px;
+ height: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ background: #0f1f1d; /* dark teal tint */
+ color: #e8f2f0;
+ padding: 16px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ border-right: 1px solid rgba(255, 255, 255, 0.06);
+
+ /* hide scrollbar */
+ -ms-overflow-style: none; /* IE/old Edge */
+ scrollbar-width: none; /* Firefox */
+ padding-right: 8px;
+}
+.sidebar::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+}
+.sidebar::-webkit-scrollbar-track,
+.sidebar::-webkit-scrollbar-thumb {
+ background: transparent;
+}
+.sidebar:hover {
+ overscroll-behavior: contain;
+}
+
+/* Brand */
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: small;
+}
+.logo {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, var(--primary), var(--accent));
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+}
+.title {
+ font-weight: 700;
+ letter-spacing: 0.2px;
+}
+
+/* Cards & controls */
+.block {
+ background: rgba(255, 255, 255, 0.04);
+ padding: 12px;
+ border-radius: 10px;
+}
+.label {
+ display: block;
+ color: #cfe7e2;
+ font-size: 12px;
+ margin-bottom: 6px;
+}
+.input {
+ appearance: none;
+ outline: none;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background: rgba(255, 255, 255, 0.08);
+ color: #fff;
+ padding: 8px 10px;
+ border-radius: 10px;
+}
+.input.flex {
+ flex: 1;
+}
+.btn {
+ border: none;
+ border-radius: 10px;
+ padding: 8px 12px;
+ cursor: pointer;
+ color: #fff;
+ background: var(--primary);
+}
+.btn:hover {
+ filter: brightness(1.05);
+}
+.btn-ghost {
+ background: transparent;
+ color: #cfe7e2;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+}
+.btn-ghost:hover {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+/* Selected location card */
+.locationCard {
+ background: #0a1716;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ padding: 10px;
+ border-radius: 10px;
+}
+.locName {
+ font-weight: 600;
+}
+.locLL,
+.locDetecting {
+ color: #b9e0d9;
+}
+.hint {
+ color: #a9c9c3;
+ font-size: 12px;
+ margin-top: 6px;
+}
+
+/* Updates */
+.tabs {
+ display: flex;
+ gap: 6px;
+}
+.tab {
+ flex: 1;
+ text-align: center;
+ padding: 8px 10px;
+ border-radius: 10px;
+ cursor: pointer;
+ color: #d7efe9;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+.tab-active {
+ background: linear-gradient(135deg, var(--primary), var(--accent));
+ color: #fff;
+ border: none;
+}
+.updates {
+ margin-top: 8px;
+}
+.updateItem {
+ background: #0a1716;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ padding: 8px 10px;
+ border-radius: 10px;
+}
+.muted {
+ color: var(--muted);
+}
+
+/* MAIN (map + chat column) */
+.main {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* MAP */
+.mapWrap {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+.map {
+ width: 100%;
+ height: 100%;
+}
+
+/* CHAT — taller to reduce map height a bit */
+.chat {
+ background: #0f1f1d;
+ color: #e8f2f0;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ display: flex;
+ flex-direction: column;
+ height: 340px; /* was 260px */
+}
+.chatHdr {
+ padding: 10px 12px;
+ font-weight: 600;
+ color: #cfe7e2;
+ background: linear-gradient(180deg, #10211f, #0d1b19);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+.chatBody {
+ flex: 1;
+ overflow: auto;
+ padding: 10px 12px;
+ background: #0f1f1d;
+}
+.msg {
+ padding: 8px 10px;
+ border-radius: 10px;
+ margin: 6px 0;
+ max-width: 80%;
+}
+.msg.user {
+ background: rgba(0, 148, 50, 0.15);
+ border: 1px solid rgba(0, 148, 50, 0.35);
+ margin-left: auto;
+}
+.msg.assistant {
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ margin-right: auto;
+}
+
+/* Input row (keeps Attach inside the row — no JSX changes) */
+.chatInputRow {
+ display: flex;
+ gap: 8px;
+ padding: 10px 12px;
+ background: #0b1514;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+}
+.input-chat {
+ flex: 1;
+ appearance: none;
+ outline: none;
+ background: #152422;
+ color: #e8f2f0;
+ border: 1px solid #2a4642;
+ border-radius: 10px;
+ padding: 10px 12px;
+}
+.input-chat::placeholder {
+ color: #9fbab5;
+}
+.chatInputRow .btn {
+ background: var(--primary);
+ color: #fff;
+ border: none;
+ border-radius: 10px;
+ padding: 10px 14px;
+ cursor: pointer;
+}
+.chatInputRow .btn:hover {
+ filter: brightness(1.05);
+}
+
+/* Optional: Leaflet locate styles kept for future use (no duplicates) */
+.leaflet-control-locate a {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ line-height: 28px;
+ text-align: center;
+ background: #fff;
+ color: #fff;
+ border-radius: 4px;
+ text-decoration: none;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
+ font-weight: 700;
+}
+.leaflet-control-locate a:hover {
+ filter: brightness(1.06);
+}
+
+.leaflet-interactive.current-location-marker {
+ animation: pulse 2s infinite;
+}
+@keyframes pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(66, 133, 244, 0.6);
+ }
+ 70% {
+ box-shadow: 0 0 0 15px rgba(66, 133, 244, 0);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(66, 133, 244, 0);
+ }
+}
+
+.locate-btn-icon {
+ width: 14px;
+ height: 14px;
+ border: 2px solid #4285f4;
+ border-radius: 50%;
+ position: relative;
+}
+.locate-btn-icon::before,
+.locate-btn-icon::after {
+ content: "";
+ position: absolute;
+ background: #4285f4;
+}
+.locate-btn-icon::before {
+ top: 50%;
+ left: -4px;
+ width: 22px;
+ height: 2px;
+ transform: translateY(-50%);
+}
+.locate-btn-icon::after {
+ left: 50%;
+ top: -4px;
+ height: 22px;
+ width: 2px;
+ transform: translateX(-50%);
+}
+
+.msg,
+.msg.assistant,
+.msg.user {
+ white-space: pre-wrap; /* preserves \n and wraps long lines */
+}
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..11f02fe2a0061d6e6e1f271b21da95423b448b32
--- /dev/null
+++ b/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json
new file mode 100644
index 0000000000000000000000000000000000000000..227a6c6723b6136d17cdcd42288a01a5e5abcd25
--- /dev/null
+++ b/web/tsconfig.app.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ffef600d959ec9e396d5a260bd3f5b927b2cef8
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
new file mode 100644
index 0000000000000000000000000000000000000000..f85a39906e5571aa351e61e43fff98bc0bedaa27
--- /dev/null
+++ b/web/tsconfig.node.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8b0f57b91aeb45c54467e29f983a0893dc83c4d9
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})