diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..5cb02570a9bc151a954c48087557a2065edd1899 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +venv +.venv +__pycache__/ +*.pyc +node_modules/ +web/node_modules/ +web/.vite/ +data/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b93357628915feb2cdee18f839985dab84fa25da --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Python +__pycache__/ +*.pyc + +# Env +.env +venv/ +.venv/ + +# Data +data/** +!data/.gitkeep + +# Node +web/node_modules/ +web/dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..78cf8c2a4175595a7e47f3f3f623faa7ca532ad6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# ---------- Stage 1: build the React app ---------- +FROM node:20-alpine AS webbuilder +WORKDIR /web +COPY web/package*.json ./ +RUN npm ci +COPY web/ . +RUN npm run build + +# ---------- Stage 2: Python runtime ---------- +FROM python:3.11-slim +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PORT=7860 \ + DATA_DIR=/data +WORKDIR /app + +# (optional) if you hit build issues with some libs +RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/* + +# Copy backend and install deps +COPY backend ./backend +# Use a simple requirements file for predictability +COPY requirements.txt ./ +RUN pip install --upgrade pip && pip install -r requirements.txt + +# Copy built frontend into /app/web/dist so FastAPI can serve it +COPY --from=webbuilder /web/dist ./web/dist + +# Prepare data dir for sqlite + uploads +RUN mkdir -p ${DATA_DIR}/uploads +VOLUME ["/data"] + +# Spaces require a single port—expose default; they’ll pass $PORT +EXPOSE 7860 + +# Start FastAPI bound to Spaces' $PORT +CMD ["bash","-lc","uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT}"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/agents/classifier.py b/backend/app/agents/classifier.py new file mode 100644 index 0000000000000000000000000000000000000000..4a5890b2bd277c43813c3733ce2b89a246cac679 --- /dev/null +++ b/backend/app/agents/classifier.py @@ -0,0 +1,53 @@ +# same content as your current classifier.py, but model name from settings +from __future__ import annotations +from typing import Optional +from pydantic import BaseModel, Field +from langchain_openai import ChatOpenAI +from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate +from ..config.settings import settings + +class ReportClassification(BaseModel): + category: str = Field(..., description="taxonomy id like 'crime.gunshot'") + label: str = Field(..., description="short human title") + description: Optional[str] = Field(None, description="one sentence, no emojis") + severity: Optional[str] = None + confidence: float = Field(..., ge=0, le=1) + +CATEGORY_TO_ICON = { + "crime.gunshot": "3d-gun", + "crime.robbery": "3d-robbery", + "crime.sex_offender": "3d-sex", + "crime.suspicious": "3d-alert", + "incident.missing_person": "3d-user_search", + "incident.lost_item": "3d-search", + "incident.medical": "3d-ambulance", + "incident.car_accident": "3d-car", + "road.flood": "3d-flood", + "road.blocked": "3d-traffic", + "road.construction": "3d-construction", + "help.general": "3d-help", + "help.ride": "3d-ride", + "other.unknown": "3d-info", +} + +SYSTEM = ("You classify short community reports into a strict taxonomy. " + "Return ONLY the schema fields. If unclear, choose other.unknown.") + +EXAMPLES = [ + {"input": "I heard gunshots near 5th and Pine!", + "output_json": '{"category":"crime.gunshot","label":"Gunshots reported","description":"Multiple shots heard near 5th and Pine.","severity":"high","confidence":0.9}'}, + {"input": "Car crash blocking the left lane on I-66", + "output_json": '{"category":"incident.car_accident","label":"Car accident","description":"Crash reported blocking the left lane on I-66.","severity":"medium","confidence":0.85}'}, +] + +example_block = ChatPromptTemplate.from_messages([("human", "{input}"), ("ai", "{output_json}")]) +prompt = ChatPromptTemplate.from_messages([ + ("system", SYSTEM), + FewShotChatMessagePromptTemplate(example_prompt=example_block, examples=EXAMPLES), + ("human", "{text}"), +]) + +_model = ChatOpenAI(model=settings.OPENAI_MODEL_CLASSIFIER, temperature=0).with_structured_output(ReportClassification) + +def classify_report_text(text: str) -> ReportClassification: + return (prompt | _model).invoke({"text": text}) diff --git a/backend/app/agents/graph.py b/backend/app/agents/graph.py new file mode 100644 index 0000000000000000000000000000000000000000..fce3f44bf3e6cee42037816fbd33a4a5044f7d8e --- /dev/null +++ b/backend/app/agents/graph.py @@ -0,0 +1,81 @@ +from __future__ import annotations +from typing import Annotated, Dict, List, Optional, TypedDict +from langgraph.graph import StateGraph, START, END +from langgraph.prebuilt import ToolNode +from langgraph.graph.message import add_messages +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, BaseMessage, ToolMessage +from langgraph.checkpoint.sqlite.aio import SqliteSaver +import sqlite3 + +from .tools import TOOLS +from ..config.settings import settings + +SYSTEM_PROMPT = """ +You are PulseMap Agent — a calm, friendly assistant inside a live community map. +You help people add reports and discover what’s happening around them. + +### What to do +- If the user reports an incident (e.g. "flooded underpass here"), call `add_report(lat, lon, text, photo_url?)`. +- If the user asks about nearby updates (e.g. "what’s near me?", "any reports here?"), call `find_reports_near(lat, lon, radius_km=?, limit=?)`. + • Default radius = 25 miles (~40 km). Default limit = 10. +- If no coordinates in the message but `user_location` is provided, use that. +- If a photo URL is available, pass it through. + +### How to answer +- Speak like a helpful neighbor, not a robot. +- Use plain text only. No **bold**, no numbered lists, no markdown tables. +- After a tool call, start with a quick recap then list items newest first using hyphen bullets. + *“I checked within 25 miles of your location and found 3 updates.”* +For each item, one line like: + - 🔫 Gunshot — Severity: High; Confidence: 0.9; Time: 2h ago; Source: User; Photo: yes +- If nothing found: + - “I didn’t find anything within 25 miles in the last 48 hours. Want me to widen the search?” + +### Safety +- Keep a supportive tone. Do not dramatize. +- End with situational advice when it makes sense (e.g. “Avoid driving through floodwater”). +- Only mention calling 911 if the report itself clearly describes an urgent danger. +- Never invent reports — summarize only what tools/feed data provide. +""" + +# Long-lived sessions DB (same filename as before) +conn = sqlite3.connect(str(settings.SESSIONS_DB), check_same_thread=False) + +model = ChatOpenAI( + model=settings.OPENAI_MODEL_AGENT, + temperature=0.2, + openai_api_key=settings.OPENAI_API_KEY, + streaming=True, +).bind_tools(TOOLS) + +class AgentState(TypedDict): + messages: Annotated[List[BaseMessage], add_messages] + user_location: Optional[Dict[str, float]] + photo_url: Optional[str] + +def model_call(state: AgentState, config=None) -> AgentState: + loc = state.get("user_location") + loc_hint = f"User location (fallback): lat={loc['lat']}, lon={loc['lon']}" if (loc and 'lat' in loc and 'lon' in loc) else "User location: unknown" + photo = state.get("photo_url") or "" + photo_hint = f"Photo URL available: {photo}" if photo else "No photo URL in context." + system = SystemMessage(content=SYSTEM_PROMPT + "\n" + loc_hint + "\n" + photo_hint + "\nOnly call another tool if the user asks for more.") + msgs = [system, *state["messages"]] + ai_msg: AIMessage = model.invoke(msgs) + return {"messages": [ai_msg]} + +def should_continue(state: AgentState) -> str: + last = state["messages"][-1] + if getattr(last, "tool_calls", None): + return "continue" + return "end" + +graph = StateGraph(AgentState) +graph.add_node("agent", model_call) +graph.add_node("tools", ToolNode(tools=TOOLS)) +graph.add_edge(START, "agent") +graph.add_conditional_edges("agent", should_continue, {"continue": "tools", "end": END}) +graph.add_edge("tools", "agent") + +checkpointer = SqliteSaver(conn) +APP = graph.compile(checkpointer=checkpointer) diff --git a/backend/app/agents/tools.py b/backend/app/agents/tools.py new file mode 100644 index 0000000000000000000000000000000000000000..5aca4496b6e6fb573d29e1402cb07b50b81ac097 --- /dev/null +++ b/backend/app/agents/tools.py @@ -0,0 +1,40 @@ +import json +from datetime import datetime, timezone +from typing import Optional +from langchain.tools import tool +from .classifier import classify_report_text, CATEGORY_TO_ICON +from ..services.reports import add_report, find_reports_near + +@tool("add_report") +def add_report_tool(lat: float, lon: float, text: str = "User report", photo_url: Optional[str] = None) -> str: + """ + Add a user report as a map point (GeoJSON Feature). + Returns a JSON string: {"ok": true, "feature": ...} + """ + cls = classify_report_text(text or "User report") + icon_name = CATEGORY_TO_ICON.get(cls.category, "3d-info") + props = { + "title": cls.label, + "text": cls.description or (text.strip() if text else "User report"), + "category": cls.category, + "emoji": icon_name, + "severity": cls.severity, + "confidence": cls.confidence, + "source": "user", + "reported_at": datetime.now(timezone.utc).isoformat(), + } + if photo_url: + props["photo_url"] = photo_url + feat = add_report(float(lat), float(lon), text or cls.label, props=props) + return json.dumps({"ok": True, "feature": feat}) + +@tool("find_reports_near") +def find_reports_near_tool(lat: float, lon: float, radius_km: float = 10.0, limit: int = 20) -> str: + """ + Find user reports near a location. + Returns a JSON string: {"ok": true, "count": N, "results": [Feature,...]} + """ + res = find_reports_near(float(lat), float(lon), float(radius_km), int(limit)) + return json.dumps({"ok": True, "count": len(res), "results": res}) + +TOOLS = [add_report_tool, find_reports_near_tool] diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..efa498b4afce317a0911dd19632dfe1d669d2480 --- /dev/null +++ b/backend/app/config/settings.py @@ -0,0 +1,38 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict +from pathlib import Path +from pydantic import Field + +class Settings(BaseSettings): + + model_config = SettingsConfigDict( + env_file=".env", + extra="ignore", + case_sensitive=False, + populate_by_name=True, + ) + + FRONTEND_DIST: Path = Path("web") / "dist" + + # Models + OPENAI_API_KEY: str | None = None + OPENAI_MODEL_AGENT: str = "gpt-4o" + OPENAI_MODEL_CLASSIFIER: str = "gpt-4o-mini" + + # Data paths + DATA_DIR: Path = Path("data") + REPORTS_DB: Path = DATA_DIR / "pulsemaps_reports.db" + SESSIONS_DB: Path = DATA_DIR / "pulsemap_sessions.db" + UPLOADS_DIR: Path = DATA_DIR / "uploads" + + # Defaults + DEFAULT_RADIUS_KM: float = 40.0 # ~25 miles + DEFAULT_LIMIT: int = 10 + MAX_AGE_HOURS: int = 48 + + firms_map_key: str | None = Field(default=None, alias="FIRMS_MAP_KEY") + gdacs_rss_url: str | None = Field(default="https://www.gdacs.org/xml/rss.xml", alias="GDACS_RSS_URL") + nvidia_api_key: str | None = Field(default=None, alias="NVIDIA_API_KEY") + +settings = Settings() +settings.DATA_DIR.mkdir(exist_ok=True) +settings.UPLOADS_DIR.mkdir(parents=True, exist_ok=True) \ No newline at end of file diff --git a/backend/app/data/db.py b/backend/app/data/db.py new file mode 100644 index 0000000000000000000000000000000000000000..153085bf11e0a0f0c8f1f8b734342e24e51299bb --- /dev/null +++ b/backend/app/data/db.py @@ -0,0 +1,7 @@ +import sqlite3 +from . import store # ensure tables are created on import (store does CREATE TABLE) + +def get_reports_conn() -> sqlite3.Connection: + # store.py already keeps a module-level connection; this is a placeholder + from .store import _CONN as REPORTS_CONN # type: ignore + return REPORTS_CONN diff --git a/backend/app/data/geo.py b/backend/app/data/geo.py new file mode 100644 index 0000000000000000000000000000000000000000..2c4df61a4b4beb6729c410c3478d838276b1468d --- /dev/null +++ b/backend/app/data/geo.py @@ -0,0 +1,13 @@ +from math import radians, sin, cos, asin, sqrt +from typing import Tuple + +def haversine_km(a: Tuple[float, float], b: Tuple[float, float]) -> float: + """Distance in km between (lat,lon) points a, b.""" + lat1, lon1 = a + lat2, lon2 = b + R = 6371.0 + dlat = radians(lat2 - lat1) + dlon = radians(lon2 - lon1) + lat1r, lat2r = radians(lat1), radians(lat2) + h = sin(dlat/2)**2 + cos(lat1r)*cos(lat2r)*sin(dlon/2)**2 + return 2 * R * asin(sqrt(h)) diff --git a/backend/app/data/store.py b/backend/app/data/store.py new file mode 100644 index 0000000000000000000000000000000000000000..19cfb558bca59402a7225959a3b384af0a0df694 --- /dev/null +++ b/backend/app/data/store.py @@ -0,0 +1,69 @@ +# same content as your current store.py, just moved here +from __future__ import annotations +import json, sqlite3 +from datetime import datetime, timezone, timedelta +from typing import Dict, Any, List, Optional +from pathlib import Path +from ..data.geo import haversine_km + +Path("data").mkdir(exist_ok=True) +_CONN = sqlite3.connect("data/pulsemaps_reports.db", check_same_thread=False) +_CONN.execute(""" +CREATE TABLE IF NOT EXISTS reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lat REAL NOT NULL, + lon REAL NOT NULL, + text TEXT NOT NULL, + props_json TEXT, + created_at TEXT NOT NULL +) +""") +_CONN.commit() + +def _row_to_feature(row: tuple) -> Dict[str, Any]: + _id, lat, lon, text, props_json, created_at = row + props = {"type": "user_report", "text": text, "reported_at": created_at} + if props_json: + try: props.update(json.loads(props_json)) + except Exception: props["raw_props"] = props_json + return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [lon, lat]}, "properties": props} + +def add_report(lat: float, lon: float, text: str = "User report", props: dict | None = None): + created_at = datetime.now(timezone.utc).isoformat() + props_json = json.dumps(props or {}) + _CONN.execute("INSERT INTO reports (lat, lon, text, props_json, created_at) VALUES (?,?,?,?,?)", + (float(lat), float(lon), text, props_json, created_at)) + _CONN.commit() + return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [float(lon), float(lat)]}, + "properties": {"type": "user_report", "text": text, "reported_at": created_at, **(props or {})}} + +def get_feature_collection() -> Dict[str, Any]: + cur = _CONN.execute("SELECT id, lat, lon, text, props_json, created_at FROM reports ORDER BY id DESC") + feats = [_row_to_feature(r) for r in cur.fetchall()] + return {"type": "FeatureCollection", "features": feats} + +def find_reports_near(lat: float, lon: float, radius_km: float = 10.0, limit: int = 20, max_age_hours: Optional[int] = None) -> List[Dict[str, Any]]: + params: list[Any] = [] + sql = "SELECT id, lat, lon, text, props_json, created_at FROM reports" + if max_age_hours is not None: + cutoff = datetime.now(timezone.utc) - timedelta(hours=int(max_age_hours)) + sql += " WHERE datetime(created_at) >= datetime(?)" + params.append(cutoff.isoformat()) + sql += " ORDER BY id DESC LIMIT 2000" + cur = _CONN.execute(sql, params) + + center = (lat, lon) + cand = [] + for r in cur.fetchall(): + _, lat2, lon2, *_ = r + d = haversine_km(center, (lat2, lon2)) + if d <= radius_km: + cand.append((d, r)) + cand.sort(key=lambda x: x[0]) + out = [_row_to_feature(r) for _, r in cand[:max(1, limit)]] + return out + +def clear_reports() -> dict[str, any]: + _CONN.execute("DELETE FROM reports") + _CONN.commit() + return {"ok": True, "message": "All reports cleared."} diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..5d89feddc7ddb7317659e10f8d7b19a24a3ddf72 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pathlib import Path + +from .config.settings import settings + +app = FastAPI(title="PulseMap Agent – API", version="0.2.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], +) + +# Static uploads +app.mount("/uploads", StaticFiles(directory=str(settings.UPLOADS_DIR)), name="uploads") + +# Routers +from .routers import chat, reports, feeds, uploads # noqa +from .routers.feeds import updates as updates_router +app.include_router(chat.router) +app.include_router(reports.router) +app.include_router(feeds.router) +app.include_router(updates_router) +app.include_router(uploads.router) + +if settings.FRONTEND_DIST.exists(): + app.mount("/", StaticFiles(directory=str(settings.FRONTEND_DIST), html=True), name="spa") + +@app.get("/health") +def health(): + from datetime import datetime, timezone + return {"ok": True, "time": datetime.now(timezone.utc).isoformat()} diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..b6128571808a9d840f7c111af1a1fab2f0e5102e --- /dev/null +++ b/backend/app/routers/chat.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter, Body +from typing import Dict, Any, Optional + +from ..services.chat_agent import run_chat + +router = APIRouter(prefix="/chat", tags=["chat"]) + +@router.post("") +def chat(payload: Dict[str, Any] = Body(...)): + """ + Body: { "message": str, "user_location": {lat,lon}?, "session_id"?: str, "photo_url"?: str } + """ + msg = payload.get("message", "") + if not isinstance(msg, str) or not msg.strip(): + return {"reply": "Please type something.", "tool_used": None} + return run_chat( + message=msg.strip(), + user_location=payload.get("user_location"), + session_id=payload.get("session_id"), + photo_url=payload.get("photo_url"), + ) + +@router.post("/reset") +def reset_chat(payload: Dict[str, Any] = Body(...)): + sid = payload.get("session_id") + if not sid: + return {"ok": False, "error": "session_id required"} + # Same guidance as before—client can rotate session_id for SqliteSaver threads. + return {"ok": True} diff --git a/backend/app/routers/feeds.py b/backend/app/routers/feeds.py new file mode 100644 index 0000000000000000000000000000000000000000..792e591db3156b7dea3ab04fb97c2a2a3dd17c5e --- /dev/null +++ b/backend/app/routers/feeds.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, HTTPException +from typing import Any, Dict, Optional +from ..services.feeds import ( + fetch_usgs_quakes_geojson, fetch_nws_alerts_geojson, + eonet_geojson_points, firms_geojson_points, # <-- use normalized point outputs + local_updates as _local_updates, global_updates as _global_updates +) + +router = APIRouter(prefix="/feeds", tags=["feeds"]) + +@router.get("/usgs") +async def usgs(): + return {"data": await fetch_usgs_quakes_geojson()} + +@router.get("/nws") +async def nws(): + return {"data": await fetch_nws_alerts_geojson()} + +@router.get("/eonet") +async def eonet(): + return {"data": await eonet_geojson_points()} + +@router.get("/firms") +async def firms(): + # Return pointified features for map markers + return {"data": await firms_geojson_points()} + +# Convenience endpoints parallel to your previous design +updates = APIRouter(prefix="/updates", tags=["updates"]) + +@updates.get("/local") +async def local_updates(lat: float, lon: float, radius_miles: float = 25.0, + max_age_hours: int = 48, limit: int = 100): + return await _local_updates(lat, lon, radius_miles, max_age_hours, limit) + +@updates.get("/global") +async def global_updates(limit: int = 200, max_age_hours: Optional[int] = None): + return await _global_updates(limit, max_age_hours) + +router.include_router(updates) diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py new file mode 100644 index 0000000000000000000000000000000000000000..40d0ce09105790bfe9b2f22866aa07c8e3b6cd9b --- /dev/null +++ b/backend/app/routers/reports.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter +from ..data.store import get_feature_collection, clear_reports + +router = APIRouter(prefix="/reports", tags=["reports"]) + +@router.get("") +def reports(): + return get_feature_collection() + +@router.post("/clear") +def clear_reports_api(): + return clear_reports() diff --git a/backend/app/routers/uploads.py b/backend/app/routers/uploads.py new file mode 100644 index 0000000000000000000000000000000000000000..d07a6db5a7b02bbf01ed18d5f1dc6ddc294f5085 --- /dev/null +++ b/backend/app/routers/uploads.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, UploadFile, File, Request, HTTPException +import os +from uuid import uuid4 +from ..config.settings import settings + +router = APIRouter(prefix="/upload", tags=["uploads"]) + +@router.post("/photo") +async def upload_photo(request: Request, file: UploadFile = File(...)): + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="Only image files are allowed.") + ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg" + if ext not in [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"]: + ext = ".jpg" + + data = await file.read() + if len(data) > 5 * 1024 * 1024: + raise HTTPException(status_code=413, detail="Image too large (max 5MB).") + + name = f"{uuid4().hex}{ext}" + (settings.UPLOADS_DIR / name).write_bytes(data) + + base = str(request.base_url).rstrip("/") + url = f"{base}/uploads/{name}" + return {"ok": True, "url": url, "path": f"/uploads/{name}"} diff --git a/backend/app/services/chat_agent.py b/backend/app/services/chat_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..39df7111208cb0d1b46d642da94ec2378e58b253 --- /dev/null +++ b/backend/app/services/chat_agent.py @@ -0,0 +1,26 @@ +from typing import Dict, Any, Optional +from langchain_core.messages import HumanMessage, AIMessage, ToolMessage +from ..agents.graph import APP + +def run_chat(message: str, + user_location: Optional[Dict[str, float]] = None, + session_id: Optional[str] = None, + photo_url: Optional[str] = None) -> Dict[str, Any]: + from uuid import uuid4 + sid = session_id or str(uuid4()) + init = {"messages": [HumanMessage(content=message)], "user_location": user_location, "photo_url": photo_url} + cfg = {"configurable": {"thread_id": sid}} + final = APP.invoke(init, config=cfg) + + reply, tool_used, tool_result = "", None, None + for m in final["messages"]: + if isinstance(m, AIMessage): + reply = m.content or reply + elif isinstance(m, ToolMessage) and getattr(m, "name", None) in {"add_report", "find_reports_near"}: + import json + try: + tool_used = m.name + tool_result = json.loads(m.content) if isinstance(m.content, str) else m.content + except Exception: + tool_result = {"raw": m.content} + return {"reply": reply, "tool_used": tool_used, "tool_result": tool_result, "session_id": sid} diff --git a/backend/app/services/feeds.py b/backend/app/services/feeds.py new file mode 100644 index 0000000000000000000000000000000000000000..acdf4ed56ab8cc04b3bc0a93acea24472b2d19d5 --- /dev/null +++ b/backend/app/services/feeds.py @@ -0,0 +1,241 @@ +import asyncio +from datetime import datetime, timezone +from typing import Any, Dict, Optional, List, Iterable, Tuple +from dateutil import parser as dtparser + +from ..data.geo import haversine_km +from .fetchers import ( + fetch_usgs_quakes_geojson, fetch_nws_alerts_geojson, + fetch_eonet_events_geojson, fetch_firms_hotspots_geojson +) + +def _flatten_lonlats(coords: Any) -> List[Tuple[float, float]]: + """Collect (lon, lat) pairs from nested coordinate arrays.""" + out: List[Tuple[float, float]] = [] + if not isinstance(coords, (list, tuple)): + return out + if len(coords) >= 2 and isinstance(coords[0], (int, float)) and isinstance(coords[1], (int, float)): + # Single coordinate pair [lon, lat, ...] + out.append((float(coords[0]), float(coords[1]))) + else: + for c in coords: + out.extend(_flatten_lonlats(c)) + return out + +def _centroid_from_geom(geom: Dict[str, Any]) -> Optional[Tuple[float, float]]: + """Return (lon, lat) for any geometry by taking a simple average of all coords.""" + if not geom or "type" not in geom: + return None + gtype = geom.get("type") + coords = geom.get("coordinates") + + # Fast path for Point + if gtype == "Point" and isinstance(coords, (list, tuple)) and len(coords) >= 2: + return (float(coords[0]), float(coords[1])) + + # Generic centroid for Polygon/MultiPolygon/LineString/etc. + pts = _flatten_lonlats(coords) + if not pts: + return None + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + return (sum(xs) / len(xs), sum(ys) / len(ys)) + +def _mk_point_feature(lon: float, lat: float, props: Dict[str, Any]) -> Dict[str, Any]: + return { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [float(lon), float(lat)]}, + "properties": props or {}, + } + +def _report_to_update(f: Dict[str, Any]) -> Dict[str, Any]: + p = f.get("properties", {}) or {} + lat = f["geometry"]["coordinates"][1] + lon = f["geometry"]["coordinates"][0] + return { + "kind": "report", + "title": p.get("title") or p.get("text") or "User report", + "emoji": p.get("emoji") or "📝", + "time": p.get("reported_at"), + "lat": float(lat), "lon": float(lon), + "severity": p.get("severity"), + "sourceUrl": None, + "raw": p, + } + +def _quake_to_update(f: Dict[str, Any]) -> Dict[str, Any] | None: + p = f.get("properties", {}) or {} + g = f.get("geometry", {}) or {} + if g.get("type") != "Point": return None + lon, lat = g["coordinates"][:2] + title = p.get("place") or p.get("title") or "Earthquake" + mag = p.get("mag") or p.get("Magnitude") or p.get("m") + ts = p.get("time") + if isinstance(ts, (int, float)): + time_iso = datetime.fromtimestamp(ts/1000, tz=timezone.utc).isoformat() + else: + time_iso = p.get("updated") if isinstance(p.get("updated"), str) else datetime.now(timezone.utc).isoformat() + return {"kind": "quake", "title": title, "emoji": "💥", "time": time_iso, + "lat": float(lat), "lon": float(lon), "severity": f"M{mag}" if mag is not None else None, + "sourceUrl": p.get("url") or p.get("detail"), "raw": p} + +def _eonet_to_update(f: Dict[str, Any]) -> Dict[str, Any] | None: + p = f.get("properties", {}) or {} + g = f.get("geometry", {}) or {} + if g.get("type") != "Point": return None + lon, lat = g["coordinates"][:2] + title = p.get("title") or p.get("category") or "Event" + cat = (p.get("category") or (p.get("categories") or [{}])[0].get("title") or "").lower() + if "wildfire" in cat: emoji = "🔥" + elif "volcano" in cat: emoji = "🌋" + elif "earthquake" in cat or "seismic" in cat: emoji = "💥" + elif any(k in cat for k in ["storm","cyclone","hurricane","typhoon"]): emoji = "🌀" + elif "flood" in cat: emoji = "🌊" + elif "landslide" in cat: emoji = "🏔️" + elif any(k in cat for k in ["ice","snow","blizzard"]): emoji = "❄️" + elif any(k in cat for k in ["dust","smoke","haze"]): emoji = "🌫️" + else: emoji = "⚠️" + time_iso = p.get("time") or p.get("updated") or datetime.now(timezone.utc).isoformat() + return {"kind": "eonet", "title": title, "emoji": emoji, "time": time_iso, + "lat": float(lat), "lon": float(lon), "sourceUrl": p.get("link") or p.get("url"), "raw": p} + +def _firms_to_update(f: Dict[str, Any]) -> Dict[str, Any] | None: + p = f.get("properties", {}) or {} + g = f.get("geometry", {}) or {} + if g.get("type") != "Point": return None + lon, lat = g["coordinates"][:2] + time_iso = p.get("acq_datetime") or p.get("acq_date") or datetime.now(timezone.utc).isoformat() + sev = p.get("confidence") or p.get("brightness") or p.get("frp") + return {"kind": "fire", "title": "Fire hotspot", "emoji": "🔥", "time": time_iso, + "lat": float(lat), "lon": float(lon), "severity": sev, "sourceUrl": None, "raw": p} + +def _within(lat: float, lon: float, u: Dict[str, Any], radius_km: float) -> bool: + return haversine_km((lat, lon), (u["lat"], u["lon"])) <= radius_km + +def _is_recent(iso: str | None, max_age_hours: int) -> bool: + if not iso: return False + try: + t = dtparser.isoparse(iso) + if not t.tzinfo: t = t.replace(tzinfo=timezone.utc) + except Exception: + return False + return (datetime.now(timezone.utc) - t).total_seconds() <= max_age_hours * 3600 + +async def _gather_feeds(): + results = await asyncio.gather( + fetch_usgs_quakes_geojson(), fetch_nws_alerts_geojson(), + fetch_eonet_events_geojson(), fetch_firms_hotspots_geojson(), + return_exceptions=True + ) + def ok(x): return {"features": []} if isinstance(x, Exception) or not x else x + return {"usgs": ok(results[0]), "nws": ok(results[1]), "eonet": ok(results[2]), "firms": ok(results[3])} + +async def local_updates(lat: float, lon: float, radius_miles: float, max_age_hours: int, limit: int): + from ..data.store import find_reports_near + km = float(radius_miles) * 1.609344 + near_reports = find_reports_near(lat, lon, radius_km=km, limit=limit, max_age_hours=max_age_hours) + updates: List[Dict[str, Any]] = [_report_to_update(f) for f in near_reports] + feeds = await _gather_feeds() + + for f in (feeds["usgs"].get("features") or []): + u = _quake_to_update(f) + if u and _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km): + updates.append(u) + for u in _nws_to_updates(feeds["nws"]): + if _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km): + updates.append(u) + for f in (feeds["eonet"].get("features") or []): + u = _eonet_to_update(f) + if u and _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km): + updates.append(u) + for f in (feeds["firms"].get("features") or []): + u = _firms_to_update(f) + if u and _is_recent(u["time"], max_age_hours) and _within(lat, lon, u, km): + updates.append(u) + + updates.sort(key=lambda x: x["time"] or "", reverse=True) + return {"count": min(len(updates), limit), "updates": updates[:limit]} + +def _nws_to_updates(fc: Dict[str, Any]) -> list[Dict[str, Any]]: + out: list[Dict[str, Any]] = [] + for f in (fc.get("features") or []): + p = f.get("properties", {}) or {} + g = f.get("geometry", {}) or {} + coords = None + if g.get("type") == "Polygon": + poly = g["coordinates"][0] + if poly: + lats = [c[1] for c in poly]; lons = [c[0] for c in poly] + coords = (sum(lats)/len(lats), sum(lons)/len(lons)) + elif g.get("type") == "Point": + coords = (g["coordinates"][1], g["coordinates"][0]) + if not coords: + continue + sev = p.get("severity") or "Unknown" + issued = p.get("effective") or p.get("onset") or p.get("sent") or datetime.now(timezone.utc).isoformat() + out.append({"kind": "nws", "title": p.get("event") or "NWS Alert", "emoji": "⚠️", + "time": issued, "lat": float(coords[0]), "lon": float(coords[1]), + "severity": sev, "sourceUrl": p.get("@id") or p.get("id"), "raw": p}) + return out + +async def global_updates(limit: int, max_age_hours: Optional[int]): + from ..data.store import get_feature_collection + fc = get_feature_collection() + reports = fc.get("features") or [] + rep_updates = [_report_to_update(f) for f in reports] + feeds = await _gather_feeds() + nws_updates = _nws_to_updates(feeds["nws"]) + quake_updates = [_ for f in (feeds["usgs"].get("features") or []) if (_ := _quake_to_update(f))] + eonet_updates = [_ for f in (feeds["eonet"].get("features") or []) if (_ := _eonet_to_update(f))] + firms_updates = [_ for f in (feeds["firms"].get("features") or []) if (_ := _firms_to_update(f))] + + updates = rep_updates + nws_updates + quake_updates + eonet_updates + firms_updates + if max_age_hours is not None: + updates = [u for u in updates if _is_recent(u["time"], max_age_hours)] + updates.sort(key=lambda x: x["time"] or "", reverse=True) + return {"count": min(len(updates), limit), "updates": updates[:limit]} + +async def eonet_geojson_points() -> Dict[str, Any]: + """Always return Point features for EONET (polygon events -> centroid).""" + fc = await fetch_eonet_events_geojson() or {} + features = [] + for f in (fc.get("features") or []): + g = f.get("geometry") or {} + p = f.get("properties") or {} + cen = _centroid_from_geom(g) + if not cen: + continue + lon, lat = cen + # Keep a stable, small prop set the map can style + props = { + "source": "eonet", + "title": p.get("title") or p.get("category") or "Event", + "emoji": "⚠️", # the map can replace based on category if it wants + "raw": p, + } + features.append(_mk_point_feature(lon, lat, props)) + return {"type": "FeatureCollection", "features": features} + +async def firms_geojson_points() -> Dict[str, Any]: + """Always return Point features for FIRMS (skip invalid rows).""" + fc = await fetch_firms_hotspots_geojson() or {} + features = [] + for f in (fc.get("features") or []): + g = f.get("geometry") or {} + p = f.get("properties") or {} + cen = _centroid_from_geom(g) + if not cen: + # Some rows can be malformed; skip them + continue + lon, lat = cen + props = { + "source": "firms", + "title": "Fire hotspot", + "emoji": "🔥", + "confidence": p.get("confidence"), + "brightness": p.get("brightness"), + "time": p.get("acq_datetime") or p.get("acq_date"), + "raw": p, + } + features.append(_mk_point_feature(lon, lat, props)) + return {"type": "FeatureCollection", "features": features} diff --git a/backend/app/services/fetchers.py b/backend/app/services/fetchers.py new file mode 100644 index 0000000000000000000000000000000000000000..c70d14d5cd2f6fcc1aefda9cf276df2d1be6dd14 --- /dev/null +++ b/backend/app/services/fetchers.py @@ -0,0 +1,154 @@ +import httpx +import os, io, csv +import asyncio +import random +import httpx + + +# Keep URLs simple & stable; you can lift to config/env later. +USGS_ALL_HOUR = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson" +NWS_ALERTS_ACTIVE = "https://api.weather.gov/alerts/active" +EONET_EVENTS_GEOJSON = "https://eonet.gsfc.nasa.gov/api/v3/events/geojson?status=open&days=7" +DATASETS = ["VIIRS_NOAA20_NRT", "VIIRS_SNPP_NRT"] + + +import httpx + +def _in_usa(lat: float, lon: float) -> bool: + # CONUS + if 24.5 <= lat <= 49.5 and -125.0 <= lon <= -66.0: + return True + # Alaska (rough) + if 51.0 <= lat <= 71.0 and -170.0 <= lon <= -129.0: + return True + # Hawaii + if 18.5 <= lat <= 22.5 and -161.0 <= lon <= -154.0: + return True + return False + +async def fetch_json_once( + url: str, + headers: dict, + *, + connect_timeout: float = 3, + read_timeout: float = 12, +): + """ + Single attempt fetch; no retries, no delay. + """ + timeout = httpx.Timeout( + connect=connect_timeout, + read=read_timeout, + write=read_timeout, + pool=connect_timeout, + ) + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + r = await client.get(url, headers=headers) + r.raise_for_status() + return r.json() + +async def fetch_usgs_quakes_geojson(): + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get(USGS_ALL_HOUR, headers={"Accept":"application/geo+json"}) + r.raise_for_status() + return r.json() + +async def fetch_nws_alerts_geojson(): + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get(NWS_ALERTS_ACTIVE, headers={"Accept":"application/geo+json"}) + r.raise_for_status() + return r.json() + +async def fetch_eonet_events_geojson(): + return await fetch_json_once( + EONET_EVENTS_GEOJSON, + headers={"Accept": "application/geo+json"}, + connect_timeout=3, + read_timeout=12, + ) + +def _get_num(d: dict, *keys): + for k in keys: + if k in d and d[k] not in (None, ""): + try: + return float(d[k]) + except Exception: + pass + raise KeyError("no numeric value") + +async def _fetch_firms_csv_rows(key: str, dataset: str, hours: int = 1) -> list[dict]: + url = f"https://firms.modaps.eosdis.nasa.gov/api/area/csv/{key}/{dataset}/world/{hours}" + async with httpx.AsyncClient(timeout=20) as client: + r = await client.get(url, headers={"Accept": "text/csv", "User-Agent": "PulseMap/1.0"}) + text = r.text or "" + + # Some FIRMS edges return text/plain or octet-stream; parse anyway + # Strip BOM if present + if text and text[:1] == "\ufeff": + text = text[1:] + + # Try CSV parse + try: + reader = csv.DictReader(io.StringIO(text)) + rows = [row for row in reader] + except Exception: + rows = [] + + # If we got nothing, surface first 200 chars to the caller for logging + if not rows: + return [{"__error__": (text[:200] if text else "empty response")}] + + return rows + +async def fetch_firms_hotspots_geojson(): + """ + NASA FIRMS: returns GeoJSON FeatureCollection (Points). + Requires env FIRMS_MAP_KEY. Tries NOAA-20 first, then SNPP. World, last 24h (1 day segment). + """ + key = "95fa2dac8d20024aa6a17229dbf5ce74" + if not key: + return {"type": "FeatureCollection", "features": [], "_note": "Set FIRMS_MAP_KEY to enable."} + + errors = [] + for dataset in DATASETS: + rows = await _fetch_firms_csv_rows(key, dataset, hours=1) + if rows and "__error__" in rows[0]: + errors.append(f"{dataset}: {rows[0]['__error__']}") + continue + + feats = [] + for i, row in enumerate(rows): + if i >= 1500: + break + try: + lat = _get_num(row, "latitude", "LATITUDE", "lat", "LAT") + lon = _get_num(row, "longitude", "LONGITUDE", "lon", "LON") + except Exception: + continue + + props = { + "source": "FIRMS", + "dataset": dataset, + "acq_date": row.get("acq_date") or row.get("ACQ_DATE"), + "acq_time": row.get("acq_time") or row.get("ACQ_TIME"), + "instrument": row.get("instrument") or row.get("INSTRUMENT"), + "confidence": row.get("confidence") or row.get("CONFIDENCE"), + "frp": row.get("frp") or row.get("FRP"), + "daynight": row.get("daynight") or row.get("DAYNIGHT"), + } + feats.append({ + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [lon, lat]}, + "properties": props, + }) + + feats = [f for f in feats + if _in_usa(f["geometry"]["coordinates"][1], f["geometry"]["coordinates"][0])] + if feats: + return {"type": "FeatureCollection", "features": feats, "_note": f"{dataset} ok, {len(feats)} points (USA only)"} + + # Try next dataset if this one returned 0 points + errors.append(f"{dataset}: 0 rows or no valid coordinates") + + # If we got here, nothing worked + return {"type": "FeatureCollection", "features": [], "_note": f"FIRMS empty. Details: {' | '.join(errors[:2])}"} \ No newline at end of file diff --git a/backend/app/services/reports.py b/backend/app/services/reports.py new file mode 100644 index 0000000000000000000000000000000000000000..ae8e42d4a5f235aa559a1f38f908943346c26a57 --- /dev/null +++ b/backend/app/services/reports.py @@ -0,0 +1,9 @@ +from typing import Dict, Any, List, Optional +from ..data.store import add_report as _add, find_reports_near as _find + +def add_report(lat: float, lon: float, text: str, props: dict | None = None) -> Dict[str, Any]: + return _add(lat, lon, text, props) + +def find_reports_near(lat: float, lon: float, radius_km: float, limit: int, + max_age_hours: Optional[int] = None) -> List[Dict[str, Any]]: + return _find(lat, lon, radius_km, limit, max_age_hours=max_age_hours) diff --git a/backend/app/types/models.py b/backend/app/types/models.py new file mode 100644 index 0000000000000000000000000000000000000000..517e8a3a3a5d4ae3331df3e88b585e41369eccc7 --- /dev/null +++ b/backend/app/types/models.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel, Field +from typing import Optional, List, Any + +class UserLocation(BaseModel): + lat: float + lon: float + +class ChatRequest(BaseModel): + message: str + user_location: Optional[UserLocation] = None + session_id: Optional[str] = None + photo_url: Optional[str] = None + +class Update(BaseModel): + kind: str + title: str + emoji: str + time: Optional[str] + lat: float + lon: float + severity: Optional[str] = None + sourceUrl: Optional[str] = None + raw: Any + +class UpdatesResponse(BaseModel): + count: int + updates: List[Update] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..335430d8a29d2cb52cbde7523362312ba4339ad9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "pulsemaps-backend" +version = "0.2.0" +requires-python = ">=3.10" +dependencies = [ + "fastapi", + "uvicorn[standard]", + "pydantic", + "pydantic-settings", + "python-dateutil", + "httpx", + "langchain", + "langchain-openai", + "langgraph", +] + +[tool.ruff] +line-length = 100 +select = ["E","F","I","UP"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..e5a4cc00368bb031ac719be28d543f8b75d2974f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.115.4 +uvicorn[standard]==0.31.0 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-multipart==0.0.9 +python-dateutil==2.9.0.post0 +httpx==0.27.2 + +# LangChain stack +langchain==0.2.16 +langchain-openai==0.1.26 +openai==1.40.6 +langgraph==0.2.34 +aiosqlite==0.20.0 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000000000000000000000000000000000000..7959ce4269342e8ede1d06bded69ec800d8503d9 --- /dev/null +++ b/web/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..d94e7deb72c90f03254225baf04f31b621b4877c --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { globalIgnores } from 'eslint/config' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..33d519fc5e11b18253f2af486e43415174cbce02 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + PulseMap Agent + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..92515c5ad3f298a54bd7789ad52c202acdb6a517 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1731 @@ +{ + "name": "cta-web", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cta-web", + "version": "0.0.1", + "dependencies": { + "@vis.gl/react-google-maps": "^1.5.5", + "leaflet": "^1.9.4", + "lucide-react": "^0.542.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1" + }, + "devDependencies": { + "@types/leaflet": "^1.9.20", + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^5.0.1", + "typescript": "^5.9.2", + "vite": "^5.4.19" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", + "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.3", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", + "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.32", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", + "integrity": "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.48.0.tgz", + "integrity": "sha512-aVzKH922ogVAWkKiyKXorjYymz2084zrhrZRXtLrA5eEx5SO8Dj0c/4FpCHZyn7MKzhW2pW4tK28vVr+5oQ2xw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.48.0.tgz", + "integrity": "sha512-diOdQuw43xTa1RddAFbhIA8toirSzFMcnIg8kvlzRbK26xqEnKJ/vqQnghTAajy2Dcy42v+GMPMo6jq67od+Dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.48.0.tgz", + "integrity": "sha512-QhR2KA18fPlJWFefySJPDYZELaVqIUVnYgAOdtJ+B/uH96CFg2l1TQpX19XpUMWUqMyIiyY45wje8K6F4w4/CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.48.0.tgz", + "integrity": "sha512-Q9RMXnQVJ5S1SYpNSTwXDpoQLgJ/fbInWOyjbCnnqTElEyeNvLAB3QvG5xmMQMhFN74bB5ZZJYkKaFPcOG8sGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.48.0.tgz", + "integrity": "sha512-3jzOhHWM8O8PSfyft+ghXZfBkZawQA0PUGtadKYxFqpcYlOYjTi06WsnYBsbMHLawr+4uWirLlbhcYLHDXR16w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.48.0.tgz", + "integrity": "sha512-NcD5uVUmE73C/TPJqf78hInZmiSBsDpz3iD5MF/BuB+qzm4ooF2S1HfeTChj5K4AV3y19FFPgxonsxiEpy8v/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.48.0.tgz", + "integrity": "sha512-JWnrj8qZgLWRNHr7NbpdnrQ8kcg09EBBq8jVOjmtlB3c8C6IrynAJSMhMVGME4YfTJzIkJqvSUSVJRqkDnu/aA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.48.0.tgz", + "integrity": "sha512-9xu92F0TxuMH0tD6tG3+GtngwdgSf8Bnz+YcsPG91/r5Vgh5LNofO48jV55priA95p3c92FLmPM7CvsVlnSbGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.48.0.tgz", + "integrity": "sha512-NLtvJB5YpWn7jlp1rJiY0s+G1Z1IVmkDuiywiqUhh96MIraC0n7XQc2SZ1CZz14shqkM+XN2UrfIo7JB6UufOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.48.0.tgz", + "integrity": "sha512-QJ4hCOnz2SXgCh+HmpvZkM+0NSGcZACyYS8DGbWn2PbmA0e5xUk4bIP8eqJyNXLtyB4gZ3/XyvKtQ1IFH671vQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.48.0.tgz", + "integrity": "sha512-Pk0qlGJnhILdIC5zSKQnprFjrGmjfDM7TPZ0FKJxRkoo+kgMRAg4ps1VlTZf8u2vohSicLg7NP+cA5qE96PaFg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.48.0.tgz", + "integrity": "sha512-/dNFc6rTpoOzgp5GKoYjT6uLo8okR/Chi2ECOmCZiS4oqh3mc95pThWma7Bgyk6/WTEvjDINpiBCuecPLOgBLQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.48.0.tgz", + "integrity": "sha512-YBwXsvsFI8CVA4ej+bJF2d9uAeIiSkqKSPQNn0Wyh4eMDY4wxuSp71BauPjQNCKK2tD2/ksJ7uhJ8X/PVY9bHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.48.0.tgz", + "integrity": "sha512-FI3Rr2aGAtl1aHzbkBIamsQyuauYtTF9SDUJ8n2wMXuuxwchC3QkumZa1TEXYIv/1AUp1a25Kwy6ONArvnyeVQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.48.0.tgz", + "integrity": "sha512-Dx7qH0/rvNNFmCcIRe1pyQ9/H0XO4v/f0SDoafwRYwc2J7bJZ5N4CHL/cdjamISZ5Cgnon6iazAVRFlxSoHQnQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.48.0.tgz", + "integrity": "sha512-GUdZKTeKBq9WmEBzvFYuC88yk26vT66lQV8D5+9TgkfbewhLaTHRNATyzpQwwbHIfJvDJ3N9WJ90wK/uR3cy3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.48.0.tgz", + "integrity": "sha512-ao58Adz/v14MWpQgYAb4a4h3fdw73DrDGtaiF7Opds5wNyEQwtO6M9dBh89nke0yoZzzaegq6J/EXs7eBebG8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.48.0.tgz", + "integrity": "sha512-kpFno46bHtjZVdRIOxqaGeiABiToo2J+st7Yce+aiAoo1H0xPi2keyQIP04n2JjDVuxBN6bSz9R6RdTK5hIppw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.48.0.tgz", + "integrity": "sha512-rFYrk4lLk9YUTIeihnQMiwMr6gDhGGSbWThPEDfBoU/HdAtOzPXeexKi7yU8jO+LWRKnmqPN9NviHQf6GDwBcQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.48.0.tgz", + "integrity": "sha512-sq0hHLTgdtwOPDB5SJOuaoHyiP1qSwg+71TQWk8iDS04bW1wIE0oQ6otPiRj2ZvLYNASLMaTp8QRGUVZ+5OL5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/google.maps": { + "version": "3.58.1", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", + "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.20", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz", + "integrity": "sha512-rooalPMlk61LCaLOvBF2VIf9M47HgMQqi5xQ9QRi7c8PkdIe0WrIi5IxXUXQjAdL0c+vcQ01mYWbthzmp9GHWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.24", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", + "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vis.gl/react-google-maps": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.5.5.tgz", + "integrity": "sha512-LgHtK1AtE2/BN4dPoK05oWu0jWmeDdyX0Ffqi+mZc+M4apaHn2sUxxKXAxhPF90O9vcsiou/ntm6/XBWX+gpqw==", + "license": "MIT", + "dependencies": { + "@types/google.maps": "^3.54.10", + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "react": ">=16.8.0 || ^19.0 || ^19.0.0-rc", + "react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.1.tgz", + "integrity": "sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.3", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.32", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.25.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", + "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001735", + "electron-to-chromium": "^1.5.204", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001737", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", + "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.208", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.208.tgz", + "integrity": "sha512-ozZyibehoe7tOhNaf16lKmljVf+3npZcJIEbJRVftVsmAg5TeA1mGS9dVCZzOwr2xT7xK15V0p7+GZqSPgkuPg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.542.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", + "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.48.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.0.tgz", + "integrity": "sha512-BXHRqK1vyt9XVSEHZ9y7xdYtuYbwVod2mLwOMFP7t/Eqoc1pHRlG/WdV2qNeNvZHRQdLedaFycljaYYM96RqJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.48.0", + "@rollup/rollup-android-arm64": "4.48.0", + "@rollup/rollup-darwin-arm64": "4.48.0", + "@rollup/rollup-darwin-x64": "4.48.0", + "@rollup/rollup-freebsd-arm64": "4.48.0", + "@rollup/rollup-freebsd-x64": "4.48.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.48.0", + "@rollup/rollup-linux-arm-musleabihf": "4.48.0", + "@rollup/rollup-linux-arm64-gnu": "4.48.0", + "@rollup/rollup-linux-arm64-musl": "4.48.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.48.0", + "@rollup/rollup-linux-ppc64-gnu": "4.48.0", + "@rollup/rollup-linux-riscv64-gnu": "4.48.0", + "@rollup/rollup-linux-riscv64-musl": "4.48.0", + "@rollup/rollup-linux-s390x-gnu": "4.48.0", + "@rollup/rollup-linux-x64-gnu": "4.48.0", + "@rollup/rollup-linux-x64-musl": "4.48.0", + "@rollup/rollup-win32-arm64-msvc": "4.48.0", + "@rollup/rollup-win32-ia32-msvc": "4.48.0", + "@rollup/rollup-win32-x64-msvc": "4.48.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..65b696e11e067a8dd604f41b8328a69f4fc465cb --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "cta-web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --port 5174" + }, + "dependencies": { + "@vis.gl/react-google-maps": "^1.5.5", + "leaflet": "^1.9.4", + "lucide-react": "^0.542.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1" + }, + "devDependencies": { + "@types/leaflet": "^1.9.20", + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^5.0.1", + "typescript": "^5.9.2", + "vite": "^5.4.19" + } +} diff --git a/web/public/icons/3d/3d-alert.png b/web/public/icons/3d/3d-alert.png new file mode 100644 index 0000000000000000000000000000000000000000..9a5ec71147bc070889143610f96c69c63838fcbe Binary files /dev/null and b/web/public/icons/3d/3d-alert.png differ diff --git a/web/public/icons/3d/3d-ambulance.png b/web/public/icons/3d/3d-ambulance.png new file mode 100644 index 0000000000000000000000000000000000000000..fdac8e707ce7404ae419aa62bc7aeb6d80c429b6 Binary files /dev/null and b/web/public/icons/3d/3d-ambulance.png differ diff --git a/web/public/icons/3d/3d-car.png b/web/public/icons/3d/3d-car.png new file mode 100644 index 0000000000000000000000000000000000000000..f34c5ca1451dd444e720c328ace2859e1ee25afc Binary files /dev/null and b/web/public/icons/3d/3d-car.png differ diff --git a/web/public/icons/3d/3d-construction.png b/web/public/icons/3d/3d-construction.png new file mode 100644 index 0000000000000000000000000000000000000000..79872a124d32ef95b7767c193bc7077961a93970 Binary files /dev/null and b/web/public/icons/3d/3d-construction.png differ diff --git a/web/public/icons/3d/3d-flood.png b/web/public/icons/3d/3d-flood.png new file mode 100644 index 0000000000000000000000000000000000000000..107087a40c3da0e2d2bd1f27c884b2cae986eae5 Binary files /dev/null and b/web/public/icons/3d/3d-flood.png differ diff --git a/web/public/icons/3d/3d-gun.png b/web/public/icons/3d/3d-gun.png new file mode 100644 index 0000000000000000000000000000000000000000..c4981624b6ed7920656c6ef998e8e193968eda9e Binary files /dev/null and b/web/public/icons/3d/3d-gun.png differ diff --git a/web/public/icons/3d/3d-help.png b/web/public/icons/3d/3d-help.png new file mode 100644 index 0000000000000000000000000000000000000000..7ff10554e27c9bf8fc562131f37acb8b13731bcf Binary files /dev/null and b/web/public/icons/3d/3d-help.png differ diff --git a/web/public/icons/3d/3d-info.png b/web/public/icons/3d/3d-info.png new file mode 100644 index 0000000000000000000000000000000000000000..2939cb57a44cca41e45ac45fcffe36e05fd8af48 Binary files /dev/null and b/web/public/icons/3d/3d-info.png differ diff --git a/web/public/icons/3d/3d-ride.png b/web/public/icons/3d/3d-ride.png new file mode 100644 index 0000000000000000000000000000000000000000..f1e539c4029c808e44bd3e7b5e07a5ff32db7f7c Binary files /dev/null and b/web/public/icons/3d/3d-ride.png differ diff --git a/web/public/icons/3d/3d-robbery.png b/web/public/icons/3d/3d-robbery.png new file mode 100644 index 0000000000000000000000000000000000000000..71e615ad147e17e68b595a82a1f077866912f3ab Binary files /dev/null and b/web/public/icons/3d/3d-robbery.png differ diff --git a/web/public/icons/3d/3d-search.png b/web/public/icons/3d/3d-search.png new file mode 100644 index 0000000000000000000000000000000000000000..ce9eb0c657a42e896a63a69f1684c6bada7874ba Binary files /dev/null and b/web/public/icons/3d/3d-search.png differ diff --git a/web/public/icons/3d/3d-sex.png b/web/public/icons/3d/3d-sex.png new file mode 100644 index 0000000000000000000000000000000000000000..6e556f479cc828b4c66ad67442781ade6d0a41ed Binary files /dev/null and b/web/public/icons/3d/3d-sex.png differ diff --git a/web/public/icons/3d/3d-traffic.png b/web/public/icons/3d/3d-traffic.png new file mode 100644 index 0000000000000000000000000000000000000000..f2349176bb80a99bf8619ba11ff1d90c8a4b01bb Binary files /dev/null and b/web/public/icons/3d/3d-traffic.png differ diff --git a/web/public/icons/3d/3d-user_search.png b/web/public/icons/3d/3d-user_search.png new file mode 100644 index 0000000000000000000000000000000000000000..2ff7496d91d85a7a2b6b77d3c5ed1586f3007074 Binary files /dev/null and b/web/public/icons/3d/3d-user_search.png differ diff --git a/web/public/vite.svg b/web/public/vite.svg new file mode 100644 index 0000000000000000000000000000000000000000..e7b8dfb1b2a60bd50538bec9f876511b9cac21e3 --- /dev/null +++ b/web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..25481688365e2dfc3734d893af44cc2c9c6182bc --- /dev/null +++ b/web/src/App.css @@ -0,0 +1,48 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +.sidebar { + display: flex; + flex-direction: column; + min-height: 0; +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6cbffbc1930fcd2b1238a51f8241942eb582777e --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,159 @@ +import React from "react"; +import "./style.css"; +import type { FC, SelectMeta } from "./lib/types"; +import { REPORTS_URL } from "./lib/constants"; +import { useFeeds } from "./hooks/useFeeds"; +import { useSessionId } from "./hooks/useSessionId"; +import { useUpdates } from "./hooks/useUpdates"; +import { useChat } from "./hooks/useChat"; +import MapCanvas from "./components/map/MapCanvas"; +import SelectedLocationCard from "./components/sidebar/SelectedLocationCard"; +import UpdatesPanel from "./components/sidebar/UpdatesPanel"; +import ChatPanel from "./components/chat/ChatPanel"; + +export default function App() { + const [selectedLL, setSelectedLL] = React.useState<[number, number] | null>( + null + ); + const [selectedMeta, setSelectedMeta] = React.useState( + null + ); + + const [reports, setReports] = React.useState({ + type: "FeatureCollection", + features: [], + }); + const { nws, quakes, eonet, firms } = useFeeds(); + + const sessionId = useSessionId(); + const { + activeTab, + setActiveTab, + localUpdates, + globalUpdates, + loadingLocal, + loadingGlobal, + } = useUpdates(selectedLL); + + const { + messages, + draft, + setDraft, + isStreaming, + hasFirstToken, + chatBodyRef, + send, + pendingPhotoUrl, + setPendingPhotoUrl, + isUploading, + onFileChosen, + } = useChat(sessionId, selectedLL); + + const fileInputRef = React.useRef(null); + + const loadReports = React.useCallback(async () => { + const fc = await fetch(REPORTS_URL) + .then((r) => r.json()) + .catch(() => ({ type: "FeatureCollection", features: [] })); + setReports(fc); + }, []); + + React.useEffect(() => { + loadReports(); + }, [loadReports]); + + const selectPoint = React.useCallback( + (ll: [number, number], meta: SelectMeta) => { + setSelectedLL(ll); + setSelectedMeta(meta); + }, + [] + ); + + const pickPhoto = React.useCallback(() => fileInputRef.current?.click(), []); + const onSend = React.useCallback(async () => { + const res = await send(); + if (res?.tool_used === "add_report") await loadReports(); + }, [send, loadReports]); + + return ( +
+ + +
+
+ +
+ + setPendingPhotoUrl(null)} + isUploading={isUploading} + /> + + {/* hidden file input lives here */} + { + const f = e.target.files?.[0]; + if (f) onFileChosen(f); + }} + /> +
+
+ ); +} diff --git a/web/src/assets/react.svg b/web/src/assets/react.svg new file mode 100644 index 0000000000000000000000000000000000000000..6c87de9bb3358469122cc991d5cf578927246184 --- /dev/null +++ b/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/components/ReportIcon.tsx b/web/src/components/ReportIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..251597e38a537d3ac3f28b2031ded744a9dfaed5 --- /dev/null +++ b/web/src/components/ReportIcon.tsx @@ -0,0 +1,34 @@ +// components/ReportIcon.tsx +import * as Lucide from "lucide-react"; + +const pascal = (s: string) => + s.replace(/(^\w|-\w)/g, (m) => m.replace("-", "").toUpperCase()); + +export function ReportIcon({ + name = "info", + size = 32, +}: { + name?: string; + size?: number; +}) { + const key = (name || "info").toLowerCase(); + + // 3D local PNGs: name like "3d:police-light" + if (key.startsWith("3d-")) { + const file = key; // "police-light" + return ( + {file} + ); + } + + // fallback to your existing Lucide logic + const Comp = (Lucide as any)[pascal(key)] ?? (Lucide as any).Info; + return ; +} diff --git a/web/src/components/chat/ChatPanel.tsx b/web/src/components/chat/ChatPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b735c3419b8a3ea68fc0e2dc5a8683e18e39ca6 --- /dev/null +++ b/web/src/components/chat/ChatPanel.tsx @@ -0,0 +1,110 @@ +import TypingDots from "./TypingDots"; +import type { Message } from "../../lib/types"; + +export default function ChatPanel({ + messages, + draft, + setDraft, + isStreaming, + hasFirstToken, + chatBodyRef, + onSend, + pendingThumb, + onAttachClick, + onClearAttach, + isUploading, +}: { + messages: Message[]; + draft: string; + setDraft: (s: string) => void; + isStreaming: boolean; + hasFirstToken: boolean; + chatBodyRef: React.RefObject; + onSend: () => void; + pendingThumb?: string | null; + onAttachClick: () => void; + onClearAttach: () => void; + isUploading: boolean; +}) { + return ( +
+
Assistant
+
+ {messages.length === 0 ? ( +
+ Try: “Flooded underpass here”, or “List reports near me”. +
+ ) : ( + messages.map((m, idx) => ( +
+ {isStreaming && + !hasFirstToken && + idx === messages.length - 1 && + m.role === "assistant" ? ( +
+ +
+ ) : ( + <> + {m.text} + {m.image && ( +
+ attachment +
+ )} + + )} +
+ )) + )} +
+ +
+ setDraft(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onSend()} + /> + + {pendingThumb && ( +
+ attachment + +
+ )} + +
+
+ ); +} diff --git a/web/src/components/chat/TypingDots.tsx b/web/src/components/chat/TypingDots.tsx new file mode 100644 index 0000000000000000000000000000000000000000..918a3dd384e689a92d47fec085bd38bbe65a5870 --- /dev/null +++ b/web/src/components/chat/TypingDots.tsx @@ -0,0 +1,10 @@ +export default function TypingDots() { + return ( + + + + + + + ); +} diff --git a/web/src/components/map/MapCanvas.tsx b/web/src/components/map/MapCanvas.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fc8afac2ef8f709aa2d61356d03700157394709a --- /dev/null +++ b/web/src/components/map/MapCanvas.tsx @@ -0,0 +1,179 @@ +import { + APIProvider, + Map, + AdvancedMarker, + useMap, +} from "@vis.gl/react-google-maps"; +import { useEffect } from "react"; +import SearchControl from "./controls/SearchControl"; +import MyLocationControl from "./controls/MyLocationControl"; +import SingleSelect from "./controls/SingleSelect"; +import NWSDataLayer from "./overlays/NWSDataLayer"; +import EmojiMarker from "./overlays/EmojiMarker"; +import { GMAPS_KEY, MAP_ID } from "../../lib/constants"; +import { eonetEmoji } from "../../lib/utils"; +import type { FC, SelectMeta } from "../../lib/types"; +import { ReportIcon } from "../../components/ReportIcon"; +import FirmsLayer from "./overlays/FirmsLayer"; + +function PanOnSelect({ ll }: { ll: [number, number] | null }) { + const map = useMap(); + useEffect(() => { + if (!map || !ll) return; + map.panTo({ lat: ll[0], lng: ll[1] }); + const z = map.getZoom?.() ?? 0; + if (z < 14) map.setZoom(14); // tweak 14–16 as you like + }, [map, ll]); + return null; +} + +export default function MapCanvas({ + selectedLL, + selectedMeta, + setSelected, + nws, + quakes, + eonet, + firms, + reports, +}: { + selectedLL: [number, number] | null; + selectedMeta: SelectMeta | null; + setSelected: (ll: [number, number], meta: SelectMeta) => void; + nws: FC | null; + quakes: FC | null; + eonet: FC | null; + firms: FC | null; + reports: FC; +}) { + return ( + + + + + + + setSelected(ll, meta)} + /> + setSelected(ll, meta)} + /> + + {selectedLL && ( + + setSelected(ll, { kind: "click", title: "Selected point" }) + } + /> + )} + + {/* Quakes */} + {quakes?.features?.map((f: any, i: number) => { + const g = f.geometry; + if (!g || g.type !== "Point") return null; + const [lng, lat] = g.coordinates as [number, number]; + const p = f.properties || {}; + const m = p.mag ?? p.Magnitude ?? p.m ?? null; + const place = p.place || p.title || "Earthquake"; + const src = p.url || p.detail || p.sources || ""; + return ( + + setSelected([lat, lng], { + kind: "quake", + title: "Earthquake at " + place, + severity: m !== null ? `M${m}` : undefined, + sourceUrl: src || undefined, + confidence: 1, + emoji: "💥", + raw: p, + }) + } + /> + ); + })} + + {/* EONET */} + {eonet?.features?.map((f: any, i: number) => { + const g = f.geometry; + if (!g || g.type !== "Point") return null; + const [lng, lat] = g.coordinates as [number, number]; + const p = f.properties || {}; + const title = p.title || p.category || "Event"; + const emoji = eonetEmoji(p); + const src = p.link || p.url || ""; + return ( + + setSelected([lat, lng], { + kind: "eonet", + title, + sourceUrl: src || undefined, + confidence: 1, + emoji, + raw: p, + }) + } + /> + ); + })} + + {/* User reports (keep your AdvancedMarker + ReportIcon) */} + {reports.features.map((f, i) => { + if (f.geometry?.type !== "Point") return null; + const [lng, lat] = f.geometry.coordinates as [number, number]; + const p = f.properties || {}; + const iconName = p.icon || p.emoji || "info"; + const title = p.title || "User report"; + const desc = p.text || ""; + return ( + + setSelected([lat, lng], { + kind: "report", + title, + subtitle: desc, + severity: p.severity, + confidence: p.confidence, + category: p.category, + emoji: p.emoji, + raw: p, + }) + } + > +
+ +
+
+ ); + })} +
+
+ ); +} diff --git a/web/src/components/map/controls/MyLocationControl.tsx b/web/src/components/map/controls/MyLocationControl.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9285b2dfdf373960975b76b15c319da94836639b --- /dev/null +++ b/web/src/components/map/controls/MyLocationControl.tsx @@ -0,0 +1,49 @@ +import { useEffect } from "react"; +import { useMap } from "@vis.gl/react-google-maps"; +import type { SelectMeta } from "../../../lib/types"; + +export default function MyLocationControl({ + onLocated, +}: { + onLocated: (ll: [number, number], meta: SelectMeta) => void; +}) { + const map = useMap(); + useEffect(() => { + if (!map) return; + const btn = document.createElement("div"); + btn.style.margin = "10px"; + btn.innerHTML = ``; + map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(btn); + + const click = () => { + if (!navigator.geolocation) return; + navigator.geolocation.getCurrentPosition( + (pos) => { + const ll: [number, number] = [ + pos.coords.latitude, + pos.coords.longitude, + ]; + map.setCenter({ lat: ll[0], lng: ll[1] }); + map.setZoom(13); + onLocated(ll, { kind: "mylocation", title: "My location" }); + }, + undefined, + { enableHighAccuracy: true } + ); + }; + btn.addEventListener("click", click); + + return () => { + btn.removeEventListener("click", click); + const arr = map.controls[google.maps.ControlPosition.RIGHT_BOTTOM]; + for (let i = 0; i < arr.getLength(); i++) { + if (arr.getAt(i) === (btn as any)) { + arr.removeAt(i); + break; + } + } + }; + }, [map, onLocated]); + + return null; +} diff --git a/web/src/components/map/controls/SearchControl.tsx b/web/src/components/map/controls/SearchControl.tsx new file mode 100644 index 0000000000000000000000000000000000000000..100528edfff893f40c4cc96e5f4e2b27f20c216b --- /dev/null +++ b/web/src/components/map/controls/SearchControl.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { useMap } from "@vis.gl/react-google-maps"; +import type { SelectMeta } from "../../../lib/types"; + +export default function SearchControl({ + onPlace, +}: { + onPlace: (ll: [number, number], meta: SelectMeta) => void; +}) { + const map = useMap(); + const onPlaceRef = React.useRef(onPlace); + React.useEffect(() => { + onPlaceRef.current = onPlace; + }, [onPlace]); + + React.useEffect(() => { + if (!map || !window.google) return; + const container = document.createElement("div"); + Object.assign(container.style, { + background: "#fff", + borderRadius: "8px", + boxShadow: "0 1px 4px rgba(0,0,0,.3)", + margin: "10px", + padding: "4px", + }); + + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = "Search places…"; + input.setAttribute("aria-label", "Search places"); + Object.assign(input.style, { + border: "0", + outline: "0", + padding: "10px 12px", + width: "260px", + borderRadius: "6px", + } as CSSStyleDeclaration); + + container.appendChild(input); + map.controls[google.maps.ControlPosition.TOP_LEFT].push(container); + + const ac = new google.maps.places.Autocomplete(input, { + fields: ["geometry", "name", "formatted_address"], + types: ["geocode"], + }); + const listener = ac.addListener("place_changed", () => { + const place = ac.getPlace(); + const loc = place?.geometry?.location; + if (loc) { + const ll: [number, number] = [loc.lat(), loc.lng()]; + map.setCenter({ lat: ll[0], lng: ll[1] }); + map.setZoom(12); + onPlaceRef.current(ll, { + kind: "search", + title: place.name || "Search result", + subtitle: place.formatted_address, + raw: place, + }); + } + }); + + return () => { + google.maps.event.removeListener(listener); + const arr = map.controls[google.maps.ControlPosition.TOP_LEFT]; + for (let i = 0; i < arr.getLength(); i++) { + if (arr.getAt(i) === (container as any)) { + arr.removeAt(i); + break; + } + } + }; + }, [map]); + + return null; +} diff --git a/web/src/components/map/controls/SingleSelect.tsx b/web/src/components/map/controls/SingleSelect.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e7a000ad01a308fb01435d41f92c5e7985e18d62 --- /dev/null +++ b/web/src/components/map/controls/SingleSelect.tsx @@ -0,0 +1,38 @@ +import { useEffect } from "react"; +import { useMap } from "@vis.gl/react-google-maps"; +import type { SelectMeta } from "../../../lib/types"; + +export default function SingleSelect({ + onPick, +}: { + onPick: (ll: [number, number], meta: SelectMeta) => void; +}) { + const map = useMap(); + useEffect(() => { + if (!map) return; + map.setOptions({ disableDoubleClickZoom: true }); + const onClick = map.addListener("click", (e: google.maps.MapMouseEvent) => { + if (!e.latLng) return; + onPick([e.latLng.lat(), e.latLng.lng()], { + kind: "click", + title: "Selected point", + }); + }); + const onDbl = map.addListener( + "dblclick", + (e: google.maps.MapMouseEvent) => { + if (!e.latLng) return; + onPick([e.latLng.lat(), e.latLng.lng()], { + kind: "click", + title: "Selected point", + }); + } + ); + return () => { + google.maps.event.removeListener(onClick); + google.maps.event.removeListener(onDbl); + }; + }, [map, onPick]); + + return null; +} diff --git a/web/src/components/map/overlays/EmojiMarker.tsx b/web/src/components/map/overlays/EmojiMarker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0851c525cb6cf07a2ee24867d6e11f7d4e096e71 --- /dev/null +++ b/web/src/components/map/overlays/EmojiMarker.tsx @@ -0,0 +1,63 @@ +import { AdvancedMarker, Marker } from "@vis.gl/react-google-maps"; + +export default function EmojiMarker(props: { + position: google.maps.LatLngLiteral; + emoji: string; + title?: string; + draggable?: boolean; + onDragEnd?: (ll: [number, number]) => void; + onClick?: () => void; +}) { + const { + position, + emoji, + title, + draggable = false, + onDragEnd, + onClick, + } = props; + const hasAdvanced = + typeof window !== "undefined" && + !!(window as any).google?.maps?.marker?.AdvancedMarkerElement; + + if (hasAdvanced) { + return ( + { + if (onDragEnd && e.latLng) + onDragEnd([e.latLng.lat(), e.latLng.lng()]); + }} + onClick={onClick} + zIndex={100} + > +
+ {emoji} +
+
+ ); + } + + return ( + { + if (onDragEnd && e.latLng) onDragEnd([e.latLng.lat(), e.latLng.lng()]); + }} + onClick={onClick} + title={title} + /> + ); +} diff --git a/web/src/components/map/overlays/FirmsLayer.tsx b/web/src/components/map/overlays/FirmsLayer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..805be1a37c7a283981241034a2826b875852213a --- /dev/null +++ b/web/src/components/map/overlays/FirmsLayer.tsx @@ -0,0 +1,53 @@ +// components/map/overlays/FirmsLayer.tsx +import React from "react"; +import type { FC, SelectMeta } from "../../../lib/types"; +import EmojiMarker from "./EmojiMarker"; + +export default function FirmsLayer({ + firms, + onSelect, +}: { + firms: FC | null; + onSelect: (ll: [number, number], meta: SelectMeta) => void; +}) { + if (!firms?.features?.length) return null; + + const push = (lat: number, lon: number, p: any, k: string) => ( + + onSelect([lat, lon], { + kind: "fire", + title: "Fire hotspot", + severity: p.confidence ?? p.brightness ?? p.frp, + confidence: 1, + emoji: "🔥", + raw: p, + }) + } + /> + ); + + const out: React.ReactNode[] = []; + firms.features.forEach((f: any, i: number) => { + const g = f?.geometry, + p = f?.properties || {}; + if (!g) return; + if (g.type === "Point" && Array.isArray(g.coordinates)) { + const [lon, lat] = g.coordinates; + if (Number.isFinite(lat) && Number.isFinite(lon)) + out.push(push(lat, lon, p, `fi-${i}`)); + } else if (g.type === "MultiPoint" && Array.isArray(g.coordinates)) { + g.coordinates.forEach((c: any, j: number) => { + const [lon, lat] = c || []; + if (Number.isFinite(lat) && Number.isFinite(lon)) + out.push(push(lat, lon, p, `fi-${i}-${j}`)); + }); + } + }); + + return <>{out}; +} diff --git a/web/src/components/map/overlays/NWSDataLayer.tsx b/web/src/components/map/overlays/NWSDataLayer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5cfebbaa3484f7c0b5a9d9b932bfbc6785399020 --- /dev/null +++ b/web/src/components/map/overlays/NWSDataLayer.tsx @@ -0,0 +1,63 @@ +import { useEffect } from "react"; +import { useMap } from "@vis.gl/react-google-maps"; +import type { FC, SelectMeta } from "../../../lib/types"; +import { sevColor } from "../../../lib/utils"; + +export default function NWSDataLayer({ + nws, + onSelect, +}: { + nws: FC | null; + onSelect: (ll: [number, number], meta: SelectMeta) => void; +}) { + const map = useMap(); + useEffect(() => { + if (!map) return; + map.data.forEach((f) => map.data.remove(f)); + + if (nws?.features?.length) { + map.data.addGeoJson(nws as any); + map.data.setStyle((f) => { + const sev = (f.getProperty("severity") || "Unknown") as string; + const color = sevColor(sev); + return { + strokeColor: color, + strokeWeight: 1.2, + fillColor: color, + fillOpacity: 0.18, + }; + }); + + const clickListener = map.data.addListener( + "click", + (e: google.maps.Data.MouseEvent) => { + const p: any = e.feature; + const title = + (p.getProperty && p.getProperty("event")) || "NWS Alert"; + const sev = (p.getProperty && p.getProperty("severity")) || "Unknown"; + const src = + (p.getProperty && (p.getProperty("@id") || p.getProperty("id"))) || + ""; + if (e.latLng) { + onSelect([e.latLng.lat(), e.latLng.lng()], { + kind: "nws", + title, + severity: sev, + sourceUrl: src || undefined, + confidence: 1, + emoji: "⚠️", + raw: p?.g ?? p, + }); + } + } + ); + + return () => { + google.maps.event.removeListener(clickListener); + map.data.forEach((f) => map.data.remove(f)); + }; + } + }, [map, nws, onSelect]); + + return null; +} diff --git a/web/src/components/sidebar/SelectedLocationCard.tsx b/web/src/components/sidebar/SelectedLocationCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dbb29956bccff3c94bf5000d9fd56f6ed354ef86 --- /dev/null +++ b/web/src/components/sidebar/SelectedLocationCard.tsx @@ -0,0 +1,147 @@ +import type { SelectMeta } from "../../lib/types"; + +const isEmoji = (s: string) => !!s && /\p{Extended_Pictographic}/u.test(s); +const cleanTitle = (t?: string) => { + if (!t) return t; + return t + .replace(/^(?:[a-z0-9]+-){1,3}(?=[A-Z])/i, "") // slug stuck to title (no space) + .replace(/^(?:[a-z0-9]+-){1,3}\s+/i, ""); // slug + space +}; + +export default function SelectedLocationCard({ + selectedLL, + selectedMeta, + onClear, +}: { + selectedLL: [number, number] | null; + selectedMeta: SelectMeta | null; + onClear: () => void; +}) { + const photoSrc = + selectedMeta?.raw?.photo_url ?? + selectedMeta?.raw?.photoUrl ?? + selectedMeta?.raw?.image_url ?? + selectedMeta?.raw?.imageUrl ?? + (selectedMeta as any)?.photo_url ?? + (selectedMeta as any)?.photoUrl ?? + null; + + const showEmoji = + selectedMeta?.emoji && isEmoji(String(selectedMeta.emoji)) + ? String(selectedMeta.emoji) + : null; + + const displayTitle = cleanTitle(selectedMeta?.title) || "Selected"; + + return ( +
+ +
+ {selectedLL ? ( + <> +
+ {showEmoji ? ( + + {showEmoji} + + ) : null} + {displayTitle} +
+ + {selectedMeta?.subtitle && ( +
{selectedMeta.subtitle}
+ )} + +
+ {selectedLL[0].toFixed(4)}, {selectedLL[1].toFixed(4)} +
+ +
+ {(selectedMeta?.category || selectedMeta?.raw?.category) && ( +
+ Category:{" "} + {selectedMeta.category || selectedMeta.raw?.category} +
+ )} + {(selectedMeta?.severity !== undefined || + selectedMeta?.raw?.severity !== undefined) && ( +
+ Severity/Mag:{" "} + {String( + selectedMeta?.severity ?? selectedMeta?.raw?.severity + )} +
+ )} +
+ Confidence:{" "} + {(() => { + const k = selectedMeta?.kind; + const fromMeta = + selectedMeta?.confidence ?? selectedMeta?.raw?.confidence; + const official = + k && ["nws", "quake", "eonet", "fire"].includes(k); + const val = fromMeta ?? (official ? 1 : undefined); + return val !== undefined ? String(val) : "—"; + })()} +
+ {selectedMeta?.raw?.source ? ( +
+ Source: {selectedMeta.raw.source} +
+ ) : selectedMeta?.kind && + ["nws", "quake", "eonet", "fire"].includes( + selectedMeta.kind + ) ? ( +
+ Source: {selectedMeta.kind.toUpperCase()} +
+ ) : null} + {selectedMeta?.sourceUrl && ( + + )} +
+ + ) : ( +
Use search, 📍, or click the map.
+ )} + + {photoSrc && ( +
+ Attached +
+ )} + +
+ Only one point is active. Drag 📍 to fine-tune; chat uses this point. +
+ + {selectedLL && ( +
+ +
+ )} +
+
+ ); +} diff --git a/web/src/components/sidebar/UpdatesPanel.tsx b/web/src/components/sidebar/UpdatesPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..01db739f0579a5a3a1d1c7ffadb2097c9cbcad35 --- /dev/null +++ b/web/src/components/sidebar/UpdatesPanel.tsx @@ -0,0 +1,133 @@ +import type { UpdateItem } from "../../lib/types"; +import { formatAgo } from "../../lib/utils"; + +const isEmoji = (s: string) => !!s && /\p{Extended_Pictographic}/u.test(s); + +// Same slug cleaner used above +const cleanTitle = (t?: string) => { + if (!t) return t; + return t + .replace(/^(?:[a-z0-9]+-){1,3}(?=[A-Z])/i, "") + .replace(/^(?:[a-z0-9]+-){1,3}\s+/i, ""); +}; + +export default function UpdatesPanel({ + activeTab, + setActiveTab, + localUpdates, + globalUpdates, + loadingLocal, + loadingGlobal, + selectedLL, + onView, +}: { + activeTab: "local" | "global"; + setActiveTab: (t: "local" | "global") => void; + localUpdates: UpdateItem[]; + globalUpdates: UpdateItem[]; + loadingLocal: boolean; + loadingGlobal: boolean; + selectedLL: [number, number] | null; + onView: (u: UpdateItem) => void; +}) { + const renderList = ( + list: UpdateItem[], + loading: boolean, + emptyMsg: string + ) => ( + <> + {loading &&
Loading…
} + {!loading && list.length === 0 &&
{emptyMsg}
} + {!loading && + list.map((u, i) => { + // DEBUG: inspect each item + // eslint-disable-next-line no-console + console.debug("[UpdatesPanel] item:", u); + + const showEmoji = + u.emoji && isEmoji(String(u.emoji)) ? u.emoji : null; + const title = cleanTitle(u.title) || u.title || "Update"; + + return ( +
+
+ {showEmoji ?
{showEmoji}
: null} +
+
{title}
+
+ {formatAgo(u.time)} · {u.kind} + {u.severity ? <> · {String(u.severity)} : null} +
+ {u.sourceUrl && ( + + )} +
+ +
+
+ ); + })} + + ); + + return ( +
+
+ + +
+
e.stopPropagation()} + > + {activeTab === "local" ? ( + selectedLL ? ( + renderList(localUpdates, loadingLocal, "No recent updates here.") + ) : ( +
+ Pick a point (search/📍/click) to load local updates within 25 + miles (last 48h). +
+ ) + ) : ( + renderList( + globalUpdates, + loadingGlobal, + "No global updates right now." + ) + )} +
+
+ ); +} diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts new file mode 100644 index 0000000000000000000000000000000000000000..e63bf7553f199fc54423c1d50ec5b3250d5879a7 --- /dev/null +++ b/web/src/hooks/useChat.ts @@ -0,0 +1,127 @@ +import { useCallback, useRef, useState } from "react"; +import { CHAT_URL, UPLOAD_URL } from "../lib/constants"; +import type { Message } from "../lib/types"; + +export function useChat( + sessionId: string, + selectedLL: [number, number] | null +) { + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const [hasFirstToken, setHasFirstToken] = useState(false); + const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const chatBodyRef = useRef(null); + + const scrollToBottom = useCallback(() => { + const el = chatBodyRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, []); + + const typeOut = useCallback( + async (fullText: string) => { + const step = fullText.length > 1200 ? 6 : fullText.length > 400 ? 3 : 1; + const delayMs = + fullText.length > 1200 ? 4 : fullText.length > 400 ? 8 : 15; + let firstTokenSet = false; + for (let i = 0; i < fullText.length; i += step) { + const acc = fullText.slice(0, i + step); + setMessages((m) => { + const out = [...m]; + for (let j = out.length - 1; j >= 0; j--) { + if (out[j].role === "assistant") { + out[j] = { ...out[j], text: acc }; + break; + } + } + return out; + }); + if (!firstTokenSet && acc.length > 0) { + setHasFirstToken(true); + firstTokenSet = true; + } + scrollToBottom(); + await new Promise((r) => setTimeout(r, delayMs)); + } + setIsStreaming(false); + setHasFirstToken(true); + scrollToBottom(); + }, + [scrollToBottom] + ); + + const onFileChosen = useCallback(async (file: File) => { + setIsUploading(true); + try { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch(UPLOAD_URL, { method: "POST", body: fd }).then( + (r) => r.json() + ); + const url = + res?.url || + (res?.path + ? (import.meta.env.VITE_API_BASE || "http://localhost:8000") + + res.path + : ""); + if (url) setPendingPhotoUrl(url); + } finally { + setIsUploading(false); + } + }, []); + + const send = useCallback(async () => { + const text = draft.trim(); + if (!text) return; + + const attached = pendingPhotoUrl; // capture now + setPendingPhotoUrl(null); // clear immediately + setMessages((m) => [ + ...m, + { role: "user", text, image: attached || undefined }, + ]); + setDraft(""); + setTimeout(scrollToBottom, 0); + + setIsStreaming(true); + setHasFirstToken(false); + setMessages((m) => [...m, { role: "assistant", text: "" }]); + setTimeout(scrollToBottom, 0); + + let finalText = text; + if (selectedLL) + finalText += `\n\n[COORDS lat=${selectedLL[0]} lon=${selectedLL[1]}]`; + + const payload: any = { message: finalText, session_id: sessionId }; + if (selectedLL) + payload.user_location = { lat: selectedLL[0], lon: selectedLL[1] }; + if (attached) payload.photo_url = attached; + + const res = await fetch(CHAT_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + .then((r) => r.json()) + .catch(() => ({ reply: "Something went wrong." })); + + await typeOut(res.reply || "(no reply)"); + return res; // caller can react to tool_used (e.g., reload reports) + }, [draft, pendingPhotoUrl, selectedLL, sessionId, scrollToBottom, typeOut]); + + return { + messages, + draft, + setDraft, + isStreaming, + hasFirstToken, + chatBodyRef, + send, + pendingPhotoUrl, + setPendingPhotoUrl, + isUploading, + onFileChosen, + }; +} diff --git a/web/src/hooks/useFeeds.ts b/web/src/hooks/useFeeds.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b3ac3a2ccb204ce54b3f8c5c64cd90aea350fca --- /dev/null +++ b/web/src/hooks/useFeeds.ts @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import type { FC } from "../lib/types"; +import { NWS_URL, USGS_URL, EONET_URL, FIRMS_URL } from "../lib/constants"; + +// normalize FIRMS (same as you had) +function normalizeFirms( + j: any +): { type: "FeatureCollection"; features: any[] } | null { + if (!j) return null; + if (j.type === "FeatureCollection" && Array.isArray(j.features)) return j; + if (j.data?.type === "FeatureCollection" && Array.isArray(j.data.features)) + return j.data; + if (Array.isArray(j.features)) + return { type: "FeatureCollection", features: j.features }; + const rows = Array.isArray(j?.rows) + ? j.rows + : Array.isArray(j?.items) + ? j.items + : Array.isArray(j) + ? j + : null; + if (rows) { + const features = rows + .map((r: any) => { + const lat = Number(r.lat ?? r.latitude ?? r.LAT ?? r.LATITUDE); + const lon = Number(r.lon ?? r.longitude ?? r.LON ?? r.LONGITUDE); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + return { + type: "Feature", + geometry: { type: "Point", coordinates: [lon, lat] }, + properties: r, + }; + }) + .filter(Boolean); + return { type: "FeatureCollection", features }; + } + return null; +} + +// small helper: fetch with timeout; return null on any failure +async function fetchJSON(url: string, timeoutMs = 8000): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const r = await fetch(url, { signal: ctrl.signal }); + if (!r.ok) return null; + return await r.json(); + } catch { + return null; + } finally { + clearTimeout(t); + } +} + +export function useFeeds() { + const [nws, setNws] = useState(null); + const [quakes, setQuakes] = useState(null); + const [eonet, setEonet] = useState(null); + const [firms, setFirms] = useState(null); + + useEffect(() => { + let mounted = true; + + (async () => { + const [nwsRes, usgsRes, eonetRes, firmsRes] = await Promise.allSettled([ + fetchJSON(NWS_URL, 8000), + fetchJSON(USGS_URL, 8000), + fetchJSON(EONET_URL, 5000), // shorter timeout since it’s flaky right now + fetchJSON(FIRMS_URL, 8000), + ]); + + if (!mounted) return; + + const val = (x: PromiseSettledResult) => + x.status === "fulfilled" ? x.value : null; + + const a = val(nwsRes), + b = val(usgsRes), + c = val(eonetRes), + d = val(firmsRes); + + setNws(a?.data || a || null); + setQuakes(b?.data || b || null); + setEonet(c?.data || c || null); + + const firmsFC = normalizeFirms(d); + setFirms(firmsFC); + + console.log("FIRMS note:", (d && d._note) || firmsFC?._note || null); + console.log("FIRMS normalized:", firmsFC?.features?.length); + })(); + + return () => { + mounted = false; + }; + }, []); + + return { nws, quakes, eonet, firms }; +} diff --git a/web/src/hooks/useSessionId.ts b/web/src/hooks/useSessionId.ts new file mode 100644 index 0000000000000000000000000000000000000000..38c010c787e7d000ff47dd71ad161e0619f440db --- /dev/null +++ b/web/src/hooks/useSessionId.ts @@ -0,0 +1,11 @@ +import { useState } from "react"; +export const useSessionId = () => { + const [sessionId] = useState(() => { + const existing = localStorage.getItem("pulsemaps_session"); + if (existing) return existing; + const fresh = crypto.randomUUID(); + localStorage.setItem("pulsemaps_session", fresh); + return fresh; + }); + return sessionId; +}; diff --git a/web/src/hooks/useUpdates.ts b/web/src/hooks/useUpdates.ts new file mode 100644 index 0000000000000000000000000000000000000000..8dc63b1cd86f8e81f5b6a53a343312286188ec21 --- /dev/null +++ b/web/src/hooks/useUpdates.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useState } from "react"; +import type { UpdateItem } from "../lib/types"; +import { UPDATES_LOCAL_URL, UPDATES_GLOBAL_URL } from "../lib/constants"; +import { toQuery } from "../lib/utils"; + +export function useUpdates(selectedLL: [number, number] | null) { + const [activeTab, setActiveTab] = useState<"local" | "global">("local"); + const [localUpdates, setLocal] = useState([]); + const [globalUpdates, setGlobal] = useState([]); + const [loadingLocal, setLLoad] = useState(false); + const [loadingGlobal, setGLoad] = useState(false); + + const loadLocal = useCallback(async (ll: [number, number]) => { + setLLoad(true); + try { + const url = + UPDATES_LOCAL_URL + + toQuery({ + lat: ll[0], + lon: ll[1], + radius_miles: 25, + max_age_hours: 48, + limit: 100, + }); + const j = await fetch(url).then((r) => r.json()); + setLocal(j.updates || []); + } catch { + setLocal([]); + } finally { + setLLoad(false); + } + }, []); + + const loadGlobal = useCallback(async () => { + setGLoad(true); + try { + const j = await fetch(UPDATES_GLOBAL_URL + "?limit=200").then((r) => + r.json() + ); + setGlobal(j.updates || []); + } catch { + setGlobal([]); + } finally { + setGLoad(false); + } + }, []); + + useEffect(() => { + loadGlobal(); + }, [loadGlobal]); + useEffect(() => { + if (selectedLL) loadLocal(selectedLL); + }, [selectedLL, loadLocal]); + + return { + activeTab, + setActiveTab, + localUpdates, + globalUpdates, + loadingLocal, + loadingGlobal, + }; +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000000000000000000000000000000000000..08a3ac9e1e5c44ce374f782d7c4fa3aa70e4c1ff --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..b18e5c92082f3cb84b3209c304f4098c320578c2 --- /dev/null +++ b/web/src/lib/constants.ts @@ -0,0 +1,14 @@ +export const GMAPS_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY; +export const MAP_ID = import.meta.env.VITE_GOOGLE_MAPS_MAP_ID; + +const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000"; +export const REPORTS_URL = `${API_BASE}/reports`; +export const CHAT_URL = `${API_BASE}/chat`; +export const NWS_URL = `${API_BASE}/feeds/nws`; +export const USGS_URL = `${API_BASE}/feeds/usgs`; +export const EONET_URL = `${API_BASE}/feeds/eonet`; +export const FIRMS_URL = `${API_BASE}/feeds/firms`; +export const UPLOAD_URL = `${API_BASE}/upload/photo`; + +export const UPDATES_LOCAL_URL = `${API_BASE}/updates/local`; +export const UPDATES_GLOBAL_URL = `${API_BASE}/updates/global`; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..4eb6a78b529356d7893e0cd56658064757b90c04 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,43 @@ +export type Feature = { + type: "Feature"; + geometry: { type: "Point" | "Polygon" | "MultiPolygon"; coordinates: any }; + properties: Record; +}; +export type FC = { type: "FeatureCollection"; features: Feature[] }; + +export type SelectMeta = { + kind: + | "search" + | "mylocation" + | "click" + | "quake" + | "fire" + | "eonet" + | "report" + | "nws"; + title?: string; + subtitle?: string; + severity?: string | number; + sourceUrl?: string; + confidence?: number; + emoji?: string; + category?: string; + raw?: any; +}; + +export type Message = { + role: "user" | "assistant"; + text: string; + image?: string; +}; + +export type UpdateItem = { + kind: "report" | "quake" | "nws" | "eonet" | "fire"; + title: string; + emoji: string; + time: string; + lat: number; + lon: number; + severity?: string | number; + sourceUrl?: string; +}; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc2b046516c7e593da9b5fa7d46f3f6a2da5ba71 --- /dev/null +++ b/web/src/lib/utils.ts @@ -0,0 +1,59 @@ +export const sevColor = (sev?: string): string => { + switch ((sev || "").toLowerCase()) { + case "extreme": + return "#6f00ff"; + case "severe": + return "#d7191c"; + case "moderate": + return "#fdae61"; + case "minor": + return "#ffff99"; + default: + return "#9e9e9e"; + } +}; + +export const eonetEmoji = (p: any) => { + const s = ( + p?.category || + p?.categories?.[0]?.title || + p?.title || + "" + ).toLowerCase(); + if (s.includes("wildfire")) return "🔥"; + if (s.includes("volcano")) return "🌋"; + if (s.includes("earthquake") || s.includes("seismic")) return "💥"; + if ( + s.includes("storm") || + s.includes("cyclone") || + s.includes("hurricane") || + s.includes("typhoon") + ) + return "🌀"; + if (s.includes("flood")) return "🌊"; + if (s.includes("landslide")) return "🏔️"; + if (s.includes("drought")) return "🌵"; + if (s.includes("ice") || s.includes("snow") || s.includes("blizzard")) + return "❄️"; + if (s.includes("dust") || s.includes("smoke") || s.includes("haze")) + return "🌫️"; + return "⚠️"; +}; + +export const toQuery = (o: Record) => + "?" + + Object.entries(o) + .map( + ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}` + ) + .join("&"); + +export const formatAgo = (iso?: string) => { + if (!iso) return ""; + const t = new Date(iso); + const s = Math.max(0, (Date.now() - t.getTime()) / 1000); + if (s < 60) return `${Math.floor(s)}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +}; diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d192394a3471f138de744687147b6c305f168a5b --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; +import "leaflet/dist/leaflet.css"; +import "./style.css"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000000000000000000000000000000000000..840fbf672817d60fa63860d029e33470a24d328c --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,347 @@ +/* THEME */ +:root { + --primary: #006266; /* main brand */ + --accent: #009432; + --accent-2: #a3cb38; + --accent-3: #c4e538; + --bg: #121212; + --panel: #ffffff; + --muted: #667085; + --border: #e6e8eb; +} + +/* BASE */ +html, +body, +#root { + height: 100%; + margin: 0; + background: var(--bg); + font: 14px/1.3 system-ui, -apple-system, "Segoe UI", Roboto, Arial; +} +body { + overflow: hidden; +} /* panes handle their own scroll */ + +/* LAYOUT */ +.shell { + display: flex; + height: 100vh; + overflow: hidden; +} + +/* SIDEBAR */ +.sidebar { + flex: 0 0 360px; /* wider: was ~320px */ + max-width: 420px; + min-width: 300px; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + + background: #0f1f1d; /* dark teal tint */ + color: #e8f2f0; + padding: 16px 14px; + display: flex; + flex-direction: column; + gap: 14px; + border-right: 1px solid rgba(255, 255, 255, 0.06); + + /* hide scrollbar */ + -ms-overflow-style: none; /* IE/old Edge */ + scrollbar-width: none; /* Firefox */ + padding-right: 8px; +} +.sidebar::-webkit-scrollbar { + width: 0; + height: 0; +} +.sidebar::-webkit-scrollbar-track, +.sidebar::-webkit-scrollbar-thumb { + background: transparent; +} +.sidebar:hover { + overscroll-behavior: contain; +} + +/* Brand */ +.brand { + display: flex; + align-items: center; + gap: 10px; + font-size: small; +} +.logo { + width: 36px; + height: 36px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--accent)); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; +} +.title { + font-weight: 700; + letter-spacing: 0.2px; +} + +/* Cards & controls */ +.block { + background: rgba(255, 255, 255, 0.04); + padding: 12px; + border-radius: 10px; +} +.label { + display: block; + color: #cfe7e2; + font-size: 12px; + margin-bottom: 6px; +} +.input { + appearance: none; + outline: none; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.08); + color: #fff; + padding: 8px 10px; + border-radius: 10px; +} +.input.flex { + flex: 1; +} +.btn { + border: none; + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; + color: #fff; + background: var(--primary); +} +.btn:hover { + filter: brightness(1.05); +} +.btn-ghost { + background: transparent; + color: #cfe7e2; + border: 1px solid rgba(255, 255, 255, 0.18); +} +.btn-ghost:hover { + background: rgba(255, 255, 255, 0.06); +} + +/* Selected location card */ +.locationCard { + background: #0a1716; + border: 1px solid rgba(255, 255, 255, 0.08); + padding: 10px; + border-radius: 10px; +} +.locName { + font-weight: 600; +} +.locLL, +.locDetecting { + color: #b9e0d9; +} +.hint { + color: #a9c9c3; + font-size: 12px; + margin-top: 6px; +} + +/* Updates */ +.tabs { + display: flex; + gap: 6px; +} +.tab { + flex: 1; + text-align: center; + padding: 8px 10px; + border-radius: 10px; + cursor: pointer; + color: #d7efe9; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); +} +.tab-active { + background: linear-gradient(135deg, var(--primary), var(--accent)); + color: #fff; + border: none; +} +.updates { + margin-top: 8px; +} +.updateItem { + background: #0a1716; + border: 1px solid rgba(255, 255, 255, 0.08); + padding: 8px 10px; + border-radius: 10px; +} +.muted { + color: var(--muted); +} + +/* MAIN (map + chat column) */ +.main { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +/* MAP */ +.mapWrap { + position: relative; + flex: 1 1 auto; + min-height: 0; +} +.map { + width: 100%; + height: 100%; +} + +/* CHAT — taller to reduce map height a bit */ +.chat { + background: #0f1f1d; + color: #e8f2f0; + border-top: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + height: 340px; /* was 260px */ +} +.chatHdr { + padding: 10px 12px; + font-weight: 600; + color: #cfe7e2; + background: linear-gradient(180deg, #10211f, #0d1b19); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} +.chatBody { + flex: 1; + overflow: auto; + padding: 10px 12px; + background: #0f1f1d; +} +.msg { + padding: 8px 10px; + border-radius: 10px; + margin: 6px 0; + max-width: 80%; +} +.msg.user { + background: rgba(0, 148, 50, 0.15); + border: 1px solid rgba(0, 148, 50, 0.35); + margin-left: auto; +} +.msg.assistant { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + margin-right: auto; +} + +/* Input row (keeps Attach inside the row — no JSX changes) */ +.chatInputRow { + display: flex; + gap: 8px; + padding: 10px 12px; + background: #0b1514; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} +.input-chat { + flex: 1; + appearance: none; + outline: none; + background: #152422; + color: #e8f2f0; + border: 1px solid #2a4642; + border-radius: 10px; + padding: 10px 12px; +} +.input-chat::placeholder { + color: #9fbab5; +} +.chatInputRow .btn { + background: var(--primary); + color: #fff; + border: none; + border-radius: 10px; + padding: 10px 14px; + cursor: pointer; +} +.chatInputRow .btn:hover { + filter: brightness(1.05); +} + +/* Optional: Leaflet locate styles kept for future use (no duplicates) */ +.leaflet-control-locate a { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + line-height: 28px; + text-align: center; + background: #fff; + color: #fff; + border-radius: 4px; + text-decoration: none; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); + font-weight: 700; +} +.leaflet-control-locate a:hover { + filter: brightness(1.06); +} + +.leaflet-interactive.current-location-marker { + animation: pulse 2s infinite; +} +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(66, 133, 244, 0.6); + } + 70% { + box-shadow: 0 0 0 15px rgba(66, 133, 244, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(66, 133, 244, 0); + } +} + +.locate-btn-icon { + width: 14px; + height: 14px; + border: 2px solid #4285f4; + border-radius: 50%; + position: relative; +} +.locate-btn-icon::before, +.locate-btn-icon::after { + content: ""; + position: absolute; + background: #4285f4; +} +.locate-btn-icon::before { + top: 50%; + left: -4px; + width: 22px; + height: 2px; + transform: translateY(-50%); +} +.locate-btn-icon::after { + left: 50%; + top: -4px; + height: 22px; + width: 2px; + transform: translateX(-50%); +} + +.msg, +.msg.assistant, +.msg.user { + white-space: pre-wrap; /* preserves \n and wraps long lines */ +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..11f02fe2a0061d6e6e1f271b21da95423b448b32 --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000000000000000000000000000000000000..227a6c6723b6136d17cdcd42288a01a5e5abcd25 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..1ffef600d959ec9e396d5a260bd3f5b927b2cef8 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000000000000000000000000000000000000..f85a39906e5571aa351e61e43fff98bc0bedaa27 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b0f57b91aeb45c54467e29f983a0893dc83c4d9 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})