diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8c9237 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Node +node_modules/ +dist/ +.vite/ + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +*.egg +.eggs/ +*.whl +.venv/ +venv/ +env/ + +# Byte-compiled / optimized +*.so + +# Jupyter +.ipynb_checkpoints/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* + +# macOS +__MACOSX/ + +# Model artifacts (large binaries) +*.pkl + +# Misc +*.bak +*.tmp diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..60a5521 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,32 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Copy dependency manifests +COPY requirements.txt . +COPY backend/requirements.txt backend/requirements.txt +COPY earnings/earnings/requirements.txt earnings/earnings/requirements.txt +COPY drivepulse_stress_model/requirements.txt drivepulse_stress_model/requirements.txt + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir -r backend/requirements.txt \ + && pip install --no-cache-dir -r earnings/earnings/requirements.txt \ + && pip install --no-cache-dir -r drivepulse_stress_model/requirements.txt + +# Copy source code +COPY backend/ backend/ +COPY earnings/ earnings/ +COPY drivepulse_stress_model/ drivepulse_stress_model/ + +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8000 + +# Match local dev layout: run from /app/backend so imports like `from utils...` +# and `from data...` resolve the same as `cd backend && python main.py` +WORKDIR /app/backend + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] + + diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..7a206bf --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,22 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ . +RUN npm run build + +FROM nginx:1.27-alpine + +WORKDIR /usr/share/nginx/html + +COPY --from=build /app/dist ./ + +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] + diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..0a0a6fc --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: uvicorn backend.main:app --host 0.0.0.0 --port ${PORT:-8000} diff --git a/README.md b/README.md new file mode 100644 index 0000000..637ccbc --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# πŸš— DrivePulse + +Real-time driver wellness & earnings intelligence platform for ride-hailing drivers. Uses on-device sensor data (accelerometer, gyroscope, microphone) with ML models to detect stressful driving situations and forecast earnings velocity. + +--- + +## Features + +- **Dashboard** β€” Daily trips, earnings, stress score, timeline +- **Trip Detail** β€” Map playback, sensor charts, event detection with explainability +- **Trends** β€” Weekly/monthly earnings, stress, and velocity charts +- **Goals** β€” Set and track daily earnings targets +- **Predict** β€” Enter sensor/earnings values β†’ instant ML prediction *(judge-facing)* +- **Batch Upload** β€” Upload CSV β†’ run inference on multiple trips at once *(judge-facing)* +- **Explainability** β€” Per-event feature contributions, confidence badges +- **Feedback** β€” Thumbs up/down on detected events +- **Auth** β€” Login / register with demo accounts or new profile + +To log **multiple trips at once**, go to the `Trips` tab and use **Import CSV**. + +--- + +## Architecture + +``` +Driver-Pulse/ +β”œβ”€β”€ backend/ # FastAPI REST API (25 endpoints) +β”‚ β”œβ”€β”€ main.py # Routes, middleware, Pydantic models +β”‚ β”œβ”€β”€ data/ +β”‚ β”‚ β”œβ”€β”€ sample_data.py # Synthetic trip/route/event generator +β”‚ β”‚ β”œβ”€β”€ batch_processor.py # Loads ML models, runs batch inference +β”‚ β”‚ β”œβ”€β”€ trips_import.py # CSV trip import parser +β”‚ β”‚ β”œβ”€β”€ users.py # In-memory auth store +β”‚ β”‚ └── config.py # Batch limits & constants +β”‚ └── utils/ +β”‚ └── logging.py # Timestamped structured logging +β”‚ +β”œβ”€β”€ frontend/ # React 18 + Vite + Tailwind SPA +β”‚ └── src/ +β”‚ β”œβ”€β”€ pages/ # 8 pages: Home, Dashboard, Trips, TripDetail, +β”‚ β”‚ # Trends, Goals, Predict, BatchUpload +β”‚ β”œβ”€β”€ components/ # 16 reusable components +β”‚ β”œβ”€β”€ api/client.js # Centralised API client +β”‚ └── utils/sanityChecks.js # Input validation helpers +β”‚ +β”œβ”€β”€ drivepulse_stress_model/ # Stress Detection ML pipeline +β”‚ β”œβ”€β”€ run.py # CLI entry (--generate --calibrate --train --demo) +β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”œβ”€β”€ generate_data.py # Synthetic sensor window generator (3,150 samples) +β”‚ β”‚ β”œβ”€β”€ train.py # RF classifier training + evaluation +β”‚ β”‚ β”œβ”€β”€ inference.py # InferenceEngine with rule-based fallback +β”‚ β”‚ └── hal.py # Hardware Abstraction Layer (device calibration) +β”‚ β”œβ”€β”€ model/ # Trained artifacts (rf_model.pkl, baselines, contract) +β”‚ └── calibration/ # Device calibration profile +β”‚ +β”œβ”€β”€ earnings/earnings/ # Earnings Forecasting ML pipeline +β”‚ β”œβ”€β”€ run.py # Sequential pipeline entry +β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”œβ”€β”€ build_dataset.py # Merges drivers + goals + velocity + trips +β”‚ β”‚ β”œβ”€β”€ features.py # 14-feature engineering (lags, rolling avg, rush flags) +β”‚ β”‚ β”œβ”€β”€ augment.py # 5Γ— Gaussian noise augmentation +β”‚ β”‚ β”œβ”€β”€ train.py # RF regressor training + evaluation +β”‚ β”‚ └── inference.py # Batch velocity prediction +β”‚ β”œβ”€β”€ model/ # Trained artifacts (rf_model.pkl, contract) +β”‚ └── data/ # Source CSVs (drivers, goals, velocity, trips) +β”‚ +β”œβ”€β”€ streamlit_app.py # Standalone Streamlit demo (3 tabs) +β”œβ”€β”€ tests/data/ # Example CSVs for batch & import testing +└── requirements.txt # Root Python dependencies +``` + +```mermaid +flowchart LR + browser[Browser_ReactApp] --> api[FastAPI_Backend] + api --> tripsStore[InMemory_Trips_+_Goals] + api --> stressBatch[Stress_Batch_Processor] + api --> earningsBatch[Earnings_Batch_Processor] + stressBatch --> stressModel[Stress_Model_Files] + earningsBatch --> earningsModel[Earnings_Model_Files] +``` + +--- + +## Setup + +### Prerequisites +- Python 3.9+ +- Node.js 18+ + - Docker Desktop (for judge-friendly containerisation) + +### Install & Run (local dev) + +```bash +# Install Python dependencies +pip install -r requirements.txt + +# Start backend (http://localhost:8000) +cd backend && python main.py + +# In a new terminal β€” start frontend (http://localhost:5173) +cd frontend && npm install && npm run dev +``` + +Open **http://localhost:5173** in your browser. + +--- + +### Run with Docker + +With [Docker Desktop](https://www.docker.com/products/docker-desktop/) running: + +```bash +# From the repo root (Driver-Pulse/) +docker compose up --build +``` + +Then open: + +- Frontend: `http://localhost:5173` +- Backend (direct): `http://localhost:8000/api/health` + +The frontend talks to the backend via `/api/*`, which is proxied by Nginx inside the `frontend` container to the `backend` container. + +**Judge login (demo account):** + +- Username: `judge@uber.com` +- Password: `hackathon2026` + +--- + +## Tech Stack + +| Layer | Tech | +|-------|------| +| Frontend | React 18, Vite, Tailwind CSS, Recharts, Leaflet | +| Backend | FastAPI, Uvicorn | +| ML | scikit-learn, NumPy, Pandas | + +--- + +## Data Flow + +- **Trips & goals**: Manual entry or CSV import hit `/api/trips` or `/api/trips/import-csv`, which update an in-memory trips list. Goals (`/api/goals`) and dashboard (`/api/dashboard`) recompute current earnings, hours, and forecast from those trips. +- **Batch stress & earnings**: Batch CSV uploads are processed by backend helpers that engineer features, call local models, and return per-row predictions plus summaries as JSON. + +--- + +## Scalability & Modularity + +- **Backend**: FastAPI routes in `backend/main.py` delegate to small modules in `backend/data/` for trips, goals, imports, and batch processing, so swapping the in-memory store for a database or separate ML service is a local change. +- **Frontend**: The React app uses a single API client layer (`frontend/src/api/client.js`) plus page/component separation, making it easy to plug in global state, auth, or feature flags without rewriting screens. +- **Batch endpoints**: Batch CSV processing is stateless per request, so multiple backend instances can handle uploads in parallel behind a load balancer. + +--- + +## Testing & Validation Notes + +- **Frontend sanity checks** β€” lightweight helpers in `frontend/src/utils/sanityChecks.js` validate money inputs, time ranges, and clamp goal targets. +- **Example test files** β€” illustrative, non-wired tests live in `frontend/src/__tests__/` (e.g. `EarningsProgress.test.jsx`, `TripsAddTrip.test.jsx`) to show how key components and behaviours could be validated in a full test setup. diff --git a/backend/data/__init__.py b/backend/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/data/batch_processor.py b/backend/data/batch_processor.py new file mode 100644 index 0000000..3962f78 --- /dev/null +++ b/backend/data/batch_processor.py @@ -0,0 +1,443 @@ +""" +DrivePulse Backend β€” Batch CSV processing. +Loads trained stress + earnings models, runs inference on uploaded CSV data. +""" + +import io +import csv +import json +import math +import numpy as np +import joblib +import pandas as pd +from pathlib import Path +from typing import Optional + +from .config import BATCH_ROW_SOFT_LIMIT + +ROOT = Path(__file__).resolve().parent.parent.parent # project root (Driver-Pulse/) + +# ── Stress model ────────────────────────────────────────────── + +STRESS_MODEL_DIR = ROOT / "drivepulse_stress_model" / "model" + +STRESS_SITUATIONS = { + 0: {"name": "NORMAL", "emoji": "βœ…", "severity": "low"}, + 1: {"name": "TRAFFIC_STOP", "emoji": "🚦", "severity": "low"}, + 2: {"name": "SPEED_BREAKER", "emoji": "⚠️", "severity": "medium"}, + 3: {"name": "CONFLICT", "emoji": "😠", "severity": "high"}, + 4: {"name": "ESCALATING", "emoji": "πŸ”΄", "severity": "high"}, + 5: {"name": "ARGUMENT_ONLY", "emoji": "πŸ—£οΈ", "severity": "medium"}, + 6: {"name": "MUSIC_OR_CALL", "emoji": "🎡", "severity": "low"}, +} + +NOTIFY_ON = {3, 4, 5} +SAFETY_ON = {3, 4} + +_stress_clf = None +_stress_mean = None +_stress_std = None +_stress_feats = None + + +def _load_stress_model(): + global _stress_clf, _stress_mean, _stress_std, _stress_feats + if _stress_clf is not None: + return True + try: + _stress_clf = joblib.load(STRESS_MODEL_DIR / "rf_model.pkl") + _stress_mean = np.load(STRESS_MODEL_DIR / "baseline_mean.npy") + _stress_std = np.load(STRESS_MODEL_DIR / "baseline_std.npy") + _stress_feats = json.loads( + (STRESS_MODEL_DIR / "feature_contract.json").read_text() + )["features"] + print(f"[batch] Stress model loaded ({len(_stress_feats)} features)") + return True + except Exception as e: + print(f"[batch] Stress model load failed: {e}") + return False + + +def predict_stress_row(row: dict) -> dict: + """Run stress prediction on a single row dict.""" + if not _load_stress_model(): + return _stress_fallback(row) + + x = np.array( + [float(row.get(f, 0.0)) for f in _stress_feats], dtype=np.float32 + ).reshape(1, -1) + xn = (x - _stress_mean) / _stress_std + pred = int(_stress_clf.predict(xn)[0]) + proba = _stress_clf.predict_proba(xn)[0] + conf = float(proba[pred]) + + if conf < 0.50: + pred, conf = 0, conf + + sit = STRESS_SITUATIONS[pred] + conf_label = "high" if conf >= 0.75 else ("medium" if conf >= 0.50 else "low") + + # Top 3 feature importance (simple absolute deviation approach) + deviations = [] + for i, f in enumerate(_stress_feats): + val = float(row.get(f, 0.0)) + mean_val = float(_stress_mean[i]) if i < len(_stress_mean) else 0 + std_val = float(_stress_std[i]) if i < len(_stress_std) else 1 + z = abs((val - mean_val) / max(std_val, 1e-6)) + deviations.append({"feature": f, "z_score": round(z, 3), "value": round(val, 3)}) + deviations.sort(key=lambda d: d["z_score"], reverse=True) + top3 = deviations[:3] + + return { + "situation_id": pred, + "situation_name": sit["name"], + "emoji": sit["emoji"], + "severity": sit["severity"], + "confidence": round(conf, 3), + "confidence_level": conf_label, + "should_notify": pred in NOTIFY_ON and conf_label != "low", + "is_safety_critical": pred in SAFETY_ON and conf_label == "high", + "top_features": top3, + "all_probabilities": { + STRESS_SITUATIONS[i]["name"]: round(float(proba[i]), 4) + for i in range(len(proba)) + }, + } + + +def _stress_fallback(row: dict) -> dict: + """Rule-based fallback when model files are missing.""" + motion = float(row.get("motion_p95", 0)) + audio = float(row.get("audio_db_p90", 50)) + speed = float(row.get("speed_mean", 30)) + lead = float(row.get("audio_leads_motion", 0)) + + if motion > 3.5 and audio > 80: + pred, conf = (4, 0.60) if lead < -5 else (3, 0.65) + elif motion > 3.5 and speed < 20: + pred, conf = 2, 0.70 + elif motion > 3.5: + pred, conf = 1, 0.65 + elif audio > 80: + pred, conf = 5, 0.60 + else: + pred, conf = 0, 0.90 + + sit = STRESS_SITUATIONS[pred] + return { + "situation_id": pred, + "situation_name": sit["name"], + "emoji": sit["emoji"], + "severity": sit["severity"], + "confidence": round(conf, 3), + "confidence_level": "medium", + "should_notify": pred in NOTIFY_ON, + "is_safety_critical": pred in SAFETY_ON, + "top_features": [], + "all_probabilities": {}, + } + + +# ── Earnings model ──────────────────────────────────────────── + +EARNINGS_MODEL_DIR = ROOT / "earnings" / "earnings" / "model" + +EARNINGS_FEATURES = [ + "elapsed_hours", "current_velocity", "velocity_delta", + "trips_completed", "trip_rate", "hour_of_day", + "is_morning_rush", "is_lunch_rush", + "velocity_last_1", "velocity_last_2", "velocity_last_3", + "rolling_velocity_3", "rolling_velocity_5", "goal_pressure", +] + +_earnings_clf = None + + +def _load_earnings_model(): + global _earnings_clf + if _earnings_clf is not None: + return True + try: + _earnings_clf = joblib.load(EARNINGS_MODEL_DIR / "rf_model.pkl") + print(f"[batch] Earnings model loaded") + return True + except Exception as e: + print(f"[batch] Earnings model load failed: {e}") + return False + + +def predict_earnings_row(row: dict) -> dict: + """Run earnings prediction on a single row dict.""" + if not _load_earnings_model(): + return {"predicted_velocity": None, "error": "Model not loaded"} + + x = np.array( + [float(row.get(f, 0.0)) for f in EARNINGS_FEATURES], dtype=np.float32 + ).reshape(1, -1) + predicted = float(_earnings_clf.predict(x)[0]) + predicted = max(0, round(predicted, 2)) + + target_velocity = float(row.get("target_velocity", 200)) + current_velocity = float(row.get("current_velocity", 0)) + target_earnings = float(row.get("target_earnings", 1800)) + current_earnings = float(row.get("cumulative_earnings", 0)) + elapsed = float(row.get("elapsed_hours", 0)) + remaining_earnings = max(0, target_earnings - current_earnings) + + if predicted > 0: + hours_needed = remaining_earnings / predicted + else: + hours_needed = None + + if predicted >= target_velocity * 1.1: + forecast = "ahead" + elif predicted >= target_velocity * 0.9: + forecast = "on_track" + else: + forecast = "at_risk" + + return { + "predicted_velocity": round(predicted, 2), + "target_velocity": target_velocity, + "current_velocity": current_velocity, + "forecast_status": forecast, + "remaining_earnings": round(remaining_earnings, 2), + "hours_to_target": round(hours_needed, 2) if hours_needed else None, + "pct_target": round(min(100, (current_earnings / max(target_earnings, 1)) * 100), 1), + } + + +# ── Feature engineering for earnings (from raw trip CSV) ────── + +def engineer_earnings_features(df: pd.DataFrame) -> pd.DataFrame: + """ + Takes a raw trip-level DataFrame and engineers the 14 features + needed for the earnings model. Columns expected: + driver_id, timestamp, cumulative_earnings, elapsed_hours, + current_velocity, target_velocity, velocity_delta, + trips_completed, target_earnings + """ + df = df.copy() + + # Parse timestamp β†’ hour info + if "timestamp" in df.columns: + ts = pd.to_datetime(df["timestamp"], errors="coerce") + df["hour_of_day"] = ts.dt.hour.fillna(12).astype(int) + elif "hour_of_day" not in df.columns: + df["hour_of_day"] = 12 + + df["is_morning_rush"] = df["hour_of_day"].between(7, 9).astype(int) + df["is_lunch_rush"] = df["hour_of_day"].between(12, 14).astype(int) + + # trip_rate + df["elapsed_hours"] = pd.to_numeric(df.get("elapsed_hours", 1), errors="coerce").fillna(1) + df["trips_completed"] = pd.to_numeric(df.get("trips_completed", 0), errors="coerce").fillna(0) + df["trip_rate"] = df["trips_completed"] / df["elapsed_hours"].replace(0, 1) + + # Velocity lags + rolling + df["current_velocity"] = pd.to_numeric(df.get("current_velocity", 0), errors="coerce").fillna(0).clip(0, 600) + df["velocity_delta"] = pd.to_numeric(df.get("velocity_delta", 0), errors="coerce").fillna(0) + df["target_velocity"] = pd.to_numeric(df.get("target_velocity", 200), errors="coerce").fillna(200) + + df = df.sort_values(["driver_id", "hour_of_day"] if "driver_id" in df.columns else ["hour_of_day"]) + grp = "driver_id" if "driver_id" in df.columns else None + + if grp: + df["velocity_last_1"] = df.groupby(grp)["current_velocity"].shift(1) + df["velocity_last_2"] = df.groupby(grp)["current_velocity"].shift(2) + df["velocity_last_3"] = df.groupby(grp)["current_velocity"].shift(3) + df["rolling_velocity_3"] = df.groupby(grp)["current_velocity"].transform( + lambda s: s.rolling(3, min_periods=1).mean() + ) + df["rolling_velocity_5"] = df.groupby(grp)["current_velocity"].transform( + lambda s: s.rolling(5, min_periods=1).mean() + ) + else: + df["velocity_last_1"] = df["current_velocity"].shift(1) + df["velocity_last_2"] = df["current_velocity"].shift(2) + df["velocity_last_3"] = df["current_velocity"].shift(3) + df["rolling_velocity_3"] = df["current_velocity"].rolling(3, min_periods=1).mean() + df["rolling_velocity_5"] = df["current_velocity"].rolling(5, min_periods=1).mean() + + df["goal_pressure"] = df["target_velocity"] - df["current_velocity"] + + # Fill NaNs from shifting + df = df.bfill().ffill().fillna(0) + + return df + + +# ── Batch processing ────────────────────────────────────────── + +def process_stress_csv(csv_content: str) -> dict: + """Process a CSV of sensor windows. Returns per-row predictions + summary.""" + _load_stress_model() + + reader = csv.DictReader(io.StringIO(csv_content)) + rows = list(reader) + + if not rows: + return {"error": "CSV is empty", "results": [], "summary": {}} + + row_count = len(rows) + row_limit_warning = None + if BATCH_ROW_SOFT_LIMIT and row_count > BATCH_ROW_SOFT_LIMIT: + row_limit_warning = ( + f"Processed {row_count} rows; consider chunking to {BATCH_ROW_SOFT_LIMIT} rows per upload for large workloads." + ) + results = [] + severity_counts = {"low": 0, "medium": 0, "high": 0} + situation_counts = {} + + for i, row in enumerate(rows): + # Add constant features + row["motion_std"] = 0.3 + row["z_dev_max"] = 0.5 + row["spikes_above3"] = 0 + row["spikes_above5"] = 0 + row["audio_class_max"] = 2.0 + row["audio_class_mean"] = 1.0 + row["sustained_max"] = 10.0 + row["sustained_sum"] = 50.0 + row["cadence_var_max"] = 0.6 + row["audio_leads_motion"] = 0.0 + row["audio_onset_sec"] = 0.0 + row["brake_t_sec"] = 0.0 + row["is_low_speed"] = 0 + row["both_elevated"] = 0 + row["audio_only"] = 0 + pred = predict_stress_row(row) + pred["row_index"] = i + # Carry through any extra columns (trip_id, timestamp, etc.) + for k in ("trip_id", "window_id", "timestamp", "start_time", "driver_id"): + if k in row: + pred[k] = row[k] + + results.append(pred) + severity_counts[pred["severity"]] = severity_counts.get(pred["severity"], 0) + 1 + situation_counts[pred["situation_name"]] = situation_counts.get(pred["situation_name"], 0) + 1 + + total = len(results) + avg_confidence = round(sum(r["confidence"] for r in results) / total, 3) + notify_count = sum(1 for r in results if r["should_notify"]) + safety_count = sum(1 for r in results if r["is_safety_critical"]) + + summary = { + "total_windows": total, + "severity_counts": severity_counts, + "situation_counts": situation_counts, + "avg_confidence": avg_confidence, + "notifications_triggered": notify_count, + "safety_critical_count": safety_count, + "stress_score": round( + (severity_counts.get("high", 0) * 5 + severity_counts.get("medium", 0) * 3) + / max(total, 1), + 2, + ), + } + if row_limit_warning: + summary["row_limit_warning"] = row_limit_warning + + return { + "results": results, + "summary": summary, + } + + +def process_earnings_csv(csv_content: str) -> dict: + """Process a CSV of earnings/trip data. Returns per-row velocity predictions + summary.""" + _load_earnings_model() + + df = pd.read_csv(io.StringIO(csv_content)) + + if df.empty: + return {"error": "CSV is empty", "results": [], "summary": {}} + + row_count = len(df) + row_limit_warning = None + if BATCH_ROW_SOFT_LIMIT and row_count > BATCH_ROW_SOFT_LIMIT: + row_limit_warning = ( + f"Processed {row_count} rows; consider chunking to {BATCH_ROW_SOFT_LIMIT} rows per upload for large workloads." + ) + + # Fill defaults for missing columns + for col, default in [ + ("driver_id", "driver-001"), + ("target_velocity", 200), + ("velocity_delta", 0), + ("target_earnings", 1800), + ("cumulative_earnings", 0), + ]: + if col not in df.columns: + df[col] = default + + df = engineer_earnings_features(df) + + results = [] + total_predicted = 0 + + for i, row in df.iterrows(): + row_dict = row.to_dict() + pred = predict_earnings_row(row_dict) + pred["row_index"] = int(i) + for k in ("driver_id", "timestamp", "trip_id", "hour_of_day"): + if k in row_dict: + pred[k] = row_dict[k] + if isinstance(pred[k], float) and not math.isnan(pred[k]): + pred[k] = int(pred[k]) if k == "hour_of_day" else pred[k] + elif isinstance(pred[k], float) and math.isnan(pred[k]): + pred[k] = None + + pred["cumulative_earnings"] = round(float(row_dict.get("cumulative_earnings", 0)), 2) + pred["elapsed_hours"] = round(float(row_dict.get("elapsed_hours", 0)), 2) + + results.append(pred) + if pred["predicted_velocity"] is not None: + total_predicted += pred["predicted_velocity"] + + total = len(results) + avg_velocity = round(total_predicted / max(total, 1), 2) + + forecast_counts = {"ahead": 0, "on_track": 0, "at_risk": 0} + for r in results: + forecast_counts[r.get("forecast_status", "on_track")] += 1 + + summary = { + "total_entries": total, + "avg_predicted_velocity": avg_velocity, + "forecast_counts": forecast_counts, + "best_velocity": max((r["predicted_velocity"] for r in results if r["predicted_velocity"]), default=0), + "worst_velocity": min((r["predicted_velocity"] for r in results if r["predicted_velocity"]), default=0), + } + if row_limit_warning: + summary["row_limit_warning"] = row_limit_warning + + return { + "results": results, + "summary": summary, + } + + +# ── Template generators ────────────────────────────────────── + +def stress_csv_template() -> str: + """Return a CSV template string with headers + 2 sample rows.""" + feats = [ + "motion_max", "motion_mean", "motion_p95", "brake_intensity", "lateral_max", + "speed_mean", "speed_at_brake", "speed_drop", + "audio_db_max", "audio_db_mean", "audio_db_p90", "audio_db_std", + "cadence_var_mean", "argument_frac", "loud_frac", + ] + header = "trip_id,timestamp," + ",".join(feats) + row1 = "trip-001,08:15:00,1.2,0.6,1.1,0.8,0.5,35,35,5,68,62,67,4,0.1,0.15" + row2 = "trip-002,09:30:00,5.1,1.8,4.8,4.9,2.1,40,40,25,94,82,91,8,0.72,0.95" + return header + "\n" + row1 + "\n" + row2 + "\n" + + +def earnings_csv_template() -> str: + """Return a CSV template string for earnings upload.""" + header = "driver_id,timestamp,cumulative_earnings,elapsed_hours,current_velocity,target_velocity,velocity_delta,trips_completed,target_earnings" + row1 = "driver-001,08:00:00,0,0.5,0,200,0,0,1800" + row2 = "driver-001,09:00:00,185,1.5,185,200,-15,2,1800" + row3 = "driver-001,10:00:00,420,2.5,210,200,10,4,1800" + return header + "\n" + row1 + "\n" + row2 + "\n" + row3 + "\n" diff --git a/backend/data/config.py b/backend/data/config.py new file mode 100644 index 0000000..7911bef --- /dev/null +++ b/backend/data/config.py @@ -0,0 +1,9 @@ +""" +Shared configuration and limits for backend demo modules. +Kept small and simple on purpose so it is easy to swap out in a real deployment. +""" + +# Soft limit for batch CSV rows. Requests above this size are still processed, +# but responses can include a warning so callers know to chunk work if needed. +BATCH_ROW_SOFT_LIMIT = 5000 + diff --git a/backend/data/sample_data.py b/backend/data/sample_data.py new file mode 100644 index 0000000..e6e68da --- /dev/null +++ b/backend/data/sample_data.py @@ -0,0 +1,535 @@ +""" +DrivePulse Backend β€” Sample data generator. +Produces realistic trips, events, signals, and driver metrics. +""" + +import random +import math +import uuid +from datetime import datetime, timedelta +from typing import List, Dict, Any + +random.seed(42) + +# Canonical "today" used across demo endpoints. +# (The frontend defaults to this date as well.) +TODAY = datetime(2026, 3, 8) +TODAY_STR = TODAY.strftime("%Y-%m-%d") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +SITUATIONS = { + 0: {"name": "NORMAL", "emoji": "βœ…", "severity": "low"}, + 1: {"name": "TRAFFIC_STOP", "emoji": "🚦", "severity": "low"}, + 2: {"name": "SPEED_BREAKER", "emoji": "⚠️", "severity": "medium"}, + 3: {"name": "CONFLICT", "emoji": "😠", "severity": "high"}, + 4: {"name": "ESCALATING", "emoji": "πŸ”΄", "severity": "high"}, + 5: {"name": "ARGUMENT_ONLY", "emoji": "πŸ—£οΈ", "severity": "medium"}, + 6: {"name": "MUSIC_OR_CALL", "emoji": "🎡", "severity": "low"}, +} + +STRESS_TIPS = [ + {"id": 1, "title": "Deep Breathing", "text": "Try 4-7-8 breathing: inhale 4s, hold 7s, exhale 8s. Helps reduce cortisol instantly.", "cta": "Try now"}, + {"id": 2, "title": "Pull Over Briefly", "text": "If safe, stop for 2 minutes. A short pause resets your stress response.", "cta": "Find safe spot"}, + {"id": 3, "title": "Adjust Temperature", "text": "Lower the AC by 2Β°. Cooler air helps reduce tension and improve alertness.", "cta": "Got it"}, + {"id": 4, "title": "Play Calming Audio", "text": "Switch to lo-fi or nature sounds. Reduces heart rate within 3 minutes.", "cta": "Open music"}, + {"id": 5, "title": "Stretch at Next Stop", "text": "Roll your shoulders and stretch your neck at the next red light or stop.", "cta": "Remind me"}, + {"id": 6, "title": "Hydrate", "text": "Dehydration increases stress hormones. Take a sip of water.", "cta": "Thanks"}, + {"id": 7, "title": "Positive Self-Talk", "text": "Remind yourself: 'This moment will pass. I'm doing well today.'", "cta": "Noted"}, + {"id": 8, "title": "Micro-break", "text": "After this trip, take a 5-minute walk. It clears mental fatigue.", "cta": "Schedule break"}, +] + +# Bangalore area coordinates for route generation +BANGALORE_CENTER = (12.9716, 77.5946) +ROUTE_ANCHORS = [ + [(12.9716, 77.5946), (12.9780, 77.6050), (12.9850, 77.6150), (12.9900, 77.6200)], + [(12.9350, 77.6140), (12.9400, 77.6050), (12.9500, 77.5970), (12.9600, 77.5900)], + [(12.9600, 77.5700), (12.9550, 77.5800), (12.9500, 77.5900), (12.9450, 77.6000)], + [(12.9800, 77.5500), (12.9750, 77.5600), (12.9700, 77.5750), (12.9716, 77.5946)], + [(12.9200, 77.6200), (12.9300, 77.6100), (12.9400, 77.5950), (12.9500, 77.5850)], + [(12.9900, 77.5800), (12.9850, 77.5900), (12.9800, 77.6000), (12.9750, 77.6100)], +] + + +def _interp_route(anchors: list, n_points: int = 60) -> list: + """Interpolate between anchor points to create a smooth route.""" + route = [] + segs = len(anchors) - 1 + pts_per_seg = max(n_points // segs, 2) + for i in range(segs): + for j in range(pts_per_seg): + t = j / pts_per_seg + lat = anchors[i][0] + t * (anchors[i + 1][0] - anchors[i][0]) + random.gauss(0, 0.0002) + lng = anchors[i][1] + t * (anchors[i + 1][1] - anchors[i][1]) + random.gauss(0, 0.0002) + route.append([round(lat, 6), round(lng, 6)]) + route.append(list(anchors[-1])) + return route + + +def _gen_signals(duration_min: int, events: list) -> Dict[str, list]: + """Generate time-series signals: speed, accel_magnitude, audio_db.""" + n = duration_min * 6 # one sample every 10s + ts = list(range(0, duration_min * 60, 10)) + speed = [] + accel = [] + audio = [] + + base_speed = random.uniform(20, 45) + for i in range(n): + t_sec = ts[i] if i < len(ts) else i * 10 + # Base speed with traffic variation + s = base_speed + 10 * math.sin(2 * math.pi * i / n) + random.gauss(0, 3) + a = abs(random.gauss(1.0, 0.3)) + d = random.gauss(55, 5) + + # Inject spikes near events + for ev in events: + ev_sec = ev["offset_sec"] + if abs(t_sec - ev_sec) < 30: + proximity = 1 - abs(t_sec - ev_sec) / 30 + if ev["label"] in ("CONFLICT", "ESCALATING", "ARGUMENT_ONLY"): + d += 20 * proximity + a += 1.5 * proximity + elif ev["label"] == "SPEED_BREAKER": + a += 3 * proximity + s = max(5, s - 15 * proximity) + elif ev["label"] == "TRAFFIC_STOP": + s = max(0, s - s * proximity) + + speed.append(round(max(0, s), 1)) + accel.append(round(max(0, a), 2)) + audio.append(round(max(30, min(100, d)), 1)) + + return {"timestamps": ts[:n], "speed": speed, "accel_magnitude": accel, "audio_db": audio} + + +def _gen_events(duration_min: int, stress_level: str) -> list: + """Generate random events for a trip based on stress level.""" + if stress_level == "low": + n_events = random.randint(0, 2) + pool = [0, 1, 6] + elif stress_level == "medium": + n_events = random.randint(2, 4) + pool = [0, 1, 2, 5, 6] + else: + n_events = random.randint(3, 6) + pool = [1, 2, 3, 4, 5] + + events = [] + used_offsets = set() + for _ in range(n_events): + sit_id = random.choice(pool) + sit = SITUATIONS[sit_id] + offset = random.randint(60, (duration_min - 1) * 60) + while any(abs(offset - u) < 30 for u in used_offsets): + offset = random.randint(60, (duration_min - 1) * 60) + used_offsets.add(offset) + + confidence = round(random.uniform(0.55, 0.98), 2) + conf_level = "high" if confidence > 0.85 else ("medium" if confidence > 0.65 else "low") + + # Feature contributions (SHAP-like) + all_contribs = [ + {"feature": "accel_magnitude", "direction": "↑", "contribution": round(random.uniform(0.05, 0.35), 3)}, + {"feature": "audio_db_max", "direction": "↑", "contribution": round(random.uniform(0.03, 0.30), 3)}, + {"feature": "speed_drop", "direction": "↑", "contribution": round(random.uniform(0.02, 0.25), 3)}, + {"feature": "brake_intensity", "direction": "↑", "contribution": round(random.uniform(0.01, 0.20), 3)}, + {"feature": "lateral_max", "direction": "↑", "contribution": round(random.uniform(0.01, 0.15), 3)}, + {"feature": "sustained_max", "direction": "↑" if sit_id in (3, 4, 5) else "β†’", "contribution": round(random.uniform(0.01, 0.18), 3)}, + {"feature": "cadence_var_mean", "direction": "↑" if sit_id in (3, 4) else "β†’", "contribution": round(random.uniform(0.005, 0.10), 3)}, + ] + all_contribs.sort(key=lambda x: x["contribution"], reverse=True) + top3 = all_contribs[:3] + + model_inputs = { + "motion_max": round(random.uniform(1, 8), 2), + "audio_db_max": round(random.uniform(50, 95), 1), + "speed_mean": round(random.uniform(5, 50), 1), + "brake_intensity": round(random.uniform(0, 5), 2), + "lateral_max": round(random.uniform(0.2, 3), 2), + } + + events.append({ + "id": str(uuid.uuid4())[:8], + "offset_sec": offset, + "timestamp": None, # filled later + "label": sit["name"], + "emoji": sit["emoji"], + "severity": sit["severity"], + "situation_id": sit_id, + "confidence": confidence, + "confidence_level": conf_level, + "explain": { + "model_inputs": model_inputs, + "top_features": top3, + "summary": f"Detected {sit['name'].lower().replace('_', ' ')} β€” primary driver: {top3[0]['feature']} {top3[0]['direction']}" + }, + "feedback": None, + }) + + events.sort(key=lambda e: e["offset_sec"]) + return events + + +def _gen_trip(trip_idx: int, day: datetime, hour: int) -> Dict[str, Any]: + """Generate a single trip.""" + stress_options = ["low", "low", "medium", "medium", "high"] + stress_level = random.choice(stress_options) + + duration_min = random.randint(12, 55) + start_time = day.replace(hour=hour, minute=random.randint(0, 59), second=0) + end_time = start_time + timedelta(minutes=duration_min) + + route_anchors = random.choice(ROUTE_ANCHORS) + route = _interp_route(route_anchors, n_points=max(30, duration_min)) + + distance_km = round(random.uniform(3, 25), 1) + base_fare = 30 + distance_km * 12 + duration_min * 2 + surge = round(random.choice([1.0, 1.0, 1.0, 1.2, 1.5, 1.8, 2.0]), 1) + fare = round(base_fare * surge, 0) + + events = _gen_events(duration_min, stress_level) + stress_score = 0 + for ev in events: + w = {"low": 1, "medium": 3, "high": 5}.get(ev["severity"], 1) + stress_score += w * ev["confidence"] + stress_score = round(min(10, stress_score), 1) + + # Fill event timestamps + for ev in events: + ev["timestamp"] = (start_time + timedelta(seconds=ev["offset_sec"])).isoformat() + + signals = _gen_signals(duration_min, events) + + # Place event markers on route + for ev in events: + frac = ev["offset_sec"] / (duration_min * 60) + idx = min(int(frac * len(route)), len(route) - 1) + ev["location"] = route[idx] + + trip_id = f"trip-{trip_idx:03d}" + return { + "id": trip_id, + "date": day.strftime("%Y-%m-%d"), + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "duration_min": duration_min, + "distance_km": distance_km, + "fare": fare, + "surge_multiplier": surge, + "stress_score": stress_score, + "stress_level": stress_level, + "events_count": len(events), + "events": events, + "route": route, + "pickup": route[0], + "dropoff": route[-1], + "signals": signals, + } + + +# --------------------------------------------------------------------------- +# Public generators +# --------------------------------------------------------------------------- + +def generate_trips(today: datetime = None) -> List[Dict]: + """Generate ~8-10 trips for today and ~8-10 for yesterday.""" + if today is None: + today = datetime(2026, 3, 8) + yesterday = today - timedelta(days=1) + + trips = [] + idx = 1 + + for day, label in [(today, "today"), (yesterday, "yesterday")]: + n_trips = random.randint(7, 10) + hours = sorted(random.sample(range(6, 23), n_trips)) + for h in hours: + trips.append(_gen_trip(idx, day, h)) + idx += 1 + + return trips + + +def generate_driver_profile() -> Dict: + return { + "id": "driver-001", + "name": "Ravi Kumar", + "city": "Bangalore", + "rating": 4.82, + "experience_months": 18, + "avg_hours_per_day": 10, + "avg_earnings_per_hour": 185, + "shift_preference": "morning", + } + + +def generate_goals() -> Dict: + return { + "daily_target": 1800, + "current_earnings": 1230, + "target_hours": 10, + "current_hours": 6.5, + "trips_completed": 8, + "forecast_status": "on_track", + "goal_probability": 0.78, + "required_velocity": 220, + "current_velocity": 189, + } + + +def build_dashboard(trips: List[Dict], goals: Dict) -> Dict: + """Build dashboard summary from trips and goals.""" + today_trips = [t for t in trips if t["date"] == TODAY_STR] + + total_earnings = sum(t["fare"] for t in today_trips) + total_hours = round(sum(t["duration_min"] for t in today_trips) / 60, 1) + stress_events = sum(1 for t in today_trips for e in t["events"] if e["severity"] in ("medium", "high")) + high_stress_count = sum(1 for t in today_trips for e in t["events"] if e["severity"] == "high") + pct_target = round(min(100, (total_earnings / goals["daily_target"]) * 100), 1) if goals["daily_target"] else 0 + + return { + "date": TODAY_STR, + "total_trips": len(today_trips), + "total_hours": total_hours, + "total_earnings": total_earnings, + "stress_events": stress_events, + "high_stress_events": high_stress_count, + "pct_target_achieved": pct_target, + "avg_stress_score": round(sum(t["stress_score"] for t in today_trips) / max(len(today_trips), 1), 1), + "earnings_velocity": goals["current_velocity"], + } + + +def build_weekly_metrics(trips: List[Dict]) -> Dict: + """Build aggregated metrics for trends page.""" + today = TODAY + days = [] + for i in range(6, -1, -1): + day = today - timedelta(days=i) + day_str = day.strftime("%Y-%m-%d") + day_label = day.strftime("%a") + day_trips = [t for t in trips if t["date"] == day_str] + + # For days without data, simulate + if not day_trips: + n = random.randint(5, 10) + earnings = round(random.uniform(1200, 2200), 0) + hours = round(random.uniform(6, 11), 1) + stress = round(random.uniform(2, 6), 1) + stress_events = random.randint(2, 8) + else: + n = len(day_trips) + earnings = sum(t["fare"] for t in day_trips) + hours = round(sum(t["duration_min"] for t in day_trips) / 60, 1) + stress = round(sum(t["stress_score"] for t in day_trips) / n, 1) + stress_events = sum(1 for t in day_trips for e in t["events"] if e["severity"] in ("medium", "high")) + + days.append({ + "date": day_str, + "day": day_label, + "trips": n, + "earnings": earnings, + "hours": hours, + "avg_stress": stress, + "stress_events": stress_events, + "velocity": round(earnings / max(hours, 1), 0), + }) + + return { + "range": "7d", + "days": days, + "summary": { + "avg_daily_earnings": round(sum(d["earnings"] for d in days) / 7, 0), + "avg_daily_trips": round(sum(d["trips"] for d in days) / 7, 1), + "avg_stress": round(sum(d["avg_stress"] for d in days) / 7, 1), + "total_earnings": sum(d["earnings"] for d in days), + "total_trips": sum(d["trips"] for d in days), + "best_day": max(days, key=lambda d: d["earnings"])["day"], + }, + } + + +def build_monthly_metrics() -> Dict: + """Build 30-day metric trend.""" + today = TODAY + days = [] + for i in range(29, -1, -1): + day = today - timedelta(days=i) + n = random.randint(5, 12) + earnings = round(random.uniform(1000, 2500), 0) + hours = round(random.uniform(5, 12), 1) + days.append({ + "date": day.strftime("%Y-%m-%d"), + "day": day.strftime("%d %b"), + "trips": n, + "earnings": earnings, + "hours": hours, + "avg_stress": round(random.uniform(1.5, 7), 1), + "stress_events": random.randint(0, 10), + "velocity": round(earnings / max(hours, 1), 0), + }) + return { + "range": "30d", + "days": days, + "summary": { + "avg_daily_earnings": round(sum(d["earnings"] for d in days) / 30, 0), + "avg_daily_trips": round(sum(d["trips"] for d in days) / 30, 1), + "avg_stress": round(sum(d["avg_stress"] for d in days) / 30, 1), + "total_earnings": sum(d["earnings"] for d in days), + "total_trips": sum(d["trips"] for d in days), + }, + } + + +# --------------------------------------------------------------------------- +# Singleton data store +# --------------------------------------------------------------------------- + +_TRIPS = None +_PROFILE = None +_GOALS = None + + +def get_trips(): + global _TRIPS + if _TRIPS is None: + _TRIPS = generate_trips() + return _TRIPS + + +def create_user_trip( + *, + date: str, + start_time_iso: str, + end_time_iso: str, + duration_min: int, + distance_km: float, + fare: float, + stress_score: float = 0.0, +): + """Create a minimal, UI-compatible trip for manual entry/import flows.""" + # Keep detail page working by providing empty events + synthetic route/signals. + events = [] + route_anchors = ROUTE_ANCHORS[0] + route = _interp_route(route_anchors, n_points=max(30, duration_min)) + signals = _gen_signals(duration_min, events) + + stress_score = float(stress_score or 0.0) + # Derive stress_level for existing UI (dots/filters). + if stress_score <= 3: + stress_level = "low" + elif stress_score <= 6: + stress_level = "medium" + else: + stress_level = "high" + + return { + "id": f"user-{str(uuid.uuid4())[:8]}", + "date": date, + "start_time": start_time_iso, + "end_time": end_time_iso, + "duration_min": int(duration_min), + "distance_km": float(distance_km), + "fare": float(fare), + "surge_multiplier": 1.0, + "stress_score": round(min(10.0, max(0.0, stress_score)), 1), + "stress_level": stress_level, + "events_count": 0, + "events": events, + "route": route, + "pickup": route[0] if route else None, + "dropoff": route[-1] if route else None, + "signals": signals, + } + + +def add_trip(trip: Dict[str, Any]) -> Dict[str, Any]: + trips = get_trips() + trips.insert(0, trip) + return trip + + +def get_profile(): + global _PROFILE + if _PROFILE is None: + _PROFILE = generate_driver_profile() + return _PROFILE + + +def get_goals(): + global _GOALS + if _GOALS is None: + _GOALS = generate_goals() + # Always derive "current" metrics from trips so UI stays consistent after add/import. + goals = _GOALS + trips = get_trips() + today_trips = [t for t in trips if t.get("date") == TODAY_STR] + current_earnings = round(sum(float(t.get("fare", 0) or 0) for t in today_trips), 2) + current_hours = round(sum(float(t.get("duration_min", 0) or 0) for t in today_trips) / 60, 2) + trips_completed = len(today_trips) + current_velocity = round(current_earnings / current_hours, 0) if current_hours > 0 else 0 + + goals["current_earnings"] = current_earnings + goals["current_hours"] = current_hours + goals["trips_completed"] = trips_completed + goals["current_velocity"] = current_velocity + + _recompute_goal_derivatives(goals) + _GOALS = goals + return goals + + +def set_goal_target(target: float): + global _GOALS + goals = get_goals() + goals["daily_target"] = float(target) + + _recompute_goal_derivatives(goals) + + _GOALS = goals + return _GOALS + + +def _recompute_goal_derivatives(goals: Dict[str, Any]) -> None: + """ + Keep all goal-derived fields (remaining earnings/hours, required velocity, + probability and forecast status) in one place so they stay consistent + between get_goals() and set_goal_target(). + """ + remaining_earnings = max(0, float(goals.get("daily_target") or 0) - float(goals.get("current_earnings") or 0)) + remaining_hours = max(0.1, float(goals.get("target_hours") or 0) - float(goals.get("current_hours") or 0)) + goals["required_velocity"] = round(remaining_earnings / remaining_hours, 0) if goals.get("daily_target") else 0 + goals["remaining_earnings"] = round(remaining_earnings, 2) + goals["remaining_hours"] = round( + max(0.0, float(goals.get("target_hours") or 0) - float(goals.get("current_hours") or 0)), + 2, + ) + current_velocity = float(goals.get("current_velocity") or 0) + goals["hours_to_target"] = round(remaining_earnings / current_velocity, 2) if current_velocity > 0 else None + + velocity_ratio = goals["current_velocity"] / max(goals["required_velocity"], 1) + time_ratio = goals["current_hours"] / max(goals["target_hours"], 0.1) + + if velocity_ratio >= 1.2: + prob = 0.95 + elif velocity_ratio >= 1.0: + prob = 0.75 + elif velocity_ratio >= 0.8: + prob = 0.55 + else: + prob = 0.25 + + if time_ratio > 0.8: + prob *= 0.8 + + goals["goal_probability"] = round(min(0.99, max(0.01, prob)), 2) + + if goals["current_velocity"] >= goals["required_velocity"] * 1.1: + goals["forecast_status"] = "ahead" + elif goals["current_velocity"] >= goals["required_velocity"] * 0.9: + goals["forecast_status"] = "on_track" + else: + goals["forecast_status"] = "at_risk" diff --git a/backend/data/trips_import.py b/backend/data/trips_import.py new file mode 100644 index 0000000..b6b280d --- /dev/null +++ b/backend/data/trips_import.py @@ -0,0 +1,112 @@ +import csv +from datetime import datetime +from typing import Dict, Any, List, Optional, Tuple + +from .sample_data import create_user_trip, add_trip + + +REQUIRED_COLUMNS = ["date", "start_time", "end_time", "distance_km", "fare"] +OPTIONAL_COLUMNS = ["stress_score"] + + +def trips_csv_template() -> str: + header = REQUIRED_COLUMNS + OPTIONAL_COLUMNS + example_rows = [ + { + "date": "2026-03-08", + "start_time": "09:15", + "end_time": "09:45", + "distance_km": "8.2", + "fare": "310", + "stress_score": "2.5", + }, + { + "date": "2026-03-08", + "start_time": "18:05", + "end_time": "18:40", + "distance_km": "11.6", + "fare": "520", + "stress_score": "6.3", + }, + ] + lines = [",".join(header)] + for r in example_rows: + lines.append(",".join(str(r.get(k, "")) for k in header)) + return "\n".join(lines) + "\n" + + +def _parse_dt(date: str, dt_or_hhmm: str) -> datetime: + s = (dt_or_hhmm or "").strip() + if "T" in s: + return datetime.fromisoformat(s) + hh, mm = s.split(":") + return datetime.fromisoformat(f"{date}T{int(hh):02d}:{int(mm):02d}:00") + + +def import_trips_csv(csv_content: str) -> Dict[str, Any]: + """ + Parse a Trips CSV and append trips to the in-memory store. + Returns summary + per-row results (created trip IDs or errors). + """ + reader = csv.DictReader(csv_content.splitlines()) + if not reader.fieldnames: + return {"error": "CSV missing header row"} + + missing = [c for c in REQUIRED_COLUMNS if c not in reader.fieldnames] + if missing: + return {"error": f"Missing required columns: {', '.join(missing)}"} + + results: List[Dict[str, Any]] = [] + created: List[Dict[str, Any]] = [] + + for idx, row in enumerate(reader): + try: + date = (row.get("date") or "").strip() + if not date: + raise ValueError("date is required") + + start_dt = _parse_dt(date, row.get("start_time")) + end_dt = _parse_dt(date, row.get("end_time")) + if end_dt <= start_dt: + raise ValueError("end_time must be after start_time") + + duration_min = int(round((end_dt - start_dt).total_seconds() / 60)) + if duration_min <= 0: + raise ValueError("duration must be at least 1 minute") + + distance_km = float(row.get("distance_km")) + fare = float(row.get("fare")) + + stress_score_raw = (row.get("stress_score") or "").strip() + if stress_score_raw == "": + stress_score = 0.0 + else: + stress_score = float(stress_score_raw) + if stress_score < 0 or stress_score > 10: + raise ValueError("stress_score must be between 0 and 10") + + trip = create_user_trip( + date=date, + start_time_iso=start_dt.isoformat(), + end_time_iso=end_dt.isoformat(), + duration_min=duration_min, + distance_km=distance_km, + fare=fare, + stress_score=stress_score, + ) + add_trip(trip) + created.append(trip) + results.append({"row_index": idx, "status": "created", "trip_id": trip["id"]}) + except Exception as e: + results.append({"row_index": idx, "status": "error", "error": str(e)}) + + return { + "summary": { + "total_rows": len(results), + "created": sum(1 for r in results if r["status"] == "created"), + "errors": sum(1 for r in results if r["status"] == "error"), + }, + "results": results, + "created_trips": created, + } + diff --git a/backend/data/users.py b/backend/data/users.py new file mode 100644 index 0000000..f6eaac2 --- /dev/null +++ b/backend/data/users.py @@ -0,0 +1,146 @@ +""" +DrivePulse Backend β€” User authentication and driver profiles. +Manages login, registration, and driver data. +""" + +import json +from datetime import datetime +from typing import Optional, Dict + +# In-memory user store (in production, use a real database) +_USERS = { + "alex.kumar": { + "driver_id": "DRV001", + "password": "password123", # In production, use bcrypt + "name": "Alex Kumar", + "email": "alex.kumar@example.com", + "phone": "+91 9876543210", + "city": "Mumbai", + "vehicle_type": "Sedan", + "vehicle_number": "MH01AB1234", + "shift_preference": "morning", + "avg_hours_per_day": 7.5, + "avg_earnings_per_hour": 185, + "experience_months": 18, + "rating": 4.8, + "total_trips": 342, + "total_earnings": 48000, + }, + "priya.singh": { + "driver_id": "DRV002", + "password": "password123", + "name": "Priya Singh", + "email": "priya.singh@example.com", + "phone": "+91 9876543211", + "city": "Bangalore", + "vehicle_type": "SUV", + "vehicle_number": "KA01CD5678", + "shift_preference": "evening", + "avg_hours_per_day": 8.2, + "avg_earnings_per_hour": 195, + "experience_months": 24, + "rating": 4.9, + "total_trips": 456, + "total_earnings": 62000, + }, +} + + +def register_user(username: str, password: str, driver_data: dict) -> Optional[Dict]: + """Register a new user with driver profile.""" + if username in _USERS: + return None # User already exists + + user = { + "driver_id": driver_data.get("driver_id", f"DRV{len(_USERS) + 1000}"), + "password": password, # In production, use bcrypt.hashpw() + "name": driver_data.get("name", ""), + "email": driver_data.get("email", ""), + "phone": driver_data.get("phone", ""), + "city": driver_data.get("city", ""), + "vehicle_type": driver_data.get("vehicle_type", ""), + "vehicle_number": driver_data.get("vehicle_number", ""), + "shift_preference": driver_data.get("shift_preference", "morning"), + "avg_hours_per_day": driver_data.get("avg_hours_per_day", 7.0), + "avg_earnings_per_hour": driver_data.get("avg_earnings_per_hour", 180), + "experience_months": driver_data.get("experience_months", 0), + "rating": driver_data.get("rating", 4.5), + "total_trips": driver_data.get("total_trips", 0), + "total_earnings": driver_data.get("total_earnings", 0), + } + + _USERS[username] = user + return { + "driver_id": user["driver_id"], + "name": user["name"], + "email": user["email"], + "city": user["city"], + "rating": user["rating"], + "vehicle_type": user["vehicle_type"], + } + + +def login_user(username: str, password: str) -> Optional[Dict]: + """Authenticate a user and return their profile.""" + if username not in _USERS: + return None + + user = _USERS[username] + + # Simple password check (in production, use bcrypt.checkpw()) + if user["password"] != password: + return None + + return { + "driver_id": user["driver_id"], + "username": username, + "name": user["name"], + "email": user["email"], + "phone": user["phone"], + "city": user["city"], + "vehicle_type": user["vehicle_type"], + "vehicle_number": user["vehicle_number"], + "rating": user["rating"], + "total_trips": user["total_trips"], + "total_earnings": user["total_earnings"], + "avg_hours_per_day": user["avg_hours_per_day"], + "avg_earnings_per_hour": user["avg_earnings_per_hour"], + "experience_months": user["experience_months"], + "shift_preference": user["shift_preference"], + } + + +def get_user_profile(driver_id: str) -> Optional[Dict]: + """Get user profile by driver ID.""" + for user in _USERS.values(): + if user["driver_id"] == driver_id: + return { + "driver_id": user["driver_id"], + "name": user["name"], + "email": user["email"], + "phone": user["phone"], + "city": user["city"], + "vehicle_type": user["vehicle_type"], + "vehicle_number": user["vehicle_number"], + "rating": user["rating"], + "total_trips": user["total_trips"], + "total_earnings": user["total_earnings"], + "avg_hours_per_day": user["avg_hours_per_day"], + "avg_earnings_per_hour": user["avg_earnings_per_hour"], + "experience_months": user["experience_months"], + "shift_preference": user["shift_preference"], + } + return None + + +def list_all_users() -> list: + """List all available users for demo purposes.""" + return [ + { + "username": username, + "name": user["name"], + "city": user["city"], + "rating": user["rating"], + } + for username, user in _USERS.items() + ] diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..d264abe --- /dev/null +++ b/backend/main.py @@ -0,0 +1,490 @@ +""" +DrivePulse Backend β€” FastAPI application. +Serves trip data, events, metrics, goals, and stress tips. +""" + +from fastapi import FastAPI, HTTPException, Query, UploadFile, File +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import PlainTextResponse +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + +from utils.logging import log_info, log_warn + +from data.sample_data import ( + get_trips, get_profile, get_goals, set_goal_target, + build_dashboard, build_weekly_metrics, build_monthly_metrics, + STRESS_TIPS, SITUATIONS, + create_user_trip, add_trip, +) +from data.batch_processor import ( + process_stress_csv, process_earnings_csv, + stress_csv_template, earnings_csv_template, + predict_stress_row, predict_earnings_row, +) +from data.trips_import import import_trips_csv, trips_csv_template +from data.users import ( + login_user, register_user, get_user_profile, list_all_users, +) + +app = FastAPI(title="DrivePulse API", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ── Feedback storage (in-memory) ────────────────────────────────────────── + +_feedback_store: dict = {} + + +class FeedbackPayload(BaseModel): + label: str # "correct" | "incorrect" | "not_relevant" + comment: Optional[str] = None + + +class GoalPayload(BaseModel): + daily_target: float + + +class LoginPayload(BaseModel): + username: str + password: str + + +class RegisterPayload(BaseModel): + username: str + password: str + name: str + email: str + phone: str + city: str + vehicle_type: str + vehicle_number: str + shift_preference: str = "morning" + avg_hours_per_day: float = 7.0 + avg_earnings_per_hour: float = 180 + experience_months: int = 0 + + +class TripCreatePayload(BaseModel): + date: str # YYYY-MM-DD + start_time: str # "HH:MM" (local) or ISO datetime + end_time: str # "HH:MM" (local) or ISO datetime + distance_km: float + fare: float + stress_score: Optional[float] = None # 0-10 (optional) + + +# ── Routes ──────────────────────────────────────────────────────────────── + +@app.get("/api/health") +def health(): + log_info("health check") + return {"status": "ok", "timestamp": datetime.now().isoformat()} + + +# ── Authentication ─────────────────────────────────────────────────────── + +@app.post("/api/auth/login") +def login(payload: LoginPayload): + """Authenticate a driver and return their profile.""" + user = login_user(payload.username, payload.password) + if not user: + raise HTTPException(401, "Invalid username or password") + return user + + +@app.post("/api/auth/register") +def register(payload: RegisterPayload): + """Register a new driver account.""" + driver_data = { + "name": payload.name, + "email": payload.email, + "phone": payload.phone, + "city": payload.city, + "vehicle_type": payload.vehicle_type, + "vehicle_number": payload.vehicle_number, + "shift_preference": payload.shift_preference, + "avg_hours_per_day": payload.avg_hours_per_day, + "avg_earnings_per_hour": payload.avg_earnings_per_hour, + "experience_months": payload.experience_months, + } + result = register_user(payload.username, payload.password, driver_data) + if not result: + raise HTTPException(400, "Username already exists") + return result + + +@app.get("/api/auth/users") +def list_users(): + """List all available demo users.""" + return list_all_users() + + +@app.get("/api/profile") +def profile(): + return get_profile() + + +# ── Dashboard ───────────────────────────────────────────────────────────── + +@app.get("/api/dashboard") +def dashboard(): + trips = get_trips() + goals = get_goals() + return build_dashboard(trips, goals) + + +# ── Trips ───────────────────────────────────────────────────────────────── + +@app.get("/api/trips/template", response_class=PlainTextResponse) +def trips_template(): + """Download a CSV template for trips import.""" + return PlainTextResponse( + content=trips_csv_template(), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=trips_template.csv"}, + ) + + +@app.post("/api/trips") +def create_trip(payload: TripCreatePayload): + """Create a single trip (manual entry) and persist in the in-memory store.""" + try: + date = payload.date + + def _to_iso(dt_or_hhmm: str) -> datetime: + s = (dt_or_hhmm or "").strip() + # Accept ISO datetime if provided + if "T" in s: + return datetime.fromisoformat(s) + # Accept HH:MM, combine with date + hh, mm = s.split(":") + return datetime.fromisoformat(f"{date}T{int(hh):02d}:{int(mm):02d}:00") + + start_dt = _to_iso(payload.start_time) + end_dt = _to_iso(payload.end_time) + if end_dt <= start_dt: + raise HTTPException(400, "end_time must be after start_time") + + duration_min = int(round((end_dt - start_dt).total_seconds() / 60)) + if duration_min <= 0: + raise HTTPException(400, "Trip duration must be at least 1 minute") + + stress_score = payload.stress_score + if stress_score is None: + stress_score = 0.0 + try: + stress_score = float(stress_score) + except Exception: + raise HTTPException(400, "stress_score must be a number between 0 and 10") + if stress_score < 0 or stress_score > 10: + raise HTTPException(400, "stress_score must be between 0 and 10") + + trip = create_user_trip( + date=date, + start_time_iso=start_dt.isoformat(), + end_time_iso=end_dt.isoformat(), + duration_min=duration_min, + distance_km=payload.distance_km, + fare=payload.fare, + stress_score=stress_score, + ) + add_trip(trip) + return trip + except HTTPException: + raise + except Exception as e: + raise HTTPException(400, str(e)) + + +@app.get("/api/trips") +def list_trips( + date: Optional[str] = Query(None, description="YYYY-MM-DD"), + stress: Optional[str] = Query(None, description="high | medium | low | any"), + earnings_min: Optional[float] = Query(None), + earnings_max: Optional[float] = Query(None), + time_of_day: Optional[str] = Query(None, description="morning|afternoon|evening|night"), + duration_min: Optional[int] = Query(None), + duration_max: Optional[int] = Query(None), + preset: Optional[str] = Query(None, description="high_stress|high_earnings|night|short"), +): + trips = get_trips() + + # Presets + if preset == "high_stress": + stress = "high" + elif preset == "high_earnings": + earnings_min = 500 + elif preset == "night": + time_of_day = "night" + elif preset == "short": + duration_max = 15 + + if date: + trips = [t for t in trips if t["date"] == date] + if stress and stress != "any": + trips = [t for t in trips if t["stress_level"] == stress] + if earnings_min is not None: + trips = [t for t in trips if t["fare"] >= earnings_min] + if earnings_max is not None: + trips = [t for t in trips if t["fare"] <= earnings_max] + if duration_min is not None: + trips = [t for t in trips if t["duration_min"] >= duration_min] + if duration_max is not None: + trips = [t for t in trips if t["duration_min"] <= duration_max] + if time_of_day: + def _in_tod(t): + h = datetime.fromisoformat(t["start_time"]).hour + if time_of_day == "morning": + return 5 <= h < 12 + elif time_of_day == "afternoon": + return 12 <= h < 17 + elif time_of_day == "evening": + return 17 <= h < 21 + elif time_of_day == "night": + return h >= 21 or h < 5 + return True + trips = [t for t in trips if _in_tod(t)] + + # Strip heavy data from list view + lite = [] + for t in trips: + lite.append({k: v for k, v in t.items() if k not in ("signals", "route", "events")}) + lite[-1]["events_summary"] = [ + {"label": e["label"], "severity": e["severity"]} + for e in t["events"] + ] + return {"trips": lite, "count": len(lite)} + + +@app.get("/api/trips/{trip_id}") +def get_trip(trip_id: str): + trips = get_trips() + trip = next((t for t in trips if t["id"] == trip_id), None) + if not trip: + raise HTTPException(404, "Trip not found") + # Attach feedback if any + for ev in trip["events"]: + ev["feedback"] = _feedback_store.get(ev["id"]) + return trip + + +@app.get("/api/sample-trip") +def sample_trip(): + """Return a feature-rich sample trip for judges / quick start.""" + trips = get_trips() + # Pick the trip with the most events + best = max(trips, key=lambda t: len(t["events"])) + return best + + +# ── Events & Feedback ───────────────────────────────────────────────────── + +@app.get("/api/trips/{trip_id}/events") +def trip_events(trip_id: str): + trips = get_trips() + trip = next((t for t in trips if t["id"] == trip_id), None) + if not trip: + raise HTTPException(404, "Trip not found") + events = trip["events"] + for ev in events: + ev["feedback"] = _feedback_store.get(ev["id"]) + return {"trip_id": trip_id, "events": events} + + +@app.post("/api/events/{event_id}/feedback") +def post_feedback(event_id: str, payload: FeedbackPayload): + _feedback_store[event_id] = {"label": payload.label, "comment": payload.comment} + return {"status": "ok", "event_id": event_id, "feedback": _feedback_store[event_id]} + + +# ── Goals ───────────────────────────────────────────────────────────────── + +@app.get("/api/goals") +def goals(): + return get_goals() + + +@app.post("/api/goals") +def update_goal(payload: GoalPayload): + return set_goal_target(payload.daily_target) + + +# ── Metrics / Trends ────────────────────────────────────────────────────── + +@app.get("/api/metrics") +def metrics(range: str = Query("7d", description="7d | 30d")): + if range == "30d": + return build_monthly_metrics() + return build_weekly_metrics(get_trips()) + + +# ── Stress Tips ─────────────────────────────────────────────────────────── + +@app.get("/api/tips") +def tips(severity: Optional[str] = Query(None)): + import random as _r + selected = _r.sample(STRESS_TIPS, min(3, len(STRESS_TIPS))) + return {"tips": selected} + + +# ── Situations reference ────────────────────────────────────────────────── + +@app.get("/api/situations") +def situations(): + return SITUATIONS + + +# ── Batch CSV Upload ────────────────────────────────────────────────────── + +@app.post("/api/batch/stress") +async def batch_stress(file: UploadFile = File(...)): + """Upload a CSV of sensor windows β†’ get stress predictions for all rows.""" + content = (await file.read()).decode("utf-8") + if not content.strip(): + raise HTTPException(400, "Empty file") + result = process_stress_csv(content) + if "error" in result and result["error"]: + raise HTTPException(400, result["error"]) + return result + + +@app.post("/api/batch/earnings") +async def batch_earnings(file: UploadFile = File(...)): + """Upload a CSV of trip/earnings data β†’ get velocity predictions for all rows.""" + content = (await file.read()).decode("utf-8") + if not content.strip(): + raise HTTPException(400, "Empty file") + result = process_earnings_csv(content) + if "error" in result and result["error"]: + raise HTTPException(400, result["error"]) + return result + + +@app.get("/api/batch/template/stress", response_class=PlainTextResponse) +def stress_template(): + """Download a CSV template for stress batch upload.""" + return PlainTextResponse( + content=stress_csv_template(), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=stress_template.csv"}, + ) + + +@app.get("/api/batch/template/earnings", response_class=PlainTextResponse) +def earnings_template(): + """Download a CSV template for earnings batch upload.""" + return PlainTextResponse( + content=earnings_csv_template(), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=earnings_template.csv"}, + ) + + +# ── Trips CSV Import (creates trips) ─────────────────────────────────────── + +@app.post("/api/trips/import-csv") +async def import_trips(file: UploadFile = File(...)): + """Upload a Trips CSV and persist rows as trips (in-memory).""" + content = (await file.read()).decode("utf-8") + if not content.strip(): + raise HTTPException(400, "Empty file") + result = import_trips_csv(content) + if "error" in result and result["error"]: + raise HTTPException(400, result["error"]) + return result + + +# ── Single-row manual prediction ────────────────────────────────────────── + +@app.post("/api/predict/stress") +def predict_stress(payload: dict): + """Submit a single row of sensor features β†’ get stress prediction.""" + try: + # Add constant features + payload.setdefault("motion_std", 0.3) + payload.setdefault("z_dev_max", 0.5) + payload.setdefault("spikes_above3", 0) + payload.setdefault("spikes_above5", 0) + payload.setdefault("audio_class_max", 2.0) + payload.setdefault("audio_class_mean", 1.0) + payload.setdefault("sustained_max", 10.0) + payload.setdefault("sustained_sum", 50.0) + payload.setdefault("cadence_var_max", 0.6) + payload.setdefault("audio_leads_motion", 0.0) + payload.setdefault("audio_onset_sec", 0.0) + payload.setdefault("brake_t_sec", 0.0) + payload.setdefault("is_low_speed", 0) + payload.setdefault("both_elevated", 0) + payload.setdefault("audio_only", 0) + result = predict_stress_row(payload) + return result + except Exception as e: + raise HTTPException(400, str(e)) + + +@app.post("/api/predict/earnings") +def predict_earnings(payload: dict): + """Submit a single row of earnings features β†’ get velocity prediction.""" + try: + result = predict_earnings_row(payload) + return result + except Exception as e: + raise HTTPException(400, str(e)) + + +@app.get("/api/features/stress") +def stress_features(): + """Return the list of stress model input features with defaults.""" + features = [ + {"name": "motion_max", "label": "Motion Max (g)", "default": 1.5, "group": "Motion"}, + {"name": "motion_mean", "label": "Motion Mean (g)", "default": 0.8, "group": "Motion"}, + {"name": "motion_p95", "label": "Motion P95 (g)", "default": 1.2, "group": "Motion"}, + {"name": "brake_intensity", "label": "Brake Intensity", "default": 0.5, "group": "Motion"}, + {"name": "lateral_max", "label": "Lateral Max (g)", "default": 0.8, "group": "Motion"}, + {"name": "speed_mean", "label": "Speed Mean (km/h)", "default": 30.0, "group": "Speed"}, + {"name": "speed_at_brake", "label": "Speed at Brake (km/h)", "default": 25.0, "group": "Speed"}, + {"name": "speed_drop", "label": "Speed Drop (km/h)", "default": 5.0, "group": "Speed"}, + {"name": "audio_db_max", "label": "Audio dB Max", "default": 65.0, "group": "Audio"}, + {"name": "audio_db_mean", "label": "Audio dB Mean", "default": 55.0, "group": "Audio"}, + {"name": "audio_db_p90", "label": "Audio dB P90", "default": 62.0, "group": "Audio"}, + {"name": "audio_db_std", "label": "Audio dB Std", "default": 5.0, "group": "Audio"}, + {"name": "cadence_var_mean", "label": "Cadence Var Mean", "default": 0.3, "group": "Voice"}, + {"name": "argument_frac", "label": "Argument Fraction", "default": 0.0, "group": "Voice"}, + {"name": "loud_frac", "label": "Loud Fraction", "default": 0.1, "group": "Voice"}, + ] + return {"features": features, "total": len(features)} + + +@app.get("/api/features/earnings") +def earnings_features(): + """Return the list of earnings model input features with defaults.""" + features = [ + {"name": "avg_earnings_per_hour", "label": "Avg Earnings/hr (β‚Ή)", "default": 250.0, "group": "Earnings"}, + {"name": "experience_months", "label": "Experience (months)", "default": 12, "group": "Driver"}, + {"name": "rating", "label": "Rating (1-5)", "default": 4.5, "group": "Driver"}, + {"name": "target_earnings", "label": "Target Earnings (β‚Ή)", "default": 2000.0, "group": "Earnings"}, + {"name": "remaining_earnings", "label": "Remaining Earnings (β‚Ή)", "default": 1200.0, "group": "Earnings"}, + {"name": "remaining_hours", "label": "Remaining Hours", "default": 5.0, "group": "Time"}, + {"name": "required_velocity", "label": "Required Velocity (β‚Ή/hr)", "default": 240.0, "group": "Earnings"}, + {"name": "trips_completed", "label": "Trips Completed", "default": 5, "group": "Trip"}, + {"name": "trip_rate", "label": "Trip Rate (trips/hr)", "default": 2.0, "group": "Trip"}, + {"name": "hour_of_day", "label": "Hour of Day (0-23)", "default": 14, "group": "Time"}, + {"name": "is_morning_rush", "label": "Morning Rush (0/1)", "default": 0, "group": "Time"}, + {"name": "is_lunch_rush", "label": "Lunch Rush (0/1)", "default": 0, "group": "Time"}, + {"name": "is_evening_rush", "label": "Evening Rush (0/1)", "default": 0, "group": "Time"}, + {"name": "current_velocity", "label": "Current Velocity (β‚Ή/hr)", "default": 200.0, "group": "Earnings"}, + ] + return {"features": features, "total": len(features)} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..fcec658 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.104 +uvicorn>=0.24 +pydantic>=2.0 +python-multipart>=0.0.6 +numpy>=1.24.0 +pandas>=2.0.0 +scikit-learn>=1.3.0 +joblib>=1.3.0 +python-dateutil>=2.8 \ No newline at end of file diff --git a/backend/utils/logging.py b/backend/utils/logging.py new file mode 100644 index 0000000..661fdb5 --- /dev/null +++ b/backend/utils/logging.py @@ -0,0 +1,20 @@ +""" +Very small logging helper for the demo backend. +In a real deployment this is where structured logging or APM hooks would live. +""" + +from datetime import datetime + + +def _ts() -> str: + return datetime.now().isoformat() + + +def log_info(message: str) -> None: + print(f"[info] { _ts() } {message}") + + +def log_warn(message: str) -> None: + print(f"[warn] { _ts() } {message}") + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9a0de62 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.9" + +services: + backend: + build: + context: . + dockerfile: Dockerfile.backend + container_name: drivepulse-backend + ports: + - "8000:8000" + + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + container_name: drivepulse-frontend + ports: + - "5173:80" + depends_on: + - backend + diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md new file mode 100644 index 0000000..c5631a3 --- /dev/null +++ b/docs/PROGRESS_LOG.md @@ -0,0 +1,27 @@ +# Progress Log (Development History) + +A chronological log of our problem-solving process and major iterations. + + +--- + +**Mar 4:** Project kickoff. Team discussion on requirements, user persona, and value proposition. Decided on stress + earnings as dual focus. + +**Mar 5:** Finalized tech stack (FastAPI, React, scikit-learn). Planned architecture and repo structure. Set up the project repository and initial folder structure. + +**Mar 6:** Backend development: implemented API skeleton (trips, goals, dashboard), created synthetic trip data generator, and batch processor. + +**Mar 7:** Backend ML: built and trained initial stress detection (RF, HAL, Platt calibration) and earnings velocity models. Added endpoints for batch inference and CSV import. + +**Mar 8:** Frontend development: built dashboard, trip list, trip detail, earnings progress bar. Integrated backend APIs. Added batch upload and explainability features. + +**Mar 9:** Documentation: wrote design doc (HTML), architecture diagram (SVG), and progress log. Re-checked all features, tested flows, and made final edits based on feedback. Prepared for deployment. + +**Mar 10:** Deployment and submission: added Dockerised deployment (FastAPI + React via Nginx, `docker-compose.yml` at repo root), verified end-to-end flows in containers, and documented judge login plus Docker run instructions in all README/docs before final submission. + +--- + +**Outcome:** +Successfully built and deployed DrivePulse β€” a driver safety and earnings intelligence platform that transforms raw trip signals into actionable insights. The system combines real-time stress detection with earnings velocity forecasting while maintaining privacy through on-device processing. All core features (stress detection, earnings forecasting, explainability tools, batch testing, and feedback collection) were implemented as planned. Documentation, architecture diagrams, and progress logs were completed and the project was submitted on time. + +--- \ No newline at end of file diff --git a/docs/architecture_explanation.html b/docs/architecture_explanation.html new file mode 100644 index 0000000..88a0b2d --- /dev/null +++ b/docs/architecture_explanation.html @@ -0,0 +1,327 @@ + + + + + + DrivePulse β€” System Architecture + + + + + +
+

πŸš— DrivePulse

+

System Architecture

+
UBER SHE++ HACKATHON 2026
+
MerinRishitLavisha
+
+ + + +
+ + +
+

01 Architecture Diagram

+

End-to-end data flow: sensors β†’ on-device processing β†’ server β†’ driver insights β†’ feedback loop.

+ +
+ + + + + + + ON-DEVICE (EDGE) + + + OFF-DEVICE (SERVER) + + + FEEDBACK LOOP + + + + πŸ“± Accelerometer + 10 Hz x/y/z motion + + + 🎀 Microphone + dB envelope only + + + πŸ“ GPS + Speed + km/h, lat/lng + + + πŸ’° Trip Earnings + β‚Ή per trip, hours + + + + + + + + + HAL Calibration + Device normalisation + AGC + road baseline + + + + + + + Feature Extraction + 30s windows, Z-score norm + ~12 stress / 14 earnings feats + + + + + + + + + + Local ML Inference + RF Classifier (stress) Β· <1ms + RF Regressor (earnings) + + + + REST + + + + + + + πŸ”” Driver Alerts + Real-time, on-device + + + + πŸ“Š Driver Dashboard + Glanceable UI + + + + + + + FastAPI Backend + 25 REST endpoints + Auth, CORS, validation + + + + Data Store + In-memory (MVP) + Trips, goals, events + + + + + Batch Processor + CSV upload β†’ inference + Stress + Earnings models + + + + + Model Artifacts + rf_model.pkl (Γ—2) + baselines, contracts + + + + + Aggregation Engine + Dashboard summaries + Trends, goal tracking + + + + + Driver Web App (React) + 8 pages, 16 components + Maps, charts, modals + + + + + + + πŸ‘πŸ‘Ž Feedback + Collected per event + + + + + πŸ”„ Retrain Loop + Future: feedback β†’ model + + + + + + + + + + + + DrivePulse β€” End-to-End Data Flow + + +

Right-click β†’ "Save image as" or screenshot this diagram to export as PNG.

+
+
+ + +
+

02 Architecture Explanation

+

Design decisions and engineering trade-offs.

+ +

Real-Time vs. Post-Trip Processing

+ + +

Connectivity & Resilience

+ + +

Battery & Resource Management

+ + +

Privacy & Data Minimisation

+ + +

Key Trade-offs

+
+ + + + + + +
DecisionGainedGave Up
On-device inferenceZero-latency alerts, offline supportCan't use large models or cross-driver patterns
RandomForest over DLLightweight, interpretable, CPU-onlyLower accuracy on complex temporal patterns
dB envelope over raw audioPrivacy, low CPU, no storageNo speech-to-text or fine-grained analysis
In-memory store over DBZero setup for judgesNo persistence across restarts
Feedback collected, not loopedSimpler systemModel doesn't self-improve (yet)
+ +
+ + +
+

03 Deployment & Docker

+

How a judge runs the full system in minutes.

+ +
+ Hackathon requirement: Include a working Dockerfile and docker-compose.yml + so a judge can run the entire stack with a single command. DrivePulse ships two services + (FastAPI backend + React frontend) wired together behind Nginx. +
+ +

Containers

+ + +

Judge Runbook

+
    +
  1. Prerequisite: Install Docker Desktop and ensure it is running.
  2. +
  3. Clone repo: git clone ... && cd Driver-Pulse
  4. +
  5. One command: + docker compose up --build from the repo root (Driver-Pulse/).
  6. +
  7. Open app: visit http://localhost:5173 in the browser + (no manual dependency installation required).
  8. +
+ +
+ Judge credentials: we provide a pre-populated demo account.
+ Username: judge@uber.com Β· Password: hackathon2026. +
+ +
+ +
+ + + + diff --git a/docs/architecture_image.png b/docs/architecture_image.png new file mode 100644 index 0000000..c1bf0ab Binary files /dev/null and b/docs/architecture_image.png differ diff --git a/docs/design.html b/docs/design.html new file mode 100644 index 0000000..7338047 --- /dev/null +++ b/docs/design.html @@ -0,0 +1,321 @@ + + + + + + DrivePulse β€” Design Document + + + + + +
+

πŸš— DrivePulse

+

Design Document

+
UBER SHE++ HACKATHON 2026
+
MerinRishitLavisha
+
+ + + +
+ + +
+

01 Product Vision

+

Who it's for, what it does, and why it's built this way.

+ +

User Persona

+

+ Full-time ride-hailing driver in an Indian metro city (Bangalore in our demo). + Drives 8–10 hours daily. Cares about two things: staying safe on chaotic roads and + hitting a daily earnings target. Not tech-savvy β€” needs information delivered at a glance, + not buried in charts. +

+ +

Core Value Proposition

+

+ DrivePulse transforms raw trip signals β€” accelerometer, speed, audio dB β€” into two actionable insights: +

+
    +
  1. Safety alerts β€” Detects dangerous situations (hard braking + loud cabin, escalating conflicts) in real-time and tells the driver why each event was flagged.
  2. +
  3. Earnings forecasting β€” Predicts earnings velocity (β‚Ή/hr), shows whether the driver is on track to hit their daily target, and estimates time-to-goal.
  4. +
+ +

The "Glanceable" Test

+

+ A driver can't read a dense dashboard while navigating traffic. Every screen in DrivePulse is designed to pass the 2-second glance test: +

+ + +
+ Design principle: The driver should never have to interpret raw numbers. + Every metric is translated into a status (ahead / on_track / at_risk), a colour (green / yellow / red), + or a plain-language message. Detailed signal charts and explainability modals exist for the + Trip Detail page β€” but they're opt-in, not the default view. +
+
+ + +
+

02 Stress Detection Algorithm

+

Lightweight signal processing, sensor fusion, and false-positive reduction.

+ +

Pipeline

+
+
Raw Sensors10 Hz
β†’
+
HALDevice Norm
β†’
+
30s Window~12 features
β†’
+
Z-ScoreBaseline ΞΌ/Οƒ
β†’
+
RF Classify→ 7 classes
β†’
+
Platt Cal→ true prob
+
+ +

Cleaning Noisy Motion Data (HAL)

+

Phone sensors are noisy and device-dependent. Our Hardware Abstraction Layer runs a two-phase calibration:

+ +

After HAL, accelerometer output = net motion above road vibration, audio = net cabin noise above ambient. Device-agnostic.

+ +

Interpreting Audio Intensity

+

Audio features are acoustic aggregates only β€” no raw audio stored (privacy by design). Key features per 30s window:

+ +

The insight: arguments have irregular cadence + sustained loudness, while music has regular cadence + steady levels. These two features alone separate ARGUMENT from MUSIC_OR_CALL well.

+ +

Multi-Sensor Fusion

+

Dangerous situations are rarely single-sensor. The key fusion features:

+ + +

Reducing False Positives

+
    +
  1. Platt calibration: Sigmoid recalibration so 85% confidence β‰ˆ 85% true probability. Alerts only on HIGH (β‰₯75%).
  2. +
  3. Confidence gating: If top-class confidence <50%, system defaults to NORMAL β€” no alert.
  4. +
  5. Class weighting: CONFLICT and ESCALATING weighted 3Γ— during training (favour recall on dangerous events); Platt step corrects the resulting overconfidence.
  6. +
  7. Rule-based fallback: If model files are missing, simple thresholds (motion >3.5g + audio >80 dB β†’ CONFLICT) ensure the app never crashes.
  8. +
+ +

Output

+

Per 30s window: situation class (7 classes), calibrated confidence (HIGH / MEDIUM / LOW), top feature deviations (for explainability), safety flag, <1ms inference on CPU.

+
+ + +
+

03 Earnings Velocity Algorithm

+

Current speed, forecasting, and cold-start handling.

+ +

Pipeline

+
+
Driver Statetrips, hours, β‚Ή
β†’
+
Feature Eng14 features
β†’
+
RF Regress300 trees
β†’
+
β‚Ή/hr + Status+ probability
+
+ +

Current Earnings Speed

+

Earnings velocity = cumulative earnings Γ· elapsed hours (β‚Ή/hr). Required velocity = remaining earnings Γ· remaining hours. The gap between the two gives an instant "am I on track?" signal.

+ +

Feature Engineering (14 features)

+
+ + + + + +
GroupFeaturesWhy
Currentcurrent_velocity, elapsed_hours, trips_completed, trip_rateHow is the driver doing right now?
Contexthour_of_day, is_morning/lunch/evening_rushDemand varies by time of day
Historyvelocity_last_1/2/3, rolling_velocity_3/5Recent trend (momentum)
Goalgoal_pressure (target βˆ’ current velocity)How urgently driver needs to speed up
+ +

Forecasting

+

RF regressor predicts next-period velocity. From that we derive:

+ + +

Handling Cold Start

+

First 15 min of a shift β€” no velocity lags, unstable trip rate, no rolling averages.

+ + +
+ Edge case: A driver with a β‚Ή5,000 target and 2 hours left sees required = β‚Ή2,500/hr β€” clearly unreachable. + The model predicts honestly; the dashboard shows πŸ”΄ at_risk. We don't hide bad news. +
+
+ + +
+

04 Execution Strategy

+

MVP scope, phased build, and the cut line.

+ +

MVP Scope

+
+ + + + + + +
LayerWhat's included
ML β€” StressLightweight RF classifier, ~12 key features, 7 classes, Platt calibration, HAL, rule-based fallback
ML β€” EarningsRF regressor, 14 features, velocity prediction, 5 derived goal metrics
BackendFastAPI (25 endpoints), in-memory store, batch processor, CSV import, auth
FrontendReact SPA (8 pages, 16 components), maps, charts, explainability modals
Judge toolsPredict page (single-row testing), Batch Upload (CSV inference + charts)
+ +

Phased Build

+
    +
  1. Data & Models β€” Feature engineering, train both RF models, validate with cross-validation.
  2. +
  3. Backend API β€” FastAPI with core endpoints, in-memory data store, batch inference.
  4. +
  5. Frontend Core β€” Dashboard, Trips, Trip Detail with map + charts, auth flow.
  6. +
  7. Polish & Judge Tools β€” Predict, Batch Upload, explainability, Trends, Goals.
  8. +
  9. Docs & Deploy β€” Design doc, architecture diagram, README, deployment.
  10. +
+ +

The Cut Line

+
+
+

What we cut:

+
    +
  • Real-time phone sensor streaming (simulated with synthetic data)
  • +
  • Persistent database (in-memory = zero-setup for judges)
  • +
  • Push notifications / WebSockets
  • +
  • Model retraining from feedback
  • +
  • Payment integration / real fare calculation
  • +
+
+
+

Why:

+
    +
  • Each adds setup complexity without demonstrating core algorithm thinking
  • +
  • Architecture supports a DB β€” we just don't require one to run
  • +
  • Feedback collection is built; retraining is a production concern
  • +
  • Time focused on model quality + explainability + UX
  • +
+
+
+ +

Key Trade-offs

+
+ + + + +
ChoiceGainedGave Up
RandomForest over deep learningLightweight, interpretable, CPU-only, <1ms inferencePotentially lower accuracy on complex patterns
In-memory store over DBZero setup, instant startupNo persistence across restarts
Synthetic sensor dataFull control over class balanceMay not generalise to real sensors
+
+ + +
+

05 Judge Experience & Docker

+

How we designed the repo and Docker setup so judges can run DrivePulse in a few minutes.

+ +

Zero-setup runtime

+

To avoid asking judges to install Python, Node, or ML libraries locally, we ship a fully containerised stack:

+ + +

Judge run instructions

+

We repeat the exact steps (as required by the hackathon) in the root README.md and docs:

+
    +
  1. Install Docker Desktop and ensure it is running.
  2. +
  3. git clone ... && cd Driver-Pulse
  4. +
  5. From the repo root run: docker compose up --build
  6. +
  7. Visit http://localhost:5173 to use the app; optional backend health at http://localhost:8000/api/health.
  8. +
+ +
+ Judge login: we provide a pre-configured demo account so judges land in a realistic dashboard immediately.
+ Username: judge@uber.com Β· Password: hackathon2026. +
+ +
+ This approach satisfies the hackathon's Docker criterion: a working Dockerfile and docker-compose.yml + at the repo root, with a single docker compose up --build command to start the entire application. +
+
+ +
+ + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..011ae1e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + DrivePulse β€” Driver Wellness & Earnings + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..2ad91dd --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3105 @@ +{ + "name": "drivepulse-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "drivepulse-frontend", + "version": "1.0.0", + "dependencies": { + "date-fns": "^3.2.0", + "leaflet": "^1.9.4", + "lucide-react": "^0.302.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1", + "react-router-dom": "^6.21.0", + "recharts": "^2.10.0" + }, + "devDependencies": { + "@types/leaflet": "^1.9.8", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "vite": "^5.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "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.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "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.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "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.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "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/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "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/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@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.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "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/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "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/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "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/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "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/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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", + "peer": true + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "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.302.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.302.0.tgz", + "integrity": "sha512-JZX+1fjpqxvQmEgItvPOAwRlqf0Eg9dSZMxljA2/V2M6dluOhQCPBhewIlSJWgkNu0M36kViOgmTAMnDaAMOFw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "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.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "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/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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", + "peer": true, + "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", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "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/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "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.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.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/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "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/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0bd8f65 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "drivepulse-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0", + "recharts": "^2.10.0", + "react-leaflet": "^4.2.1", + "leaflet": "^1.9.4", + "lucide-react": "^0.302.0", + "date-fns": "^3.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.0.0", + "tailwindcss": "^3.4.0", + "postcss": "^8.4.32", + "autoprefixer": "^10.4.16", + "@types/leaflet": "^1.9.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..d9d41b4 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,64 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import { useState, useEffect } from 'react' +import Layout from './components/Layout' +import Dashboard from './pages/Dashboard' +import Trips from './pages/Trips' +import TripDetail from './pages/TripDetail' +import Trends from './pages/Trends' +import Goals from './pages/Goals' +import BatchUpload from './pages/BatchUpload' +import Predict from './pages/Predict' +import Home from './pages/Home' + +export default function App() { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + // Check if user is already logged in + const storedUser = localStorage.getItem('user') + if (storedUser) { + try { + setUser(JSON.parse(storedUser)) + } catch (e) { + localStorage.removeItem('user') + } + } + setLoading(false) + }, []) + + const handleLoginSuccess = (userData) => { + setUser(userData) + } + + const handleLogout = () => { + setUser(null) + localStorage.removeItem('user') + } + + if (loading) { + return ( +
+
+
+ ) + } + + if (!user) { + return + } + + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/frontend/src/__tests__/EarningsProgress.test.jsx b/frontend/src/__tests__/EarningsProgress.test.jsx new file mode 100644 index 0000000..cce50bd --- /dev/null +++ b/frontend/src/__tests__/EarningsProgress.test.jsx @@ -0,0 +1,32 @@ +// NOTE: Illustrative test only – runner not wired. +// Shows how EarningsProgress could be tested with a real test setup. + +import EarningsProgress from '../components/EarningsProgress' + +const mockGoals = { + daily_target: 1800, + current_earnings: 900, + current_velocity: 200, + required_velocity: 225, + goal_probability: 0.75, + current_hours: 4.5, + target_hours: 10, + trips_completed: 6, + forecast_status: 'on_track', +} + +// Pseudo-test demonstrating expected derived values. +function exampleUsage() { + const pct = Math.round((mockGoals.current_earnings / mockGoals.daily_target) * 100) + const remaining = mockGoals.daily_target - mockGoals.current_earnings + + // In a real test you might assert: + // expect(screen.getByText(`β‚Ή${mockGoals.current_earnings.toLocaleString()}`)).toBeInTheDocument() + // expect(screen.getByText(`${pct}% achieved`)).toBeInTheDocument() + // expect(screen.getByText(`β‚Ή${remaining.toLocaleString()} remaining`)).toBeInTheDocument() + + return { pct, remaining } +} + +export { EarningsProgress, mockGoals, exampleUsage } + diff --git a/frontend/src/__tests__/TripsAddTrip.test.jsx b/frontend/src/__tests__/TripsAddTrip.test.jsx new file mode 100644 index 0000000..648a327 --- /dev/null +++ b/frontend/src/__tests__/TripsAddTrip.test.jsx @@ -0,0 +1,43 @@ +// NOTE: Illustrative test only – runner not wired. +// Shows intended behaviour for adding trips vs goals aggregation. + +import { isValidTimeRange } from '../utils/sanityChecks' + +// Minimal shapes copied from backend logic for explanation purposes only. +const TODAY = '2026-03-08' + +function goalsFromTrips(trips, goals) { + const todayTrips = trips.filter(t => t.date === TODAY) + const current_earnings = todayTrips.reduce((sum, t) => sum + (t.fare || 0), 0) + const current_hours = todayTrips.reduce((sum, t) => sum + (t.duration_min || 0), 0) / 60 + + return { + ...goals, + current_earnings, + current_hours, + } +} + +// Pseudo-test: adding a trip on a non-today date should NOT affect today's goals. +function exampleTripBehaviour() { + const baseGoals = { daily_target: 1800, current_earnings: 1000, current_hours: 5 } + + const trips = [ + { id: 't1', date: TODAY, fare: 500, duration_min: 30 }, + { id: 't2', date: TODAY, fare: 500, duration_min: 30 }, + ] + + const withTodayGoals = goalsFromTrips(trips, baseGoals) + + const newTripOtherDay = { id: 't3', date: '2026-03-07', fare: 400, duration_min: 40 } + const withOtherDayTripGoals = goalsFromTrips([...trips, newTripOtherDay], baseGoals) + + // In a real test you might assert: + // expect(withOtherDayTripGoals.current_earnings).toBe(withTodayGoals.current_earnings) + // expect(withOtherDayTripGoals.current_hours).toBe(withTodayGoals.current_hours) + + return { withTodayGoals, withOtherDayTripGoals } +} + +export { isValidTimeRange, goalsFromTrips, exampleTripBehaviour } + diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..887e60b --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,82 @@ +// Default to relative /api for local dev and Docker. +// In hosted environments (Vercel/Netlify), set VITE_API_BASE to your backend URL. +const BASE = import.meta.env.VITE_API_BASE || '/api'; + +// Single place to plug in auth headers, retries, error normalisation, etc. +async function request(path, options = {}) { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json', ...options.headers }, + ...options, + }); + if (!res.ok) { + const message = `API ${res.status}: ${res.statusText}`; + const error = new Error(message); + error.status = res.status; + throw error; + } + return res.json(); +} + +export const api = { + // Authentication + login: (username, password) => + request('/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }), + + register: (userData) => + request('/auth/register', { + method: 'POST', + body: JSON.stringify(userData), + }), + + listUsers: () => request('/auth/users'), + + // Dashboard + getDashboard: () => request('/dashboard'), + getProfile: () => request('/profile'), + + // Trips + getTrips: (params = {}) => { + const qs = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') qs.set(k, v); + }); + const q = qs.toString(); + return request(`/trips${q ? '?' + q : ''}`); + }, + getTrip: (id) => request(`/trips/${id}`), + getSampleTrip: () => request('/sample-trip'), + createTrip: (payload) => + request('/trips', { method: 'POST', body: JSON.stringify(payload) }), + importTripsCsv: async (file) => { + const formData = new FormData() + formData.append('file', file) + const res = await fetch(`${BASE}/trips/import-csv`, { method: 'POST', body: formData }) + if (!res.ok) { + const text = await res.text() + throw new Error(text || `Import failed (${res.status})`) + } + return res.json() + }, + + // Events + getTripEvents: (tripId) => request(`/trips/${tripId}/events`), + postFeedback: (eventId, label, comment = null) => + request(`/events/${eventId}/feedback`, { + method: 'POST', + body: JSON.stringify({ label, comment }), + }), + + // Goals + getGoals: () => request('/goals'), + setGoal: (daily_target) => + request('/goals', { method: 'POST', body: JSON.stringify({ daily_target }) }), + + // Metrics + getMetrics: (range = '7d') => request(`/metrics?range=${range}`), + + // Tips + getTips: () => request('/tips'), +}; diff --git a/frontend/src/components/ConfidenceBadge.jsx b/frontend/src/components/ConfidenceBadge.jsx new file mode 100644 index 0000000..9fe323c --- /dev/null +++ b/frontend/src/components/ConfidenceBadge.jsx @@ -0,0 +1,15 @@ +const levelConfig = { + low: { bg: 'bg-green-100', text: 'text-green-800', label: 'Low' }, + medium: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Med' }, + high: { bg: 'bg-red-100', text: 'text-red-800', label: 'High' }, +} + +export default function ConfidenceBadge({ level, score }) { + const cfg = levelConfig[level] || levelConfig.low + return ( + + {cfg.label} + {score !== undefined && {Math.round(score * 100)}%} + + ) +} diff --git a/frontend/src/components/EarningsProgress.jsx b/frontend/src/components/EarningsProgress.jsx new file mode 100644 index 0000000..2b05641 --- /dev/null +++ b/frontend/src/components/EarningsProgress.jsx @@ -0,0 +1,104 @@ +export default function EarningsProgress({ goals }) { + if (!goals) return null + + const currentEarnings = Number(goals.current_earnings || 0) + const dailyTarget = Number(goals.daily_target || 0) + const safeTarget = dailyTarget > 0 ? dailyTarget : 0 + const pct = safeTarget > 0 + ? Math.min(100, Math.round((currentEarnings / safeTarget) * 100)) + : 0 + + const statusColor = { + ahead: 'text-uber-green', + on_track: 'text-uber-blue', + at_risk: 'text-uber-red', + }[goals.forecast_status] || 'text-uber-gray-500' + + const prettyStatus = + typeof goals.forecast_status === 'string' + ? goals.forecast_status.replace('_', ' ') + : 'on track' + + return ( +
+
+

Daily Earnings Target

+ + {goals.forecast_status?.replace('_', ' ')} + +
+ +
+ β‚Ή{currentEarnings.toLocaleString()} + / β‚Ή{safeTarget.toLocaleString()} +
+ + {/* Progress bar */} +
+
+
+
+ {pct}% achieved + β‚Ή{Math.max(0, safeTarget - currentEarnings).toLocaleString()} remaining +
+ + {/* Extra stats */} +
+
+

β‚Ή{goals.current_velocity}

+

Current β‚Ή/hr

+
+
+

β‚Ή{goals.required_velocity}

+

Required β‚Ή/hr

+
+
+

+ {Number.isFinite(goals.goal_probability) + ? Math.round(goals.goal_probability * 100) + : 0} + % +

+

Probability

+
+
+ + {/* Details grid */} +
+
+

Remaining

+

β‚Ή{Math.max(0, safeTarget - currentEarnings).toLocaleString()}

+

to reach target

+
+
+

Time Worked

+

{goals.current_hours}h

+

of {goals.target_hours}h target

+
+
+

Trips Today

+

{goals.trips_completed}

+

completed

+
+
+

Status

+

+ {prettyStatus.charAt(0).toUpperCase() + prettyStatus.slice(1)} +

+

today's pace

+
+
+
+ ) +} diff --git a/frontend/src/components/EventCard.jsx b/frontend/src/components/EventCard.jsx new file mode 100644 index 0000000..be8cab2 --- /dev/null +++ b/frontend/src/components/EventCard.jsx @@ -0,0 +1,70 @@ +import { useState } from 'react' +import ConfidenceBadge from './ConfidenceBadge' +import FeedbackButtons from './FeedbackButtons' +import ExplainModal from './ExplainModal' +import { Info, MapPin } from 'lucide-react' + +export default function EventCard({ event, onJumpTo, onFeedback }) { + const [showExplain, setShowExplain] = useState(false) + + const severityColor = { + low: 'border-l-uber-green', + medium: 'border-l-uber-yellow', + high: 'border-l-uber-red', + }[event.severity] || 'border-l-uber-gray-300' + + const time = event.timestamp + ? new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + : `+${Math.round(event.offset_sec / 60)}m` + + return ( + <> +
+
+
+
+ {event.emoji} + {event.label.replace(/_/g, ' ')} + +
+

{time}

+ {event.explain?.summary && ( +

{event.explain.summary}

+ )} +
+ +
+ + {onJumpTo && ( + + )} +
+
+ +
+ +
+
+ + {showExplain && ( + setShowExplain(false)} /> + )} + + ) +} diff --git a/frontend/src/components/ExplainModal.jsx b/frontend/src/components/ExplainModal.jsx new file mode 100644 index 0000000..2670510 --- /dev/null +++ b/frontend/src/components/ExplainModal.jsx @@ -0,0 +1,73 @@ +import { X } from 'lucide-react' + +export default function ExplainModal({ event, onClose }) { + const { explain } = event + if (!explain) return null + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

Why This Happened

+

+ {event.emoji} {event.label.replace(/_/g, ' ')} +

+
+ +
+ + {/* Top 3 feature contributions */} +
+

Top Feature Contributions

+
+ {explain.top_features?.map((f, i) => { + const pct = Math.round(f.contribution * 100) + const barColor = i === 0 ? 'bg-uber-red' : i === 1 ? 'bg-uber-orange' : 'bg-uber-yellow' + return ( +
+
+ + {f.feature.replace(/_/g, ' ')} {f.direction} + + {pct}% +
+
+
+
+
+ ) + })} +
+
+ + {/* Model inputs */} +
+

Model Inputs (snapshot)

+
+ {explain.model_inputs && + Object.entries(explain.model_inputs).map(([k, v]) => ( +
+ {k.replace(/_/g, ' ')} + {v} +
+ ))} +
+
+ + {/* Summary */} + {explain.summary && ( +
+

{explain.summary}

+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/FeedbackButtons.jsx b/frontend/src/components/FeedbackButtons.jsx new file mode 100644 index 0000000..d705f50 --- /dev/null +++ b/frontend/src/components/FeedbackButtons.jsx @@ -0,0 +1,47 @@ +import { useState } from 'react' +import { ThumbsUp, ThumbsDown, MinusCircle } from 'lucide-react' +import { api } from '../api/client' + +const options = [ + { key: 'correct', label: 'Correct', icon: ThumbsUp, color: 'text-uber-green hover:bg-green-50' }, + { key: 'incorrect', label: 'Incorrect', icon: ThumbsDown, color: 'text-uber-red hover:bg-red-50' }, + { key: 'not_relevant', label: 'Not Relevant', icon: MinusCircle, color: 'text-uber-gray-500 hover:bg-uber-gray-100' }, +] + +export default function FeedbackButtons({ eventId, current, onFeedback }) { + const [selected, setSelected] = useState(current || null) + const [sending, setSending] = useState(false) + + const handleClick = async (label) => { + if (sending) return + setSending(true) + try { + await api.postFeedback(eventId, label) + setSelected(label) + if (onFeedback) onFeedback(eventId, label) + } catch { + // silent + } finally { + setSending(false) + } + } + + return ( +
+ Feedback: + {options.map(({ key, label, icon: Icon, color }) => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/FilterChips.jsx b/frontend/src/components/FilterChips.jsx new file mode 100644 index 0000000..b079c0d --- /dev/null +++ b/frontend/src/components/FilterChips.jsx @@ -0,0 +1,27 @@ +const presets = [ + { key: 'high_stress', label: 'High Stress', emoji: 'πŸ”΄' }, + { key: 'high_earnings', label: 'High Earnings', emoji: 'πŸ’°' }, + { key: 'night', label: 'Night Trips', emoji: 'πŸŒ™' }, + { key: 'short', label: 'Short Trips', emoji: '⚑' }, +] + +export default function FilterChips({ active, onSelect }) { + return ( +
+ {presets.map(p => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..db796d4 --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router-dom' +import Sidebar from './Sidebar' + +export default function Layout({ user, onLogout }) { + return ( +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/components/SampleTripCard.jsx b/frontend/src/components/SampleTripCard.jsx new file mode 100644 index 0000000..ea9fd45 --- /dev/null +++ b/frontend/src/components/SampleTripCard.jsx @@ -0,0 +1,40 @@ +import { useNavigate } from 'react-router-dom' +import { Play } from 'lucide-react' +import { api } from '../api/client' +import { useState } from 'react' + +export default function SampleTripCard() { + const navigate = useNavigate() + const [loading, setLoading] = useState(false) + + const handlePlay = async () => { + setLoading(true) + try { + const trip = await api.getSampleTrip() + navigate(`/trips/${trip.id}`) + } catch { + alert('Failed to load sample trip') + } finally { + setLoading(false) + } + } + + return ( +
+

Quick Start

+

Explore a Sample Trip

+

+ See stress detection, earnings tracking, and event explainability in action. +

+ +
+ ) +} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..a1ee69b --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -0,0 +1,89 @@ +import { NavLink } from 'react-router-dom' +import { LayoutDashboard, MapPin, TrendingUp, Target, Activity, Upload, PenLine, LogOut, User, Star, Truck } from 'lucide-react' + +const links = [ + { to: '/', label: 'Dashboard', icon: LayoutDashboard }, + { to: '/trips', label: 'Trips', icon: MapPin }, + { to: '/trends', label: 'Trends', icon: TrendingUp }, + { to: '/goals', label: 'Goals', icon: Target }, + { to: '/predict', label: 'Predict', icon: PenLine }, + { to: '/batch', label: 'Batch Upload', icon: Upload }, +] + +export default function Sidebar({ user, onLogout }) { + return ( + + ) +} diff --git a/frontend/src/components/SignalCharts.jsx b/frontend/src/components/SignalCharts.jsx new file mode 100644 index 0000000..f4ada29 --- /dev/null +++ b/frontend/src/components/SignalCharts.jsx @@ -0,0 +1,61 @@ +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, +} from 'recharts' + +export default function SignalCharts({ signals, cursorTime }) { + if (!signals || !signals.timestamps) return null + + const data = signals.timestamps.map((t, i) => ({ + time: t, + timeLabel: `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`, + speed: signals.speed[i], + accel: signals.accel_magnitude[i], + audio: signals.audio_db[i], + })) + + const charts = [ + { key: 'speed', label: 'Speed (km/h)', color: '#276EF1', domain: [0, 80] }, + { key: 'accel', label: 'Accel Magnitude (g)', color: '#E11900', domain: [0, 8] }, + { key: 'audio', label: 'Audio (dB)', color: '#FF6937', domain: [30, 100] }, + ] + + return ( +
+ {charts.map(({ key, label, color, domain }) => ( +
+

{label}

+ + + + + + `Time: ${v}`} + /> + + {cursorTime !== undefined && ( + null} + stroke="transparent" + /> + )} + + +
+ ))} +
+ ) +} diff --git a/frontend/src/components/StressTips.jsx b/frontend/src/components/StressTips.jsx new file mode 100644 index 0000000..1d6c573 --- /dev/null +++ b/frontend/src/components/StressTips.jsx @@ -0,0 +1,39 @@ +import { useState, useEffect } from 'react' +import { api } from '../api/client' +import { Lightbulb, X } from 'lucide-react' + +export default function StressTips() { + const [tips, setTips] = useState([]) + const [dismissed, setDismissed] = useState(new Set()) + + useEffect(() => { + api.getTips().then(res => setTips(res.tips)).catch(() => {}) + }, []) + + const visible = tips.filter(t => !dismissed.has(t.id)) + if (visible.length === 0) return null + + return ( +
+

+ + Stress-Reduction Tips +

+ {visible.map(tip => ( +
+ +

{tip.title}

+

{tip.text}

+ +
+ ))} +
+ ) +} diff --git a/frontend/src/components/SummaryCard.jsx b/frontend/src/components/SummaryCard.jsx new file mode 100644 index 0000000..a06fae3 --- /dev/null +++ b/frontend/src/components/SummaryCard.jsx @@ -0,0 +1,14 @@ +export default function SummaryCard({ icon: Icon, label, value, sub, color = 'text-uber-black' }) { + return ( +
+
+ +
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+
+ ) +} diff --git a/frontend/src/components/TimelineSlider.jsx b/frontend/src/components/TimelineSlider.jsx new file mode 100644 index 0000000..4e0c26c --- /dev/null +++ b/frontend/src/components/TimelineSlider.jsx @@ -0,0 +1,65 @@ +import { Play, Pause, SkipBack, SkipForward } from 'lucide-react' +import { useState, useEffect, useRef } from 'react' + +export default function TimelineSlider({ maxSec, currentSec, onChange, isPlaying, onPlayPause }) { + const pct = maxSec > 0 ? (currentSec / maxSec) * 100 : 0 + + const formatTime = (s) => { + const m = Math.floor(s / 60) + const sec = Math.floor(s % 60) + return `${m}:${String(sec).padStart(2, '0')}` + } + + return ( +
+
+ + + + + + + {formatTime(currentSec)} + + {/* Slider */} +
+
+
+
+ onChange(Number(e.target.value))} + className="absolute inset-x-0 w-full h-6 opacity-0 cursor-pointer" + /> +
+
+ + {formatTime(maxSec)} +
+
+ ) +} diff --git a/frontend/src/components/TodayTimeline.jsx b/frontend/src/components/TodayTimeline.jsx new file mode 100644 index 0000000..9e6d486 --- /dev/null +++ b/frontend/src/components/TodayTimeline.jsx @@ -0,0 +1,60 @@ +import { useNavigate } from 'react-router-dom' + +const stressColors = { + low: 'bg-uber-green/20 border-uber-green', + medium: 'bg-uber-yellow/20 border-uber-yellow', + high: 'bg-uber-red/20 border-uber-red', +} + +export default function TodayTimeline({ trips }) { + const navigate = useNavigate() + if (!trips || trips.length === 0) return null + + const minH = Math.min(...trips.map(t => new Date(t.start_time).getHours())) + const maxH = Math.max(...trips.map(t => new Date(t.end_time).getHours())) + 1 + const span = Math.max(maxH - minH, 1) + + return ( +
+

Today Timeline

+ + {/* Hour labels */} +
+
+ {Array.from({ length: span + 1 }, (_, i) => { + const h = minH + i + return {h % 12 || 12}{h < 12 ? 'a' : 'p'} + })} +
+ + {/* Track */} +
+ {trips.map((t) => { + const startH = new Date(t.start_time).getHours() + new Date(t.start_time).getMinutes() / 60 + const endH = new Date(t.end_time).getHours() + new Date(t.end_time).getMinutes() / 60 + const left = ((startH - minH) / span) * 100 + const width = Math.max(((endH - startH) / span) * 100, 2) + + return ( +
+
+ +
+ Low + Medium + High +
+
+ ) +} diff --git a/frontend/src/components/TripListItem.jsx b/frontend/src/components/TripListItem.jsx new file mode 100644 index 0000000..ddc956e --- /dev/null +++ b/frontend/src/components/TripListItem.jsx @@ -0,0 +1,77 @@ +import { useNavigate } from 'react-router-dom' +import { Clock, DollarSign, AlertTriangle, ChevronRight } from 'lucide-react' +import ConfidenceBadge from './ConfidenceBadge' + +const stressDot = { + low: 'bg-uber-green', + medium: 'bg-uber-yellow', + high: 'bg-uber-red', +} + +export default function TripListItem({ trip }) { + const navigate = useNavigate() + const start = new Date(trip.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + const end = new Date(trip.end_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + + return ( + + ) +} diff --git a/frontend/src/components/TripMap.jsx b/frontend/src/components/TripMap.jsx new file mode 100644 index 0000000..ce34f1a --- /dev/null +++ b/frontend/src/components/TripMap.jsx @@ -0,0 +1,106 @@ +import { useEffect, useRef } from 'react' +import L from 'leaflet' +import 'leaflet/dist/leaflet.css' + +// Fix Leaflet default icons +delete L.Icon.Default.prototype._getIconUrl +L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', + iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', + shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', +}) + +const severityColor = { low: '#06C167', medium: '#FFC043', high: '#E11900' } + +const eventIcon = (severity) => + L.divIcon({ + html: `
`, + className: '', + iconSize: [14, 14], + iconAnchor: [7, 7], + }) + +const cursorIcon = L.divIcon({ + html: `
`, + className: '', + iconSize: [18, 18], + iconAnchor: [9, 9], +}) + +export default function TripMap({ route, events, cursorIndex }) { + const mapRef = useRef(null) + const mapInstance = useRef(null) + const cursorMarker = useRef(null) + + useEffect(() => { + if (!mapRef.current || !route || route.length === 0) return + + // Init map + if (!mapInstance.current) { + mapInstance.current = L.map(mapRef.current, { + scrollWheelZoom: true, + zoomControl: true, + }) + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OSM', + }).addTo(mapInstance.current) + } + + const map = mapInstance.current + + // Clear layers except tiles + map.eachLayer(layer => { + if (!(layer instanceof L.TileLayer)) map.removeLayer(layer) + }) + + // Polyline + const polyline = L.polyline(route, { + color: '#276EF1', + weight: 4, + opacity: 0.8, + }).addTo(map) + + // Start / End + L.marker(route[0], { + icon: L.divIcon({ + html: `
`, + className: '', iconSize: [12, 12], iconAnchor: [6, 6], + }), + }).addTo(map).bindPopup('Start') + + L.marker(route[route.length - 1], { + icon: L.divIcon({ + html: `
`, + className: '', iconSize: [12, 12], iconAnchor: [6, 6], + }), + }).addTo(map).bindPopup('End') + + // Event markers + events?.forEach(ev => { + if (ev.location) { + L.marker(ev.location, { icon: eventIcon(ev.severity) }) + .addTo(map) + .bindPopup(`${ev.emoji} ${ev.label.replace(/_/g, ' ')}
Confidence: ${Math.round(ev.confidence * 100)}%`) + } + }) + + map.fitBounds(polyline.getBounds(), { padding: [30, 30] }) + + // Cursor marker + cursorMarker.current = L.marker(route[0], { icon: cursorIcon }).addTo(map) + + return () => { + // don't destroy map, just clean up + } + }, [route, events]) + + // Update cursor position + useEffect(() => { + if (cursorMarker.current && route && cursorIndex !== undefined) { + const idx = Math.min(Math.max(0, cursorIndex), route.length - 1) + cursorMarker.current.setLatLng(route[idx]) + } + }, [cursorIndex, route]) + + return
+} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..9884599 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,22 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #CBCBCB; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #AFAFAF; } + +/* Leaflet overrides */ +.leaflet-container { border-radius: 12px; } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..b15cdd6 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + , +) diff --git a/frontend/src/pages/BatchUpload.jsx b/frontend/src/pages/BatchUpload.jsx new file mode 100644 index 0000000..d53b067 --- /dev/null +++ b/frontend/src/pages/BatchUpload.jsx @@ -0,0 +1,577 @@ +import { useState, useRef } from 'react' +import { Upload, Download, FileSpreadsheet, Activity, DollarSign, AlertTriangle, ChevronDown, ChevronUp, Info, BarChart3, CheckCircle, XCircle, Bell } from 'lucide-react' +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + PieChart, Pie, Cell, Legend, +} from 'recharts' + +const SEVERITY_COLORS = { low: '#06C167', medium: '#FFC043', high: '#E11900' } +const FORECAST_COLORS = { ahead: '#06C167', on_track: '#276EF1', at_risk: '#E11900' } +const SITUATION_COLORS = { + NORMAL: '#06C167', TRAFFIC_STOP: '#276EF1', SPEED_BREAKER: '#FFC043', + CONFLICT: '#E11900', ESCALATING: '#A3000B', ARGUMENT_ONLY: '#FF6937', MUSIC_OR_CALL: '#7356BF', +} + +export default function BatchUpload() { + const [mode, setMode] = useState('stress') // 'stress' | 'earnings' + const [file, setFile] = useState(null) + const [loading, setLoading] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const [expandedRow, setExpandedRow] = useState(null) + const inputRef = useRef(null) + + const handleUpload = async () => { + if (!file) return + setLoading(true) + setError(null) + setResult(null) + + const formData = new FormData() + formData.append('file', file) + + try { + const endpoint = mode === 'stress' ? '/api/batch/stress' : '/api/batch/earnings' + const res = await fetch(endpoint, { method: 'POST', body: formData }) + if (!res.ok) { + const text = await res.text() + throw new Error(text || `Upload failed (${res.status})`) + } + const data = await res.json() + setResult(data) + } catch (err) { + setError(err.message || 'Upload failed') + } finally { + setLoading(false) + } + } + + const downloadTemplate = (type) => { + const a = document.createElement('a') + a.href = `/api/batch/template/${type}` + a.download = `${type}_template.csv` + a.click() + } + + const downloadResults = () => { + if (!result) return + const json = JSON.stringify(result, null, 2) + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${mode}_batch_results.json` + a.click() + URL.revokeObjectURL(url) + } + + const downloadResultsCSV = () => { + if (!result?.results?.length) return + const rows = result.results + const keys = Object.keys(rows[0]).filter(k => typeof rows[0][k] !== 'object') + const header = keys.join(',') + const lines = rows.map(r => keys.map(k => { + const v = r[k] + return v === null || v === undefined ? '' : v + }).join(',')) + const csv = header + '\n' + lines.join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${mode}_batch_results.csv` + a.click() + URL.revokeObjectURL(url) + } + + return ( +
+ {/* Header */} +
+

Batch CSV Upload

+

+ Upload a CSV file with multiple trip windows to get stress & earnings predictions in bulk β€” no manual entry needed. +

+
+ + {/* Mode toggle */} +
+ {[ + { key: 'stress', label: 'Stress Detection', icon: Activity }, + { key: 'earnings', label: 'Earnings Forecast', icon: DollarSign }, + ].map(({ key, label, icon: Icon }) => ( + + ))} +
+ + {/* Template + Upload card */} +
+ {/* Template download */} +
+
+ +

CSV Format

+
+

+ {mode === 'stress' + ? 'Each row = one 30-second sensor window. Requires 15 feature columns (motion, audio, speed aggregates). Add optional trip_id and timestamp columns for identification.' + : 'Each row = one earnings velocity log entry. Requires driver_id, timestamp, cumulative_earnings, elapsed_hours, current_velocity, target_velocity, velocity_delta, trips_completed, target_earnings.' + } +

+
+

Required columns

+
+ {(mode === 'stress' + ? ['motion_max', 'motion_mean', 'motion_p95', 'brake_intensity', 'audio_db_max', 'audio_db_p90', 'speed_mean', '...+8 more'] + : ['driver_id', 'timestamp', 'cumulative_earnings', 'elapsed_hours', 'current_velocity', 'target_velocity', 'trips_completed', 'target_earnings'] + ).map(col => ( + + {col} + + ))} +
+
+ +
+ + {/* File upload */} +
+

Upload CSV

+ + {/* Drop zone */} +
inputRef.current?.click()} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) setFile(f) }} + className="border-2 border-dashed border-uber-gray-200 rounded-xl p-8 text-center cursor-pointer + hover:border-uber-blue hover:bg-blue-50/30 transition-colors" + > + +

+ {file ? ( + {file.name} + ) : ( + <>Drop CSV here or click to browse + )} +

+

Supports .csv files

+ { if (e.target.files[0]) setFile(e.target.files[0]) }} + /> +
+ + {file && ( +
+
+ + {file.name} + ({(file.size / 1024).toFixed(1)} KB) +
+ +
+ )} + + +
+
+ + {/* Error */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Results */} + {result && mode === 'stress' && } + {result && mode === 'earnings' && } + + {/* Export buttons */} + {result && ( +
+ + +
+ )} +
+ ) +} + + +/* ── Stress Results ─────────────────────────────────────── */ + +function StressResults({ result, expandedRow, setExpandedRow }) { + const { summary, results } = result + + const pieData = Object.entries(summary.situation_counts || {}).map(([name, count]) => ({ + name, value: count, fill: SITUATION_COLORS[name] || '#AFAFAF', + })) + const severityData = Object.entries(summary.severity_counts || {}).map(([name, count]) => ({ + name: name.charAt(0).toUpperCase() + name.slice(1), value: count, fill: SEVERITY_COLORS[name], + })) + + return ( + <> + {/* Summary cards */} +
+ + + + + 3 ? 'text-uber-red' : 'text-uber-green'} /> +
+ + {/* Charts */} +
+
+

Situation Distribution

+ + + `${name.replace(/_/g, ' ')} (${value})`}> + {pieData.map((d, i) => )} + + + + +
+
+

Severity Breakdown

+ + + + + + + + {severityData.map((d, i) => )} + + + +
+
+ + {/* Results table */} +
+
+

Per-Window Results ({results.length})

+
+
+ + + + + + + + + + + + + + {results.map((r, i) => ( + setExpandedRow(expandedRow === i ? null : i)} /> + ))} + +
#TripSituationSeverityConfidenceNotifyDetails
+
+
+ + ) +} + +function StressRow({ r, i, expanded, onToggle }) { + return ( + <> + + {r.row_index + 1} + {r.trip_id || r.timestamp || '-'} + + + {r.emoji} + {r.situation_name?.replace(/_/g, ' ')} + + + + + {r.severity} + + + +
+
+
= 0.75 ? 'bg-uber-green' : r.confidence >= 0.5 ? 'bg-uber-yellow' : 'bg-uber-red'}`} + style={{ width: `${Math.round(r.confidence * 100)}%` }} + /> +
+ {Math.round(r.confidence * 100)}% +
+ + + {r.should_notify ? : β€”} + + + + + + {expanded && ( + + +
+ {/* Top features */} + {r.top_features?.length > 0 && ( +
+

Top Feature Deviations

+
+ {r.top_features.map((f, j) => ( +
+ {f.feature} +
+
+
+ z={f.z_score} + val={f.value} +
+ ))} +
+
+ )} + {/* All probabilities */} + {r.all_probabilities && Object.keys(r.all_probabilities).length > 0 && ( +
+

Class Probabilities

+
+ {Object.entries(r.all_probabilities).sort(([,a], [,b]) => b - a).map(([cls, prob]) => ( +
+ {cls.replace(/_/g, ' ')} +
+
+
+ {(prob * 100).toFixed(1)}% +
+ ))} +
+
+ )} +
+ + + )} + + ) +} + + +/* ── Earnings Results ───────────────────────────────────── */ + +function EarningsResults({ result, expandedRow, setExpandedRow }) { + const { summary, results } = result + + const forecastData = Object.entries(summary.forecast_counts || {}).map(([name, count]) => ({ + name: name.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase()), + value: count, + fill: FORECAST_COLORS[name] || '#AFAFAF', + })) + + const velocityTrend = results.map((r, i) => ({ + idx: i + 1, + predicted: r.predicted_velocity, + target: r.target_velocity, + current: r.current_velocity, + hour: r.hour_of_day ?? i, + })) + + return ( + <> + {/* Summary cards */} +
+ + + + +
+ + {/* Charts */} +
+
+

Predicted vs Target Velocity

+ + + + + `β‚Ή${v}`} /> + [`β‚Ή${v}`, '']} /> + + + + + +
+
+

Forecast Status

+ + + `${name} (${value})`} + > + {forecastData.map((d, i) => )} + + + + +
+
+ + {/* Results table */} +
+
+

Per-Entry Results ({results.length})

+
+
+ + + + + + + + + + + + + + + {results.map((r, i) => ( + setExpandedRow(expandedRow === i ? null : i)} /> + ))} + +
#DriverHourPredicted β‚Ή/hrTarget β‚Ή/hrStatusProgressDetails
+
+
+ + ) +} + +function EarningsRow({ r, i, expanded, onToggle }) { + const statusColor = { + ahead: 'bg-green-100 text-green-700', + on_track: 'bg-blue-100 text-blue-700', + at_risk: 'bg-red-100 text-red-700', + } + return ( + <> + + {r.row_index + 1} + {r.driver_id || '-'} + {r.hour_of_day ?? '-'} + β‚Ή{r.predicted_velocity} + β‚Ή{r.target_velocity} + + + {r.forecast_status?.replace(/_/g, ' ')} + + + +
+
+
+
+ {r.pct_target}% +
+ + + + + + {expanded && ( + + +
+
Cumulative Earnings

β‚Ή{r.cumulative_earnings}

+
Elapsed Hours

{r.elapsed_hours}h

+
Remaining

β‚Ή{r.remaining_earnings}

+
Hours to Target

{r.hours_to_target ?? 'β€”'}h

+
+ + + )} + + ) +} + + +/* ── Shared summary card ────────────────────────────────── */ + +function SumCard({ label, value, icon: Icon, color = 'text-uber-black' }) { + return ( +
+ +

{value}

+

{label}

+
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..2e10106 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,112 @@ +import { useState, useEffect } from 'react' +import { api } from '../api/client' +import SummaryCard from '../components/SummaryCard' +import TodayTimeline from '../components/TodayTimeline' +import SampleTripCard from '../components/SampleTripCard' +import EarningsProgress from '../components/EarningsProgress' +import StressTips from '../components/StressTips' +import { Car, Clock, DollarSign, AlertTriangle, Target, Activity } from 'lucide-react' + +export default function Dashboard() { + const [dashboard, setDashboard] = useState(null) + const [trips, setTrips] = useState([]) + const [goals, setGoals] = useState(null) + const [selectedDay, setSelectedDay] = useState('today') + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const today = '2026-03-08' + const yesterday = '2026-03-07' + + useEffect(() => { + loadData() + }, [selectedDay]) + + const loadData = async () => { + setLoading(true) + try { + setError('') + const [dash, tripRes, goalsRes] = await Promise.all([ + api.getDashboard(), + api.getTrips({ date: selectedDay === 'today' ? today : yesterday }), + api.getGoals(), + ]) + setDashboard(dash) + setTrips(tripRes.trips) + setGoals(goalsRes) + } catch (err) { + console.error('Failed to load dashboard:', err) + setError('Unable to load latest dashboard data. Please try again in a moment.') + } finally { + setLoading(false) + } + } + + if (loading && !dashboard) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Dashboard

+

+ {selectedDay === 'today' ? 'Today' : 'Yesterday'}, {selectedDay === 'today' ? 'March 8, 2026' : 'March 7, 2026'} +

+
+ + {/* Today/Yesterday toggle */} +
+ {['today', 'yesterday'].map(day => ( + + ))} +
+
+ + {/* Quick Start */} + + + {/* Summary Cards */} + {dashboard && ( +
+ + + + + +
+ )} + + {error && ( +

+ {error} +

+ )} + + {/* Timeline + Earnings */} +
+ + +
+ + {/* Stress Tips */} + +
+ ) +} diff --git a/frontend/src/pages/Goals.jsx b/frontend/src/pages/Goals.jsx new file mode 100644 index 0000000..2ff6226 --- /dev/null +++ b/frontend/src/pages/Goals.jsx @@ -0,0 +1,141 @@ +import { useState, useEffect } from 'react' +import { api } from '../api/client' +import EarningsProgress from '../components/EarningsProgress' +import StressTips from '../components/StressTips' +import { Target, Save, CheckCircle, TrendingUp, Clock, DollarSign } from 'lucide-react' +import { clampDailyTarget, isValidMoney } from '../utils/sanityChecks' + +export default function Goals() { + const [goals, setGoals] = useState(null) + const [loading, setLoading] = useState(true) + const [targetInput, setTargetInput] = useState('') + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + api.getGoals() + .then(g => { + setGoals(g) + setTargetInput(g.daily_target) + }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + const handleSave = async () => { + setError('') + const clamped = clampDailyTarget(targetInput) + if (!isValidMoney(clamped) || clamped <= 0) { + setError('Please enter a positive daily target') + return + } + setSaving(true) + try { + const updated = await api.setGoal(Number(clamped)) + setGoals(updated) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } catch { + setError('Failed to save goal') + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+

Personalization & Goals

+ + {/* Set target */} +
+
+ +

Daily Earnings Target

+
+ +
+
+ β‚Ή + setTargetInput(e.target.value)} + className="w-full pl-8 pr-4 py-3 border border-uber-gray-200 rounded-lg text-lg font-semibold outline-none focus:border-uber-blue transition" + placeholder="1800" + /> +
+ +
+ + {error && ( +

+ {error} +

+ )} + +

+ Set your daily earnings target. Your progress will be tracked in real-time on the dashboard. +

+
+ + {/* Current progress */} + + + {/* Goal milestones */} + {/* {goals && ( +
+

Today's Progress

+
+ {[25, 50, 75, 100].map(pct => { + const amount = Math.round(goals.daily_target * pct / 100) + const reached = goals.current_earnings >= amount + return ( +
+
+ {reached ? 'βœ“' : `${pct}%`} +
+
+

+ β‚Ή{amount.toLocaleString()} milestone +

+
+ + {reached ? 'Reached' : 'Pending'} + +
+ ) + })} +
+
+ )} */} + + {/* Stress tips */} + +
+ ) +} diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 0000000..8b364ac --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -0,0 +1,397 @@ +import { useState, useEffect } from 'react' +import { Mail, Lock, User, Phone, MapPin, Truck, Users, LogIn, UserPlus } from 'lucide-react' + +export default function Home({ onLoginSuccess }) { + const [mode, setMode] = useState('login') // 'login' | 'register' + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [demoUsers, setDemoUsers] = useState([]) + const [loadingUsers, setLoadingUsers] = useState(true) + + // Login form state + const [loginForm, setLoginForm] = useState({ username: '', password: '' }) + + // Register form state + const [registerForm, setRegisterForm] = useState({ + username: '', + password: '', + name: '', + email: '', + phone: '', + city: '', + vehicle_type: 'Sedan', + vehicle_number: '', + shift_preference: 'morning', + avg_hours_per_day: 7.0, + avg_earnings_per_hour: 180, + experience_months: 0, + }) + + // Load demo users + useEffect(() => { + fetch('/api/auth/users') + .then(r => r.json()) + .then(users => setDemoUsers(users)) + .catch(() => {}) + .finally(() => setLoadingUsers(false)) + }, []) + + const handleLogin = async (e) => { + e.preventDefault() + setError(null) + setLoading(true) + + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: loginForm.username, + password: loginForm.password, + }), + }) + if (!res.ok) { + const text = await res.text() + throw new Error(text || 'Login failed') + } + const user = await res.json() + localStorage.setItem('user', JSON.stringify(user)) + onLoginSuccess(user) + } catch (err) { + setError(err.message || 'Login failed') + } finally { + setLoading(false) + } + } + + const handleRegister = async (e) => { + e.preventDefault() + setError(null) + setLoading(true) + + try { + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(registerForm), + }) + if (!res.ok) { + const text = await res.text() + throw new Error(text || 'Registration failed') + } + const user = await res.json() + // Auto-login after registration + const loginRes = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: registerForm.username, + password: registerForm.password, + }), + }) + const fullUser = await loginRes.json() + localStorage.setItem('user', JSON.stringify(fullUser)) + onLoginSuccess(fullUser) + } catch (err) { + setError(err.message || 'Registration failed') + } finally { + setLoading(false) + } + } + + const demoLogin = (username) => { + setLoginForm({ username, password: '' }) + setError(null) + } + + return ( +
+
+ {/* Header */} +
+

DrivePulse

+

Smart Stress Detection & Earnings Tracking

+
+ +
+ {/* Left side - Demo users */} +
+
+ +

Demo Users

+
+ + {loadingUsers ? ( +
+
+
+ ) : ( +
+ {demoUsers.map(user => ( + + ))} +
+ )} + +
+

πŸ’‘ Tip

+

+ Click on any demo user above, then click Continue to login. +
+ Default password: password123 +

+
+
+ + {/* Right side - Login/Register form */} +
+ {/* Mode toggle */} +
+ {[ + { key: 'login', label: 'Login', icon: LogIn }, + { key: 'register', label: 'Register', icon: UserPlus }, + ].map(({ key, label, icon: Icon }) => ( + + ))} +
+ + {error && ( +
+ {error} +
+ )} + + {mode === 'login' ? ( +
+
+ + setLoginForm({...loginForm, username: e.target.value})} + className="w-full px-4 py-2 border border-uber-gray-200 rounded-lg focus:border-uber-blue focus:outline-none transition" + placeholder="e.g., alex.kumar" + required + /> +
+ +
+ + setLoginForm({...loginForm, password: e.target.value})} + className="w-full px-4 py-2 border border-uber-gray-200 rounded-lg focus:border-uber-blue focus:outline-none transition" + placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" + required + /> +
+ + +
+ ) : ( +
+
+
+ + setRegisterForm({...registerForm, username: e.target.value})} + className="w-full px-3 py-2 border border-uber-gray-200 rounded-lg focus:border-uber-blue focus:outline-none transition text-sm" + placeholder="username" + required + /> +
+
+ + setRegisterForm({...registerForm, password: e.target.value})} + className="w-full px-3 py-2 border border-uber-gray-200 rounded-lg focus:border-uber-blue focus:outline-none transition text-sm" + placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" + required + /> +
+
+ +
+ + setRegisterForm({...registerForm, name: e.target.value})} + className="w-full px-3 py-2 border border-uber-gray-200 rounded-lg focus:border-uber-blue focus:outline-none transition text-sm" + placeholder="John Doe" + required + /> +
+ +
+
+ + setRegisterForm({...registerForm, email: e.target.value})} + className="w-full px-3 py-2 border border-uber-gray-200 rounded-lg focus:border-uber-blue focus:outline-none transition text-sm" + placeholder="email@example.com" + required + /> +
+
+ + setRegisterForm({...registerForm, phone: e.target.value})} + className="w-full px-3 py-2 border border-uber-gray-200 rounded-lg focus:border-uber-blue focus:outline-none transition text-sm" + placeholder="+91 XXXXXXXXXX" + required + /> +
+
+ +
+
+ + setRegisterForm({...registerForm, city: e.target.value})} + className="w-full px-3 py-2 border border-uber-gray-200 rounded-lg focus:border-uber-blue focus:outline-none transition text-sm" + placeholder="Mumbai" + required + /> +
+
+ + +
+
+ +
+ + setRegisterForm({...registerForm, vehicle_number: e.target.value})} + className="w-full px-3 py-2 border border-uber-gray-200 rounded-lg focus:border-uber-blue focus:outline-none transition text-sm" + placeholder="MH01AB1234" + required + /> +
+ +
+
+ + +
+
+ + setRegisterForm({...registerForm, experience_months: parseInt(e.target.value)})} + className="w-full px-3 py-2 border border-uber-gray-200 rounded-lg focus:border-uber-blue focus:outline-none transition text-sm" + placeholder="0" + min="0" + /> +
+
+ + +
+ )} +
+
+ + {/* Footer */} +
+

Your stress detection and earnings companion πŸš—βœ¨

+
+
+
+ ) +} diff --git a/frontend/src/pages/Predict.jsx b/frontend/src/pages/Predict.jsx new file mode 100644 index 0000000..d26924c --- /dev/null +++ b/frontend/src/pages/Predict.jsx @@ -0,0 +1,449 @@ +import { useState, useEffect } from 'react' +import { + Activity, DollarSign, Send, RotateCcw, AlertTriangle, CheckCircle, + ChevronDown, ChevronUp, Zap, Info, Loader2, +} from 'lucide-react' +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, +} from 'recharts' + +const SEVERITY_BG = { low: 'bg-green-50 border-green-200', medium: 'bg-yellow-50 border-yellow-200', high: 'bg-red-50 border-red-200' } +const SEVERITY_TEXT = { low: 'text-green-700', medium: 'text-yellow-700', high: 'text-red-700' } +const SITUATION_COLORS = { + NORMAL: '#06C167', TRAFFIC_STOP: '#276EF1', SPEED_BREAKER: '#FFC043', + CONFLICT: '#E11900', ESCALATING: '#A3000B', ARGUMENT_ONLY: '#FF6937', MUSIC_OR_CALL: '#7356BF', +} +const FORECAST_BG = { ahead: 'bg-green-50 border-green-200', on_track: 'bg-blue-50 border-blue-200', at_risk: 'bg-red-50 border-red-200' } +const FORECAST_TEXT = { ahead: 'text-green-700', on_track: 'text-blue-700', at_risk: 'text-red-700' } + +export default function Predict() { + const [mode, setMode] = useState('stress') + const [featureDefs, setFeatureDefs] = useState([]) + const [values, setValues] = useState({}) + const [loading, setLoading] = useState(false) + const [loadingFeatures, setLoadingFeatures] = useState(true) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const [collapsedGroups, setCollapsedGroups] = useState({}) + + // Load feature definitions when mode changes + useEffect(() => { + setLoadingFeatures(true) + setResult(null) + setError(null) + fetch(`/api/features/${mode}`) + .then(r => r.json()) + .then(data => { + setFeatureDefs(data.features) + const defaults = {} + data.features.forEach(f => { defaults[f.name] = f.default }) + setValues(defaults) + setCollapsedGroups({}) + }) + .catch(e => setError('Failed to load feature definitions')) + .finally(() => setLoadingFeatures(false)) + }, [mode]) + + const handleChange = (name, rawVal) => { + setValues(prev => ({ ...prev, [name]: rawVal === '' ? '' : Number(rawVal) })) + } + + const handleReset = () => { + const defaults = {} + featureDefs.forEach(f => { defaults[f.name] = f.default }) + setValues(defaults) + setResult(null) + setError(null) + } + + const handlePredict = async () => { + setLoading(true) + setError(null) + setResult(null) + try { + const payload = {} + featureDefs.forEach(f => { + payload[f.name] = values[f.name] === '' ? 0 : Number(values[f.name]) + }) + const res = await fetch(`/api/predict/${mode}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + const txt = await res.text() + throw new Error(txt || `Error ${res.status}`) + } + setResult(await res.json()) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + const toggleGroup = (group) => { + setCollapsedGroups(prev => ({ ...prev, [group]: !prev[group] })) + } + + // Group features + const groups = {} + featureDefs.forEach(f => { + if (!groups[f.group]) groups[f.group] = [] + groups[f.group].push(f) + }) + + return ( +
+ {/* Header */} +
+

Manual Prediction

+

+ Type in sensor or earnings values directly to get a single prediction +

+
+ + {/* Mode toggle */} +
+ + +
+ + {loadingFeatures ? ( +
+ + Loading features… +
+ ) : ( +
+ {/* Input form β€” left 3 cols */} +
+
+
+

+ Input Features ({featureDefs.length}) +

+ +
+ +
+ {Object.entries(groups).map(([groupName, fields]) => ( +
+ {/* Group header */} + + + {/* Fields */} + {!collapsedGroups[groupName] && ( +
+ {fields.map(f => ( +
+ + handleChange(f.name, e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-uber-gray-200 text-sm text-uber-gray-800 focus:outline-none focus:ring-2 focus:ring-uber-blue focus:border-transparent transition-all" + placeholder={String(f.default)} + /> +
+ ))} +
+ )} +
+ ))} +
+ + {/* Submit */} +
+ + +
+
+ + {/* Hint box */} +
+ +

+ {mode === 'stress' + ? 'These are 30-second window features from accelerometer, gyroscope, and microphone sensors. Defaults represent a calm driving scenario.' + : 'Enter your current shift stats. The model predicts your earnings velocity (β‚Ή/hr) and whether you\'re on track to hit your target.' + } +

+
+
+ + {/* Result panel β€” right 2 cols */} +
+ {error && ( +
+ +

{error}

+
+ )} + + {!result && !error && ( +
+ +

+ Fill in values and hit "Run Prediction" +

+

+ Results will appear here +

+
+ )} + + {result && mode === 'stress' && } + {result && mode === 'earnings' && } +
+
+ )} +
+ ) +} + + +/* ─── Stress result card ────────────────────────────────── */ +function StressResult({ result }) { + const sev = result.severity || 'low' + const conf = result.confidence || 0 + const confPct = (conf * 100).toFixed(1) + + // Build probability chart data + const probaData = result.all_probabilities + ? Object.entries(result.all_probabilities).map(([name, val]) => ({ + name: name.replace(/_/g, ' '), + value: +(val * 100).toFixed(1), + fill: SITUATION_COLORS[name] || '#999', + })) + : [] + + return ( +
+ {/* Main result */} +
+
+ {result.emoji} +
+

+ {result.situation_name?.replace(/_/g, ' ')} +

+

+ {sev.toUpperCase()} severity +

+
+
+ + {/* Confidence bar */} +
+
+ Confidence + {confPct}% +
+
+
= 0.85 ? 'bg-green-500' : conf >= 0.65 ? 'bg-yellow-500' : 'bg-red-400' + }`} + style={{ width: `${confPct}%` }} + /> +
+
+ + {/* Flags */} +
+ {result.should_notify && ( + + πŸ”” Notify + + )} + {result.is_safety_critical && ( + + 🚨 Safety + + )} + {!result.should_notify && !result.is_safety_critical && ( + + No alerts + + )} +
+
+ + {/* Top features */} + {result.top_features?.length > 0 && ( +
+

Top Contributing Features

+
+ {result.top_features.map((f, i) => ( +
+ {f.feature} +
+ z={f.z_score} + {f.value} +
+
+ ))} +
+
+ )} + + {/* Probability distribution */} + {probaData.length > 0 && ( +
+

Class Probabilities

+ + + + `${v}%`} fontSize={11} /> + + `${v}%`} /> + + {probaData.map((entry, i) => ( + + ))} + + + +
+ )} +
+ ) +} + + +/* ─── Earnings result card ──────────────────────────────── */ +function EarningsResult({ result }) { + if (result.error) { + return ( +
+

{result.error}

+
+ ) + } + + const vel = result.predicted_velocity + const forecast = result.forecast_status || 'on_track' + const fBg = FORECAST_BG[forecast] || FORECAST_BG.on_track + const fText = FORECAST_TEXT[forecast] || FORECAST_TEXT.on_track + + return ( +
+ {/* Main result */} +
+
+ +
+

+ β‚Ή{vel?.toFixed(2)}/hr +

+

+ {forecast.replace(/_/g, ' ').toUpperCase()} +

+
+
+ + {/* Stats */} +
+ {result.target_velocity != null && ( + + )} + {result.current_velocity != null && ( + + )} + {result.remaining_earnings != null && ( + + )} + {result.hours_to_target != null && ( + + )} +
+
+ + {/* Velocity comparison */} +
+

Velocity Comparison

+
+ + + {result.target_velocity != null && ( + + )} +
+
+
+ ) +} + +function Stat({ label, value }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +function VelBar({ label, value, max, color }) { + const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0 + return ( +
+
+ {label} + β‚Ή{value.toFixed(0)}/hr +
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/Trends.jsx b/frontend/src/pages/Trends.jsx new file mode 100644 index 0000000..02ea263 --- /dev/null +++ b/frontend/src/pages/Trends.jsx @@ -0,0 +1,144 @@ +import { useState, useEffect } from 'react' +import { api } from '../api/client' +import { + BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, Legend, AreaChart, Area, +} from 'recharts' +import { TrendingUp, TrendingDown, DollarSign, Car, AlertTriangle, Clock } from 'lucide-react' + +export default function Trends() { + const [range, setRange] = useState('7d') + const [metrics, setMetrics] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + setLoading(true) + api.getMetrics(range) + .then(setMetrics) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [range]) + + if (loading) { + return ( +
+
+
+ ) + } + + if (!metrics) return null + + const { days, summary } = metrics + + const summaryCards = [ + { label: 'Avg Daily Earnings', value: `β‚Ή${summary.avg_daily_earnings?.toLocaleString()}`, icon: DollarSign, color: 'text-uber-green' }, + { label: 'Avg Daily Trips', value: summary.avg_daily_trips, icon: Car, color: 'text-uber-blue' }, + { label: 'Avg Stress Score', value: summary.avg_stress, icon: AlertTriangle, color: 'text-uber-red' }, + { label: 'Total Earnings', value: `β‚Ή${summary.total_earnings?.toLocaleString()}`, icon: TrendingUp, color: 'text-uber-orange' }, + ] + + return ( +
+ {/* Header + range toggle */} +
+

Personal Trends

+
+ {[{ key: '7d', label: 'Week' }, { key: '30d', label: 'Month' }].map(r => ( + + ))} +
+
+ + {/* Summary cards */} +
+ {summaryCards.map(({ label, value, icon: Icon, color }) => ( +
+ +

{value}

+

{label}

+
+ ))} +
+ + {/* Earnings chart */} +
+

Earnings Trend

+ + + + + + + + + + + `β‚Ή${v}`} /> + [`β‚Ή${v}`, 'Earnings']} /> + + + +
+ + {/* Stress + Trips chart */} +
+
+

Average Stress

+ + + + + + + + + +
+ +
+

Trips Count

+ + + + + + + + + +
+
+ + {/* Velocity chart */} +
+

Earnings Velocity (β‚Ή/hr)

+ + + + + + + + + + + `β‚Ή${v}`} /> + [`β‚Ή${v}`, 'Velocity']} /> + + + +
+
+ ) +} diff --git a/frontend/src/pages/TripDetail.jsx b/frontend/src/pages/TripDetail.jsx new file mode 100644 index 0000000..9da4940 --- /dev/null +++ b/frontend/src/pages/TripDetail.jsx @@ -0,0 +1,216 @@ +import { useState, useEffect, useRef } from 'react' +import { useParams, Link } from 'react-router-dom' +import { api } from '../api/client' +import TripMap from '../components/TripMap' +import SignalCharts from '../components/SignalCharts' +import TimelineSlider from '../components/TimelineSlider' +import EventCard from '../components/EventCard' +import ConfidenceBadge from '../components/ConfidenceBadge' +import { ArrowLeft, Download, Clock, DollarSign, MapPin, Activity } from 'lucide-react' + +export default function TripDetail() { + const { tripId } = useParams() + const [trip, setTrip] = useState(null) + const [loading, setLoading] = useState(true) + const [currentSec, setCurrentSec] = useState(0) + const [isPlaying, setIsPlaying] = useState(false) + const playRef = useRef(null) + + useEffect(() => { + api.getTrip(tripId) + .then(setTrip) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [tripId]) + + // Playback + useEffect(() => { + if (isPlaying && trip) { + playRef.current = setInterval(() => { + setCurrentSec(prev => { + const next = prev + 10 + if (next >= trip.duration_min * 60) { + setIsPlaying(false) + return trip.duration_min * 60 + } + return next + }) + }, 500) + } + return () => clearInterval(playRef.current) + }, [isPlaying, trip]) + + const handlePlayPause = () => { + if (currentSec >= (trip?.duration_min || 0) * 60) setCurrentSec(0) + setIsPlaying(!isPlaying) + } + + const jumpTo = (sec) => { + setCurrentSec(sec) + setIsPlaying(false) + } + + const handleExport = (format) => { + if (!trip) return + let content, filename, mime + if (format === 'json') { + content = JSON.stringify(trip, null, 2) + filename = `${trip.id}.json` + mime = 'application/json' + } else { + // CSV of signals + const rows = trip.signals.timestamps.map((t, i) => + [t, trip.signals.speed[i], trip.signals.accel_magnitude[i], trip.signals.audio_db[i]].join(',') + ) + content = 'timestamp,speed,accel_magnitude,audio_db\n' + rows.join('\n') + filename = `${trip.id}_signals.csv` + mime = 'text/csv' + } + const blob = new Blob([content], { type: mime }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + } + + if (loading) { + return ( +
+
+
+ ) + } + + if (!trip) { + return ( +
+

Trip not found

+ Back to trips +
+ ) + } + + const maxSec = trip.duration_min * 60 + const cursorIndex = trip.route + ? Math.min(Math.floor((currentSec / maxSec) * trip.route.length), trip.route.length - 1) + : 0 + + return ( +
+ {/* Header */} +
+
+ + + +
+

Trip {trip.id}

+

+ {new Date(trip.start_time).toLocaleDateString()} ·{' '} + {new Date(trip.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} –{' '} + {new Date(trip.end_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

+
+
+ + {/* Export */} +
+ + +
+
+ + {/* Stats row */} +
+
+ +

{trip.duration_min} min

+

Duration

+
+
+ +

{trip.distance_km} km

+

Distance

+
+
+ +

β‚Ή{trip.fare}

+

+ Fare {trip.surge_multiplier > 1 && `(${trip.surge_multiplier}Γ— surge)`} +

+
+
+ +

6 ? 'text-uber-red' : + trip.stress_score > 3 ? 'text-uber-yellow' : 'text-uber-green' + }`}>{trip.stress_score}/10

+

Stress Score

+
+
+

{trip.events_count}

+

Events

+
+
+ + {/* Map */} +
+ +
+ + {/* Playback slider */} + + + {/* Signal charts + Events side by side */} +
+
+

Sensor Signals

+ +
+
+

+ Detected Events ({trip.events.length}) +

+
+ {trip.events.map(ev => ( + { + setTrip(prev => ({ + ...prev, + events: prev.events.map(e => + e.id === id ? { ...e, feedback: { label } } : e + ), + })) + }} + /> + ))} + {trip.events.length === 0 && ( +

No events detected

+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/Trips.jsx b/frontend/src/pages/Trips.jsx new file mode 100644 index 0000000..39153a9 --- /dev/null +++ b/frontend/src/pages/Trips.jsx @@ -0,0 +1,493 @@ +import { useState, useEffect, useRef } from 'react' +import { api } from '../api/client' +import TripListItem from '../components/TripListItem' +import FilterChips from '../components/FilterChips' +import { Calendar, SlidersHorizontal, Plus, Upload, Download, X } from 'lucide-react' +import { isValidMoney, isValidTimeRange } from '../utils/sanityChecks' + +export default function Trips() { + const [trips, setTrips] = useState([]) + const [loading, setLoading] = useState(true) + const [date, setDate] = useState('2026-03-08') + const [preset, setPreset] = useState(null) + const [showFilters, setShowFilters] = useState(false) + const [showAddTrip, setShowAddTrip] = useState(false) + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState('') + const [newTrip, setNewTrip] = useState({ + date: '2026-03-08', + start_time: '', + end_time: '', + distance_km: '', + fare: '', + stress_score: '', + }) + + const [importing, setImporting] = useState(false) + const [importError, setImportError] = useState('') + const [importSummary, setImportSummary] = useState(null) + const importInputRef = useRef(null) + const [filters, setFilters] = useState({ + stress: '', + earnings_min: '', + earnings_max: '', + duration_min: '', + duration_max: '', + time_of_day: '', + }) + const [confidenceFilter, setConfidenceFilter] = useState('') + + useEffect(() => { + loadTrips() + }, [date, preset]) + + useEffect(() => { + setNewTrip(t => ({ ...t, date })) + }, [date]) + + const loadTrips = async () => { + setLoading(true) + try { + const params = { date } + if (preset) params.preset = preset + if (filters.stress) params.stress = filters.stress + if (filters.earnings_min) params.earnings_min = filters.earnings_min + if (filters.earnings_max) params.earnings_max = filters.earnings_max + if (filters.duration_min) params.duration_min = filters.duration_min + if (filters.duration_max) params.duration_max = filters.duration_max + if (filters.time_of_day) params.time_of_day = filters.time_of_day + + const res = await api.getTrips(params) + setTrips(res.trips) + } catch (err) { + console.error('Failed to load trips:', err) + } finally { + setLoading(false) + } + } + + const submitNewTrip = async (e) => { + e.preventDefault() + setCreateError('') + // Basic client-side validation mirroring backend rules + if (!newTrip.date) { + setCreateError('Date is required') + return + } + if (!isValidTimeRange(newTrip.start_time, newTrip.end_time)) { + setCreateError('End time must be after start time') + return + } + if (!isValidMoney(newTrip.distance_km)) { + setCreateError('Please enter a valid distance') + return + } + if (!isValidMoney(newTrip.fare)) { + setCreateError('Please enter a valid fare') + return + } + if (newTrip.stress_score !== '' && newTrip.stress_score !== null && newTrip.stress_score !== undefined) { + const s = Number(newTrip.stress_score) + if (!Number.isFinite(s) || s < 0 || s > 10) { + setCreateError('Stress score must be a number between 0 and 10') + return + } + } + setCreating(true) + try { + const payload = { + date: newTrip.date, + start_time: newTrip.start_time, + end_time: newTrip.end_time, + distance_km: Number(newTrip.distance_km), + fare: Number(newTrip.fare), + } + if (newTrip.stress_score !== '' && newTrip.stress_score !== null && newTrip.stress_score !== undefined) { + payload.stress_score = Number(newTrip.stress_score) + } + await api.createTrip(payload) + setShowAddTrip(false) + setNewTrip(t => ({ ...t, start_time: '', end_time: '', distance_km: '', fare: '', stress_score: '' })) + await loadTrips() + } catch (err) { + setCreateError(err?.message || 'Failed to add trip') + } finally { + setCreating(false) + } + } + + const handleImportTripsCsv = async (file) => { + if (!file) return + if (!file.name.toLowerCase().endsWith('.csv')) { + setImportError('Please upload a .csv file') + return + } + // 1 MB soft limit – avoids accidental huge files + const maxSizeBytes = 1 * 1024 * 1024 + if (file.size > maxSizeBytes) { + setImportError('CSV is too large (max 1MB)') + return + } + setImportError('') + setImportSummary(null) + setImporting(true) + try { + const res = await api.importTripsCsv(file) + setImportSummary(res.summary) + await loadTrips() + } catch (err) { + setImportError(err?.message || 'Import failed') + } finally { + setImporting(false) + if (importInputRef.current) importInputRef.current.value = '' + } + } + + const applyFilters = () => { + setPreset(null) + loadTrips() + setShowFilters(false) + } + + const clearFilters = () => { + setFilters({ stress: '', earnings_min: '', earnings_max: '', duration_min: '', duration_max: '', time_of_day: '' }) + setPreset(null) + setConfidenceFilter('') + } + + // Client-side confidence filtering on events + let displayTrips = trips + if (confidenceFilter) { + displayTrips = trips.filter(t => { + if (!t.events_summary) return true + return t.events_summary.some(e => { + // We don't have confidence in summary, use stress_level as proxy + if (confidenceFilter === 'high') return t.stress_level === 'high' + if (confidenceFilter === 'medium') return t.stress_level === 'medium' + if (confidenceFilter === 'low') return t.stress_level === 'low' + return true + }) + }) + } + + return ( +
+ {/* Header */} +
+

Trip History

+
+
+ + setDate(e.target.value)} + className="text-sm outline-none bg-transparent" + /> +
+ + Template + + + handleImportTripsCsv(e.target.files?.[0])} + /> + + +
+
+ + {/* Import feedback (mobile + status) */} + {(importError || importSummary) && ( +
+ {importError ? ( + Import failed: {importError} + ) : ( + + Imported: {importSummary.created} created, {importSummary.errors} errors (from {importSummary.total_rows} rows) + + )} +
+ + Template + + +
+
+ )} + + {/* Quick filter presets */} + { setPreset(p); clearFilters() }} /> + + {/* Advanced filters panel */} + {showFilters && ( +
+

Advanced Filters

+
+
+ + +
+
+ + +
+
+ + +
+
+ + setFilters(f => ({ ...f, earnings_min: e.target.value }))} + className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none" + placeholder="0" + /> +
+
+ + setFilters(f => ({ ...f, earnings_max: e.target.value }))} + className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none" + placeholder="∞" + /> +
+
+ + setFilters(f => ({ ...f, duration_max: e.target.value }))} + className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none" + placeholder="∞" + /> +
+
+
+ + +
+
+ )} + + {/* Results count */} +

+ {displayTrips.length} trip{displayTrips.length !== 1 ? 's' : ''} found +

+ + {/* Trip list */} + {loading ? ( +
+
+
+ ) : displayTrips.length === 0 ? ( +
+

No trips found

+

Try a different date or adjust your filters

+
+ ) : ( +
+ {displayTrips.map(trip => ( + + ))} +
+ )} + + {/* Add Trip Modal */} + {showAddTrip && ( +
+
+
+
+

Add trip

+

Manual entry (individual trip)

+
+ +
+ +
+ {createError && ( +
+ Error: {createError} +
+ )} + +
+
+ + setNewTrip(t => ({ ...t, date: e.target.value }))} + className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none" + required + /> +
+
+ + setNewTrip(t => ({ ...t, start_time: e.target.value }))} + className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none" + required + /> +
+
+ + setNewTrip(t => ({ ...t, end_time: e.target.value }))} + className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none" + required + /> +
+
+ + setNewTrip(t => ({ ...t, distance_km: e.target.value }))} + className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none" + placeholder="8.2" + required + /> +
+
+ + setNewTrip(t => ({ ...t, fare: e.target.value }))} + className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none" + placeholder="310" + required + /> +
+
+ + setNewTrip(t => ({ ...t, stress_score: e.target.value }))} + className="w-full border border-uber-gray-200 rounded-lg px-3 py-2 text-sm outline-none" + placeholder="0.0" + /> +
+
+ +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/frontend/src/utils/sanityChecks.js b/frontend/src/utils/sanityChecks.js new file mode 100644 index 0000000..efb9bcc --- /dev/null +++ b/frontend/src/utils/sanityChecks.js @@ -0,0 +1,19 @@ +export function isValidMoney(value) { + const n = Number(value); + return Number.isFinite(n) && n >= 0; +} + +export function isValidTimeRange(start, end) { + if (!start || !end) return false; + // HH:MM 24h format compares correctly as strings + return end > start; +} + +export function clampDailyTarget(target, min = 0, max = 10000) { + const n = Number(target); + if (!Number.isFinite(n)) return min; + if (n < min) return min; + if (n > max) return max; + return n; +} + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..5c3431a --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,32 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx}'], + theme: { + extend: { + colors: { + uber: { + black: '#000000', + white: '#FFFFFF', + green: '#06C167', + blue: '#276EF1', + red: '#E11900', + orange: '#FF6937', + yellow: '#FFC043', + gray: { + 50: '#F6F6F6', + 100: '#EEEEEE', + 200: '#E2E2E2', + 300: '#CBCBCB', + 400: '#AFAFAF', + 500: '#757575', + 600: '#545454', + 700: '#333333', + 800: '#1F1F1F', + 900: '#141414', + }, + }, + }, + }, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..1406734 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..3024d03 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 80; + + # Serve the React SPA + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri /index.html; + } + + # Proxy API calls to the backend container + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2007904 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Driver-Pulse", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0d160ca --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +pandas>=2.0.0 +numpy>=1.24.0 +scikit-learn>=1.3.0 +joblib>=1.3.0 +streamlit>=1.28.0 +fastapi>=0.104 +uvicorn>=0.24 +pydantic>=2.0 +python-dateutil>=2.8 +python-multipart>=0.0.6 diff --git a/tests/data/earnings_batch_example.csv b/tests/data/earnings_batch_example.csv new file mode 100644 index 0000000..44fbaa3 --- /dev/null +++ b/tests/data/earnings_batch_example.csv @@ -0,0 +1,5 @@ +driver_id,timestamp,cumulative_earnings,elapsed_hours,current_velocity,target_velocity,velocity_delta,trips_completed,target_earnings +driver-001,08:00:00,0,0.5,0,200,0,0,1800 +driver-001,09:00:00,185,1.5,185,200,-15,2,1800 +driver-001,10:00:00,420,2.5,210,200,10,4,1800 + diff --git a/tests/data/stress_batch_example.csv b/tests/data/stress_batch_example.csv new file mode 100644 index 0000000..1afe748 --- /dev/null +++ b/tests/data/stress_batch_example.csv @@ -0,0 +1,4 @@ +trip_id,timestamp,motion_max,motion_mean,motion_p95,brake_intensity,lateral_max,speed_mean,speed_at_brake,speed_drop,audio_db_max,audio_db_mean,audio_db_p90,audio_db_std,cadence_var_mean,argument_frac,loud_frac +trip-001,08:15:00,1.2,0.6,1.1,0.8,0.5,35,35,5,68,62,67,4,0.1,0.15 +trip-002,09:30:00,5.1,1.8,4.8,4.9,2.1,40,40,25,94,82,91,8,0.72,0.95 + diff --git a/tests/data/trips_import_example.csv b/tests/data/trips_import_example.csv new file mode 100644 index 0000000..50af253 --- /dev/null +++ b/tests/data/trips_import_example.csv @@ -0,0 +1,4 @@ +date,start_time,end_time,distance_km,fare,stress_score +2026-03-08,09:15,09:45,8.2,310,2.5 +2026-03-08,18:05,18:40,11.6,520,6.3 +