krishnadhulipalla commited on
Commit
0e11366
·
1 Parent(s): d3bceca

First commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +9 -0
  2. .gitignore +16 -0
  3. Dockerfile +37 -0
  4. backend/app/__init__.py +0 -0
  5. backend/app/agents/classifier.py +53 -0
  6. backend/app/agents/graph.py +81 -0
  7. backend/app/agents/tools.py +40 -0
  8. backend/app/config/settings.py +38 -0
  9. backend/app/data/db.py +7 -0
  10. backend/app/data/geo.py +13 -0
  11. backend/app/data/store.py +69 -0
  12. backend/app/main.py +33 -0
  13. backend/app/routers/chat.py +29 -0
  14. backend/app/routers/feeds.py +40 -0
  15. backend/app/routers/reports.py +12 -0
  16. backend/app/routers/uploads.py +25 -0
  17. backend/app/services/chat_agent.py +26 -0
  18. backend/app/services/feeds.py +241 -0
  19. backend/app/services/fetchers.py +154 -0
  20. backend/app/services/reports.py +9 -0
  21. backend/app/types/models.py +27 -0
  22. pyproject.toml +19 -0
  23. requirements.txt +14 -0
  24. web/.gitignore +24 -0
  25. web/README.md +69 -0
  26. web/eslint.config.js +23 -0
  27. web/index.html +12 -0
  28. web/package-lock.json +1731 -0
  29. web/package.json +27 -0
  30. web/public/icons/3d/3d-alert.png +0 -0
  31. web/public/icons/3d/3d-ambulance.png +0 -0
  32. web/public/icons/3d/3d-car.png +0 -0
  33. web/public/icons/3d/3d-construction.png +0 -0
  34. web/public/icons/3d/3d-flood.png +0 -0
  35. web/public/icons/3d/3d-gun.png +0 -0
  36. web/public/icons/3d/3d-help.png +0 -0
  37. web/public/icons/3d/3d-info.png +0 -0
  38. web/public/icons/3d/3d-ride.png +0 -0
  39. web/public/icons/3d/3d-robbery.png +0 -0
  40. web/public/icons/3d/3d-search.png +0 -0
  41. web/public/icons/3d/3d-sex.png +0 -0
  42. web/public/icons/3d/3d-traffic.png +0 -0
  43. web/public/icons/3d/3d-user_search.png +0 -0
  44. web/public/vite.svg +1 -0
  45. web/src/App.css +48 -0
  46. web/src/App.tsx +159 -0
  47. web/src/assets/react.svg +1 -0
  48. web/src/components/ReportIcon.tsx +34 -0
  49. web/src/components/chat/ChatPanel.tsx +110 -0
  50. web/src/components/chat/TypingDots.tsx +10 -0
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ venv
3
+ .venv
4
+ __pycache__/
5
+ *.pyc
6
+ node_modules/
7
+ web/node_modules/
8
+ web/.vite/
9
+ data/
.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+
5
+ # Env
6
+ .env
7
+ venv/
8
+ .venv/
9
+
10
+ # Data
11
+ data/**
12
+ !data/.gitkeep
13
+
14
+ # Node
15
+ web/node_modules/
16
+ web/dist/
Dockerfile ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---------- Stage 1: build the React app ----------
2
+ FROM node:20-alpine AS webbuilder
3
+ WORKDIR /web
4
+ COPY web/package*.json ./
5
+ RUN npm ci
6
+ COPY web/ .
7
+ RUN npm run build
8
+
9
+ # ---------- Stage 2: Python runtime ----------
10
+ FROM python:3.11-slim
11
+ ENV PYTHONUNBUFFERED=1 \
12
+ PIP_NO_CACHE_DIR=1 \
13
+ PORT=7860 \
14
+ DATA_DIR=/data
15
+ WORKDIR /app
16
+
17
+ # (optional) if you hit build issues with some libs
18
+ RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Copy backend and install deps
21
+ COPY backend ./backend
22
+ # Use a simple requirements file for predictability
23
+ COPY requirements.txt ./
24
+ RUN pip install --upgrade pip && pip install -r requirements.txt
25
+
26
+ # Copy built frontend into /app/web/dist so FastAPI can serve it
27
+ COPY --from=webbuilder /web/dist ./web/dist
28
+
29
+ # Prepare data dir for sqlite + uploads
30
+ RUN mkdir -p ${DATA_DIR}/uploads
31
+ VOLUME ["/data"]
32
+
33
+ # Spaces require a single port—expose default; they’ll pass $PORT
34
+ EXPOSE 7860
35
+
36
+ # Start FastAPI bound to Spaces' $PORT
37
+ CMD ["bash","-lc","uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT}"]
backend/app/__init__.py ADDED
File without changes
backend/app/agents/classifier.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # same content as your current classifier.py, but model name from settings
2
+ from __future__ import annotations
3
+ from typing import Optional
4
+ from pydantic import BaseModel, Field
5
+ from langchain_openai import ChatOpenAI
6
+ from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate
7
+ from ..config.settings import settings
8
+
9
+ class ReportClassification(BaseModel):
10
+ category: str = Field(..., description="taxonomy id like 'crime.gunshot'")
11
+ label: str = Field(..., description="short human title")
12
+ description: Optional[str] = Field(None, description="one sentence, no emojis")
13
+ severity: Optional[str] = None
14
+ confidence: float = Field(..., ge=0, le=1)
15
+
16
+ CATEGORY_TO_ICON = {
17
+ "crime.gunshot": "3d-gun",
18
+ "crime.robbery": "3d-robbery",
19
+ "crime.sex_offender": "3d-sex",
20
+ "crime.suspicious": "3d-alert",
21
+ "incident.missing_person": "3d-user_search",
22
+ "incident.lost_item": "3d-search",
23
+ "incident.medical": "3d-ambulance",
24
+ "incident.car_accident": "3d-car",
25
+ "road.flood": "3d-flood",
26
+ "road.blocked": "3d-traffic",
27
+ "road.construction": "3d-construction",
28
+ "help.general": "3d-help",
29
+ "help.ride": "3d-ride",
30
+ "other.unknown": "3d-info",
31
+ }
32
+
33
+ SYSTEM = ("You classify short community reports into a strict taxonomy. "
34
+ "Return ONLY the schema fields. If unclear, choose other.unknown.")
35
+
36
+ EXAMPLES = [
37
+ {"input": "I heard gunshots near 5th and Pine!",
38
+ "output_json": '{"category":"crime.gunshot","label":"Gunshots reported","description":"Multiple shots heard near 5th and Pine.","severity":"high","confidence":0.9}'},
39
+ {"input": "Car crash blocking the left lane on I-66",
40
+ "output_json": '{"category":"incident.car_accident","label":"Car accident","description":"Crash reported blocking the left lane on I-66.","severity":"medium","confidence":0.85}'},
41
+ ]
42
+
43
+ example_block = ChatPromptTemplate.from_messages([("human", "{input}"), ("ai", "{output_json}")])
44
+ prompt = ChatPromptTemplate.from_messages([
45
+ ("system", SYSTEM),
46
+ FewShotChatMessagePromptTemplate(example_prompt=example_block, examples=EXAMPLES),
47
+ ("human", "{text}"),
48
+ ])
49
+
50
+ _model = ChatOpenAI(model=settings.OPENAI_MODEL_CLASSIFIER, temperature=0).with_structured_output(ReportClassification)
51
+
52
+ def classify_report_text(text: str) -> ReportClassification:
53
+ return (prompt | _model).invoke({"text": text})
backend/app/agents/graph.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import Annotated, Dict, List, Optional, TypedDict
3
+ from langgraph.graph import StateGraph, START, END
4
+ from langgraph.prebuilt import ToolNode
5
+ from langgraph.graph.message import add_messages
6
+ from langchain_openai import ChatOpenAI
7
+ from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, BaseMessage, ToolMessage
8
+ from langgraph.checkpoint.sqlite.aio import SqliteSaver
9
+ import sqlite3
10
+
11
+ from .tools import TOOLS
12
+ from ..config.settings import settings
13
+
14
+ SYSTEM_PROMPT = """
15
+ You are PulseMap Agent — a calm, friendly assistant inside a live community map.
16
+ You help people add reports and discover what’s happening around them.
17
+
18
+ ### What to do
19
+ - If the user reports an incident (e.g. "flooded underpass here"), call `add_report(lat, lon, text, photo_url?)`.
20
+ - 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=?)`.
21
+ • Default radius = 25 miles (~40 km). Default limit = 10.
22
+ - If no coordinates in the message but `user_location` is provided, use that.
23
+ - If a photo URL is available, pass it through.
24
+
25
+ ### How to answer
26
+ - Speak like a helpful neighbor, not a robot.
27
+ - Use plain text only. No **bold**, no numbered lists, no markdown tables.
28
+ - After a tool call, start with a quick recap then list items newest first using hyphen bullets.
29
+ *“I checked within 25 miles of your location and found 3 updates.”*
30
+ For each item, one line like:
31
+ - 🔫 Gunshot — Severity: High; Confidence: 0.9; Time: 2h ago; Source: User; Photo: yes
32
+ - If nothing found:
33
+ - “I didn’t find anything within 25 miles in the last 48 hours. Want me to widen the search?”
34
+
35
+ ### Safety
36
+ - Keep a supportive tone. Do not dramatize.
37
+ - End with situational advice when it makes sense (e.g. “Avoid driving through floodwater”).
38
+ - Only mention calling 911 if the report itself clearly describes an urgent danger.
39
+ - Never invent reports — summarize only what tools/feed data provide.
40
+ """
41
+
42
+ # Long-lived sessions DB (same filename as before)
43
+ conn = sqlite3.connect(str(settings.SESSIONS_DB), check_same_thread=False)
44
+
45
+ model = ChatOpenAI(
46
+ model=settings.OPENAI_MODEL_AGENT,
47
+ temperature=0.2,
48
+ openai_api_key=settings.OPENAI_API_KEY,
49
+ streaming=True,
50
+ ).bind_tools(TOOLS)
51
+
52
+ class AgentState(TypedDict):
53
+ messages: Annotated[List[BaseMessage], add_messages]
54
+ user_location: Optional[Dict[str, float]]
55
+ photo_url: Optional[str]
56
+
57
+ def model_call(state: AgentState, config=None) -> AgentState:
58
+ loc = state.get("user_location")
59
+ 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"
60
+ photo = state.get("photo_url") or ""
61
+ photo_hint = f"Photo URL available: {photo}" if photo else "No photo URL in context."
62
+ system = SystemMessage(content=SYSTEM_PROMPT + "\n" + loc_hint + "\n" + photo_hint + "\nOnly call another tool if the user asks for more.")
63
+ msgs = [system, *state["messages"]]
64
+ ai_msg: AIMessage = model.invoke(msgs)
65
+ return {"messages": [ai_msg]}
66
+
67
+ def should_continue(state: AgentState) -> str:
68
+ last = state["messages"][-1]
69
+ if getattr(last, "tool_calls", None):
70
+ return "continue"
71
+ return "end"
72
+
73
+ graph = StateGraph(AgentState)
74
+ graph.add_node("agent", model_call)
75
+ graph.add_node("tools", ToolNode(tools=TOOLS))
76
+ graph.add_edge(START, "agent")
77
+ graph.add_conditional_edges("agent", should_continue, {"continue": "tools", "end": END})
78
+ graph.add_edge("tools", "agent")
79
+
80
+ checkpointer = SqliteSaver(conn)
81
+ APP = graph.compile(checkpointer=checkpointer)
backend/app/agents/tools.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from datetime import datetime, timezone
3
+ from typing import Optional
4
+ from langchain.tools import tool
5
+ from .classifier import classify_report_text, CATEGORY_TO_ICON
6
+ from ..services.reports import add_report, find_reports_near
7
+
8
+ @tool("add_report")
9
+ def add_report_tool(lat: float, lon: float, text: str = "User report", photo_url: Optional[str] = None) -> str:
10
+ """
11
+ Add a user report as a map point (GeoJSON Feature).
12
+ Returns a JSON string: {"ok": true, "feature": ...}
13
+ """
14
+ cls = classify_report_text(text or "User report")
15
+ icon_name = CATEGORY_TO_ICON.get(cls.category, "3d-info")
16
+ props = {
17
+ "title": cls.label,
18
+ "text": cls.description or (text.strip() if text else "User report"),
19
+ "category": cls.category,
20
+ "emoji": icon_name,
21
+ "severity": cls.severity,
22
+ "confidence": cls.confidence,
23
+ "source": "user",
24
+ "reported_at": datetime.now(timezone.utc).isoformat(),
25
+ }
26
+ if photo_url:
27
+ props["photo_url"] = photo_url
28
+ feat = add_report(float(lat), float(lon), text or cls.label, props=props)
29
+ return json.dumps({"ok": True, "feature": feat})
30
+
31
+ @tool("find_reports_near")
32
+ def find_reports_near_tool(lat: float, lon: float, radius_km: float = 10.0, limit: int = 20) -> str:
33
+ """
34
+ Find user reports near a location.
35
+ Returns a JSON string: {"ok": true, "count": N, "results": [Feature,...]}
36
+ """
37
+ res = find_reports_near(float(lat), float(lon), float(radius_km), int(limit))
38
+ return json.dumps({"ok": True, "count": len(res), "results": res})
39
+
40
+ TOOLS = [add_report_tool, find_reports_near_tool]
backend/app/config/settings.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+ from pathlib import Path
3
+ from pydantic import Field
4
+
5
+ class Settings(BaseSettings):
6
+
7
+ model_config = SettingsConfigDict(
8
+ env_file=".env",
9
+ extra="ignore",
10
+ case_sensitive=False,
11
+ populate_by_name=True,
12
+ )
13
+
14
+ FRONTEND_DIST: Path = Path("web") / "dist"
15
+
16
+ # Models
17
+ OPENAI_API_KEY: str | None = None
18
+ OPENAI_MODEL_AGENT: str = "gpt-4o"
19
+ OPENAI_MODEL_CLASSIFIER: str = "gpt-4o-mini"
20
+
21
+ # Data paths
22
+ DATA_DIR: Path = Path("data")
23
+ REPORTS_DB: Path = DATA_DIR / "pulsemaps_reports.db"
24
+ SESSIONS_DB: Path = DATA_DIR / "pulsemap_sessions.db"
25
+ UPLOADS_DIR: Path = DATA_DIR / "uploads"
26
+
27
+ # Defaults
28
+ DEFAULT_RADIUS_KM: float = 40.0 # ~25 miles
29
+ DEFAULT_LIMIT: int = 10
30
+ MAX_AGE_HOURS: int = 48
31
+
32
+ firms_map_key: str | None = Field(default=None, alias="FIRMS_MAP_KEY")
33
+ gdacs_rss_url: str | None = Field(default="https://www.gdacs.org/xml/rss.xml", alias="GDACS_RSS_URL")
34
+ nvidia_api_key: str | None = Field(default=None, alias="NVIDIA_API_KEY")
35
+
36
+ settings = Settings()
37
+ settings.DATA_DIR.mkdir(exist_ok=True)
38
+ settings.UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
backend/app/data/db.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ from . import store # ensure tables are created on import (store does CREATE TABLE)
3
+
4
+ def get_reports_conn() -> sqlite3.Connection:
5
+ # store.py already keeps a module-level connection; this is a placeholder
6
+ from .store import _CONN as REPORTS_CONN # type: ignore
7
+ return REPORTS_CONN
backend/app/data/geo.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from math import radians, sin, cos, asin, sqrt
2
+ from typing import Tuple
3
+
4
+ def haversine_km(a: Tuple[float, float], b: Tuple[float, float]) -> float:
5
+ """Distance in km between (lat,lon) points a, b."""
6
+ lat1, lon1 = a
7
+ lat2, lon2 = b
8
+ R = 6371.0
9
+ dlat = radians(lat2 - lat1)
10
+ dlon = radians(lon2 - lon1)
11
+ lat1r, lat2r = radians(lat1), radians(lat2)
12
+ h = sin(dlat/2)**2 + cos(lat1r)*cos(lat2r)*sin(dlon/2)**2
13
+ return 2 * R * asin(sqrt(h))
backend/app/data/store.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # same content as your current store.py, just moved here
2
+ from __future__ import annotations
3
+ import json, sqlite3
4
+ from datetime import datetime, timezone, timedelta
5
+ from typing import Dict, Any, List, Optional
6
+ from pathlib import Path
7
+ from ..data.geo import haversine_km
8
+
9
+ Path("data").mkdir(exist_ok=True)
10
+ _CONN = sqlite3.connect("data/pulsemaps_reports.db", check_same_thread=False)
11
+ _CONN.execute("""
12
+ CREATE TABLE IF NOT EXISTS reports (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ lat REAL NOT NULL,
15
+ lon REAL NOT NULL,
16
+ text TEXT NOT NULL,
17
+ props_json TEXT,
18
+ created_at TEXT NOT NULL
19
+ )
20
+ """)
21
+ _CONN.commit()
22
+
23
+ def _row_to_feature(row: tuple) -> Dict[str, Any]:
24
+ _id, lat, lon, text, props_json, created_at = row
25
+ props = {"type": "user_report", "text": text, "reported_at": created_at}
26
+ if props_json:
27
+ try: props.update(json.loads(props_json))
28
+ except Exception: props["raw_props"] = props_json
29
+ return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [lon, lat]}, "properties": props}
30
+
31
+ def add_report(lat: float, lon: float, text: str = "User report", props: dict | None = None):
32
+ created_at = datetime.now(timezone.utc).isoformat()
33
+ props_json = json.dumps(props or {})
34
+ _CONN.execute("INSERT INTO reports (lat, lon, text, props_json, created_at) VALUES (?,?,?,?,?)",
35
+ (float(lat), float(lon), text, props_json, created_at))
36
+ _CONN.commit()
37
+ return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [float(lon), float(lat)]},
38
+ "properties": {"type": "user_report", "text": text, "reported_at": created_at, **(props or {})}}
39
+
40
+ def get_feature_collection() -> Dict[str, Any]:
41
+ cur = _CONN.execute("SELECT id, lat, lon, text, props_json, created_at FROM reports ORDER BY id DESC")
42
+ feats = [_row_to_feature(r) for r in cur.fetchall()]
43
+ return {"type": "FeatureCollection", "features": feats}
44
+
45
+ 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]]:
46
+ params: list[Any] = []
47
+ sql = "SELECT id, lat, lon, text, props_json, created_at FROM reports"
48
+ if max_age_hours is not None:
49
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=int(max_age_hours))
50
+ sql += " WHERE datetime(created_at) >= datetime(?)"
51
+ params.append(cutoff.isoformat())
52
+ sql += " ORDER BY id DESC LIMIT 2000"
53
+ cur = _CONN.execute(sql, params)
54
+
55
+ center = (lat, lon)
56
+ cand = []
57
+ for r in cur.fetchall():
58
+ _, lat2, lon2, *_ = r
59
+ d = haversine_km(center, (lat2, lon2))
60
+ if d <= radius_km:
61
+ cand.append((d, r))
62
+ cand.sort(key=lambda x: x[0])
63
+ out = [_row_to_feature(r) for _, r in cand[:max(1, limit)]]
64
+ return out
65
+
66
+ def clear_reports() -> dict[str, any]:
67
+ _CONN.execute("DELETE FROM reports")
68
+ _CONN.commit()
69
+ return {"ok": True, "message": "All reports cleared."}
backend/app/main.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.staticfiles import StaticFiles
4
+ from pathlib import Path
5
+
6
+ from .config.settings import settings
7
+
8
+ app = FastAPI(title="PulseMap Agent – API", version="0.2.0")
9
+
10
+ app.add_middleware(
11
+ CORSMiddleware,
12
+ allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
13
+ )
14
+
15
+ # Static uploads
16
+ app.mount("/uploads", StaticFiles(directory=str(settings.UPLOADS_DIR)), name="uploads")
17
+
18
+ # Routers
19
+ from .routers import chat, reports, feeds, uploads # noqa
20
+ from .routers.feeds import updates as updates_router
21
+ app.include_router(chat.router)
22
+ app.include_router(reports.router)
23
+ app.include_router(feeds.router)
24
+ app.include_router(updates_router)
25
+ app.include_router(uploads.router)
26
+
27
+ if settings.FRONTEND_DIST.exists():
28
+ app.mount("/", StaticFiles(directory=str(settings.FRONTEND_DIST), html=True), name="spa")
29
+
30
+ @app.get("/health")
31
+ def health():
32
+ from datetime import datetime, timezone
33
+ return {"ok": True, "time": datetime.now(timezone.utc).isoformat()}
backend/app/routers/chat.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Body
2
+ from typing import Dict, Any, Optional
3
+
4
+ from ..services.chat_agent import run_chat
5
+
6
+ router = APIRouter(prefix="/chat", tags=["chat"])
7
+
8
+ @router.post("")
9
+ def chat(payload: Dict[str, Any] = Body(...)):
10
+ """
11
+ Body: { "message": str, "user_location": {lat,lon}?, "session_id"?: str, "photo_url"?: str }
12
+ """
13
+ msg = payload.get("message", "")
14
+ if not isinstance(msg, str) or not msg.strip():
15
+ return {"reply": "Please type something.", "tool_used": None}
16
+ return run_chat(
17
+ message=msg.strip(),
18
+ user_location=payload.get("user_location"),
19
+ session_id=payload.get("session_id"),
20
+ photo_url=payload.get("photo_url"),
21
+ )
22
+
23
+ @router.post("/reset")
24
+ def reset_chat(payload: Dict[str, Any] = Body(...)):
25
+ sid = payload.get("session_id")
26
+ if not sid:
27
+ return {"ok": False, "error": "session_id required"}
28
+ # Same guidance as before—client can rotate session_id for SqliteSaver threads.
29
+ return {"ok": True}
backend/app/routers/feeds.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from typing import Any, Dict, Optional
3
+ from ..services.feeds import (
4
+ fetch_usgs_quakes_geojson, fetch_nws_alerts_geojson,
5
+ eonet_geojson_points, firms_geojson_points, # <-- use normalized point outputs
6
+ local_updates as _local_updates, global_updates as _global_updates
7
+ )
8
+
9
+ router = APIRouter(prefix="/feeds", tags=["feeds"])
10
+
11
+ @router.get("/usgs")
12
+ async def usgs():
13
+ return {"data": await fetch_usgs_quakes_geojson()}
14
+
15
+ @router.get("/nws")
16
+ async def nws():
17
+ return {"data": await fetch_nws_alerts_geojson()}
18
+
19
+ @router.get("/eonet")
20
+ async def eonet():
21
+ return {"data": await eonet_geojson_points()}
22
+
23
+ @router.get("/firms")
24
+ async def firms():
25
+ # Return pointified features for map markers
26
+ return {"data": await firms_geojson_points()}
27
+
28
+ # Convenience endpoints parallel to your previous design
29
+ updates = APIRouter(prefix="/updates", tags=["updates"])
30
+
31
+ @updates.get("/local")
32
+ async def local_updates(lat: float, lon: float, radius_miles: float = 25.0,
33
+ max_age_hours: int = 48, limit: int = 100):
34
+ return await _local_updates(lat, lon, radius_miles, max_age_hours, limit)
35
+
36
+ @updates.get("/global")
37
+ async def global_updates(limit: int = 200, max_age_hours: Optional[int] = None):
38
+ return await _global_updates(limit, max_age_hours)
39
+
40
+ router.include_router(updates)
backend/app/routers/reports.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from ..data.store import get_feature_collection, clear_reports
3
+
4
+ router = APIRouter(prefix="/reports", tags=["reports"])
5
+
6
+ @router.get("")
7
+ def reports():
8
+ return get_feature_collection()
9
+
10
+ @router.post("/clear")
11
+ def clear_reports_api():
12
+ return clear_reports()
backend/app/routers/uploads.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, UploadFile, File, Request, HTTPException
2
+ import os
3
+ from uuid import uuid4
4
+ from ..config.settings import settings
5
+
6
+ router = APIRouter(prefix="/upload", tags=["uploads"])
7
+
8
+ @router.post("/photo")
9
+ async def upload_photo(request: Request, file: UploadFile = File(...)):
10
+ if not file.content_type or not file.content_type.startswith("image/"):
11
+ raise HTTPException(status_code=400, detail="Only image files are allowed.")
12
+ ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg"
13
+ if ext not in [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"]:
14
+ ext = ".jpg"
15
+
16
+ data = await file.read()
17
+ if len(data) > 5 * 1024 * 1024:
18
+ raise HTTPException(status_code=413, detail="Image too large (max 5MB).")
19
+
20
+ name = f"{uuid4().hex}{ext}"
21
+ (settings.UPLOADS_DIR / name).write_bytes(data)
22
+
23
+ base = str(request.base_url).rstrip("/")
24
+ url = f"{base}/uploads/{name}"
25
+ return {"ok": True, "url": url, "path": f"/uploads/{name}"}
backend/app/services/chat_agent.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, Optional
2
+ from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
3
+ from ..agents.graph import APP
4
+
5
+ def run_chat(message: str,
6
+ user_location: Optional[Dict[str, float]] = None,
7
+ session_id: Optional[str] = None,
8
+ photo_url: Optional[str] = None) -> Dict[str, Any]:
9
+ from uuid import uuid4
10
+ sid = session_id or str(uuid4())
11
+ init = {"messages": [HumanMessage(content=message)], "user_location": user_location, "photo_url": photo_url}
12
+ cfg = {"configurable": {"thread_id": sid}}
13
+ final = APP.invoke(init, config=cfg)
14
+
15
+ reply, tool_used, tool_result = "", None, None
16
+ for m in final["messages"]:
17
+ if isinstance(m, AIMessage):
18
+ reply = m.content or reply
19
+ elif isinstance(m, ToolMessage) and getattr(m, "name", None) in {"add_report", "find_reports_near"}:
20
+ import json
21
+ try:
22
+ tool_used = m.name
23
+ tool_result = json.loads(m.content) if isinstance(m.content, str) else m.content
24
+ except Exception:
25
+ tool_result = {"raw": m.content}
26
+ return {"reply": reply, "tool_used": tool_used, "tool_result": tool_result, "session_id": sid}
backend/app/services/feeds.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from datetime import datetime, timezone
3
+ from typing import Any, Dict, Optional, List, Iterable, Tuple
4
+ from dateutil import parser as dtparser
5
+
6
+ from ..data.geo import haversine_km
7
+ from .fetchers import (
8
+ fetch_usgs_quakes_geojson, fetch_nws_alerts_geojson,
9
+ fetch_eonet_events_geojson, fetch_firms_hotspots_geojson
10
+ )
11
+
12
+ def _flatten_lonlats(coords: Any) -> List[Tuple[float, float]]:
13
+ """Collect (lon, lat) pairs from nested coordinate arrays."""
14
+ out: List[Tuple[float, float]] = []
15
+ if not isinstance(coords, (list, tuple)):
16
+ return out
17
+ if len(coords) >= 2 and isinstance(coords[0], (int, float)) and isinstance(coords[1], (int, float)):
18
+ # Single coordinate pair [lon, lat, ...]
19
+ out.append((float(coords[0]), float(coords[1])))
20
+ else:
21
+ for c in coords:
22
+ out.extend(_flatten_lonlats(c))
23
+ return out
24
+
25
+ def _centroid_from_geom(geom: Dict[str, Any]) -> Optional[Tuple[float, float]]:
26
+ """Return (lon, lat) for any geometry by taking a simple average of all coords."""
27
+ if not geom or "type" not in geom:
28
+ return None
29
+ gtype = geom.get("type")
30
+ coords = geom.get("coordinates")
31
+
32
+ # Fast path for Point
33
+ if gtype == "Point" and isinstance(coords, (list, tuple)) and len(coords) >= 2:
34
+ return (float(coords[0]), float(coords[1]))
35
+
36
+ # Generic centroid for Polygon/MultiPolygon/LineString/etc.
37
+ pts = _flatten_lonlats(coords)
38
+ if not pts:
39
+ return None
40
+ xs = [p[0] for p in pts]
41
+ ys = [p[1] for p in pts]
42
+ return (sum(xs) / len(xs), sum(ys) / len(ys))
43
+
44
+ def _mk_point_feature(lon: float, lat: float, props: Dict[str, Any]) -> Dict[str, Any]:
45
+ return {
46
+ "type": "Feature",
47
+ "geometry": {"type": "Point", "coordinates": [float(lon), float(lat)]},
48
+ "properties": props or {},
49
+ }
50
+
51
+ def _report_to_update(f: Dict[str, Any]) -> Dict[str, Any]:
52
+ p = f.get("properties", {}) or {}
53
+ lat = f["geometry"]["coordinates"][1]
54
+ lon = f["geometry"]["coordinates"][0]
55
+ return {
56
+ "kind": "report",
57
+ "title": p.get("title") or p.get("text") or "User report",
58
+ "emoji": p.get("emoji") or "📝",
59
+ "time": p.get("reported_at"),
60
+ "lat": float(lat), "lon": float(lon),
61
+ "severity": p.get("severity"),
62
+ "sourceUrl": None,
63
+ "raw": p,
64
+ }
65
+
66
+ def _quake_to_update(f: Dict[str, Any]) -> Dict[str, Any] | None:
67
+ p = f.get("properties", {}) or {}
68
+ g = f.get("geometry", {}) or {}
69
+ if g.get("type") != "Point": return None
70
+ lon, lat = g["coordinates"][:2]
71
+ title = p.get("place") or p.get("title") or "Earthquake"
72
+ mag = p.get("mag") or p.get("Magnitude") or p.get("m")
73
+ ts = p.get("time")
74
+ if isinstance(ts, (int, float)):
75
+ time_iso = datetime.fromtimestamp(ts/1000, tz=timezone.utc).isoformat()
76
+ else:
77
+ time_iso = p.get("updated") if isinstance(p.get("updated"), str) else datetime.now(timezone.utc).isoformat()
78
+ return {"kind": "quake", "title": title, "emoji": "💥", "time": time_iso,
79
+ "lat": float(lat), "lon": float(lon), "severity": f"M{mag}" if mag is not None else None,
80
+ "sourceUrl": p.get("url") or p.get("detail"), "raw": p}
81
+
82
+ def _eonet_to_update(f: Dict[str, Any]) -> Dict[str, Any] | None:
83
+ p = f.get("properties", {}) or {}
84
+ g = f.get("geometry", {}) or {}
85
+ if g.get("type") != "Point": return None
86
+ lon, lat = g["coordinates"][:2]
87
+ title = p.get("title") or p.get("category") or "Event"
88
+ cat = (p.get("category") or (p.get("categories") or [{}])[0].get("title") or "").lower()
89
+ if "wildfire" in cat: emoji = "🔥"
90
+ elif "volcano" in cat: emoji = "🌋"
91
+ elif "earthquake" in cat or "seismic" in cat: emoji = "💥"
92
+ elif any(k in cat for k in ["storm","cyclone","hurricane","typhoon"]): emoji = "🌀"
93
+ elif "flood" in cat: emoji = "🌊"
94
+ elif "landslide" in cat: emoji = "🏔️"
95
+ elif any(k in cat for k in ["ice","snow","blizzard"]): emoji = "❄️"
96
+ elif any(k in cat for k in ["dust","smoke","haze"]): emoji = "🌫️"
97
+ else: emoji = "⚠️"
98
+ time_iso = p.get("time") or p.get("updated") or datetime.now(timezone.utc).isoformat()
99
+ return {"kind": "eonet", "title": title, "emoji": emoji, "time": time_iso,
100
+ "lat": float(lat), "lon": float(lon), "sourceUrl": p.get("link") or p.get("url"), "raw": p}
101
+
102
+ def _firms_to_update(f: Dict[str, Any]) -> Dict[str, Any] | None:
103
+ p = f.get("properties", {}) or {}
104
+ g = f.get("geometry", {}) or {}
105
+ if g.get("type") != "Point": return None
106
+ lon, lat = g["coordinates"][:2]
107
+ time_iso = p.get("acq_datetime") or p.get("acq_date") or datetime.now(timezone.utc).isoformat()
108
+ sev = p.get("confidence") or p.get("brightness") or p.get("frp")
109
+ return {"kind": "fire", "title": "Fire hotspot", "emoji": "🔥", "time": time_iso,
110
+ "lat": float(lat), "lon": float(lon), "severity": sev, "sourceUrl": None, "raw": p}
111
+
112
+ def _within(lat: float, lon: float, u: Dict[str, Any], radius_km: float) -> bool:
113
+ return haversine_km((lat, lon), (u["lat"], u["lon"])) <= radius_km
114
+
115
+ def _is_recent(iso: str | None, max_age_hours: int) -> bool:
116
+ if not iso: return False
117
+ try:
118
+ t = dtparser.isoparse(iso)
119
+ if not t.tzinfo: t = t.replace(tzinfo=timezone.utc)
120
+ except Exception:
121
+ return False
122
+ return (datetime.now(timezone.utc) - t).total_seconds() <= max_age_hours * 3600
123
+
124
+ async def _gather_feeds():
125
+ results = await asyncio.gather(
126
+ fetch_usgs_quakes_geojson(), fetch_nws_alerts_geojson(),
127
+ fetch_eonet_events_geojson(), fetch_firms_hotspots_geojson(),
128
+ return_exceptions=True
129
+ )
130
+ def ok(x): return {"features": []} if isinstance(x, Exception) or not x else x
131
+ return {"usgs": ok(results[0]), "nws": ok(results[1]), "eonet": ok(results[2]), "firms": ok(results[3])}
132
+
133
+ async def local_updates(lat: float, lon: float, radius_miles: float, max_age_hours: int, limit: int):
134
+ from ..data.store import find_reports_near
135
+ km = float(radius_miles) * 1.609344
136
+ near_reports = find_reports_near(lat, lon, radius_km=km, limit=limit, max_age_hours=max_age_hours)
137
+ updates: List[Dict[str, Any]] = [_report_to_update(f) for f in near_reports]
138
+ feeds = await _gather_feeds()
139
+
140
+ for f in (feeds["usgs"].get("features") or []):
141
+ u = _quake_to_update(f)
142
+ if u and _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km):
143
+ updates.append(u)
144
+ for u in _nws_to_updates(feeds["nws"]):
145
+ if _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km):
146
+ updates.append(u)
147
+ for f in (feeds["eonet"].get("features") or []):
148
+ u = _eonet_to_update(f)
149
+ if u and _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km):
150
+ updates.append(u)
151
+ for f in (feeds["firms"].get("features") or []):
152
+ u = _firms_to_update(f)
153
+ if u and _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km):
154
+ updates.append(u)
155
+
156
+ updates.sort(key=lambda x: x["time"] or "", reverse=True)
157
+ return {"count": min(len(updates), limit), "updates": updates[:limit]}
158
+
159
+ def _nws_to_updates(fc: Dict[str, Any]) -> list[Dict[str, Any]]:
160
+ out: list[Dict[str, Any]] = []
161
+ for f in (fc.get("features") or []):
162
+ p = f.get("properties", {}) or {}
163
+ g = f.get("geometry", {}) or {}
164
+ coords = None
165
+ if g.get("type") == "Polygon":
166
+ poly = g["coordinates"][0]
167
+ if poly:
168
+ lats = [c[1] for c in poly]; lons = [c[0] for c in poly]
169
+ coords = (sum(lats)/len(lats), sum(lons)/len(lons))
170
+ elif g.get("type") == "Point":
171
+ coords = (g["coordinates"][1], g["coordinates"][0])
172
+ if not coords:
173
+ continue
174
+ sev = p.get("severity") or "Unknown"
175
+ issued = p.get("effective") or p.get("onset") or p.get("sent") or datetime.now(timezone.utc).isoformat()
176
+ out.append({"kind": "nws", "title": p.get("event") or "NWS Alert", "emoji": "⚠️",
177
+ "time": issued, "lat": float(coords[0]), "lon": float(coords[1]),
178
+ "severity": sev, "sourceUrl": p.get("@id") or p.get("id"), "raw": p})
179
+ return out
180
+
181
+ async def global_updates(limit: int, max_age_hours: Optional[int]):
182
+ from ..data.store import get_feature_collection
183
+ fc = get_feature_collection()
184
+ reports = fc.get("features") or []
185
+ rep_updates = [_report_to_update(f) for f in reports]
186
+ feeds = await _gather_feeds()
187
+ nws_updates = _nws_to_updates(feeds["nws"])
188
+ quake_updates = [_ for f in (feeds["usgs"].get("features") or []) if (_ := _quake_to_update(f))]
189
+ eonet_updates = [_ for f in (feeds["eonet"].get("features") or []) if (_ := _eonet_to_update(f))]
190
+ firms_updates = [_ for f in (feeds["firms"].get("features") or []) if (_ := _firms_to_update(f))]
191
+
192
+ updates = rep_updates + nws_updates + quake_updates + eonet_updates + firms_updates
193
+ if max_age_hours is not None:
194
+ updates = [u for u in updates if _is_recent(u["time"], max_age_hours)]
195
+ updates.sort(key=lambda x: x["time"] or "", reverse=True)
196
+ return {"count": min(len(updates), limit), "updates": updates[:limit]}
197
+
198
+ async def eonet_geojson_points() -> Dict[str, Any]:
199
+ """Always return Point features for EONET (polygon events -> centroid)."""
200
+ fc = await fetch_eonet_events_geojson() or {}
201
+ features = []
202
+ for f in (fc.get("features") or []):
203
+ g = f.get("geometry") or {}
204
+ p = f.get("properties") or {}
205
+ cen = _centroid_from_geom(g)
206
+ if not cen:
207
+ continue
208
+ lon, lat = cen
209
+ # Keep a stable, small prop set the map can style
210
+ props = {
211
+ "source": "eonet",
212
+ "title": p.get("title") or p.get("category") or "Event",
213
+ "emoji": "⚠️", # the map can replace based on category if it wants
214
+ "raw": p,
215
+ }
216
+ features.append(_mk_point_feature(lon, lat, props))
217
+ return {"type": "FeatureCollection", "features": features}
218
+
219
+ async def firms_geojson_points() -> Dict[str, Any]:
220
+ """Always return Point features for FIRMS (skip invalid rows)."""
221
+ fc = await fetch_firms_hotspots_geojson() or {}
222
+ features = []
223
+ for f in (fc.get("features") or []):
224
+ g = f.get("geometry") or {}
225
+ p = f.get("properties") or {}
226
+ cen = _centroid_from_geom(g)
227
+ if not cen:
228
+ # Some rows can be malformed; skip them
229
+ continue
230
+ lon, lat = cen
231
+ props = {
232
+ "source": "firms",
233
+ "title": "Fire hotspot",
234
+ "emoji": "🔥",
235
+ "confidence": p.get("confidence"),
236
+ "brightness": p.get("brightness"),
237
+ "time": p.get("acq_datetime") or p.get("acq_date"),
238
+ "raw": p,
239
+ }
240
+ features.append(_mk_point_feature(lon, lat, props))
241
+ return {"type": "FeatureCollection", "features": features}
backend/app/services/fetchers.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import os, io, csv
3
+ import asyncio
4
+ import random
5
+ import httpx
6
+
7
+
8
+ # Keep URLs simple & stable; you can lift to config/env later.
9
+ USGS_ALL_HOUR = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson"
10
+ NWS_ALERTS_ACTIVE = "https://api.weather.gov/alerts/active"
11
+ EONET_EVENTS_GEOJSON = "https://eonet.gsfc.nasa.gov/api/v3/events/geojson?status=open&days=7"
12
+ DATASETS = ["VIIRS_NOAA20_NRT", "VIIRS_SNPP_NRT"]
13
+
14
+
15
+ import httpx
16
+
17
+ def _in_usa(lat: float, lon: float) -> bool:
18
+ # CONUS
19
+ if 24.5 <= lat <= 49.5 and -125.0 <= lon <= -66.0:
20
+ return True
21
+ # Alaska (rough)
22
+ if 51.0 <= lat <= 71.0 and -170.0 <= lon <= -129.0:
23
+ return True
24
+ # Hawaii
25
+ if 18.5 <= lat <= 22.5 and -161.0 <= lon <= -154.0:
26
+ return True
27
+ return False
28
+
29
+ async def fetch_json_once(
30
+ url: str,
31
+ headers: dict,
32
+ *,
33
+ connect_timeout: float = 3,
34
+ read_timeout: float = 12,
35
+ ):
36
+ """
37
+ Single attempt fetch; no retries, no delay.
38
+ """
39
+ timeout = httpx.Timeout(
40
+ connect=connect_timeout,
41
+ read=read_timeout,
42
+ write=read_timeout,
43
+ pool=connect_timeout,
44
+ )
45
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
46
+ r = await client.get(url, headers=headers)
47
+ r.raise_for_status()
48
+ return r.json()
49
+
50
+ async def fetch_usgs_quakes_geojson():
51
+ async with httpx.AsyncClient(timeout=10) as client:
52
+ r = await client.get(USGS_ALL_HOUR, headers={"Accept":"application/geo+json"})
53
+ r.raise_for_status()
54
+ return r.json()
55
+
56
+ async def fetch_nws_alerts_geojson():
57
+ async with httpx.AsyncClient(timeout=10) as client:
58
+ r = await client.get(NWS_ALERTS_ACTIVE, headers={"Accept":"application/geo+json"})
59
+ r.raise_for_status()
60
+ return r.json()
61
+
62
+ async def fetch_eonet_events_geojson():
63
+ return await fetch_json_once(
64
+ EONET_EVENTS_GEOJSON,
65
+ headers={"Accept": "application/geo+json"},
66
+ connect_timeout=3,
67
+ read_timeout=12,
68
+ )
69
+
70
+ def _get_num(d: dict, *keys):
71
+ for k in keys:
72
+ if k in d and d[k] not in (None, ""):
73
+ try:
74
+ return float(d[k])
75
+ except Exception:
76
+ pass
77
+ raise KeyError("no numeric value")
78
+
79
+ async def _fetch_firms_csv_rows(key: str, dataset: str, hours: int = 1) -> list[dict]:
80
+ url = f"https://firms.modaps.eosdis.nasa.gov/api/area/csv/{key}/{dataset}/world/{hours}"
81
+ async with httpx.AsyncClient(timeout=20) as client:
82
+ r = await client.get(url, headers={"Accept": "text/csv", "User-Agent": "PulseMap/1.0"})
83
+ text = r.text or ""
84
+
85
+ # Some FIRMS edges return text/plain or octet-stream; parse anyway
86
+ # Strip BOM if present
87
+ if text and text[:1] == "\ufeff":
88
+ text = text[1:]
89
+
90
+ # Try CSV parse
91
+ try:
92
+ reader = csv.DictReader(io.StringIO(text))
93
+ rows = [row for row in reader]
94
+ except Exception:
95
+ rows = []
96
+
97
+ # If we got nothing, surface first 200 chars to the caller for logging
98
+ if not rows:
99
+ return [{"__error__": (text[:200] if text else "empty response")}]
100
+
101
+ return rows
102
+
103
+ async def fetch_firms_hotspots_geojson():
104
+ """
105
+ NASA FIRMS: returns GeoJSON FeatureCollection (Points).
106
+ Requires env FIRMS_MAP_KEY. Tries NOAA-20 first, then SNPP. World, last 24h (1 day segment).
107
+ """
108
+ key = "95fa2dac8d20024aa6a17229dbf5ce74"
109
+ if not key:
110
+ return {"type": "FeatureCollection", "features": [], "_note": "Set FIRMS_MAP_KEY to enable."}
111
+
112
+ errors = []
113
+ for dataset in DATASETS:
114
+ rows = await _fetch_firms_csv_rows(key, dataset, hours=1)
115
+ if rows and "__error__" in rows[0]:
116
+ errors.append(f"{dataset}: {rows[0]['__error__']}")
117
+ continue
118
+
119
+ feats = []
120
+ for i, row in enumerate(rows):
121
+ if i >= 1500:
122
+ break
123
+ try:
124
+ lat = _get_num(row, "latitude", "LATITUDE", "lat", "LAT")
125
+ lon = _get_num(row, "longitude", "LONGITUDE", "lon", "LON")
126
+ except Exception:
127
+ continue
128
+
129
+ props = {
130
+ "source": "FIRMS",
131
+ "dataset": dataset,
132
+ "acq_date": row.get("acq_date") or row.get("ACQ_DATE"),
133
+ "acq_time": row.get("acq_time") or row.get("ACQ_TIME"),
134
+ "instrument": row.get("instrument") or row.get("INSTRUMENT"),
135
+ "confidence": row.get("confidence") or row.get("CONFIDENCE"),
136
+ "frp": row.get("frp") or row.get("FRP"),
137
+ "daynight": row.get("daynight") or row.get("DAYNIGHT"),
138
+ }
139
+ feats.append({
140
+ "type": "Feature",
141
+ "geometry": {"type": "Point", "coordinates": [lon, lat]},
142
+ "properties": props,
143
+ })
144
+
145
+ feats = [f for f in feats
146
+ if _in_usa(f["geometry"]["coordinates"][1], f["geometry"]["coordinates"][0])]
147
+ if feats:
148
+ return {"type": "FeatureCollection", "features": feats, "_note": f"{dataset} ok, {len(feats)} points (USA only)"}
149
+
150
+ # Try next dataset if this one returned 0 points
151
+ errors.append(f"{dataset}: 0 rows or no valid coordinates")
152
+
153
+ # If we got here, nothing worked
154
+ return {"type": "FeatureCollection", "features": [], "_note": f"FIRMS empty. Details: {' | '.join(errors[:2])}"}
backend/app/services/reports.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, List, Optional
2
+ from ..data.store import add_report as _add, find_reports_near as _find
3
+
4
+ def add_report(lat: float, lon: float, text: str, props: dict | None = None) -> Dict[str, Any]:
5
+ return _add(lat, lon, text, props)
6
+
7
+ def find_reports_near(lat: float, lon: float, radius_km: float, limit: int,
8
+ max_age_hours: Optional[int] = None) -> List[Dict[str, Any]]:
9
+ return _find(lat, lon, radius_km, limit, max_age_hours=max_age_hours)
backend/app/types/models.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional, List, Any
3
+
4
+ class UserLocation(BaseModel):
5
+ lat: float
6
+ lon: float
7
+
8
+ class ChatRequest(BaseModel):
9
+ message: str
10
+ user_location: Optional[UserLocation] = None
11
+ session_id: Optional[str] = None
12
+ photo_url: Optional[str] = None
13
+
14
+ class Update(BaseModel):
15
+ kind: str
16
+ title: str
17
+ emoji: str
18
+ time: Optional[str]
19
+ lat: float
20
+ lon: float
21
+ severity: Optional[str] = None
22
+ sourceUrl: Optional[str] = None
23
+ raw: Any
24
+
25
+ class UpdatesResponse(BaseModel):
26
+ count: int
27
+ updates: List[Update]
pyproject.toml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "pulsemaps-backend"
3
+ version = "0.2.0"
4
+ requires-python = ">=3.10"
5
+ dependencies = [
6
+ "fastapi",
7
+ "uvicorn[standard]",
8
+ "pydantic",
9
+ "pydantic-settings",
10
+ "python-dateutil",
11
+ "httpx",
12
+ "langchain",
13
+ "langchain-openai",
14
+ "langgraph",
15
+ ]
16
+
17
+ [tool.ruff]
18
+ line-length = 100
19
+ select = ["E","F","I","UP"]
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.4
2
+ uvicorn[standard]==0.31.0
3
+ pydantic==2.9.2
4
+ pydantic-settings==2.5.2
5
+ python-multipart==0.0.9
6
+ python-dateutil==2.9.0.post0
7
+ httpx==0.27.2
8
+
9
+ # LangChain stack
10
+ langchain==0.2.16
11
+ langchain-openai==0.1.26
12
+ openai==1.40.6
13
+ langgraph==0.2.34
14
+ aiosqlite==0.20.0
web/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
web/README.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + TypeScript + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8
+ - [@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
9
+
10
+ ## Expanding the ESLint configuration
11
+
12
+ If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13
+
14
+ ```js
15
+ export default tseslint.config([
16
+ globalIgnores(['dist']),
17
+ {
18
+ files: ['**/*.{ts,tsx}'],
19
+ extends: [
20
+ // Other configs...
21
+
22
+ // Remove tseslint.configs.recommended and replace with this
23
+ ...tseslint.configs.recommendedTypeChecked,
24
+ // Alternatively, use this for stricter rules
25
+ ...tseslint.configs.strictTypeChecked,
26
+ // Optionally, add this for stylistic rules
27
+ ...tseslint.configs.stylisticTypeChecked,
28
+
29
+ // Other configs...
30
+ ],
31
+ languageOptions: {
32
+ parserOptions: {
33
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
34
+ tsconfigRootDir: import.meta.dirname,
35
+ },
36
+ // other options...
37
+ },
38
+ },
39
+ ])
40
+ ```
41
+
42
+ 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:
43
+
44
+ ```js
45
+ // eslint.config.js
46
+ import reactX from 'eslint-plugin-react-x'
47
+ import reactDom from 'eslint-plugin-react-dom'
48
+
49
+ export default tseslint.config([
50
+ globalIgnores(['dist']),
51
+ {
52
+ files: ['**/*.{ts,tsx}'],
53
+ extends: [
54
+ // Other configs...
55
+ // Enable lint rules for React
56
+ reactX.configs['recommended-typescript'],
57
+ // Enable lint rules for React DOM
58
+ reactDom.configs.recommended,
59
+ ],
60
+ languageOptions: {
61
+ parserOptions: {
62
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
63
+ tsconfigRootDir: import.meta.dirname,
64
+ },
65
+ // other options...
66
+ },
67
+ },
68
+ ])
69
+ ```
web/eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { globalIgnores } from 'eslint/config'
7
+
8
+ export default tseslint.config([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs['recommended-latest'],
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
web/index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>PulseMap Agent</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
web/package-lock.json ADDED
@@ -0,0 +1,1731 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "cta-web",
3
+ "version": "0.0.1",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "cta-web",
9
+ "version": "0.0.1",
10
+ "dependencies": {
11
+ "@vis.gl/react-google-maps": "^1.5.5",
12
+ "leaflet": "^1.9.4",
13
+ "lucide-react": "^0.542.0",
14
+ "react": "^18.3.1",
15
+ "react-dom": "^18.3.1",
16
+ "react-leaflet": "^4.2.1"
17
+ },
18
+ "devDependencies": {
19
+ "@types/leaflet": "^1.9.20",
20
+ "@types/react": "^18.3.24",
21
+ "@types/react-dom": "^18.3.7",
22
+ "@vitejs/plugin-react": "^5.0.1",
23
+ "typescript": "^5.9.2",
24
+ "vite": "^5.4.19"
25
+ }
26
+ },
27
+ "node_modules/@ampproject/remapping": {
28
+ "version": "2.3.0",
29
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
30
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
31
+ "dev": true,
32
+ "license": "Apache-2.0",
33
+ "dependencies": {
34
+ "@jridgewell/gen-mapping": "^0.3.5",
35
+ "@jridgewell/trace-mapping": "^0.3.24"
36
+ },
37
+ "engines": {
38
+ "node": ">=6.0.0"
39
+ }
40
+ },
41
+ "node_modules/@babel/code-frame": {
42
+ "version": "7.27.1",
43
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
44
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
45
+ "dev": true,
46
+ "license": "MIT",
47
+ "dependencies": {
48
+ "@babel/helper-validator-identifier": "^7.27.1",
49
+ "js-tokens": "^4.0.0",
50
+ "picocolors": "^1.1.1"
51
+ },
52
+ "engines": {
53
+ "node": ">=6.9.0"
54
+ }
55
+ },
56
+ "node_modules/@babel/compat-data": {
57
+ "version": "7.28.0",
58
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
59
+ "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
60
+ "dev": true,
61
+ "license": "MIT",
62
+ "engines": {
63
+ "node": ">=6.9.0"
64
+ }
65
+ },
66
+ "node_modules/@babel/core": {
67
+ "version": "7.28.3",
68
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
69
+ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
70
+ "dev": true,
71
+ "license": "MIT",
72
+ "dependencies": {
73
+ "@ampproject/remapping": "^2.2.0",
74
+ "@babel/code-frame": "^7.27.1",
75
+ "@babel/generator": "^7.28.3",
76
+ "@babel/helper-compilation-targets": "^7.27.2",
77
+ "@babel/helper-module-transforms": "^7.28.3",
78
+ "@babel/helpers": "^7.28.3",
79
+ "@babel/parser": "^7.28.3",
80
+ "@babel/template": "^7.27.2",
81
+ "@babel/traverse": "^7.28.3",
82
+ "@babel/types": "^7.28.2",
83
+ "convert-source-map": "^2.0.0",
84
+ "debug": "^4.1.0",
85
+ "gensync": "^1.0.0-beta.2",
86
+ "json5": "^2.2.3",
87
+ "semver": "^6.3.1"
88
+ },
89
+ "engines": {
90
+ "node": ">=6.9.0"
91
+ },
92
+ "funding": {
93
+ "type": "opencollective",
94
+ "url": "https://opencollective.com/babel"
95
+ }
96
+ },
97
+ "node_modules/@babel/generator": {
98
+ "version": "7.28.3",
99
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
100
+ "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
101
+ "dev": true,
102
+ "license": "MIT",
103
+ "dependencies": {
104
+ "@babel/parser": "^7.28.3",
105
+ "@babel/types": "^7.28.2",
106
+ "@jridgewell/gen-mapping": "^0.3.12",
107
+ "@jridgewell/trace-mapping": "^0.3.28",
108
+ "jsesc": "^3.0.2"
109
+ },
110
+ "engines": {
111
+ "node": ">=6.9.0"
112
+ }
113
+ },
114
+ "node_modules/@babel/helper-compilation-targets": {
115
+ "version": "7.27.2",
116
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
117
+ "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
118
+ "dev": true,
119
+ "license": "MIT",
120
+ "dependencies": {
121
+ "@babel/compat-data": "^7.27.2",
122
+ "@babel/helper-validator-option": "^7.27.1",
123
+ "browserslist": "^4.24.0",
124
+ "lru-cache": "^5.1.1",
125
+ "semver": "^6.3.1"
126
+ },
127
+ "engines": {
128
+ "node": ">=6.9.0"
129
+ }
130
+ },
131
+ "node_modules/@babel/helper-globals": {
132
+ "version": "7.28.0",
133
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
134
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
135
+ "dev": true,
136
+ "license": "MIT",
137
+ "engines": {
138
+ "node": ">=6.9.0"
139
+ }
140
+ },
141
+ "node_modules/@babel/helper-module-imports": {
142
+ "version": "7.27.1",
143
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
144
+ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
145
+ "dev": true,
146
+ "license": "MIT",
147
+ "dependencies": {
148
+ "@babel/traverse": "^7.27.1",
149
+ "@babel/types": "^7.27.1"
150
+ },
151
+ "engines": {
152
+ "node": ">=6.9.0"
153
+ }
154
+ },
155
+ "node_modules/@babel/helper-module-transforms": {
156
+ "version": "7.28.3",
157
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
158
+ "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
159
+ "dev": true,
160
+ "license": "MIT",
161
+ "dependencies": {
162
+ "@babel/helper-module-imports": "^7.27.1",
163
+ "@babel/helper-validator-identifier": "^7.27.1",
164
+ "@babel/traverse": "^7.28.3"
165
+ },
166
+ "engines": {
167
+ "node": ">=6.9.0"
168
+ },
169
+ "peerDependencies": {
170
+ "@babel/core": "^7.0.0"
171
+ }
172
+ },
173
+ "node_modules/@babel/helper-plugin-utils": {
174
+ "version": "7.27.1",
175
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
176
+ "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
177
+ "dev": true,
178
+ "license": "MIT",
179
+ "engines": {
180
+ "node": ">=6.9.0"
181
+ }
182
+ },
183
+ "node_modules/@babel/helper-string-parser": {
184
+ "version": "7.27.1",
185
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
186
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
187
+ "dev": true,
188
+ "license": "MIT",
189
+ "engines": {
190
+ "node": ">=6.9.0"
191
+ }
192
+ },
193
+ "node_modules/@babel/helper-validator-identifier": {
194
+ "version": "7.27.1",
195
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
196
+ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
197
+ "dev": true,
198
+ "license": "MIT",
199
+ "engines": {
200
+ "node": ">=6.9.0"
201
+ }
202
+ },
203
+ "node_modules/@babel/helper-validator-option": {
204
+ "version": "7.27.1",
205
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
206
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
207
+ "dev": true,
208
+ "license": "MIT",
209
+ "engines": {
210
+ "node": ">=6.9.0"
211
+ }
212
+ },
213
+ "node_modules/@babel/helpers": {
214
+ "version": "7.28.3",
215
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz",
216
+ "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==",
217
+ "dev": true,
218
+ "license": "MIT",
219
+ "dependencies": {
220
+ "@babel/template": "^7.27.2",
221
+ "@babel/types": "^7.28.2"
222
+ },
223
+ "engines": {
224
+ "node": ">=6.9.0"
225
+ }
226
+ },
227
+ "node_modules/@babel/parser": {
228
+ "version": "7.28.3",
229
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz",
230
+ "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==",
231
+ "dev": true,
232
+ "license": "MIT",
233
+ "dependencies": {
234
+ "@babel/types": "^7.28.2"
235
+ },
236
+ "bin": {
237
+ "parser": "bin/babel-parser.js"
238
+ },
239
+ "engines": {
240
+ "node": ">=6.0.0"
241
+ }
242
+ },
243
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
244
+ "version": "7.27.1",
245
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
246
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
247
+ "dev": true,
248
+ "license": "MIT",
249
+ "dependencies": {
250
+ "@babel/helper-plugin-utils": "^7.27.1"
251
+ },
252
+ "engines": {
253
+ "node": ">=6.9.0"
254
+ },
255
+ "peerDependencies": {
256
+ "@babel/core": "^7.0.0-0"
257
+ }
258
+ },
259
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
260
+ "version": "7.27.1",
261
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
262
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
263
+ "dev": true,
264
+ "license": "MIT",
265
+ "dependencies": {
266
+ "@babel/helper-plugin-utils": "^7.27.1"
267
+ },
268
+ "engines": {
269
+ "node": ">=6.9.0"
270
+ },
271
+ "peerDependencies": {
272
+ "@babel/core": "^7.0.0-0"
273
+ }
274
+ },
275
+ "node_modules/@babel/template": {
276
+ "version": "7.27.2",
277
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
278
+ "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
279
+ "dev": true,
280
+ "license": "MIT",
281
+ "dependencies": {
282
+ "@babel/code-frame": "^7.27.1",
283
+ "@babel/parser": "^7.27.2",
284
+ "@babel/types": "^7.27.1"
285
+ },
286
+ "engines": {
287
+ "node": ">=6.9.0"
288
+ }
289
+ },
290
+ "node_modules/@babel/traverse": {
291
+ "version": "7.28.3",
292
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz",
293
+ "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==",
294
+ "dev": true,
295
+ "license": "MIT",
296
+ "dependencies": {
297
+ "@babel/code-frame": "^7.27.1",
298
+ "@babel/generator": "^7.28.3",
299
+ "@babel/helper-globals": "^7.28.0",
300
+ "@babel/parser": "^7.28.3",
301
+ "@babel/template": "^7.27.2",
302
+ "@babel/types": "^7.28.2",
303
+ "debug": "^4.3.1"
304
+ },
305
+ "engines": {
306
+ "node": ">=6.9.0"
307
+ }
308
+ },
309
+ "node_modules/@babel/types": {
310
+ "version": "7.28.2",
311
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
312
+ "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
313
+ "dev": true,
314
+ "license": "MIT",
315
+ "dependencies": {
316
+ "@babel/helper-string-parser": "^7.27.1",
317
+ "@babel/helper-validator-identifier": "^7.27.1"
318
+ },
319
+ "engines": {
320
+ "node": ">=6.9.0"
321
+ }
322
+ },
323
+ "node_modules/@esbuild/aix-ppc64": {
324
+ "version": "0.21.5",
325
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
326
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
327
+ "cpu": [
328
+ "ppc64"
329
+ ],
330
+ "dev": true,
331
+ "license": "MIT",
332
+ "optional": true,
333
+ "os": [
334
+ "aix"
335
+ ],
336
+ "engines": {
337
+ "node": ">=12"
338
+ }
339
+ },
340
+ "node_modules/@esbuild/android-arm": {
341
+ "version": "0.21.5",
342
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
343
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
344
+ "cpu": [
345
+ "arm"
346
+ ],
347
+ "dev": true,
348
+ "license": "MIT",
349
+ "optional": true,
350
+ "os": [
351
+ "android"
352
+ ],
353
+ "engines": {
354
+ "node": ">=12"
355
+ }
356
+ },
357
+ "node_modules/@esbuild/android-arm64": {
358
+ "version": "0.21.5",
359
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
360
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
361
+ "cpu": [
362
+ "arm64"
363
+ ],
364
+ "dev": true,
365
+ "license": "MIT",
366
+ "optional": true,
367
+ "os": [
368
+ "android"
369
+ ],
370
+ "engines": {
371
+ "node": ">=12"
372
+ }
373
+ },
374
+ "node_modules/@esbuild/android-x64": {
375
+ "version": "0.21.5",
376
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
377
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
378
+ "cpu": [
379
+ "x64"
380
+ ],
381
+ "dev": true,
382
+ "license": "MIT",
383
+ "optional": true,
384
+ "os": [
385
+ "android"
386
+ ],
387
+ "engines": {
388
+ "node": ">=12"
389
+ }
390
+ },
391
+ "node_modules/@esbuild/darwin-arm64": {
392
+ "version": "0.21.5",
393
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
394
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
395
+ "cpu": [
396
+ "arm64"
397
+ ],
398
+ "dev": true,
399
+ "license": "MIT",
400
+ "optional": true,
401
+ "os": [
402
+ "darwin"
403
+ ],
404
+ "engines": {
405
+ "node": ">=12"
406
+ }
407
+ },
408
+ "node_modules/@esbuild/darwin-x64": {
409
+ "version": "0.21.5",
410
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
411
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
412
+ "cpu": [
413
+ "x64"
414
+ ],
415
+ "dev": true,
416
+ "license": "MIT",
417
+ "optional": true,
418
+ "os": [
419
+ "darwin"
420
+ ],
421
+ "engines": {
422
+ "node": ">=12"
423
+ }
424
+ },
425
+ "node_modules/@esbuild/freebsd-arm64": {
426
+ "version": "0.21.5",
427
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
428
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
429
+ "cpu": [
430
+ "arm64"
431
+ ],
432
+ "dev": true,
433
+ "license": "MIT",
434
+ "optional": true,
435
+ "os": [
436
+ "freebsd"
437
+ ],
438
+ "engines": {
439
+ "node": ">=12"
440
+ }
441
+ },
442
+ "node_modules/@esbuild/freebsd-x64": {
443
+ "version": "0.21.5",
444
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
445
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
446
+ "cpu": [
447
+ "x64"
448
+ ],
449
+ "dev": true,
450
+ "license": "MIT",
451
+ "optional": true,
452
+ "os": [
453
+ "freebsd"
454
+ ],
455
+ "engines": {
456
+ "node": ">=12"
457
+ }
458
+ },
459
+ "node_modules/@esbuild/linux-arm": {
460
+ "version": "0.21.5",
461
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
462
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
463
+ "cpu": [
464
+ "arm"
465
+ ],
466
+ "dev": true,
467
+ "license": "MIT",
468
+ "optional": true,
469
+ "os": [
470
+ "linux"
471
+ ],
472
+ "engines": {
473
+ "node": ">=12"
474
+ }
475
+ },
476
+ "node_modules/@esbuild/linux-arm64": {
477
+ "version": "0.21.5",
478
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
479
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
480
+ "cpu": [
481
+ "arm64"
482
+ ],
483
+ "dev": true,
484
+ "license": "MIT",
485
+ "optional": true,
486
+ "os": [
487
+ "linux"
488
+ ],
489
+ "engines": {
490
+ "node": ">=12"
491
+ }
492
+ },
493
+ "node_modules/@esbuild/linux-ia32": {
494
+ "version": "0.21.5",
495
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
496
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
497
+ "cpu": [
498
+ "ia32"
499
+ ],
500
+ "dev": true,
501
+ "license": "MIT",
502
+ "optional": true,
503
+ "os": [
504
+ "linux"
505
+ ],
506
+ "engines": {
507
+ "node": ">=12"
508
+ }
509
+ },
510
+ "node_modules/@esbuild/linux-loong64": {
511
+ "version": "0.21.5",
512
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
513
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
514
+ "cpu": [
515
+ "loong64"
516
+ ],
517
+ "dev": true,
518
+ "license": "MIT",
519
+ "optional": true,
520
+ "os": [
521
+ "linux"
522
+ ],
523
+ "engines": {
524
+ "node": ">=12"
525
+ }
526
+ },
527
+ "node_modules/@esbuild/linux-mips64el": {
528
+ "version": "0.21.5",
529
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
530
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
531
+ "cpu": [
532
+ "mips64el"
533
+ ],
534
+ "dev": true,
535
+ "license": "MIT",
536
+ "optional": true,
537
+ "os": [
538
+ "linux"
539
+ ],
540
+ "engines": {
541
+ "node": ">=12"
542
+ }
543
+ },
544
+ "node_modules/@esbuild/linux-ppc64": {
545
+ "version": "0.21.5",
546
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
547
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
548
+ "cpu": [
549
+ "ppc64"
550
+ ],
551
+ "dev": true,
552
+ "license": "MIT",
553
+ "optional": true,
554
+ "os": [
555
+ "linux"
556
+ ],
557
+ "engines": {
558
+ "node": ">=12"
559
+ }
560
+ },
561
+ "node_modules/@esbuild/linux-riscv64": {
562
+ "version": "0.21.5",
563
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
564
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
565
+ "cpu": [
566
+ "riscv64"
567
+ ],
568
+ "dev": true,
569
+ "license": "MIT",
570
+ "optional": true,
571
+ "os": [
572
+ "linux"
573
+ ],
574
+ "engines": {
575
+ "node": ">=12"
576
+ }
577
+ },
578
+ "node_modules/@esbuild/linux-s390x": {
579
+ "version": "0.21.5",
580
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
581
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
582
+ "cpu": [
583
+ "s390x"
584
+ ],
585
+ "dev": true,
586
+ "license": "MIT",
587
+ "optional": true,
588
+ "os": [
589
+ "linux"
590
+ ],
591
+ "engines": {
592
+ "node": ">=12"
593
+ }
594
+ },
595
+ "node_modules/@esbuild/linux-x64": {
596
+ "version": "0.21.5",
597
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
598
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
599
+ "cpu": [
600
+ "x64"
601
+ ],
602
+ "dev": true,
603
+ "license": "MIT",
604
+ "optional": true,
605
+ "os": [
606
+ "linux"
607
+ ],
608
+ "engines": {
609
+ "node": ">=12"
610
+ }
611
+ },
612
+ "node_modules/@esbuild/netbsd-x64": {
613
+ "version": "0.21.5",
614
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
615
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
616
+ "cpu": [
617
+ "x64"
618
+ ],
619
+ "dev": true,
620
+ "license": "MIT",
621
+ "optional": true,
622
+ "os": [
623
+ "netbsd"
624
+ ],
625
+ "engines": {
626
+ "node": ">=12"
627
+ }
628
+ },
629
+ "node_modules/@esbuild/openbsd-x64": {
630
+ "version": "0.21.5",
631
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
632
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
633
+ "cpu": [
634
+ "x64"
635
+ ],
636
+ "dev": true,
637
+ "license": "MIT",
638
+ "optional": true,
639
+ "os": [
640
+ "openbsd"
641
+ ],
642
+ "engines": {
643
+ "node": ">=12"
644
+ }
645
+ },
646
+ "node_modules/@esbuild/sunos-x64": {
647
+ "version": "0.21.5",
648
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
649
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
650
+ "cpu": [
651
+ "x64"
652
+ ],
653
+ "dev": true,
654
+ "license": "MIT",
655
+ "optional": true,
656
+ "os": [
657
+ "sunos"
658
+ ],
659
+ "engines": {
660
+ "node": ">=12"
661
+ }
662
+ },
663
+ "node_modules/@esbuild/win32-arm64": {
664
+ "version": "0.21.5",
665
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
666
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
667
+ "cpu": [
668
+ "arm64"
669
+ ],
670
+ "dev": true,
671
+ "license": "MIT",
672
+ "optional": true,
673
+ "os": [
674
+ "win32"
675
+ ],
676
+ "engines": {
677
+ "node": ">=12"
678
+ }
679
+ },
680
+ "node_modules/@esbuild/win32-ia32": {
681
+ "version": "0.21.5",
682
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
683
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
684
+ "cpu": [
685
+ "ia32"
686
+ ],
687
+ "dev": true,
688
+ "license": "MIT",
689
+ "optional": true,
690
+ "os": [
691
+ "win32"
692
+ ],
693
+ "engines": {
694
+ "node": ">=12"
695
+ }
696
+ },
697
+ "node_modules/@esbuild/win32-x64": {
698
+ "version": "0.21.5",
699
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
700
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
701
+ "cpu": [
702
+ "x64"
703
+ ],
704
+ "dev": true,
705
+ "license": "MIT",
706
+ "optional": true,
707
+ "os": [
708
+ "win32"
709
+ ],
710
+ "engines": {
711
+ "node": ">=12"
712
+ }
713
+ },
714
+ "node_modules/@jridgewell/gen-mapping": {
715
+ "version": "0.3.13",
716
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
717
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
718
+ "dev": true,
719
+ "license": "MIT",
720
+ "dependencies": {
721
+ "@jridgewell/sourcemap-codec": "^1.5.0",
722
+ "@jridgewell/trace-mapping": "^0.3.24"
723
+ }
724
+ },
725
+ "node_modules/@jridgewell/resolve-uri": {
726
+ "version": "3.1.2",
727
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
728
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
729
+ "dev": true,
730
+ "license": "MIT",
731
+ "engines": {
732
+ "node": ">=6.0.0"
733
+ }
734
+ },
735
+ "node_modules/@jridgewell/sourcemap-codec": {
736
+ "version": "1.5.5",
737
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
738
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
739
+ "dev": true,
740
+ "license": "MIT"
741
+ },
742
+ "node_modules/@jridgewell/trace-mapping": {
743
+ "version": "0.3.30",
744
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
745
+ "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
746
+ "dev": true,
747
+ "license": "MIT",
748
+ "dependencies": {
749
+ "@jridgewell/resolve-uri": "^3.1.0",
750
+ "@jridgewell/sourcemap-codec": "^1.4.14"
751
+ }
752
+ },
753
+ "node_modules/@react-leaflet/core": {
754
+ "version": "2.1.0",
755
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
756
+ "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
757
+ "license": "Hippocratic-2.1",
758
+ "peerDependencies": {
759
+ "leaflet": "^1.9.0",
760
+ "react": "^18.0.0",
761
+ "react-dom": "^18.0.0"
762
+ }
763
+ },
764
+ "node_modules/@rolldown/pluginutils": {
765
+ "version": "1.0.0-beta.32",
766
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz",
767
+ "integrity": "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==",
768
+ "dev": true,
769
+ "license": "MIT"
770
+ },
771
+ "node_modules/@rollup/rollup-android-arm-eabi": {
772
+ "version": "4.48.0",
773
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.48.0.tgz",
774
+ "integrity": "sha512-aVzKH922ogVAWkKiyKXorjYymz2084zrhrZRXtLrA5eEx5SO8Dj0c/4FpCHZyn7MKzhW2pW4tK28vVr+5oQ2xw==",
775
+ "cpu": [
776
+ "arm"
777
+ ],
778
+ "dev": true,
779
+ "license": "MIT",
780
+ "optional": true,
781
+ "os": [
782
+ "android"
783
+ ]
784
+ },
785
+ "node_modules/@rollup/rollup-android-arm64": {
786
+ "version": "4.48.0",
787
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.48.0.tgz",
788
+ "integrity": "sha512-diOdQuw43xTa1RddAFbhIA8toirSzFMcnIg8kvlzRbK26xqEnKJ/vqQnghTAajy2Dcy42v+GMPMo6jq67od+Dw==",
789
+ "cpu": [
790
+ "arm64"
791
+ ],
792
+ "dev": true,
793
+ "license": "MIT",
794
+ "optional": true,
795
+ "os": [
796
+ "android"
797
+ ]
798
+ },
799
+ "node_modules/@rollup/rollup-darwin-arm64": {
800
+ "version": "4.48.0",
801
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.48.0.tgz",
802
+ "integrity": "sha512-QhR2KA18fPlJWFefySJPDYZELaVqIUVnYgAOdtJ+B/uH96CFg2l1TQpX19XpUMWUqMyIiyY45wje8K6F4w4/CA==",
803
+ "cpu": [
804
+ "arm64"
805
+ ],
806
+ "dev": true,
807
+ "license": "MIT",
808
+ "optional": true,
809
+ "os": [
810
+ "darwin"
811
+ ]
812
+ },
813
+ "node_modules/@rollup/rollup-darwin-x64": {
814
+ "version": "4.48.0",
815
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.48.0.tgz",
816
+ "integrity": "sha512-Q9RMXnQVJ5S1SYpNSTwXDpoQLgJ/fbInWOyjbCnnqTElEyeNvLAB3QvG5xmMQMhFN74bB5ZZJYkKaFPcOG8sGg==",
817
+ "cpu": [
818
+ "x64"
819
+ ],
820
+ "dev": true,
821
+ "license": "MIT",
822
+ "optional": true,
823
+ "os": [
824
+ "darwin"
825
+ ]
826
+ },
827
+ "node_modules/@rollup/rollup-freebsd-arm64": {
828
+ "version": "4.48.0",
829
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.48.0.tgz",
830
+ "integrity": "sha512-3jzOhHWM8O8PSfyft+ghXZfBkZawQA0PUGtadKYxFqpcYlOYjTi06WsnYBsbMHLawr+4uWirLlbhcYLHDXR16w==",
831
+ "cpu": [
832
+ "arm64"
833
+ ],
834
+ "dev": true,
835
+ "license": "MIT",
836
+ "optional": true,
837
+ "os": [
838
+ "freebsd"
839
+ ]
840
+ },
841
+ "node_modules/@rollup/rollup-freebsd-x64": {
842
+ "version": "4.48.0",
843
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.48.0.tgz",
844
+ "integrity": "sha512-NcD5uVUmE73C/TPJqf78hInZmiSBsDpz3iD5MF/BuB+qzm4ooF2S1HfeTChj5K4AV3y19FFPgxonsxiEpy8v/A==",
845
+ "cpu": [
846
+ "x64"
847
+ ],
848
+ "dev": true,
849
+ "license": "MIT",
850
+ "optional": true,
851
+ "os": [
852
+ "freebsd"
853
+ ]
854
+ },
855
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
856
+ "version": "4.48.0",
857
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.48.0.tgz",
858
+ "integrity": "sha512-JWnrj8qZgLWRNHr7NbpdnrQ8kcg09EBBq8jVOjmtlB3c8C6IrynAJSMhMVGME4YfTJzIkJqvSUSVJRqkDnu/aA==",
859
+ "cpu": [
860
+ "arm"
861
+ ],
862
+ "dev": true,
863
+ "license": "MIT",
864
+ "optional": true,
865
+ "os": [
866
+ "linux"
867
+ ]
868
+ },
869
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
870
+ "version": "4.48.0",
871
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.48.0.tgz",
872
+ "integrity": "sha512-9xu92F0TxuMH0tD6tG3+GtngwdgSf8Bnz+YcsPG91/r5Vgh5LNofO48jV55priA95p3c92FLmPM7CvsVlnSbGQ==",
873
+ "cpu": [
874
+ "arm"
875
+ ],
876
+ "dev": true,
877
+ "license": "MIT",
878
+ "optional": true,
879
+ "os": [
880
+ "linux"
881
+ ]
882
+ },
883
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
884
+ "version": "4.48.0",
885
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.48.0.tgz",
886
+ "integrity": "sha512-NLtvJB5YpWn7jlp1rJiY0s+G1Z1IVmkDuiywiqUhh96MIraC0n7XQc2SZ1CZz14shqkM+XN2UrfIo7JB6UufOA==",
887
+ "cpu": [
888
+ "arm64"
889
+ ],
890
+ "dev": true,
891
+ "license": "MIT",
892
+ "optional": true,
893
+ "os": [
894
+ "linux"
895
+ ]
896
+ },
897
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
898
+ "version": "4.48.0",
899
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.48.0.tgz",
900
+ "integrity": "sha512-QJ4hCOnz2SXgCh+HmpvZkM+0NSGcZACyYS8DGbWn2PbmA0e5xUk4bIP8eqJyNXLtyB4gZ3/XyvKtQ1IFH671vQ==",
901
+ "cpu": [
902
+ "arm64"
903
+ ],
904
+ "dev": true,
905
+ "license": "MIT",
906
+ "optional": true,
907
+ "os": [
908
+ "linux"
909
+ ]
910
+ },
911
+ "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
912
+ "version": "4.48.0",
913
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.48.0.tgz",
914
+ "integrity": "sha512-Pk0qlGJnhILdIC5zSKQnprFjrGmjfDM7TPZ0FKJxRkoo+kgMRAg4ps1VlTZf8u2vohSicLg7NP+cA5qE96PaFg==",
915
+ "cpu": [
916
+ "loong64"
917
+ ],
918
+ "dev": true,
919
+ "license": "MIT",
920
+ "optional": true,
921
+ "os": [
922
+ "linux"
923
+ ]
924
+ },
925
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
926
+ "version": "4.48.0",
927
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.48.0.tgz",
928
+ "integrity": "sha512-/dNFc6rTpoOzgp5GKoYjT6uLo8okR/Chi2ECOmCZiS4oqh3mc95pThWma7Bgyk6/WTEvjDINpiBCuecPLOgBLQ==",
929
+ "cpu": [
930
+ "ppc64"
931
+ ],
932
+ "dev": true,
933
+ "license": "MIT",
934
+ "optional": true,
935
+ "os": [
936
+ "linux"
937
+ ]
938
+ },
939
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
940
+ "version": "4.48.0",
941
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.48.0.tgz",
942
+ "integrity": "sha512-YBwXsvsFI8CVA4ej+bJF2d9uAeIiSkqKSPQNn0Wyh4eMDY4wxuSp71BauPjQNCKK2tD2/ksJ7uhJ8X/PVY9bHQ==",
943
+ "cpu": [
944
+ "riscv64"
945
+ ],
946
+ "dev": true,
947
+ "license": "MIT",
948
+ "optional": true,
949
+ "os": [
950
+ "linux"
951
+ ]
952
+ },
953
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
954
+ "version": "4.48.0",
955
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.48.0.tgz",
956
+ "integrity": "sha512-FI3Rr2aGAtl1aHzbkBIamsQyuauYtTF9SDUJ8n2wMXuuxwchC3QkumZa1TEXYIv/1AUp1a25Kwy6ONArvnyeVQ==",
957
+ "cpu": [
958
+ "riscv64"
959
+ ],
960
+ "dev": true,
961
+ "license": "MIT",
962
+ "optional": true,
963
+ "os": [
964
+ "linux"
965
+ ]
966
+ },
967
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
968
+ "version": "4.48.0",
969
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.48.0.tgz",
970
+ "integrity": "sha512-Dx7qH0/rvNNFmCcIRe1pyQ9/H0XO4v/f0SDoafwRYwc2J7bJZ5N4CHL/cdjamISZ5Cgnon6iazAVRFlxSoHQnQ==",
971
+ "cpu": [
972
+ "s390x"
973
+ ],
974
+ "dev": true,
975
+ "license": "MIT",
976
+ "optional": true,
977
+ "os": [
978
+ "linux"
979
+ ]
980
+ },
981
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
982
+ "version": "4.48.0",
983
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.48.0.tgz",
984
+ "integrity": "sha512-GUdZKTeKBq9WmEBzvFYuC88yk26vT66lQV8D5+9TgkfbewhLaTHRNATyzpQwwbHIfJvDJ3N9WJ90wK/uR3cy3Q==",
985
+ "cpu": [
986
+ "x64"
987
+ ],
988
+ "dev": true,
989
+ "license": "MIT",
990
+ "optional": true,
991
+ "os": [
992
+ "linux"
993
+ ]
994
+ },
995
+ "node_modules/@rollup/rollup-linux-x64-musl": {
996
+ "version": "4.48.0",
997
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.48.0.tgz",
998
+ "integrity": "sha512-ao58Adz/v14MWpQgYAb4a4h3fdw73DrDGtaiF7Opds5wNyEQwtO6M9dBh89nke0yoZzzaegq6J/EXs7eBebG8A==",
999
+ "cpu": [
1000
+ "x64"
1001
+ ],
1002
+ "dev": true,
1003
+ "license": "MIT",
1004
+ "optional": true,
1005
+ "os": [
1006
+ "linux"
1007
+ ]
1008
+ },
1009
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
1010
+ "version": "4.48.0",
1011
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.48.0.tgz",
1012
+ "integrity": "sha512-kpFno46bHtjZVdRIOxqaGeiABiToo2J+st7Yce+aiAoo1H0xPi2keyQIP04n2JjDVuxBN6bSz9R6RdTK5hIppw==",
1013
+ "cpu": [
1014
+ "arm64"
1015
+ ],
1016
+ "dev": true,
1017
+ "license": "MIT",
1018
+ "optional": true,
1019
+ "os": [
1020
+ "win32"
1021
+ ]
1022
+ },
1023
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
1024
+ "version": "4.48.0",
1025
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.48.0.tgz",
1026
+ "integrity": "sha512-rFYrk4lLk9YUTIeihnQMiwMr6gDhGGSbWThPEDfBoU/HdAtOzPXeexKi7yU8jO+LWRKnmqPN9NviHQf6GDwBcQ==",
1027
+ "cpu": [
1028
+ "ia32"
1029
+ ],
1030
+ "dev": true,
1031
+ "license": "MIT",
1032
+ "optional": true,
1033
+ "os": [
1034
+ "win32"
1035
+ ]
1036
+ },
1037
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1038
+ "version": "4.48.0",
1039
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.48.0.tgz",
1040
+ "integrity": "sha512-sq0hHLTgdtwOPDB5SJOuaoHyiP1qSwg+71TQWk8iDS04bW1wIE0oQ6otPiRj2ZvLYNASLMaTp8QRGUVZ+5OL5A==",
1041
+ "cpu": [
1042
+ "x64"
1043
+ ],
1044
+ "dev": true,
1045
+ "license": "MIT",
1046
+ "optional": true,
1047
+ "os": [
1048
+ "win32"
1049
+ ]
1050
+ },
1051
+ "node_modules/@types/babel__core": {
1052
+ "version": "7.20.5",
1053
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1054
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1055
+ "dev": true,
1056
+ "license": "MIT",
1057
+ "dependencies": {
1058
+ "@babel/parser": "^7.20.7",
1059
+ "@babel/types": "^7.20.7",
1060
+ "@types/babel__generator": "*",
1061
+ "@types/babel__template": "*",
1062
+ "@types/babel__traverse": "*"
1063
+ }
1064
+ },
1065
+ "node_modules/@types/babel__generator": {
1066
+ "version": "7.27.0",
1067
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1068
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1069
+ "dev": true,
1070
+ "license": "MIT",
1071
+ "dependencies": {
1072
+ "@babel/types": "^7.0.0"
1073
+ }
1074
+ },
1075
+ "node_modules/@types/babel__template": {
1076
+ "version": "7.4.4",
1077
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1078
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1079
+ "dev": true,
1080
+ "license": "MIT",
1081
+ "dependencies": {
1082
+ "@babel/parser": "^7.1.0",
1083
+ "@babel/types": "^7.0.0"
1084
+ }
1085
+ },
1086
+ "node_modules/@types/babel__traverse": {
1087
+ "version": "7.28.0",
1088
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1089
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1090
+ "dev": true,
1091
+ "license": "MIT",
1092
+ "dependencies": {
1093
+ "@babel/types": "^7.28.2"
1094
+ }
1095
+ },
1096
+ "node_modules/@types/estree": {
1097
+ "version": "1.0.8",
1098
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
1099
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1100
+ "dev": true,
1101
+ "license": "MIT"
1102
+ },
1103
+ "node_modules/@types/geojson": {
1104
+ "version": "7946.0.16",
1105
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
1106
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
1107
+ "dev": true,
1108
+ "license": "MIT"
1109
+ },
1110
+ "node_modules/@types/google.maps": {
1111
+ "version": "3.58.1",
1112
+ "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
1113
+ "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==",
1114
+ "license": "MIT"
1115
+ },
1116
+ "node_modules/@types/leaflet": {
1117
+ "version": "1.9.20",
1118
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz",
1119
+ "integrity": "sha512-rooalPMlk61LCaLOvBF2VIf9M47HgMQqi5xQ9QRi7c8PkdIe0WrIi5IxXUXQjAdL0c+vcQ01mYWbthzmp9GHWw==",
1120
+ "dev": true,
1121
+ "license": "MIT",
1122
+ "dependencies": {
1123
+ "@types/geojson": "*"
1124
+ }
1125
+ },
1126
+ "node_modules/@types/prop-types": {
1127
+ "version": "15.7.15",
1128
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1129
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1130
+ "dev": true,
1131
+ "license": "MIT"
1132
+ },
1133
+ "node_modules/@types/react": {
1134
+ "version": "18.3.24",
1135
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
1136
+ "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==",
1137
+ "dev": true,
1138
+ "license": "MIT",
1139
+ "dependencies": {
1140
+ "@types/prop-types": "*",
1141
+ "csstype": "^3.0.2"
1142
+ }
1143
+ },
1144
+ "node_modules/@types/react-dom": {
1145
+ "version": "18.3.7",
1146
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1147
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1148
+ "dev": true,
1149
+ "license": "MIT",
1150
+ "peerDependencies": {
1151
+ "@types/react": "^18.0.0"
1152
+ }
1153
+ },
1154
+ "node_modules/@vis.gl/react-google-maps": {
1155
+ "version": "1.5.5",
1156
+ "resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.5.5.tgz",
1157
+ "integrity": "sha512-LgHtK1AtE2/BN4dPoK05oWu0jWmeDdyX0Ffqi+mZc+M4apaHn2sUxxKXAxhPF90O9vcsiou/ntm6/XBWX+gpqw==",
1158
+ "license": "MIT",
1159
+ "dependencies": {
1160
+ "@types/google.maps": "^3.54.10",
1161
+ "fast-deep-equal": "^3.1.3"
1162
+ },
1163
+ "peerDependencies": {
1164
+ "react": ">=16.8.0 || ^19.0 || ^19.0.0-rc",
1165
+ "react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc"
1166
+ }
1167
+ },
1168
+ "node_modules/@vitejs/plugin-react": {
1169
+ "version": "5.0.1",
1170
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.1.tgz",
1171
+ "integrity": "sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==",
1172
+ "dev": true,
1173
+ "license": "MIT",
1174
+ "dependencies": {
1175
+ "@babel/core": "^7.28.3",
1176
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1177
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1178
+ "@rolldown/pluginutils": "1.0.0-beta.32",
1179
+ "@types/babel__core": "^7.20.5",
1180
+ "react-refresh": "^0.17.0"
1181
+ },
1182
+ "engines": {
1183
+ "node": "^20.19.0 || >=22.12.0"
1184
+ },
1185
+ "peerDependencies": {
1186
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1187
+ }
1188
+ },
1189
+ "node_modules/browserslist": {
1190
+ "version": "4.25.3",
1191
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz",
1192
+ "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==",
1193
+ "dev": true,
1194
+ "funding": [
1195
+ {
1196
+ "type": "opencollective",
1197
+ "url": "https://opencollective.com/browserslist"
1198
+ },
1199
+ {
1200
+ "type": "tidelift",
1201
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1202
+ },
1203
+ {
1204
+ "type": "github",
1205
+ "url": "https://github.com/sponsors/ai"
1206
+ }
1207
+ ],
1208
+ "license": "MIT",
1209
+ "dependencies": {
1210
+ "caniuse-lite": "^1.0.30001735",
1211
+ "electron-to-chromium": "^1.5.204",
1212
+ "node-releases": "^2.0.19",
1213
+ "update-browserslist-db": "^1.1.3"
1214
+ },
1215
+ "bin": {
1216
+ "browserslist": "cli.js"
1217
+ },
1218
+ "engines": {
1219
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1220
+ }
1221
+ },
1222
+ "node_modules/caniuse-lite": {
1223
+ "version": "1.0.30001737",
1224
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz",
1225
+ "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==",
1226
+ "dev": true,
1227
+ "funding": [
1228
+ {
1229
+ "type": "opencollective",
1230
+ "url": "https://opencollective.com/browserslist"
1231
+ },
1232
+ {
1233
+ "type": "tidelift",
1234
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1235
+ },
1236
+ {
1237
+ "type": "github",
1238
+ "url": "https://github.com/sponsors/ai"
1239
+ }
1240
+ ],
1241
+ "license": "CC-BY-4.0"
1242
+ },
1243
+ "node_modules/convert-source-map": {
1244
+ "version": "2.0.0",
1245
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1246
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1247
+ "dev": true,
1248
+ "license": "MIT"
1249
+ },
1250
+ "node_modules/csstype": {
1251
+ "version": "3.1.3",
1252
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
1253
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
1254
+ "dev": true,
1255
+ "license": "MIT"
1256
+ },
1257
+ "node_modules/debug": {
1258
+ "version": "4.4.1",
1259
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
1260
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
1261
+ "dev": true,
1262
+ "license": "MIT",
1263
+ "dependencies": {
1264
+ "ms": "^2.1.3"
1265
+ },
1266
+ "engines": {
1267
+ "node": ">=6.0"
1268
+ },
1269
+ "peerDependenciesMeta": {
1270
+ "supports-color": {
1271
+ "optional": true
1272
+ }
1273
+ }
1274
+ },
1275
+ "node_modules/electron-to-chromium": {
1276
+ "version": "1.5.208",
1277
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.208.tgz",
1278
+ "integrity": "sha512-ozZyibehoe7tOhNaf16lKmljVf+3npZcJIEbJRVftVsmAg5TeA1mGS9dVCZzOwr2xT7xK15V0p7+GZqSPgkuPg==",
1279
+ "dev": true,
1280
+ "license": "ISC"
1281
+ },
1282
+ "node_modules/esbuild": {
1283
+ "version": "0.21.5",
1284
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1285
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1286
+ "dev": true,
1287
+ "hasInstallScript": true,
1288
+ "license": "MIT",
1289
+ "bin": {
1290
+ "esbuild": "bin/esbuild"
1291
+ },
1292
+ "engines": {
1293
+ "node": ">=12"
1294
+ },
1295
+ "optionalDependencies": {
1296
+ "@esbuild/aix-ppc64": "0.21.5",
1297
+ "@esbuild/android-arm": "0.21.5",
1298
+ "@esbuild/android-arm64": "0.21.5",
1299
+ "@esbuild/android-x64": "0.21.5",
1300
+ "@esbuild/darwin-arm64": "0.21.5",
1301
+ "@esbuild/darwin-x64": "0.21.5",
1302
+ "@esbuild/freebsd-arm64": "0.21.5",
1303
+ "@esbuild/freebsd-x64": "0.21.5",
1304
+ "@esbuild/linux-arm": "0.21.5",
1305
+ "@esbuild/linux-arm64": "0.21.5",
1306
+ "@esbuild/linux-ia32": "0.21.5",
1307
+ "@esbuild/linux-loong64": "0.21.5",
1308
+ "@esbuild/linux-mips64el": "0.21.5",
1309
+ "@esbuild/linux-ppc64": "0.21.5",
1310
+ "@esbuild/linux-riscv64": "0.21.5",
1311
+ "@esbuild/linux-s390x": "0.21.5",
1312
+ "@esbuild/linux-x64": "0.21.5",
1313
+ "@esbuild/netbsd-x64": "0.21.5",
1314
+ "@esbuild/openbsd-x64": "0.21.5",
1315
+ "@esbuild/sunos-x64": "0.21.5",
1316
+ "@esbuild/win32-arm64": "0.21.5",
1317
+ "@esbuild/win32-ia32": "0.21.5",
1318
+ "@esbuild/win32-x64": "0.21.5"
1319
+ }
1320
+ },
1321
+ "node_modules/escalade": {
1322
+ "version": "3.2.0",
1323
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1324
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1325
+ "dev": true,
1326
+ "license": "MIT",
1327
+ "engines": {
1328
+ "node": ">=6"
1329
+ }
1330
+ },
1331
+ "node_modules/fast-deep-equal": {
1332
+ "version": "3.1.3",
1333
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
1334
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
1335
+ "license": "MIT"
1336
+ },
1337
+ "node_modules/fsevents": {
1338
+ "version": "2.3.3",
1339
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1340
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1341
+ "dev": true,
1342
+ "hasInstallScript": true,
1343
+ "license": "MIT",
1344
+ "optional": true,
1345
+ "os": [
1346
+ "darwin"
1347
+ ],
1348
+ "engines": {
1349
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1350
+ }
1351
+ },
1352
+ "node_modules/gensync": {
1353
+ "version": "1.0.0-beta.2",
1354
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1355
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1356
+ "dev": true,
1357
+ "license": "MIT",
1358
+ "engines": {
1359
+ "node": ">=6.9.0"
1360
+ }
1361
+ },
1362
+ "node_modules/js-tokens": {
1363
+ "version": "4.0.0",
1364
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1365
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1366
+ "license": "MIT"
1367
+ },
1368
+ "node_modules/jsesc": {
1369
+ "version": "3.1.0",
1370
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1371
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1372
+ "dev": true,
1373
+ "license": "MIT",
1374
+ "bin": {
1375
+ "jsesc": "bin/jsesc"
1376
+ },
1377
+ "engines": {
1378
+ "node": ">=6"
1379
+ }
1380
+ },
1381
+ "node_modules/json5": {
1382
+ "version": "2.2.3",
1383
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1384
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1385
+ "dev": true,
1386
+ "license": "MIT",
1387
+ "bin": {
1388
+ "json5": "lib/cli.js"
1389
+ },
1390
+ "engines": {
1391
+ "node": ">=6"
1392
+ }
1393
+ },
1394
+ "node_modules/leaflet": {
1395
+ "version": "1.9.4",
1396
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
1397
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
1398
+ "license": "BSD-2-Clause"
1399
+ },
1400
+ "node_modules/loose-envify": {
1401
+ "version": "1.4.0",
1402
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1403
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1404
+ "license": "MIT",
1405
+ "dependencies": {
1406
+ "js-tokens": "^3.0.0 || ^4.0.0"
1407
+ },
1408
+ "bin": {
1409
+ "loose-envify": "cli.js"
1410
+ }
1411
+ },
1412
+ "node_modules/lru-cache": {
1413
+ "version": "5.1.1",
1414
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1415
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1416
+ "dev": true,
1417
+ "license": "ISC",
1418
+ "dependencies": {
1419
+ "yallist": "^3.0.2"
1420
+ }
1421
+ },
1422
+ "node_modules/lucide-react": {
1423
+ "version": "0.542.0",
1424
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz",
1425
+ "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==",
1426
+ "license": "ISC",
1427
+ "peerDependencies": {
1428
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
1429
+ }
1430
+ },
1431
+ "node_modules/ms": {
1432
+ "version": "2.1.3",
1433
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1434
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1435
+ "dev": true,
1436
+ "license": "MIT"
1437
+ },
1438
+ "node_modules/nanoid": {
1439
+ "version": "3.3.11",
1440
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
1441
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1442
+ "dev": true,
1443
+ "funding": [
1444
+ {
1445
+ "type": "github",
1446
+ "url": "https://github.com/sponsors/ai"
1447
+ }
1448
+ ],
1449
+ "license": "MIT",
1450
+ "bin": {
1451
+ "nanoid": "bin/nanoid.cjs"
1452
+ },
1453
+ "engines": {
1454
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1455
+ }
1456
+ },
1457
+ "node_modules/node-releases": {
1458
+ "version": "2.0.19",
1459
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
1460
+ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
1461
+ "dev": true,
1462
+ "license": "MIT"
1463
+ },
1464
+ "node_modules/picocolors": {
1465
+ "version": "1.1.1",
1466
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1467
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1468
+ "dev": true,
1469
+ "license": "ISC"
1470
+ },
1471
+ "node_modules/postcss": {
1472
+ "version": "8.5.6",
1473
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
1474
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
1475
+ "dev": true,
1476
+ "funding": [
1477
+ {
1478
+ "type": "opencollective",
1479
+ "url": "https://opencollective.com/postcss/"
1480
+ },
1481
+ {
1482
+ "type": "tidelift",
1483
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1484
+ },
1485
+ {
1486
+ "type": "github",
1487
+ "url": "https://github.com/sponsors/ai"
1488
+ }
1489
+ ],
1490
+ "license": "MIT",
1491
+ "dependencies": {
1492
+ "nanoid": "^3.3.11",
1493
+ "picocolors": "^1.1.1",
1494
+ "source-map-js": "^1.2.1"
1495
+ },
1496
+ "engines": {
1497
+ "node": "^10 || ^12 || >=14"
1498
+ }
1499
+ },
1500
+ "node_modules/react": {
1501
+ "version": "18.3.1",
1502
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1503
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1504
+ "license": "MIT",
1505
+ "dependencies": {
1506
+ "loose-envify": "^1.1.0"
1507
+ },
1508
+ "engines": {
1509
+ "node": ">=0.10.0"
1510
+ }
1511
+ },
1512
+ "node_modules/react-dom": {
1513
+ "version": "18.3.1",
1514
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1515
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1516
+ "license": "MIT",
1517
+ "dependencies": {
1518
+ "loose-envify": "^1.1.0",
1519
+ "scheduler": "^0.23.2"
1520
+ },
1521
+ "peerDependencies": {
1522
+ "react": "^18.3.1"
1523
+ }
1524
+ },
1525
+ "node_modules/react-leaflet": {
1526
+ "version": "4.2.1",
1527
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
1528
+ "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
1529
+ "license": "Hippocratic-2.1",
1530
+ "dependencies": {
1531
+ "@react-leaflet/core": "^2.1.0"
1532
+ },
1533
+ "peerDependencies": {
1534
+ "leaflet": "^1.9.0",
1535
+ "react": "^18.0.0",
1536
+ "react-dom": "^18.0.0"
1537
+ }
1538
+ },
1539
+ "node_modules/react-refresh": {
1540
+ "version": "0.17.0",
1541
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1542
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1543
+ "dev": true,
1544
+ "license": "MIT",
1545
+ "engines": {
1546
+ "node": ">=0.10.0"
1547
+ }
1548
+ },
1549
+ "node_modules/rollup": {
1550
+ "version": "4.48.0",
1551
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.0.tgz",
1552
+ "integrity": "sha512-BXHRqK1vyt9XVSEHZ9y7xdYtuYbwVod2mLwOMFP7t/Eqoc1pHRlG/WdV2qNeNvZHRQdLedaFycljaYYM96RqJQ==",
1553
+ "dev": true,
1554
+ "license": "MIT",
1555
+ "dependencies": {
1556
+ "@types/estree": "1.0.8"
1557
+ },
1558
+ "bin": {
1559
+ "rollup": "dist/bin/rollup"
1560
+ },
1561
+ "engines": {
1562
+ "node": ">=18.0.0",
1563
+ "npm": ">=8.0.0"
1564
+ },
1565
+ "optionalDependencies": {
1566
+ "@rollup/rollup-android-arm-eabi": "4.48.0",
1567
+ "@rollup/rollup-android-arm64": "4.48.0",
1568
+ "@rollup/rollup-darwin-arm64": "4.48.0",
1569
+ "@rollup/rollup-darwin-x64": "4.48.0",
1570
+ "@rollup/rollup-freebsd-arm64": "4.48.0",
1571
+ "@rollup/rollup-freebsd-x64": "4.48.0",
1572
+ "@rollup/rollup-linux-arm-gnueabihf": "4.48.0",
1573
+ "@rollup/rollup-linux-arm-musleabihf": "4.48.0",
1574
+ "@rollup/rollup-linux-arm64-gnu": "4.48.0",
1575
+ "@rollup/rollup-linux-arm64-musl": "4.48.0",
1576
+ "@rollup/rollup-linux-loongarch64-gnu": "4.48.0",
1577
+ "@rollup/rollup-linux-ppc64-gnu": "4.48.0",
1578
+ "@rollup/rollup-linux-riscv64-gnu": "4.48.0",
1579
+ "@rollup/rollup-linux-riscv64-musl": "4.48.0",
1580
+ "@rollup/rollup-linux-s390x-gnu": "4.48.0",
1581
+ "@rollup/rollup-linux-x64-gnu": "4.48.0",
1582
+ "@rollup/rollup-linux-x64-musl": "4.48.0",
1583
+ "@rollup/rollup-win32-arm64-msvc": "4.48.0",
1584
+ "@rollup/rollup-win32-ia32-msvc": "4.48.0",
1585
+ "@rollup/rollup-win32-x64-msvc": "4.48.0",
1586
+ "fsevents": "~2.3.2"
1587
+ }
1588
+ },
1589
+ "node_modules/scheduler": {
1590
+ "version": "0.23.2",
1591
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1592
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1593
+ "license": "MIT",
1594
+ "dependencies": {
1595
+ "loose-envify": "^1.1.0"
1596
+ }
1597
+ },
1598
+ "node_modules/semver": {
1599
+ "version": "6.3.1",
1600
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1601
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1602
+ "dev": true,
1603
+ "license": "ISC",
1604
+ "bin": {
1605
+ "semver": "bin/semver.js"
1606
+ }
1607
+ },
1608
+ "node_modules/source-map-js": {
1609
+ "version": "1.2.1",
1610
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1611
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1612
+ "dev": true,
1613
+ "license": "BSD-3-Clause",
1614
+ "engines": {
1615
+ "node": ">=0.10.0"
1616
+ }
1617
+ },
1618
+ "node_modules/typescript": {
1619
+ "version": "5.9.2",
1620
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
1621
+ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
1622
+ "dev": true,
1623
+ "license": "Apache-2.0",
1624
+ "bin": {
1625
+ "tsc": "bin/tsc",
1626
+ "tsserver": "bin/tsserver"
1627
+ },
1628
+ "engines": {
1629
+ "node": ">=14.17"
1630
+ }
1631
+ },
1632
+ "node_modules/update-browserslist-db": {
1633
+ "version": "1.1.3",
1634
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
1635
+ "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
1636
+ "dev": true,
1637
+ "funding": [
1638
+ {
1639
+ "type": "opencollective",
1640
+ "url": "https://opencollective.com/browserslist"
1641
+ },
1642
+ {
1643
+ "type": "tidelift",
1644
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1645
+ },
1646
+ {
1647
+ "type": "github",
1648
+ "url": "https://github.com/sponsors/ai"
1649
+ }
1650
+ ],
1651
+ "license": "MIT",
1652
+ "dependencies": {
1653
+ "escalade": "^3.2.0",
1654
+ "picocolors": "^1.1.1"
1655
+ },
1656
+ "bin": {
1657
+ "update-browserslist-db": "cli.js"
1658
+ },
1659
+ "peerDependencies": {
1660
+ "browserslist": ">= 4.21.0"
1661
+ }
1662
+ },
1663
+ "node_modules/vite": {
1664
+ "version": "5.4.19",
1665
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
1666
+ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
1667
+ "dev": true,
1668
+ "license": "MIT",
1669
+ "dependencies": {
1670
+ "esbuild": "^0.21.3",
1671
+ "postcss": "^8.4.43",
1672
+ "rollup": "^4.20.0"
1673
+ },
1674
+ "bin": {
1675
+ "vite": "bin/vite.js"
1676
+ },
1677
+ "engines": {
1678
+ "node": "^18.0.0 || >=20.0.0"
1679
+ },
1680
+ "funding": {
1681
+ "url": "https://github.com/vitejs/vite?sponsor=1"
1682
+ },
1683
+ "optionalDependencies": {
1684
+ "fsevents": "~2.3.3"
1685
+ },
1686
+ "peerDependencies": {
1687
+ "@types/node": "^18.0.0 || >=20.0.0",
1688
+ "less": "*",
1689
+ "lightningcss": "^1.21.0",
1690
+ "sass": "*",
1691
+ "sass-embedded": "*",
1692
+ "stylus": "*",
1693
+ "sugarss": "*",
1694
+ "terser": "^5.4.0"
1695
+ },
1696
+ "peerDependenciesMeta": {
1697
+ "@types/node": {
1698
+ "optional": true
1699
+ },
1700
+ "less": {
1701
+ "optional": true
1702
+ },
1703
+ "lightningcss": {
1704
+ "optional": true
1705
+ },
1706
+ "sass": {
1707
+ "optional": true
1708
+ },
1709
+ "sass-embedded": {
1710
+ "optional": true
1711
+ },
1712
+ "stylus": {
1713
+ "optional": true
1714
+ },
1715
+ "sugarss": {
1716
+ "optional": true
1717
+ },
1718
+ "terser": {
1719
+ "optional": true
1720
+ }
1721
+ }
1722
+ },
1723
+ "node_modules/yallist": {
1724
+ "version": "3.1.1",
1725
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1726
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1727
+ "dev": true,
1728
+ "license": "ISC"
1729
+ }
1730
+ }
1731
+ }
web/package.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "cta-web",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview --port 5174"
10
+ },
11
+ "dependencies": {
12
+ "@vis.gl/react-google-maps": "^1.5.5",
13
+ "leaflet": "^1.9.4",
14
+ "lucide-react": "^0.542.0",
15
+ "react": "^18.3.1",
16
+ "react-dom": "^18.3.1",
17
+ "react-leaflet": "^4.2.1"
18
+ },
19
+ "devDependencies": {
20
+ "@types/leaflet": "^1.9.20",
21
+ "@types/react": "^18.3.24",
22
+ "@types/react-dom": "^18.3.7",
23
+ "@vitejs/plugin-react": "^5.0.1",
24
+ "typescript": "^5.9.2",
25
+ "vite": "^5.4.19"
26
+ }
27
+ }
web/public/icons/3d/3d-alert.png ADDED
web/public/icons/3d/3d-ambulance.png ADDED
web/public/icons/3d/3d-car.png ADDED
web/public/icons/3d/3d-construction.png ADDED
web/public/icons/3d/3d-flood.png ADDED
web/public/icons/3d/3d-gun.png ADDED
web/public/icons/3d/3d-help.png ADDED
web/public/icons/3d/3d-info.png ADDED
web/public/icons/3d/3d-ride.png ADDED
web/public/icons/3d/3d-robbery.png ADDED
web/public/icons/3d/3d-search.png ADDED
web/public/icons/3d/3d-sex.png ADDED
web/public/icons/3d/3d-traffic.png ADDED
web/public/icons/3d/3d-user_search.png ADDED
web/public/vite.svg ADDED
web/src/App.css ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
43
+
44
+ .sidebar {
45
+ display: flex;
46
+ flex-direction: column;
47
+ min-height: 0;
48
+ }
web/src/App.tsx ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import "./style.css";
3
+ import type { FC, SelectMeta } from "./lib/types";
4
+ import { REPORTS_URL } from "./lib/constants";
5
+ import { useFeeds } from "./hooks/useFeeds";
6
+ import { useSessionId } from "./hooks/useSessionId";
7
+ import { useUpdates } from "./hooks/useUpdates";
8
+ import { useChat } from "./hooks/useChat";
9
+ import MapCanvas from "./components/map/MapCanvas";
10
+ import SelectedLocationCard from "./components/sidebar/SelectedLocationCard";
11
+ import UpdatesPanel from "./components/sidebar/UpdatesPanel";
12
+ import ChatPanel from "./components/chat/ChatPanel";
13
+
14
+ export default function App() {
15
+ const [selectedLL, setSelectedLL] = React.useState<[number, number] | null>(
16
+ null
17
+ );
18
+ const [selectedMeta, setSelectedMeta] = React.useState<SelectMeta | null>(
19
+ null
20
+ );
21
+
22
+ const [reports, setReports] = React.useState<FC>({
23
+ type: "FeatureCollection",
24
+ features: [],
25
+ });
26
+ const { nws, quakes, eonet, firms } = useFeeds();
27
+
28
+ const sessionId = useSessionId();
29
+ const {
30
+ activeTab,
31
+ setActiveTab,
32
+ localUpdates,
33
+ globalUpdates,
34
+ loadingLocal,
35
+ loadingGlobal,
36
+ } = useUpdates(selectedLL);
37
+
38
+ const {
39
+ messages,
40
+ draft,
41
+ setDraft,
42
+ isStreaming,
43
+ hasFirstToken,
44
+ chatBodyRef,
45
+ send,
46
+ pendingPhotoUrl,
47
+ setPendingPhotoUrl,
48
+ isUploading,
49
+ onFileChosen,
50
+ } = useChat(sessionId, selectedLL);
51
+
52
+ const fileInputRef = React.useRef<HTMLInputElement | null>(null);
53
+
54
+ const loadReports = React.useCallback(async () => {
55
+ const fc = await fetch(REPORTS_URL)
56
+ .then((r) => r.json())
57
+ .catch(() => ({ type: "FeatureCollection", features: [] }));
58
+ setReports(fc);
59
+ }, []);
60
+
61
+ React.useEffect(() => {
62
+ loadReports();
63
+ }, [loadReports]);
64
+
65
+ const selectPoint = React.useCallback(
66
+ (ll: [number, number], meta: SelectMeta) => {
67
+ setSelectedLL(ll);
68
+ setSelectedMeta(meta);
69
+ },
70
+ []
71
+ );
72
+
73
+ const pickPhoto = React.useCallback(() => fileInputRef.current?.click(), []);
74
+ const onSend = React.useCallback(async () => {
75
+ const res = await send();
76
+ if (res?.tool_used === "add_report") await loadReports();
77
+ }, [send, loadReports]);
78
+
79
+ return (
80
+ <div className="shell">
81
+ <aside className="sidebar">
82
+ <div className="brand">
83
+ <div className="logo">PM</div>
84
+ <div className="title">PulseMap Agent</div>
85
+ </div>
86
+
87
+ <SelectedLocationCard
88
+ selectedLL={selectedLL}
89
+ selectedMeta={selectedMeta}
90
+ onClear={() => {
91
+ setSelectedLL(null);
92
+ setSelectedMeta(null);
93
+ }}
94
+ />
95
+
96
+ <UpdatesPanel
97
+ activeTab={activeTab}
98
+ setActiveTab={setActiveTab}
99
+ localUpdates={localUpdates}
100
+ globalUpdates={globalUpdates}
101
+ loadingLocal={loadingLocal}
102
+ loadingGlobal={loadingGlobal}
103
+ selectedLL={selectedLL}
104
+ onView={(u) =>
105
+ selectPoint([u.lat, u.lon], {
106
+ kind: u.kind as any,
107
+ title: u.title,
108
+ subtitle: (u as any).raw?.text || "",
109
+ severity:
110
+ typeof u.severity === "undefined" ? "" : String(u.severity),
111
+ sourceUrl: u.sourceUrl,
112
+ })
113
+ }
114
+ />
115
+ </aside>
116
+
117
+ <main className="main">
118
+ <section className="mapWrap" style={{ position: "relative" }}>
119
+ <MapCanvas
120
+ selectedLL={selectedLL}
121
+ selectedMeta={selectedMeta}
122
+ setSelected={selectPoint}
123
+ nws={nws}
124
+ quakes={quakes}
125
+ eonet={eonet}
126
+ firms={firms}
127
+ reports={reports}
128
+ />
129
+ </section>
130
+
131
+ <ChatPanel
132
+ messages={messages}
133
+ draft={draft}
134
+ setDraft={setDraft}
135
+ isStreaming={isStreaming}
136
+ hasFirstToken={hasFirstToken}
137
+ chatBodyRef={chatBodyRef}
138
+ onSend={onSend}
139
+ pendingThumb={pendingPhotoUrl}
140
+ onAttachClick={pickPhoto}
141
+ onClearAttach={() => setPendingPhotoUrl(null)}
142
+ isUploading={isUploading}
143
+ />
144
+
145
+ {/* hidden file input lives here */}
146
+ <input
147
+ ref={fileInputRef}
148
+ type="file"
149
+ accept="image/*"
150
+ style={{ display: "none" }}
151
+ onChange={(e) => {
152
+ const f = e.target.files?.[0];
153
+ if (f) onFileChosen(f);
154
+ }}
155
+ />
156
+ </main>
157
+ </div>
158
+ );
159
+ }
web/src/assets/react.svg ADDED
web/src/components/ReportIcon.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // components/ReportIcon.tsx
2
+ import * as Lucide from "lucide-react";
3
+
4
+ const pascal = (s: string) =>
5
+ s.replace(/(^\w|-\w)/g, (m) => m.replace("-", "").toUpperCase());
6
+
7
+ export function ReportIcon({
8
+ name = "info",
9
+ size = 32,
10
+ }: {
11
+ name?: string;
12
+ size?: number;
13
+ }) {
14
+ const key = (name || "info").toLowerCase();
15
+
16
+ // 3D local PNGs: name like "3d:police-light"
17
+ if (key.startsWith("3d-")) {
18
+ const file = key; // "police-light"
19
+ return (
20
+ <img
21
+ src={`/icons/3d/${file}.png`}
22
+ alt={file}
23
+ width={size}
24
+ height={size}
25
+ loading="lazy"
26
+ style={{ display: "block" }}
27
+ />
28
+ );
29
+ }
30
+
31
+ // fallback to your existing Lucide logic
32
+ const Comp = (Lucide as any)[pascal(key)] ?? (Lucide as any).Info;
33
+ return <Comp size={size} />;
34
+ }
web/src/components/chat/ChatPanel.tsx ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import TypingDots from "./TypingDots";
2
+ import type { Message } from "../../lib/types";
3
+
4
+ export default function ChatPanel({
5
+ messages,
6
+ draft,
7
+ setDraft,
8
+ isStreaming,
9
+ hasFirstToken,
10
+ chatBodyRef,
11
+ onSend,
12
+ pendingThumb,
13
+ onAttachClick,
14
+ onClearAttach,
15
+ isUploading,
16
+ }: {
17
+ messages: Message[];
18
+ draft: string;
19
+ setDraft: (s: string) => void;
20
+ isStreaming: boolean;
21
+ hasFirstToken: boolean;
22
+ chatBodyRef: React.RefObject<HTMLDivElement>;
23
+ onSend: () => void;
24
+ pendingThumb?: string | null;
25
+ onAttachClick: () => void;
26
+ onClearAttach: () => void;
27
+ isUploading: boolean;
28
+ }) {
29
+ return (
30
+ <section className="chat">
31
+ <div className="chatHdr">Assistant</div>
32
+ <div className="chatBody" ref={chatBodyRef}>
33
+ {messages.length === 0 ? (
34
+ <div className="muted">
35
+ Try: “Flooded underpass here”, or “List reports near me”.
36
+ </div>
37
+ ) : (
38
+ messages.map((m, idx) => (
39
+ <div key={idx} className={`msg ${m.role}`}>
40
+ {isStreaming &&
41
+ !hasFirstToken &&
42
+ idx === messages.length - 1 &&
43
+ m.role === "assistant" ? (
44
+ <div className="pointer-events-none relative top-1 translate-y-1 z-20">
45
+ <TypingDots />
46
+ </div>
47
+ ) : (
48
+ <>
49
+ {m.text}
50
+ {m.image && (
51
+ <div style={{ marginTop: 8 }}>
52
+ <img
53
+ src={m.image}
54
+ alt="attachment"
55
+ style={{
56
+ maxWidth: 220,
57
+ maxHeight: 220,
58
+ borderRadius: 8,
59
+ objectFit: "cover",
60
+ display: "block",
61
+ }}
62
+ />
63
+ </div>
64
+ )}
65
+ </>
66
+ )}
67
+ </div>
68
+ ))
69
+ )}
70
+ </div>
71
+
72
+ <div className="chatInputRow">
73
+ <input
74
+ className="input-chat"
75
+ placeholder="Type a message…"
76
+ value={draft}
77
+ onChange={(e) => setDraft(e.target.value)}
78
+ onKeyDown={(e) => e.key === "Enter" && onSend()}
79
+ />
80
+ <button
81
+ className="btn btn-ghost"
82
+ onClick={onAttachClick}
83
+ disabled={isUploading}
84
+ >
85
+ {isUploading ? "Uploading…" : "Attach"}
86
+ </button>
87
+ {pendingThumb && (
88
+ <div className="flex items-center gap-2 px-2">
89
+ <img
90
+ src={pendingThumb}
91
+ alt="attachment"
92
+ style={{
93
+ width: 36,
94
+ height: 36,
95
+ objectFit: "cover",
96
+ borderRadius: 6,
97
+ }}
98
+ />
99
+ <button className="btn btn-ghost" onClick={onClearAttach}>
100
+
101
+ </button>
102
+ </div>
103
+ )}
104
+ <button className="btn" onClick={onSend}>
105
+ Send
106
+ </button>
107
+ </div>
108
+ </section>
109
+ );
110
+ }
web/src/components/chat/TypingDots.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function TypingDots() {
2
+ return (
3
+ <span className="inline-flex items-center gap-1 align-middle">
4
+ <span className="sr-only">…</span>
5
+ <span className="h-1.5 w-1.5 rounded-full bg-zinc-300 animate-bounce [animation-delay:-0.2s]" />
6
+ <span className="h-1.5 w-1.5 rounded-full bg-zinc-300 animate-bounce" />
7
+ <span className="h-1.5 w-1.5 rounded-full bg-zinc-300 animate-bounce [animation-delay:0.2s]" />
8
+ </span>
9
+ );
10
+ }