diff --git a/.gitignore b/.gitignore index 96b208f..cd89f80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,49 @@ +<<<<<<< HEAD +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +node_modules +.pnp +.pnp.js + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage + +# Turbo +.turbo + +# Vercel +.vercel + +# Build Outputs +.next/ +out/ +build +dist + + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem +.venv +.venv/ +__pycache__/ +*.pyc +*.pkl +models/eco_model.pth +======= .venv/ __pycache__/ *.pyc @@ -6,3 +52,4 @@ __pycache__/ node_modules/ .env dist/ +>>>>>>> 1c2f25b401a67215ea459ece945cb72cc7dbd373 diff --git a/.venv/lib/python3.10/site-packages/__pycache__/typing_extensions.cpython-310.pyc b/.venv/lib/python3.10/site-packages/__pycache__/typing_extensions.cpython-310.pyc new file mode 100644 index 0000000..d7cd18f Binary files /dev/null and b/.venv/lib/python3.10/site-packages/__pycache__/typing_extensions.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/annotated_types/__pycache__/__init__.cpython-310.pyc b/.venv/lib/python3.10/site-packages/annotated_types/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..340ced7 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/annotated_types/__pycache__/__init__.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/__init__.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..1a7d45d Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/__init__.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/_migration.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/_migration.cpython-310.pyc new file mode 100644 index 0000000..26f0516 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/_migration.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/aliases.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/aliases.cpython-310.pyc new file mode 100644 index 0000000..e7191a0 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/aliases.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/annotated_handlers.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/annotated_handlers.cpython-310.pyc new file mode 100644 index 0000000..b632779 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/annotated_handlers.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/config.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..934d9fe Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/config.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/errors.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/errors.cpython-310.pyc new file mode 100644 index 0000000..f1f3328 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/errors.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/fields.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/fields.cpython-310.pyc new file mode 100644 index 0000000..09c3bcd Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/fields.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/functional_validators.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/functional_validators.cpython-310.pyc new file mode 100644 index 0000000..0de92a2 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/functional_validators.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/json_schema.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/json_schema.cpython-310.pyc new file mode 100644 index 0000000..0f9d6af Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/json_schema.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/main.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..aa84a27 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/main.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/types.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/types.cpython-310.pyc new file mode 100644 index 0000000..b91252a Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/types.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/version.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/version.cpython-310.pyc new file mode 100644 index 0000000..055d896 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/version.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/__pycache__/warnings.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/warnings.cpython-310.pyc new file mode 100644 index 0000000..1b628a2 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/__pycache__/warnings.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/__init__.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..bddc0a3 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/__init__.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_config.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_config.cpython-310.pyc new file mode 100644 index 0000000..416f44e Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_config.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_core_metadata.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_core_metadata.cpython-310.pyc new file mode 100644 index 0000000..f3eeea1 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_core_metadata.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_core_utils.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_core_utils.cpython-310.pyc new file mode 100644 index 0000000..7f663b4 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_core_utils.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_decorators.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_decorators.cpython-310.pyc new file mode 100644 index 0000000..7c23b78 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_decorators.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_discriminated_union.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_discriminated_union.cpython-310.pyc new file mode 100644 index 0000000..ac4e83f Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_discriminated_union.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_docs_extraction.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_docs_extraction.cpython-310.pyc new file mode 100644 index 0000000..c1554f9 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_docs_extraction.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_fields.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_fields.cpython-310.pyc new file mode 100644 index 0000000..46ffc72 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_fields.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_forward_ref.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_forward_ref.cpython-310.pyc new file mode 100644 index 0000000..53f9ef8 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_forward_ref.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_generate_schema.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_generate_schema.cpython-310.pyc new file mode 100644 index 0000000..ed2f6c5 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_generate_schema.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_generics.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_generics.cpython-310.pyc new file mode 100644 index 0000000..089369a Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_generics.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_import_utils.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_import_utils.cpython-310.pyc new file mode 100644 index 0000000..fdd86ba Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_import_utils.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_internal_dataclass.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_internal_dataclass.cpython-310.pyc new file mode 100644 index 0000000..e5222d1 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_internal_dataclass.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_known_annotated_metadata.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_known_annotated_metadata.cpython-310.pyc new file mode 100644 index 0000000..f0bd744 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_known_annotated_metadata.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_mock_val_ser.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_mock_val_ser.cpython-310.pyc new file mode 100644 index 0000000..bf999f7 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_mock_val_ser.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_model_construction.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_model_construction.cpython-310.pyc new file mode 100644 index 0000000..e6b25b0 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_model_construction.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_namespace_utils.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_namespace_utils.cpython-310.pyc new file mode 100644 index 0000000..0a19ea3 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_namespace_utils.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_repr.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_repr.cpython-310.pyc new file mode 100644 index 0000000..f4e96d4 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_repr.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_schema_gather.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_schema_gather.cpython-310.pyc new file mode 100644 index 0000000..1860f3c Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_schema_gather.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_schema_generation_shared.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_schema_generation_shared.cpython-310.pyc new file mode 100644 index 0000000..a71b226 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_schema_generation_shared.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_signature.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_signature.cpython-310.pyc new file mode 100644 index 0000000..8bae134 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_signature.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_typing_extra.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_typing_extra.cpython-310.pyc new file mode 100644 index 0000000..f4e6b7f Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_typing_extra.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_utils.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_utils.cpython-310.pyc new file mode 100644 index 0000000..8f105c9 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_utils.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_validators.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_validators.cpython-310.pyc new file mode 100644 index 0000000..11d1f85 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/_internal/__pycache__/_validators.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/plugin/__pycache__/__init__.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/plugin/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..002f31c Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/plugin/__pycache__/__init__.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/plugin/__pycache__/_loader.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/plugin/__pycache__/_loader.cpython-310.pyc new file mode 100644 index 0000000..6a04bde Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/plugin/__pycache__/_loader.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic/plugin/__pycache__/_schema_validator.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic/plugin/__pycache__/_schema_validator.cpython-310.pyc new file mode 100644 index 0000000..86bdefb Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic/plugin/__pycache__/_schema_validator.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic_core/__pycache__/__init__.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic_core/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..953edfc Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic_core/__pycache__/__init__.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/pydantic_core/__pycache__/core_schema.cpython-310.pyc b/.venv/lib/python3.10/site-packages/pydantic_core/__pycache__/core_schema.cpython-310.pyc new file mode 100644 index 0000000..8d18233 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/pydantic_core/__pycache__/core_schema.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/typing_inspection/__pycache__/__init__.cpython-310.pyc b/.venv/lib/python3.10/site-packages/typing_inspection/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..8b561b7 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/typing_inspection/__pycache__/__init__.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/typing_inspection/__pycache__/introspection.cpython-310.pyc b/.venv/lib/python3.10/site-packages/typing_inspection/__pycache__/introspection.cpython-310.pyc new file mode 100644 index 0000000..47d3f1c Binary files /dev/null and b/.venv/lib/python3.10/site-packages/typing_inspection/__pycache__/introspection.cpython-310.pyc differ diff --git a/.venv/lib/python3.10/site-packages/typing_inspection/__pycache__/typing_objects.cpython-310.pyc b/.venv/lib/python3.10/site-packages/typing_inspection/__pycache__/typing_objects.cpython-310.pyc new file mode 100644 index 0000000..e607ca6 Binary files /dev/null and b/.venv/lib/python3.10/site-packages/typing_inspection/__pycache__/typing_objects.cpython-310.pyc differ diff --git a/Dockerfile b/Dockerfile index 6105231..74b0c81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,15 @@ -# Use a specific slim Python image that matches openenv.yaml +# Stage 1: Build the frontend +FROM node:20-slim AS frontend-build +WORKDIR /app +COPY package*.json ./ +COPY apps/frontend/package*.json ./apps/frontend/ +RUN rm -rf package-lock.json +RUN npm install +COPY . . +WORKDIR /app/apps/frontend +RUN npx vite build + +# Stage 2: Final image FROM python:3.10-slim # Set environment variables @@ -6,41 +17,33 @@ ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PORT=7860 -# Set working directory WORKDIR /app # Install system dependencies -# Install system dependencies with better error handling and common tools RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ curl \ - git \ && rm -rf /var/lib/apt/lists/* -# Install uv for fast dependency resolution +# Install uv and requirements RUN pip install --no-cache-dir uv - -# Copy ONLY requirements first to leverage Docker cache COPY requirements/ ./requirements/ COPY pyproject.toml uv.lock ./ - -# Install project dependencies with uv RUN uv pip install --system --no-cache -r requirements/backend.txt -r requirements/ml.txt -# Copy the rest of the application +# Copy everything COPY . . -# Set up a new user named "user" with UID 1000 for HF Spaces compatibility +# Copy the bundled frontend from Stage 1 +COPY --from=frontend-build /app/apps/frontend/dist /app/apps/frontend/dist + +# Set up user for HF RUN useradd -m -u 1000 user && \ chown -R user:user /app USER user ENV PATH="/home/user/.local/bin:$PATH" -# Expose the standard Hugging Face Space port EXPOSE 7860 -# No HEALTHCHECK needed for standard HF Spaces and validator deployments, -# simplifies build logic and reduces potential for false failures. - -# Start the application using uvicorn correctly +# Run the backend (which now serves the dist folder) CMD ["uvicorn", "apps.backend.main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md index 3817e1c..d5d2b16 100644 --- a/README.md +++ b/README.md @@ -5,96 +5,132 @@ colorFrom: green colorTo: blue sdk: docker app_port: 7860 -pinned: false +pinned: true --- -# ๐ŸŒฑ EcoNav AI โ€” Exposure Credit OpenEnv +# ๐ŸŒฑ EcoNav AI โ€” Exposure Credit Platform -An OpenEnv-compliant Reinforcement Learning environment built for the Meta AI Environmental Decision Intelligence Hackathon. +[![EcoNav CI/CD](https://github.com/omdharb-bit/EcoNav-AI/actions/workflows/main.yml/badge.svg)](https://github.com/omdharb-bit/EcoNav-AI/actions) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![OpenEnv Compliant](https://img.shields.io/badge/OpenEnv-Compliant-10b981)](https://github.com/OpenEnv/spec) -## ๐ŸŒ Environment Description & Motivation +EcoNav AI is an **intelligent routing platform** and Reinforcement Learning (RL) environment built for the **Meta AI Environmental Decision Intelligence Hackathon**. It empowers users and agents to navigate urban networks while minimizing pollution exposure and maximizing health "Exposure Credits." -EcoNav AI models a realistic urban navigation challenge where an intelligent agent must route through an Indian city network (represented as a dynamic graph). Instead of optimizing solely for distance or time, the agent optimizes to **minimize pollution exposure** and maximize **Exposure Credits**โ€”a gamified health currency. +--- -What makes this environment highly functional and unique is its integration with **live, real-world data**. The environment fetches real-time Air Quality Index (AQI) values from the Open-Meteo API. As the agent navigates segments, edge weights (pollution levels) dynamically shift based on reality. The agent earns credits for traversing clean-air segments (Grade A) and loses credits for heavily polluted segments (Grade F). This fills a real gap in mobility algorithms: exposure-aware routing evaluated as an RL task. +## ๐ŸŒ Project Overview -## ๐Ÿง  Action and Observation Spaces +EcoNav AI transforms navigation from a simple distance-minimization problem into a **multi-objective environmental optimization task**. By integrating real-time Air Quality Index (AQI) data with dynamic traffic simulations, the platform provides a realistic testing ground for eco-aware agents. -### Observation Space -The observation space is a strictly typed JSON dictionary detailing the agent's current location, remaining budget, and available next moves including real-time sensor metrics: -- `current_city` (str): Current city code (e.g. "A"). -- `current_city_name` (str): Human readable city name (e.g. "Delhi"). -- `destination` (str): Target destination code. -- `destination_name` (str): Target human readable city name. -- `visited` (list[str]): City codes already traversed. -- `neighbors` (list[dict]): Available adjacent cities, featuring: - - `distance`: Distance to neighbor (km). - - `aqi`: Real-time Air Quality Index. - - `grade`: Computed health grade (A to F). - - `credit_delta`: Credits that will be won or lost by making this move. -- `exposure_credits` (int): Current credit balance (starts at 100). -- `total_exposure` (float): Cumulative normalized pollution exposure metric. -- `steps_taken` (int): Number of steps used in current episode. -- `max_steps` (int): Step budget limit for the current task. +### Key Innovations: +- **Live AQI Integration**: Real-time pollution data fetched from the Open-Meteo API for 50+ network nodes across India. +- **Exposure Credits**: A gamified health currency that penalizes high-pollution segments (Grade F) and rewards clean-air segments (Grade A). +- **Dynamic Traffic Engine**: Simulates congestion levels that impact both travel time and localized pollution exposure. +- **OpenEnv Compliance**: Fully compatible with the OpenEnv specification for RL evaluation and agent deployment. -### Action Space -- `city` (str): A string specifying the city code to move to next. The chosen code must belong to the list of current `neighbors`. +--- -## ๐Ÿ“œ Tasks & Expected Difficulty +## ๐Ÿ›  Tech Stack -Four discrete tasks of increasing difficulty force the agent to balance trade-offs between step budget, distance, and real-world pollution grids. +The project is architected as a modern **monorepo** for seamless development and deployment. -1. **`easy_route` (Easy)**: Navigate from highly-polluted Delhi to Kolkata. Generous step budget (15 steps) allowing maximum flexibility to seek clean air routes. Baseline passing score: 0.5. -2. **`medium_route` (Medium)**: Same route, but optimized budget (8 steps). Requires balancing direct paths with pollution spikes. Baseline passing score: 0.6. -3. **`hard_pollution_dodge` (Hard)**: Start from Agra to Kolkata with a tight budget (6 steps). Requires aggressive credit optimization to survive the early exposure hits of Northern India. Baseline passing score: 0.7. -4. **`expert_credit_max` (Expert)**: Maximize exposure credits while reaching Kolkata within 10 steps. A rigorous test of the agent's multi-objective reward policy. Baseline passing score: 0.8. +- **Backend**: Python 3.10+, FastAPI, Uvicorn, Pydantic, Torch (ML scoring). +- **Frontend**: Vite, Vanilla JS, CSS3 (Glassmorphism), Leaflet.js (Mapping), Chart.js (Analytics). +- **Infrastructure**: Docker, Turbo (Build system), GitHub Actions (CI/CD). +- **Quality**: Ruff (Linting), Prettier (Formatting). -## ๐Ÿ† Baseline Scores +--- -We ran the included `inference.py` evaluating a greedy baseline heuristic (which strictly prefers neighbors with higher credit deltas and lower AQI values). +## ๐Ÿ“‚ Repository Structure + +```text +EcoNav-AI/ +โ”œโ”€โ”€ apps/ +โ”‚ โ”œโ”€โ”€ backend/ # FastAPI server, AI services, and RL endpoints +โ”‚ โ”œโ”€โ”€ frontend/ # Vite-powered dashboard and mapping interface +โ”‚ โ”œโ”€โ”€ simulator/ # ML training and evaluation logic +โ”‚ โ””โ”€โ”€ web/ # (Deprecated) Static web assets +โ”œโ”€โ”€ packages/ +โ”‚ โ””โ”€โ”€ env_core/ # Shared RL environment logic (OpenEnv Spec) +โ”œโ”€โ”€ requirements/ # Modularized dependency lists +โ”œโ”€โ”€ server/ # Production entry points for Docker/HF Spaces +โ”œโ”€โ”€ turbo.json # Monorepo configuration +โ””โ”€โ”€ inference.py # Baseline agent evaluation script +``` -| Task | Score (0.0 - 1.0) | Grade | Credits Final | Reached Goal | -|---|---|---|---|---| -| `easy_route` | 0.8161 | B | 110 | โœ… | -| `medium_route` | 0.8161 | B | 110 | โœ… | -| `hard_pollution_dodge` | 0.8326 | B | 105 | โœ… | -| `expert_credit_max`| 0.8161 | B | 110 | โœ… | -| **Average Score** | **0.8202** | | | | +--- -## ๐Ÿš€ Setup and Usage Instructions +## ๐Ÿš€ Getting Started + +### Prerequisites +- **Node.js**: >= 18.0.0 +- **Python**: >= 3.10 +- **npm**: >= 11.11.0 + +### Quick Start (Recommended) +1. **Clone the repository**: + ```bash + git clone https://github.com/omdharb-bit/EcoNav-AI.git + cd EcoNav-AI + ``` + +2. **Install Dependencies**: + ```bash + npm install # Installs monorepo build tools + pip install -r requirements/backend.txt # Installs Python backend deps + ``` + +3. **Run Everything**: + Use the Turbo-powered development command to start both the backend and frontend: + ```bash + npm run dev + ``` + *Note: If running on Windows, you may need to set `PYTHONPATH="."` manually if starting the backend via script.* + +### Manual Startup +- **Backend**: `python server/app.py` (Runs on [http://localhost:7860](http://localhost:7860)) +- **Frontend**: `npm run dev --prefix apps/frontend` (Runs on [http://localhost:5173](http://localhost:5173)) -### Run via Docker (Hugging Face Spaces) -The provided `Dockerfile` is optimized to run on Hugging Face Spaces per the OpenEnv specification (automatically exposing port `7860`). -```bash -docker build -t econav-openenv . -docker run -p 7860:7860 econav-openenv -``` - -### Local Development Setup -```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt # OR pip install fastapi uvicorn requests pydantic openai numpy pyyaml +--- -# Start the environment -uvicorn apps.backend.main:app --host 0.0.0.0 --port 8000 -``` +## ๐Ÿง  RL Evaluation (OpenEnv) -### Running Inference -Run the baseline evaluation or LLM-driven agent via `inference.py`. +The environment supports four standard evaluation tasks of increasing complexity: +1. `easy_route`: Delhi to Kolkata (15 steps). +2. `medium_route`: Delhi to Kolkata (8 steps). +3. `hard_pollution_dodge`: Agra to Kolkata (6 steps). +4. `expert_credit_max`: Maximize credits while reaching the goal (10 steps). -**To run the greedy baseline:** +**Baseline Evaluation**: ```bash -export ENV_URL="http://127.0.0.1:8000" +export ENV_URL="http://127.0.0.1:7860" python inference.py ``` -**To run the LLM Agent (OpenAI Client format):** -```bash -export ENV_URL="http://127.0.0.1:8000" -export API_BASE_URL="https://router.huggingface.co/v1" -export MODEL_NAME="meta-llama/Llama-3.1-8B-Instruct" -export HF_TOKEN="your-hf-token" +--- + +## ๐Ÿงช Development & Quality + +We maintain high code quality standards through automated linting and formatting. + +- **Linting (Python)**: `ruff check .` +- **Fix Linting**: `ruff check --fix .` +- **Formatting (JS/TS/MD)**: `npm run format` + +--- + +## ๐Ÿ† Project Status + +| Check | Status | +|---|---| +| **CI/CD Pipeline** | Passing โœ… | +| **OpenEnv Spec** | Compliant (v1.0) ๐ŸŸข | +| **Real-time Data** | Active (Open-Meteo) ๐Ÿ›ฐ๏ธ | +| **Model Version** | EcoScorer v2.1 ๐Ÿง  | + +--- + +## ๐Ÿ“œ License + +Distributed under the **MIT License**. See `LICENSE` for more information. -python inference.py -``` diff --git a/apps/backend/api/graph.py b/apps/backend/api/graph.py new file mode 100644 index 0000000..d089d58 --- /dev/null +++ b/apps/backend/api/graph.py @@ -0,0 +1,89 @@ +from fastapi import APIRouter, HTTPException + +from apps.backend.schemas.graph_schema import CityIn, RoadIn, RoadRemoveIn +from apps.backend.services import graph_store + +router = APIRouter() + + +@router.get("/graph") +def get_graph(): + """Return the full city/road graph.""" + return graph_store.get_graph() + + +@router.get("/cities") +def list_cities(): + """List all city nodes with their metadata.""" + return graph_store.get_cities() + + +@router.get("/roads") +def list_roads(): + """List all roads (edges) in the graph.""" + return graph_store.get_roads() + + +@router.post("/cities") +def add_city(body: CityIn): + """Add a new city node to the graph.""" + try: + cities = graph_store.add_city(body.node_id, body.name, body.lat, body.lng) + return {"status": "success", "message": f"City '{body.name}' added as node {body.node_id.upper()}", "cities": cities} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/cities/{node_id}") +def remove_city(node_id: str): + """Remove a city and all its connected roads.""" + try: + cities = graph_store.remove_city(node_id) + return {"status": "success", "message": f"City '{node_id.upper()}' removed", "cities": cities} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/roads") +def add_road(body: RoadIn): + """Add a road between two existing cities.""" + try: + roads = graph_store.add_road(body.from_id, body.to_id, body.distance, body.pollution) + return {"status": "success", "message": f"Road {body.from_id.upper()} โ†” {body.to_id.upper()} added", "roads": roads} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/roads") +def remove_road(body: RoadRemoveIn): + """Remove a road between two cities.""" + try: + roads = graph_store.remove_road(body.from_id, body.to_id) + return {"status": "success", "message": f"Road {body.from_id.upper()} โ†” {body.to_id.upper()} removed", "roads": roads} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/smart-add") +def smart_add_city(body: dict): + """ + Smart city addition โ€” just provide a city name. + Automatically geocodes, assigns node ID, and connects to nearest cities. + Body: {"city_name": "Mumbai"} + """ + city_name = body.get("city_name", "").strip() + if not city_name: + raise HTTPException(status_code=400, detail="city_name is required") + try: + result = graph_store.smart_add_city(city_name) + return {"status": "success", "message": f"โœ… '{city_name}' added as node {result['node_id']}", **result} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/graph/reset") +def reset_graph(): + """Reset the graph to the default 6-city network.""" + data = graph_store.reset_graph() + return {"status": "success", "message": "Graph reset to defaults", **data} + diff --git a/apps/backend/api/training.py b/apps/backend/api/training.py new file mode 100644 index 0000000..9a57923 --- /dev/null +++ b/apps/backend/api/training.py @@ -0,0 +1,21 @@ +import subprocess + +from fastapi import APIRouter + +from apps.backend.services.eco_route_model import reload_model + +router = APIRouter() + +@router.post("/trigger") +def trigger_training(): + """Manually triggers a training run and reloads the model.""" + try: + # Run the training script synchronously so we can return success + subprocess.run(["python", "models/train_model.py"], check=True) + + # Hot-reload the weights in the FastApi application + reload_model() + + return {"status": "success", "message": "Training completed and model reloaded."} + except Exception as e: + return {"status": "error", "message": str(e)} diff --git a/apps/backend/main.py b/apps/backend/main.py index a64b6fa..a904cf1 100644 --- a/apps/backend/main.py +++ b/apps/backend/main.py @@ -4,14 +4,15 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from apps.backend.api.aqi import router as aqi_router from apps.backend.api.credits import router as credits_router +from apps.backend.api.graph import router as graph_router from apps.backend.api.network import router as network_router from apps.backend.api.route import router as eco_router from apps.backend.api.simulate import router as simulate_router +from apps.backend.api.training import router as training_router from apps.backend.core.config import settings from apps.backend.services.ai_model import choose_best_route from apps.backend.services.aqi_service import fetch_route_cities_aqi @@ -41,6 +42,8 @@ async def lifespan(_: FastAPI): ) app.include_router(eco_router, prefix="/api/v1") +app.include_router(training_router, prefix="/api/v1/train") +app.include_router(graph_router, prefix="/api/v1/graph") app.include_router(aqi_router, prefix="/api/v1") app.include_router(credits_router, prefix="/api/v1") app.include_router(network_router, prefix="/api/v1") @@ -50,19 +53,6 @@ async def lifespan(_: FastAPI): app.include_router(openenv_router) -app.mount("/static", StaticFiles(directory="apps/frontend"), name="static") - - -@app.get("/") -def home(): - return FileResponse("apps/frontend/index.html") - - -@app.get("/health") -def health(): - return {"status": "ok", "service": settings.APP_NAME, "version": settings.VERSION} - - @app.get("/route") def get_best_route(): routes = [ @@ -70,7 +60,21 @@ def get_best_route(): {"path": ["A", "D", "C"], "distance": 6, "traffic": 2}, {"path": ["A", "E", "C"], "distance": 4, "traffic": 6}, ] - best, all_routes = choose_best_route(routes) - return {"best_route": best, "all_routes": all_routes} + + +# Serve frontend - check for production 'dist' folder first, otherwise serve source +base_path = os.path.abspath("apps/frontend") +dist_path = os.path.join(base_path, "dist") + +if os.path.exists(dist_path): + public_path = dist_path + app.mount("/", StaticFiles(directory=dist_path, html=True), name="frontend") +else: + public_path = base_path + # Standard public folder mount for source serving (Vite style) + extra_public = os.path.join(base_path, "public") + if os.path.exists(extra_public): + app.mount("/", StaticFiles(directory=extra_public), name="public_assets") + app.mount("/", StaticFiles(directory=base_path, html=True), name="frontend") diff --git a/apps/backend/schemas/graph_schema.py b/apps/backend/schemas/graph_schema.py new file mode 100644 index 0000000..5e6575e --- /dev/null +++ b/apps/backend/schemas/graph_schema.py @@ -0,0 +1,39 @@ +from typing import Any, Dict, List + +from pydantic import BaseModel, Field + + +class CityIn(BaseModel): + node_id: str = Field(..., description="Unique uppercase letter ID, e.g. 'G'") + name: str = Field(..., description="Human-readable city name, e.g. 'Mumbai'") + lat: float = Field(..., description="Latitude") + lng: float = Field(..., description="Longitude") + + +class RoadIn(BaseModel): + from_id: str = Field(..., alias="from", description="Source city node ID") + to_id: str = Field(..., alias="to", description="Destination city node ID") + distance: float = Field(..., gt=0, description="Distance in km") + pollution: float = Field(..., ge=0, description="AQI / pollution index") + + class Config: + populate_by_name = True + + +class RoadRemoveIn(BaseModel): + from_id: str = Field(..., alias="from", description="Source city node ID") + to_id: str = Field(..., alias="to", description="Destination city node ID") + + class Config: + populate_by_name = True + + +class CityOut(BaseModel): + name: str + lat: float + lng: float + + +class GraphOut(BaseModel): + cities: Dict[str, CityOut] + roads: List[Dict[str, Any]] diff --git a/apps/backend/schemas/route_schema.py b/apps/backend/schemas/route_schema.py index 78e0ce9..e7cc2a4 100644 --- a/apps/backend/schemas/route_schema.py +++ b/apps/backend/schemas/route_schema.py @@ -10,6 +10,13 @@ class RouteRequest(BaseModel): route_type: Optional[str] = "full" +class RouteAlternative(BaseModel): + type: str + route: List[str] + total_distance: float + total_pollution: float + exposure_credits: Optional[Dict[str, Any]] = None + class RouteResponse(BaseModel): route: List[str] total_distance: float @@ -22,3 +29,4 @@ class RouteResponse(BaseModel): data_source: Optional[str] = None exposure_credits: Optional[Dict[str, Any]] = None shortest_credits: Optional[Dict[str, Any]] = None + alternatives: Optional[List[RouteAlternative]] = None diff --git a/apps/backend/services/graph_store.py b/apps/backend/services/graph_store.py new file mode 100644 index 0000000..a29aeb0 --- /dev/null +++ b/apps/backend/services/graph_store.py @@ -0,0 +1,292 @@ +""" +Persistent graph store โ€” cities, coordinates, and roads saved to data/graph.json. +Provides thread-safe read/write with auto-initialization of default data. +""" + +from __future__ import annotations + +import json +import threading +from pathlib import Path +from typing import Any + +BASE_DIR = Path(__file__).resolve().parents[3] # EcoNav-AI root +GRAPH_FILE = BASE_DIR / "data" / "graph.json" + +_lock = threading.Lock() + +# ---- Default Graph (shipped with the app) ---- +DEFAULT_CITIES: dict[str, dict[str, Any]] = { + "A": {"name": "Delhi", "lat": 28.6139, "lng": 77.2090}, + "B": {"name": "Jaipur", "lat": 26.9124, "lng": 75.7873}, + "C": {"name": "Agra", "lat": 27.1767, "lng": 78.0081}, + "D": {"name": "Varanasi", "lat": 25.3176, "lng": 82.9739}, + "E": {"name": "Lucknow", "lat": 26.8467, "lng": 80.9462}, + "F": {"name": "Kolkata", "lat": 22.5726, "lng": 88.3639}, +} + +DEFAULT_ROADS: list[dict[str, Any]] = [ + {"from": "A", "to": "B", "distance": 235.3, "pollution": 10}, + {"from": "A", "to": "C", "distance": 178.1, "pollution": 3}, + {"from": "B", "to": "D", "distance": 739.0, "pollution": 2}, + {"from": "C", "to": "D", "distance": 536.6, "pollution": 6}, + {"from": "C", "to": "E", "distance": 293.4, "pollution": 1}, + {"from": "D", "to": "E", "distance": 264.4, "pollution": 2}, + {"from": "D", "to": "F", "distance": 627.0, "pollution": 8}, + {"from": "E", "to": "F", "distance": 887.0, "pollution": 1}, +] + + +def _default_data() -> dict: + return {"cities": DEFAULT_CITIES, "roads": DEFAULT_ROADS} + + +def _ensure_file() -> None: + if not GRAPH_FILE.exists(): + GRAPH_FILE.parent.mkdir(parents=True, exist_ok=True) + _save(_default_data()) + + +def _load() -> dict: + _ensure_file() + with open(GRAPH_FILE, "r", encoding="utf-8") as fp: + return json.load(fp) + + +def _save(data: dict) -> None: + GRAPH_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(GRAPH_FILE, "w", encoding="utf-8") as fp: + json.dump(data, fp, indent=2, ensure_ascii=False) + + +# ===================== PUBLIC API ===================== + +def get_graph() -> dict: + """Return the full graph data: {cities: {...}, roads: [...]}.""" + with _lock: + return _load() + + +def get_cities() -> dict[str, dict[str, Any]]: + with _lock: + return _load()["cities"] + + +def get_roads() -> list[dict[str, Any]]: + with _lock: + return _load()["roads"] + + +def add_city(node_id: str, name: str, lat: float, lng: float) -> dict: + """Add a new city node. Returns updated cities dict.""" + node_id = node_id.strip().upper() + with _lock: + data = _load() + if node_id in data["cities"]: + raise ValueError(f"City node '{node_id}' already exists") + data["cities"][node_id] = {"name": name.strip(), "lat": lat, "lng": lng} + _save(data) + return data["cities"] + + +def remove_city(node_id: str) -> dict: + """Remove a city and all roads connected to it.""" + node_id = node_id.strip().upper() + with _lock: + data = _load() + if node_id not in data["cities"]: + raise ValueError(f"City node '{node_id}' does not exist") + del data["cities"][node_id] + data["roads"] = [ + r for r in data["roads"] + if r["from"] != node_id and r["to"] != node_id + ] + _save(data) + return data["cities"] + + +def add_road(from_id: str, to_id: str, distance: float, pollution: float) -> list: + """Add a road between two existing cities. Returns updated roads list.""" + from_id = from_id.strip().upper() + to_id = to_id.strip().upper() + with _lock: + data = _load() + if from_id not in data["cities"]: + raise ValueError(f"City node '{from_id}' does not exist") + if to_id not in data["cities"]: + raise ValueError(f"City node '{to_id}' does not exist") + if from_id == to_id: + raise ValueError("Cannot create a road from a city to itself") + # Check for duplicate + for r in data["roads"]: + if (r["from"] == from_id and r["to"] == to_id) or \ + (r["from"] == to_id and r["to"] == from_id): + raise ValueError(f"Road between '{from_id}' and '{to_id}' already exists") + data["roads"].append({ + "from": from_id, + "to": to_id, + "distance": distance, + "pollution": pollution, + }) + _save(data) + return data["roads"] + + +def remove_road(from_id: str, to_id: str) -> list: + """Remove a road between two cities.""" + from_id = from_id.strip().upper() + to_id = to_id.strip().upper() + with _lock: + data = _load() + original_len = len(data["roads"]) + data["roads"] = [ + r for r in data["roads"] + if not ((r["from"] == from_id and r["to"] == to_id) or + (r["from"] == to_id and r["to"] == from_id)) + ] + if len(data["roads"]) == original_len: + raise ValueError(f"No road found between '{from_id}' and '{to_id}'") + _save(data) + return data["roads"] + + +def reset_graph() -> dict: + """Reset to default cities and roads.""" + with _lock: + data = _default_data() + _save(data) + return data + + +# ===================== SMART ADD ===================== + +def next_node_id() -> str: + """Return the next available single-letter node ID (A-Z).""" + data = _load() + used = set(data["cities"].keys()) + for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + if c not in used: + return c + # Fallback to two-letter IDs + for c1 in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + for c2 in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + combo = c1 + c2 + if combo not in used: + return combo + raise ValueError("No available node IDs") + + +def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + """Haversine distance between two lat/lng points in km.""" + import math + R = 6371 # Earth radius in km + dlat = math.radians(lat2 - lat1) + dlng = math.radians(lng2 - lng1) + a = (math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * + math.sin(dlng / 2) ** 2) + return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +def _estimate_pollution(distance_km: float) -> int: + """Estimate a pollution AQI index based on distance (longer routes = varied).""" + import random + random.seed(int(distance_km * 100)) + if distance_km < 200: + return random.randint(1, 4) + elif distance_km < 500: + return random.randint(3, 7) + else: + return random.randint(5, 10) + + + + + +def geocode_city(city_name: str) -> dict | None: + """Look up lat/lng for a city name using OpenStreetMap Nominatim (free, no API key).""" + import requests as _req + try: + resp = _req.get( + "https://nominatim.openstreetmap.org/search", + params={"q": city_name, "format": "json", "limit": 1, "addressdetails": 1}, + headers={"User-Agent": "EcoNav-AI/1.0"}, + timeout=5, + ) + if resp.status_code == 200 and resp.json(): + result = resp.json()[0] + return { + "lat": float(result["lat"]), + "lng": float(result["lon"]), + "display_name": result.get("display_name", city_name), + } + except Exception: + pass + return None + + +def smart_add_city(city_name: str, connect_count: int = 3) -> dict: + """ + Adds a city by name only. Automatically: + 1. Geocodes the city to get lat/lng + 2. Assigns the next available Node ID + 3. Connects to the nearest `connect_count` existing cities with realistic distances + Returns a summary dict with what was created. + """ + city_name = city_name.strip() + if not city_name: + raise ValueError("City name cannot be empty") + + # Check for duplicate name + data = _load() + for info in data["cities"].values(): + if info["name"].lower() == city_name.lower(): + raise ValueError(f"A city named '{city_name}' already exists in the graph") + + # Geocode + geo = geocode_city(city_name) + if geo is None: + raise ValueError(f"Could not find coordinates for '{city_name}'. Check the spelling.") + + lat, lng = geo["lat"], geo["lng"] + node_id = next_node_id() + + # Add the city + with _lock: + data = _load() + data["cities"][node_id] = {"name": city_name, "lat": lat, "lng": lng} + + # Calculate distances to all existing cities and connect to nearest ones + distances = [] + for nid, info in data["cities"].items(): + if nid == node_id: + continue + dist_km = _haversine_km(lat, lng, info["lat"], info["lng"]) + distances.append((nid, info["name"], dist_km)) + + distances.sort(key=lambda x: x[2]) + nearest = distances[:connect_count] + + roads_added = [] + for nid, nname, dist_km in nearest: + distance_km = round(dist_km, 1) + pollution = _estimate_pollution(dist_km) + road = {"from": node_id, "to": nid, "distance": distance_km, "pollution": pollution} + data["roads"].append(road) + roads_added.append({ + "to_id": nid, + "to_name": nname, + "distance_km": distance_km, + "pollution": pollution, + }) + + _save(data) + + return { + "node_id": node_id, + "city_name": city_name, + "lat": lat, + "lng": lng, + "roads_added": roads_added, + } + diff --git a/apps/backend/services/route_service.py b/apps/backend/services/route_service.py index 670034a..dea0151 100644 --- a/apps/backend/services/route_service.py +++ b/apps/backend/services/route_service.py @@ -7,6 +7,7 @@ from __future__ import annotations +from apps.backend.services import graph_store from apps.backend.services.aqi_service import ( fetch_all_cities_aqi, get_edge_pollution, @@ -87,6 +88,15 @@ def _build_graph_with_real_aqi(traffic_multiplier: float = 1.0) -> tuple[Graph, return g, aqi_info +def _build_graph() -> Graph: + """Build a Graph instance from the persisted graph store.""" + g = Graph() + data = graph_store.get_graph() + for node_id in data["cities"]: + g.add_city(node_id) + for road in data["roads"]: + g.add_road(road["from"], road["to"], road["distance"], road["pollution"]) + return g # ===================== # MAIN SERVICE # ===================== @@ -101,15 +111,18 @@ def get_route_service(start: str, end: str, traffic_multiplier: float = 1.0, rou # BASELINE ROUTE (shortest path) baseline = get_route(g, start, end, alpha=1.0) + if baseline is None: + return {"error": f"No path exists between '{start}' and '{end}'. Add roads to connect them."} shortest_path = baseline["path"] shortest_exposure = baseline["total_exposure"] - # RL ENV ROUTE (eco-optimised) - if route_type == "shortest": - eco_path = shortest_path - else: + paths = {} + for rtype in ["shortest", "medium", "full"]: + if rtype == "shortest": + paths[rtype] = shortest_path + continue + env = RLEnv(g, start=start, destination=end) - state = env.reset() path = [state] @@ -130,7 +143,7 @@ def get_route_service(start: str, end: str, traffic_multiplier: float = 1.0, rou type("obj", (), {"total_exposure": 0}), neighbors, destination=end, - route_type=route_type, + route_type=rtype, ) next_state, reward, done = env.step(action) @@ -144,9 +157,11 @@ def get_route_service(start: str, end: str, traffic_multiplier: float = 1.0, rou # FALLBACK if len(path) < 2 or path[-1] != end: - eco_path = shortest_path + paths[rtype] = shortest_path else: - eco_path = path + paths[rtype] = path + + eco_path = paths[route_type] # METRICS eco_exposure = compute_exposure(g, eco_path) @@ -173,6 +188,23 @@ def get_route_service(start: str, end: str, traffic_multiplier: float = 1.0, rou distances=EDGE_DISTANCES, is_eco_route=False, ) + + alternatives = [] + for rtype, rpath in paths.items(): + is_eco_r = rpath != shortest_path + r_credits = calculate_route_credits( + rpath, + distances=EDGE_DISTANCES, + is_eco_route=is_eco_r, + shortest_route=shortest_path, + ) + alternatives.append({ + "type": rtype, + "route": rpath, + "total_distance": compute_distance(g, rpath), + "total_pollution": compute_exposure(g, rpath), + "exposure_credits": route_credits_to_dict(r_credits) + }) # RESPONSE return { @@ -187,4 +219,5 @@ def get_route_service(start: str, end: str, traffic_multiplier: float = 1.0, rou "data_source": "real-time", "exposure_credits": route_credits_to_dict(eco_credits), "shortest_credits": route_credits_to_dict(shortest_credits), + "alternatives": alternatives } diff --git a/apps/frontend/app.js b/apps/frontend/app.js index 4a1ebe9..003997f 100644 --- a/apps/frontend/app.js +++ b/apps/frontend/app.js @@ -14,12 +14,15 @@ const viewTasks = document.getElementById('view-tasks'); // --- Map Initialization --- let map = L.map('map').setView([26.0, 80.0], 5); -L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors © CARTO', - subdomains: 'abcd', - maxZoom: 19 +L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { + attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EAP, and the GIS User Community', + maxZoom: 18 }).addTo(map); +// Apply cinematic dark filter to the map tiles so glowing paths pop +document.getElementById('map').style.background = '#000'; +document.querySelector('#map .leaflet-tile-pane').style.filter = 'brightness(0.35) saturate(0.8) contrast(1.4)'; + let currentRouteLayer = null; let currentShortestRouteLayer = null; let shortestRouteControlMain = null; @@ -29,12 +32,15 @@ let mapSelectMarkers = []; // Traffic Engine Map let mapTraffic = L.map('map-traffic').setView([26.0, 80.0], 5); -L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors © CARTO', - subdomains: 'abcd', - maxZoom: 19 +L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { + attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EAP, and the GIS User Community', + maxZoom: 18 }).addTo(mapTraffic); +// Apply cinematic dark filter to the map tiles so glowing paths pop +document.getElementById('map-traffic').style.background = '#000'; +document.querySelector('#map-traffic .leaflet-tile-pane').style.filter = 'brightness(0.35) saturate(0.8) contrast(1.4)'; + let currentTrafficRouteLayer = null; const CITY_COORDS = { @@ -210,62 +216,7 @@ async function geocodeCity(name) { -// --- Manual Map Selection --- -document.getElementById('btn-map-select').addEventListener('click', () => { - mapSelectionMode = !mapSelectionMode; - const btn = document.getElementById('btn-map-select'); - - if (mapSelectionMode) { - btn.style.background = 'rgba(14, 165, 233, 0.2)'; - btn.style.color = '#0ea5e9'; - btn.style.borderColor = 'rgba(14, 165, 233, 0.5)'; - document.getElementById('map').style.cursor = 'crosshair'; - mapSelectStage = 0; - // Clear previous selection markers - mapSelectMarkers.forEach(m => map.removeLayer(m)); - mapSelectMarkers = []; - - // Tooltip hint - const info = document.getElementById('info-banner-text'); - info.innerHTML = "๐Ÿ“ Click on the map to select Start City"; - info.parentElement.classList.remove('hidden'); - } else { - btn.style.background = ''; - btn.style.color = ''; - btn.style.borderColor = ''; - document.getElementById('map').style.cursor = ''; - } -}); -map.on('click', (e) => { - if (!mapSelectionMode) return; - - const { lat, lng } = e.latlng; - const coordStr = `${lat.toFixed(4)}, ${lng.toFixed(4)}`; - - if (mapSelectStage === 0) { - document.getElementById('start-node').value = coordStr; - const m = L.marker([lat, lng], {icon: L.divIcon({className: 'custom-div-icon', html: "
", iconSize:[12,12]})}).addTo(map); - mapSelectMarkers.push(m); - mapSelectStage = 1; - document.getElementById('info-banner-text').innerHTML = "๐Ÿ Click on the map to select Destination City"; - } else { - document.getElementById('end-node').value = coordStr; - const m = L.marker([lat, lng], {icon: L.divIcon({className: 'custom-div-icon', html: "
", iconSize:[12,12]})}).addTo(map); - mapSelectMarkers.push(m); - - // Reset mode and trigger search - mapSelectionMode = false; - document.getElementById('map').style.cursor = ''; - const btn = document.getElementById('btn-map-select'); - btn.style.background = ''; - btn.style.color = ''; - btn.style.borderColor = ''; - - // Auto-submit - document.getElementById('submit-route').click(); - } -}); function calculateDistance(lat1, lon1, lat2, lon2) { const R = 6371; // km @@ -277,16 +228,12 @@ function calculateDistance(lat1, lon1, lat2, lon2) { return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); } -document.getElementById('route-type').addEventListener('change', () => { - if (!routeResults.classList.contains('hidden')) { - btnSubmit.click(); - } -}); + btnSubmit.addEventListener('click', async () => { const startStr = document.getElementById('start-node').value.trim(); const endStr = document.getElementById('end-node').value.trim(); - const routeType = document.getElementById('route-type').value; + const routeType = 'full'; const multiplier = 1.0; // UI Reset @@ -320,47 +267,49 @@ btnSubmit.addEventListener('click', async () => { const seed = ((startStr.charCodeAt(0) || 0) + (endStr.charCodeAt(0) || 0)) % 100; const pseudoAqi = 50 + seed; // 50-150 AQI range - let mockDist = straightDist * 1.3; - let mockPollution = mockDist * pseudoAqi * 0.04; - let improvement = "0%"; let customCoords = [[startLat, startLng], [endLat, endLng]]; + const makeMockAlt = (type, distMult, pollMult) => { + let mockDist = straightDist * distMult; + let mockPoll = mockDist * pseudoAqi * pollMult; + let coords = [[startLat, startLng]]; + if (type === 'medium') { + coords.push([(startLat+endLat)/2 + 1.5, (startLng+endLng)/2 + 1.5]); + } else if (type === 'full') { + coords.push([(startLat+endLat)/2 + 2.5, (startLng+endLng)/2 - 2.5]); + } + coords.push([endLat, endLng]); + + return { + type: type, + route: coords.map((c, i) => i === 0 ? startStr : (i === coords.length-1 ? endStr : `Detour ${i}`)), + custom_coords: coords, + total_distance: mockDist, + total_pollution: mockPoll, + exposure_credits: { + final_credit_change: type === 'full' ? 45 : (type === 'medium' ? 10 : -35), + overall_grade: type === 'full' ? "A" : (type === 'medium' ? "B" : "D"), + overall_emoji: type === 'full' ? "๐ŸŒŸ" : (type === 'medium' ? "๐ŸŸข" : "๐Ÿ”ด"), + segments: [ + {from: startStr, to: endStr, avg_aqi: pseudoAqi, credit_delta: type === 'full' ? 45 : -35, emoji: type === 'full' ? "๐ŸŒŸ" : "๐Ÿ”ด"} + ] + } + }; + }; + + const alts = [ + makeMockAlt('shortest', 1.15, 0.05), + makeMockAlt('medium', 1.25, 0.04), + makeMockAlt('full', 1.4, 0.035) + ]; - if (routeType === 'shortest') { - mockDist = straightDist * 1.15; - mockPollution = mockDist * pseudoAqi * 0.05; - improvement = "0%"; - customCoords = [[startLat, startLng], [endLat, endLng]]; - } else if (routeType === 'medium') { - mockDist = straightDist * 1.25; - mockPollution = mockDist * pseudoAqi * 0.04; - improvement = "8% (Simulated)"; - const midLat = (startLat + endLat) / 2 + 1.5; - const midLng = (startLng + endLng) / 2 + 1.5; - customCoords = [[startLat, startLng], [midLat, midLng], [endLat, endLng]]; - } else { - mockDist = straightDist * 1.4; - mockPollution = mockDist * pseudoAqi * 0.035; - improvement = "15% (Simulated)"; - const midLat = (startLat + endLat) / 2 + 2.5; - const midLng = (startLng + endLng) / 2 - 2.5; - customCoords = [[startLat, startLng], [midLat, midLng], [endLat, endLng]]; - } - data = { - route: customCoords.map((c, i) => i === 0 ? startStr : (i === customCoords.length - 1 ? endStr : "Detour Node")), + route: [startStr, endStr], custom_coords: customCoords, - total_distance: mockDist, - total_pollution: mockPollution, - shortest_route: [startStr, endStr], - improvement: improvement, - exposure_credits: { - final_credit_change: (Math.random() > 0.5 ? 20 : -10), - overall_grade: "B", - overall_emoji: "๐ŸŸก", - segments: [ - {from: startStr, to: endStr, avg_aqi: pseudoAqi, credit_delta: 0, emoji: "๐Ÿšฆ"} - ] - } + total_distance: straightDist, + total_pollution: straightDist * pseudoAqi * 0.04, + alternatives: alts, + custom_routed_fallback: true, + exposure_credits: alts[2].exposure_credits // Give eco credits by default for global tracker }; } @@ -436,12 +385,48 @@ document.getElementById('submit-traffic').addEventListener('click', async () => const routeArr = data.route || []; // Populate the traffic stats - document.getElementById('traffic-res-dist').textContent = `Distance: ${Math.round(data.total_distance || 0)} km`; - document.getElementById('traffic-res-exp').textContent = `Exposure: ${Math.round(data.total_pollution || 0)}`; - document.getElementById('traffic-res-path').textContent = `Path: ${routeArr.map(n => CITY_NAMES[n] || n).join(' โ†’ ')}`; + const dist = Math.round(data.total_distance || 0); + const exp = Math.round(data.total_pollution || 0); + const baseExp = Math.round(exp / multiplier); + const speed = Math.max(5, Math.round(60 / multiplier)); // Baseline 60km/h + const timeHrs = (dist / speed).toFixed(1); + + document.getElementById('traffic-res-dist').innerHTML = `๐Ÿ“ ${dist} km`; + document.getElementById('traffic-res-base-exp').innerHTML = `๐ŸŒฑ Base Risk: ${baseExp}`; + let expColor = multiplier > 1.2 ? '#f43f5e' : (multiplier < 0.8 ? '#10b981' : '#f59e0b'); + document.getElementById('traffic-res-exp').innerHTML = `๐Ÿšฆ Traffic Risk: ${exp}`; + document.getElementById('traffic-res-exp').style.borderColor = expColor; + + document.getElementById('traffic-res-speed').textContent = `Avg Speed: ${speed} km/h`; + document.getElementById('traffic-res-time').textContent = `${timeHrs} hrs`; - const segStr = data.exposure_credits?.segments?.map(s => `${CITY_NAMES[s.from] || s.from} to ${CITY_NAMES[s.to] || s.to} (AQI: ${s.avg_aqi})`).join(' | '); - document.getElementById('traffic-res-segments').textContent = `Segments: ${segStr || 'N/A'}`; + // Setup banners + let bannerColor = multiplier > 1.2 ? '#ef4444' : (multiplier < 0.8 ? '#10b981' : '#f59e0b'); + document.getElementById('traffic-warning-banner').style.background = bannerColor; + document.getElementById('traffic-multiplier-badge').textContent = `x${multiplier.toFixed(1)} Flow`; + document.getElementById('traffic-multiplier-badge').style.color = bannerColor; + + // Render Timeline UI + const tlContainer = document.getElementById('traffic-segment-timeline'); + tlContainer.innerHTML = ''; + if (data.exposure_credits?.segments) { + let html = '
'; + data.exposure_credits.segments.forEach((s, i) => { + let badgeColor = s.avg_aqi > 150 ? '#ef4444' : (s.avg_aqi > 100 ? '#f59e0b' : '#10b981'); + html += ` +
+
Step ${i+1}: ${CITY_NAMES[s.from]||s.from}
+
๐Ÿ‘‰ ${CITY_NAMES[s.to]||s.to}
+
+ AQI: + ${s.avg_aqi} +
+
+ `; + }); + html += '
'; + tlContainer.innerHTML = html; + } resultsPanel.classList.remove('hidden'); resultsPanel.classList.add('animate-in'); @@ -454,6 +439,28 @@ document.getElementById('submit-traffic').addEventListener('click', async () => if (currentTrafficRouteLayer) mapTraffic.removeLayer(currentTrafficRouteLayer); if (window.trafficRouteControl) mapTraffic.removeControl(window.trafficRouteControl); + // Determine Map Path Color and Animation Speed + let mapLineColor = multiplier > 1.2 ? '#ef4444' : (multiplier < 0.8 ? '#10b981' : '#f59e0b'); + let mapLineClass = multiplier > 1.2 ? 'glowing-route glow-red' : (multiplier < 0.8 ? 'glowing-route glow-green' : 'glowing-route glow-orange'); + + let animSpeed = 1.5 * multiplier; // 0.5x = 0.75s (fast), 3.0x = 4.5s (slow) + + // Dynamically inject animation style for this speed + let styleEl = document.getElementById('dynamic-traffic-style'); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = 'dynamic-traffic-style'; + document.head.appendChild(styleEl); + } + styleEl.innerHTML = ` + @keyframes trafficFlowAnim { + to { stroke-dashoffset: -200; } + } + .flow-anim-cars { + animation: trafficFlowAnim ${Math.max(0.5, animSpeed * 3)}s linear infinite; + } + `; + const coords = data.custom_coords ? data.custom_coords : routeArr.map(n => CITY_COORDS[n]).filter(Boolean); if (coords.length > 0) { currentTrafficRouteLayer = L.featureGroup().addTo(mapTraffic); @@ -466,7 +473,10 @@ document.getElementById('submit-traffic').addEventListener('click', async () => showAlternatives: false, createMarker: function() { return null; }, lineOptions: { - styles: [{ className: 'glowing-route glow-red', weight: 6, color: '#f43f5e' }] + styles: [ + { className: mapLineClass, weight: 8, color: mapLineColor, opacity: 0.7 }, + { className: 'flow-anim-cars', weight: 3, color: '#ffffff', opacity: 1, dashArray: '8, 24' } + ] } }).addTo(mapTraffic); @@ -474,12 +484,14 @@ document.getElementById('submit-traffic').addEventListener('click', async () => const isEndpoint = i === 0 || i === coords.length - 1; L.circleMarker(coord, { radius: isEndpoint ? 8 : 5, - fillColor: isEndpoint ? "#0ea5e9" : "#f43f5e", + fillColor: isEndpoint ? "#0ea5e9" : mapLineColor, color: "#fff", weight: 2, opacity: 1, fillOpacity: 1 - }).bindTooltip(CITY_NAMES[routeArr[i]] || routeArr[i], {permanent: true, direction: "top"}).addTo(currentTrafficRouteLayer); + }).bindTooltip(CITY_NAMES[routeArr[i]] || routeArr[i], { + permanent: true, direction: "top", className: 'city-tooltip', offset: [0, -5] + }).addTo(currentTrafficRouteLayer); }); setTimeout(() => mapTraffic.invalidateSize(), 50); } @@ -532,157 +544,33 @@ document.getElementById('submit-traffic').addEventListener('click', async () => } }); -function renderRouteResults(data) { - const credits = data.exposure_credits || {}; - - // Credits - const cChange = credits.final_credit_change || 0; - const sign = cChange > 0 ? '+' : ''; - const cColor = cChange >= 0 ? '#10b981' : '#f43f5e'; - - const elCred = document.getElementById('res-credits'); - if (elCred) { - elCred.textContent = `${sign}${cChange}`; - elCred.style.color = cColor; - } - - const elGrade = document.getElementById('res-grade'); - if (elGrade) { - elGrade.textContent = `${credits.overall_emoji || ''} Grade ${credits.overall_grade || '?'}`; - } - - // Advantage - document.getElementById('res-advantage').textContent = data.improvement || '0%'; - - // Summary lines - const routeArr = data.route || []; - const shortestArr = data.shortest_route || []; - - document.getElementById('res-path').textContent = routeArr.map(n => CITY_NAMES[n] || n).join(' โ†’ '); - - // Update baseline if available - const shortPathEl = document.getElementById('short-path'); - if (shortPathEl && shortestArr.length > 0) { - shortPathEl.textContent = shortestArr.map(n => CITY_NAMES[n] || n).join(' โ†’ '); - } else if (shortPathEl) { - shortPathEl.textContent = "N/A (Simulated or no baseline)"; - } - - // Stats for Eco Path - document.getElementById('res-dist').textContent = `${Math.round(data.total_distance)} km`; - document.getElementById('res-exp').textContent = Math.round(data.total_pollution); - - // Stats for Shortest Path - const resDistShort = document.getElementById('res-dist-short'); - const resExpShort = document.getElementById('res-exp-short'); - if (resDistShort) resDistShort.textContent = `${Math.round(data.shortest_distance || data.total_distance * 0.9)} km`; - if (resExpShort) resExpShort.textContent = Math.round(data.shortest_exposure || data.total_pollution * 1.2); - - // Map Updates - Clear previous - if (currentRouteLayer) map.removeLayer(currentRouteLayer); - if (window.routeControlMain) map.removeControl(window.routeControlMain); - if (currentShortestRouteLayer) map.removeLayer(currentShortestRouteLayer); - if (shortestRouteControlMain) map.removeControl(shortestRouteControlMain); - - // 1. Draw Eco Route (Green) - const ecoCoords = data.custom_coords ? data.custom_coords : routeArr.map(n => CITY_COORDS[n]).filter(Boolean); - if (ecoCoords.length > 0) { - currentRouteLayer = L.featureGroup().addTo(map); - - window.routeControlMain = L.Routing.control({ - waypoints: ecoCoords.map(c => L.latLng(c[0], c[1])), - routeWhileDragging: false, - addWaypoints: false, - fitSelectedRoutes: true, - showAlternatives: false, - createMarker: function() { return null; }, - lineOptions: { - styles: [{ className: 'glowing-route', weight: 7, color: '#10b981', opacity: 1 }] - } - }).addTo(map); - - // Draw Eco markers - ecoCoords.forEach((coord, i) => { - const isEndpoint = i === 0 || i === ecoCoords.length - 1; - L.circleMarker(coord, { - radius: isEndpoint ? 8 : 5, - fillColor: isEndpoint ? "#0ea5e9" : "#10b981", - color: "#fff", - weight: 2, - opacity: 1, - fillOpacity: 1 - }).bindTooltip(CITY_NAMES[routeArr[i]] || routeArr[i], {permanent: i === 0, direction: "top"}).addTo(currentRouteLayer); - }); - } - - // 2. Draw Shortest Route (Red) - only if different from Eco or explicitly requested - if (shortestArr.length > 0 && (JSON.stringify(routeArr) !== JSON.stringify(shortestArr))) { - const shortCoords = shortestArr.map(n => CITY_COORDS[n]).filter(Boolean); - if (shortCoords.length > 0) { - currentShortestRouteLayer = L.featureGroup().addTo(map); - - shortestRouteControlMain = L.Routing.control({ - waypoints: shortCoords.map(c => L.latLng(c[0], c[1])), - routeWhileDragging: false, - addWaypoints: false, - fitSelectedRoutes: false, // Don't snap camera to shortest, prefer eco - showAlternatives: false, - createMarker: function() { return null; }, - lineOptions: { - styles: [{ className: 'glowing-route-shortest', weight: 4, color: '#ef4444', opacity: 0.7, dashArray: '5, 10' }] - } - }).addTo(map); - - // Subtle markers for shortest path points if they aren't endpoints - shortCoords.forEach((coord, i) => { - const isEndpoint = i === 0 || i === shortCoords.length - 1; - if (!isEndpoint) { - L.circleMarker(coord, { - radius: 3, - fillColor: "#ef4444", - color: "#fff", - weight: 1, - opacity: 0.6, - fillOpacity: 0.6 - }).addTo(currentShortestRouteLayer); - } - }); - } - } - - // Segments +function renderSegmentsData(credits, routeArr) { const segContainer = document.getElementById('segment-container'); if (segContainer) { segContainer.innerHTML = ''; - - if (credits.segments) { - credits.segments.forEach(s => { - const segSign = s.credit_delta > 0 ? '+' : ''; - const row = document.createElement('div'); - row.style.display = 'flex'; - row.style.justifyContent = 'space-between'; - row.style.padding = '0.75rem'; - row.style.background = 'rgba(0,0,0,0.2)'; - row.style.borderRadius = '8px'; - row.style.fontSize = '0.9rem'; - - row.innerHTML = ` -
${CITY_NAMES[s.from] || s.from} โ†’ ${CITY_NAMES[s.to] || s.to}
-
AQI: ${s.avg_aqi}
-
- ${s.emoji} ${segSign}${s.credit_delta} -
- `; - segContainer.appendChild(row); - }); + if (credits && credits.segments) { + credits.segments.forEach(s => { + const segSign = s.credit_delta > 0 ? '+' : ''; + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.justifyContent = 'space-between'; + row.style.padding = '0.75rem'; + row.style.background = 'rgba(0,0,0,0.2)'; + row.style.borderRadius = '8px'; + row.style.fontSize = '0.9rem'; + row.innerHTML = ` +
${CITY_NAMES[s.from] || s.from} โ†’ ${CITY_NAMES[s.to] || s.to}
+
AQI: ${s.avg_aqi}
+
+ ${s.emoji} ${segSign}${s.credit_delta} +
+ `; + segContainer.appendChild(row); + }); + } } - } // closes if (segContainer) - routeResults.classList.remove('hidden'); - routeResults.classList.add('animate-in'); - - // Segments Chart rendering - if (credits.segments && credits.segments.length > 0) { + if (credits && credits.segments && credits.segments.length > 0) { document.getElementById('route-segment-chart-card').style.display = 'block'; const ctxRoute = document.getElementById('routeSegmentChart').getContext('2d'); if (window.routeSegmentChartInstance) window.routeSegmentChartInstance.destroy(); @@ -697,38 +585,334 @@ function renderRouteResults(data) { data: { labels: rLabels, datasets: [ - { - label: 'Avg AQI Risk', - data: rAqi, - backgroundColor: 'rgba(244, 63, 94, 0.7)', - yAxisID: 'y' - }, - { - type: 'line', - label: 'Distance (km)', - data: rDist, - borderColor: '#0ea5e9', - backgroundColor: 'rgba(14, 165, 233, 0.2)', - borderWidth: 2, - tension: 0.3, - yAxisID: 'y1' - } + { label: 'Avg AQI Risk', data: rAqi, backgroundColor: 'rgba(244, 63, 94, 0.7)', yAxisID: 'y' }, + { type: 'line', label: 'Distance (km)', data: rDist, borderColor: '#0ea5e9', backgroundColor: 'rgba(14, 165, 233, 0.2)', borderWidth: 2, tension: 0.3, yAxisID: 'y1' } ] }, options: { - responsive: true, - maintainAspectRatio: false, - scales: { - y: { beginAtZero: true, grid: { color: 'rgba(255, 255, 255, 0.05)' } }, - y1: { position: 'right', grid: { drawOnChartArea: false }, beginAtZero: true }, - x: { grid: { display: false } } - } + responsive: true, maintainAspectRatio: false, + scales: { y: { beginAtZero: true, grid: { color: 'rgba(255, 255, 255, 0.05)' } }, y1: { position: 'right', grid: { drawOnChartArea: false }, beginAtZero: true }, x: { grid: { display: false } } } } }); } else { const segChart = document.getElementById('route-segment-chart-card'); if (segChart) segChart.style.display = 'none'; } +} + +function renderRouteResults(data) { + const routeArr = data.route || []; + const shortestArr = data.shortest_route || []; + + // Map Updates - Clear previous + if (window.routeLayers) { + window.routeLayers.forEach(l => map.removeLayer(l)); + } else { + if (typeof currentRouteLayer !== 'undefined' && currentRouteLayer) map.removeLayer(currentRouteLayer); + if (typeof currentShortestRouteLayer !== 'undefined' && currentShortestRouteLayer) map.removeLayer(currentShortestRouteLayer); + } + if (window.routeControls) { + window.routeControls.forEach(c => map.removeControl(c)); + } else { + if (typeof window.routeControlMain !== 'undefined' && window.routeControlMain) map.removeControl(window.routeControlMain); + if (typeof shortestRouteControlMain !== 'undefined' && shortestRouteControlMain) map.removeControl(shortestRouteControlMain); + } + window.routeLayers = []; + window.routeControls = []; + + if (data.alternatives && data.alternatives.length > 0) { + // Sort alternatives: full (eco) always first, so it never gets dropped as a duplicate + data.alternatives.sort((a, b) => { + const order = { 'full': 1, 'medium': 2, 'shortest': 3 }; + return (order[a.type] || 4) - (order[b.type] || 4); + }); + + const uniquePaths = new Set(); + data.alternatives.forEach((alt, idx) => { + const pathStr = JSON.stringify(alt.route); + if (uniquePaths.has(pathStr)) return; + uniquePaths.add(pathStr); + + const coords = alt.custom_coords ? alt.custom_coords : alt.route.map(n => CITY_COORDS[n]).filter(Boolean); + if (coords.length > 0) { + const layer = L.featureGroup(); // Do NOT addTo map immediately, we only add the first (selected) one + window.routeLayers.push(layer); + + let color = '#10b981'; // Green for full eco + let weight = 7; + let className = 'glowing-route route-path-' + alt.type; + let opacity = 1; + let dashArray = ''; + + if (alt.type === 'shortest') { + color = '#ef4444'; // Red + weight = 4; + className = 'glowing-route-shortest route-path-' + alt.type; + opacity = 0.7; + dashArray = '5, 10'; + } else if (alt.type === 'medium') { + color = '#f59e0b'; // Orange + weight = 5; + className = 'glowing-route route-path-' + alt.type; + opacity = 0.9; + } + + const ctrl = L.Routing.control({ + waypoints: coords.map(c => L.latLng(c[0], c[1])), + routeWhileDragging: false, + addWaypoints: false, + fitSelectedRoutes: alt.type === 'full', + showAlternatives: false, + createMarker: function() { return null; }, + lineOptions: { + styles: [{ className, weight, color, opacity, dashArray }] + } + }).addTo(map); + window.routeControls.push(ctrl); + + coords.forEach((coord, i) => { + const isEndpoint = i === 0 || i === coords.length - 1; + L.circleMarker(coord, { + radius: isEndpoint ? 8 : 5, + fillColor: isEndpoint ? "#0ea5e9" : color, + color: "#fff", + weight: 2, + opacity: 1, + fillOpacity: 1 + }).bindTooltip(CITY_NAMES[alt.route[i]] || alt.route[i], { + permanent: true, + direction: "top", + className: 'city-tooltip', + offset: [0, -5] + }).addTo(layer); + }); + } + }); + } else { + // Fallback for custom routing + const ecoCoords = data.custom_coords ? data.custom_coords : routeArr.map(n => CITY_COORDS[n]).filter(Boolean); + if (ecoCoords.length > 0) { + const layer = L.featureGroup().addTo(map); + window.routeLayers.push(layer); + + const ctrl = L.Routing.control({ + waypoints: ecoCoords.map(c => L.latLng(c[0], c[1])), + routeWhileDragging: false, + addWaypoints: false, + fitSelectedRoutes: true, + showAlternatives: false, + createMarker: function() { return null; }, + lineOptions: { + styles: [{ className: 'glowing-route', weight: 7, color: '#10b981', opacity: 1 }] + } + }).addTo(map); + window.routeControls.push(ctrl); + + ecoCoords.forEach((coord, i) => { + const isEndpoint = i === 0 || i === ecoCoords.length - 1; + L.circleMarker(coord, { + radius: isEndpoint ? 8 : 5, + fillColor: isEndpoint ? "#0ea5e9" : "#10b981", + color: "#fff", + weight: 2, + opacity: 1, + fillOpacity: 1 + }).bindTooltip(CITY_NAMES[routeArr[i]] || routeArr[i], {permanent: i === 0, direction: "top"}).addTo(layer); + }); + } + + if (shortestArr.length > 0 && (JSON.stringify(routeArr) !== JSON.stringify(shortestArr))) { + const shortCoords = shortestArr.map(n => CITY_COORDS[n]).filter(Boolean); + if (shortCoords.length > 0) { + const layer = L.featureGroup().addTo(map); + window.routeLayers.push(layer); + + const ctrl = L.Routing.control({ + waypoints: shortCoords.map(c => L.latLng(c[0], c[1])), + routeWhileDragging: false, + addWaypoints: false, + fitSelectedRoutes: false, + showAlternatives: false, + createMarker: function() { return null; }, + lineOptions: { + styles: [{ className: 'glowing-route-shortest', weight: 4, color: '#ef4444', opacity: 0.7, dashArray: '5, 10' }] + } + }).addTo(map); + window.routeControls.push(ctrl); + + shortCoords.forEach((coord, i) => { + const isEndpoint = i === 0 || i === shortCoords.length - 1; + if (!isEndpoint) { + L.circleMarker(coord, { + radius: 3, + fillColor: "#ef4444", + color: "#fff", + weight: 1, + opacity: 0.6, + fillOpacity: 0.6 + }).addTo(layer); + } + }); + } + } + } + + // Render Alternatives in Sidebar + const container = document.getElementById('route-alternatives-container'); + if (container) { + container.innerHTML = ''; + if (data.alternatives && data.alternatives.length > 0) { + const shortestAlt = data.alternatives.find(a => a.type === 'shortest') || data.alternatives[0]; + const baseDist = shortestAlt.total_distance; + const basePoll = shortestAlt.total_pollution; + + const uniquePathsUI = new Set(); + let currentLayerIdx = 0; + data.alternatives.forEach((alt, idx) => { + const pathStr = JSON.stringify(alt.route); + if (uniquePathsUI.has(pathStr)) return; + uniquePathsUI.add(pathStr); + + const thisLayerIdx = currentLayerIdx; + currentLayerIdx++; + + const div = document.createElement('div'); + div.className = 'card-inner'; + let bg = 'rgba(16, 185, 129, 0.05)'; + let border = 'rgba(16, 185, 129, 0.2)'; + let textCol = '#10b981'; + let title = '๐ŸŒฟ Eco Route (Low AQI)'; + + if (alt.type === 'shortest') { + bg = 'rgba(239, 68, 68, 0.05)'; + border = 'rgba(239, 68, 68, 0.2)'; + textCol = '#ef4444'; + title = '๐ŸŽ Shortest Path'; + } else if(alt.type === 'medium') { + bg = 'rgba(245, 158, 11, 0.05)'; + border = 'rgba(245, 158, 11, 0.2)'; + textCol = '#f59e0b'; + title = 'โš–๏ธ Balanced Route'; + } + + let crd = alt.exposure_credits?.final_credit_change || 0; + let grade = alt.exposure_credits?.overall_grade || '?'; + let emoji = alt.exposure_credits?.overall_emoji || 'โšช'; + + let diffDist = Math.round(alt.total_distance - baseDist); + let diffPoll = Math.round(alt.total_pollution - basePoll); + let distStr = diffDist > 0 ? `+${diffDist} km` : `${diffDist} km`; + let pollStr = diffPoll > 0 ? `+${diffPoll} Risk` : `${diffPoll} Risk`; + + let compHTML = ''; + if (alt.type === 'shortest') { + compHTML = `Baseline`; + } else if (diffPoll < 0) { + compHTML = `${distStr} | ${pollStr}`; + } else { + compHTML = `${distStr} | ${pollStr}`; + } + + let badgeHTML = ''; + if (alt.type === 'full') { + badgeHTML = `โœจ Recommended`; + } + + div.style = `background: ${bg}; border: 1px solid ${border}; border-radius: 12px; padding: 1rem; cursor: pointer; transition: all 0.2s; position:relative;`; + div.innerHTML = ` +
+
+

+ ${title} + ${emoji} Grade ${grade} + ${badgeHTML} +

+
+ Exposure: ${Math.round(alt.total_pollution)} + Credits: ${crd>0?'+':''}${crd} +
+
+
+
+ ${Math.round(alt.total_distance)} km +
+
+ ${compHTML} +
+
+
+
+ PATH: ${alt.route.map(n => CITY_NAMES[n] || n).join(' โ†’ ')} +
+ `; + + div.onclick = () => { + Array.from(container.children).forEach(c => { + c.style.border = '1px solid rgba(255,255,255,0.05)'; + c.style.boxShadow = 'none'; + }); + div.style.border = `2px solid ${textCol}`; + div.style.boxShadow = `0 0 15px ${border}`; + + if (window.routeLayers) { + window.routeLayers.forEach((l, idx) => { + if (idx === thisLayerIdx) { + map.addLayer(l); // Show markers and names for selected + } else { + map.removeLayer(l); // Hide others + } + }); + + // Path SVG Opacity logic + setTimeout(() => { + const pathsDOM = document.querySelectorAll('path.glowing-route, path.glowing-route-shortest, path[class*="route-path-"]'); + pathsDOM.forEach(el => { + if (el.classList.contains('route-path-' + alt.type)) { + el.style.opacity = '1'; + if(el.style.strokeOpacity) el.style.strokeOpacity = '1'; + if (el.parentNode) el.parentNode.appendChild(el); // Bring to front SVG trick + } else { + el.style.opacity = '0.15'; + if(el.style.strokeOpacity) el.style.strokeOpacity = '0.15'; + } + }); + }, 50); // Small delay to let LRM finish drawing if still busy + } + + renderSegmentsData(alt.exposure_credits, alt.route); + }; + + container.appendChild(div); + }); + + // Default selection + setTimeout(() => { + const reqType = 'full'; + let pickIdx = data.alternatives.findIndex(a => a.type === reqType); + if (pickIdx === -1) pickIdx = 0; + + // Map the pickIdx to the deduplicated DOM list + const u = new Set(); + let actualDomIdx = 0; + for(let i=0; i<=pickIdx; i++){ + const p = JSON.stringify(data.alternatives[i].route); + if(!u.has(p)) { u.add(p); actualDomIdx = u.size - 1; } + } + + if (container.children[actualDomIdx]) container.children[actualDomIdx].click(); + }, 50); + } else { + renderSegmentsData(data.exposure_credits, routeArr); + } + } else { + renderSegmentsData(data.exposure_credits, routeArr); + } + + const routeResults = document.getElementById('route-results'); + if (routeResults) { + routeResults.classList.remove('hidden'); + routeResults.classList.add('animate-in'); + } // Fix Leaflet grey map loading issue setTimeout(() => { @@ -740,7 +924,7 @@ function renderRouteResults(data) { // --- AQI Matrix Logic --- window.aqiLoaded = false; -let aqiChartInstance = null; +var aqiChartInstance = null; async function loadAqiData() { const container = document.getElementById('aqi-container'); diff --git a/apps/frontend/components/ai_engine.py b/apps/frontend/components/ai_engine.py new file mode 100644 index 0000000..326292e --- /dev/null +++ b/apps/frontend/components/ai_engine.py @@ -0,0 +1,147 @@ +import streamlit as st + + +def render_ai_engine(): + """ + Renders the AI Engine Control Panel tab with model status, + manual training trigger, and a live training log area. + """ + st.markdown('
๐Ÿค– AI Engine Control Panel
', unsafe_allow_html=True) + st.markdown( + '

' + 'Monitor and control the EcoNav AI model. Trigger retraining cycles on demand ' + 'and view real-time model performance metrics.' + '

', + unsafe_allow_html=True, + ) + + # ---- Model Status ---- + col1, col2, col3 = st.columns(3) + with col1: + st.markdown( + """ +
+
๐Ÿง 
+
Model Status
+
+ Active +
+
+ """, + unsafe_allow_html=True, + ) + with col2: + st.markdown( + """ +
+
โšก
+
Architecture
+
+ Weighted Score Model +
+
+ """, + unsafe_allow_html=True, + ) + with col3: + st.markdown( + """ +
+
๐Ÿ”„
+
Hot-Reload
+
+ Enabled +
+
+ """, + unsafe_allow_html=True, + ) + + st.markdown("
", unsafe_allow_html=True) + + # ---- Decision Pipeline Diagram ---- + st.markdown('
๐Ÿ—๏ธ Decision Pipeline
', unsafe_allow_html=True) + + arch_dot = """ + digraph Pipeline { + rankdir=LR; + bgcolor="transparent"; + node [shape=record, style="rounded,filled", fontname="Inter", fontsize=10, + fillcolor="#1e293b", fontcolor="#e2e8f0", color="#334155", penwidth=1.5]; + edge [color="#475569", penwidth=1.2, arrowsize=0.8]; + + input [label="{Input|start, end nodes}", fillcolor="#064e3b", color="#10b981", fontcolor="#34d399"]; + graph [label="{Graph Engine|Build city network}"]; + rl [label="{RL Agent|Explore paths}"]; + scorer [label="{Eco Scorer|distance ร— pollution}"]; + output [label="{Output|Eco Route + Metrics}", fillcolor="#1e1b4b", color="#6366f1", fontcolor="#a5b4fc"]; + + input -> graph -> rl -> scorer -> output; + } + """ + st.graphviz_chart(arch_dot, use_container_width=True) + + st.markdown("
", unsafe_allow_html=True) + + # ---- Manual Training Trigger ---- + st.markdown('
๐ŸŽฏ Manual Training
', unsafe_allow_html=True) + + if "training_log" not in st.session_state: + st.session_state.training_log = [] + + if st.button("๐Ÿš€ Trigger Model Retraining", key="btn_train", use_container_width=True): + with st.spinner("Running ML training pipeline & hot-reloading weightsโ€ฆ"): + from services.api_client import trigger_training + result = trigger_training() + + if result.get("status") == "success": + st.session_state.training_log.append("โœ… " + result.get("message", "Training completed.")) + st.success(result["message"]) + else: + st.session_state.training_log.append("โŒ " + result.get("message", "Unknown error.")) + st.error(result.get("message", "Training failed.")) + + if st.session_state.training_log: + st.markdown('
๐Ÿ“‹ Training Log
', unsafe_allow_html=True) + log_html = "
" + for entry in st.session_state.training_log: + log_html += f"
{entry}
" + log_html += "
" + st.markdown(log_html, unsafe_allow_html=True) + + st.markdown("
", unsafe_allow_html=True) + + # ---- Weight Info ---- + st.markdown('
๐Ÿ“Š Model Weights
', unsafe_allow_html=True) + + weights_html = """ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
WeightDefaultDescription
distance_weight0.15How much route length matters
pollution_weight0.45How much AQI pollution matters
exposure_weight0.40Cumulative exposure penalty
+
+ """ + st.markdown(weights_html, unsafe_allow_html=True) diff --git a/apps/frontend/components/city_manager.py b/apps/frontend/components/city_manager.py new file mode 100644 index 0000000..8bb33c4 --- /dev/null +++ b/apps/frontend/components/city_manager.py @@ -0,0 +1,376 @@ +""" +๐Ÿ™๏ธ City Manager โ€” Streamlit component for managing cities. +Just type a city name and everything is handled automatically! +""" + +import streamlit as st +from services.api_client import ( + add_road, + fetch_graph, + remove_city, + remove_road, + reset_graph, + smart_add_city, +) + + +def render_city_manager(): + st.markdown( + '
๐Ÿ™๏ธ City & Road Manager
', + unsafe_allow_html=True, + ) + st.markdown( + '

' + 'Just type a city name โ€” coordinates, Node ID, and road connections ' + 'are all handled automatically. Changes take effect immediately.' + '

', + unsafe_allow_html=True, + ) + + # โ”€โ”€ Fetch current graph โ”€โ”€ + graph = fetch_graph() + if graph is None: + st.error("โš ๏ธ Cannot load graph data. Make sure the backend is running.") + return + + cities = graph.get("cities", {}) + roads = graph.get("roads", []) + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # SMART ADD โ€” JUST TYPE A CITY NAME + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + st.markdown( + """ +
+
+ ๐ŸŒ + Quick Add City +
+
+ Type any city name โ€” we'll auto-detect its location and connect it to nearby cities in the network. +
+
+ """, + unsafe_allow_html=True, + ) + + with st.form("smart_add_form", clear_on_submit=True): + new_city = st.text_input( + "๐Ÿ™๏ธ City Name", + placeholder="e.g. Mumbai, Chennai, Hyderabad, Pune...", + help="Just type the city name. Coordinates, Node ID, and road connections are auto-generated.", + ) + submitted = st.form_submit_button( + "โšก Add City Automatically", use_container_width=True + ) + + if submitted: + if not new_city.strip(): + st.warning("Please enter a city name.") + else: + with st.spinner(f"๐Ÿ” Looking up '{new_city.strip()}'... geocoding & connecting..."): + result = smart_add_city(new_city.strip()) + + if result.get("status") == "success": + st.success(f"โœ… **{result['city_name']}** added as node **{result['node_id']}**") + + # Show what was auto-created + roads_added = result.get("roads_added", []) + if roads_added: + road_summary = "" + for r in roads_added: + road_summary += ( + f'
' + f'{result["city_name"]} โ†’ {r["to_name"]}' + f'{r["distance_km"]} km ยท AQI {r["pollution"]}' + f'
' + ) + st.markdown( + f""" +
+
+ ๐Ÿ”— Auto-connected Roads +
+ {road_summary} +
+ """, + unsafe_allow_html=True, + ) + + # Show coordinates + st.markdown( + f""" +
+ ๐Ÿ“ Coordinates: + {result['lat']:.4f}, {result['lng']:.4f} +
+ """, + unsafe_allow_html=True, + ) + + st.rerun() + else: + detail = result.get("detail", result.get("message", "Unknown error")) + st.error(f"โŒ {detail}") + + st.markdown("
", unsafe_allow_html=True) + + # โ”€โ”€ Stats row โ”€โ”€ + col_s1, col_s2, col_s3 = st.columns(3) + with col_s1: + st.markdown( + f""" +
+
๐Ÿ™๏ธ
+
Cities
+
{len(cities)}
+
+ """, + unsafe_allow_html=True, + ) + with col_s2: + st.markdown( + f""" +
+
๐Ÿ›ค๏ธ
+
Roads
+
{len(roads)}
+
+ """, + unsafe_allow_html=True, + ) + with col_s3: + used_ids = sorted(cities.keys()) + next_id = _suggest_next_id(used_ids) + st.markdown( + f""" +
+
๐Ÿ†”
+
Next Available ID
+
{next_id}
+
+ """, + unsafe_allow_html=True, + ) + + st.markdown("
", unsafe_allow_html=True) + + # โ”€โ”€ Existing Cities Table โ”€โ”€ + st.markdown( + '
๐Ÿ“ Current Cities
', + unsafe_allow_html=True, + ) + + if cities: + table_rows = "" + for nid, info in sorted(cities.items()): + table_rows += (f'' + f'{nid}' + f'{info["name"]}' + f'{info["lat"]:.4f}' + f'{info["lng"]:.4f}' + f'') + th_style = 'text-align:left;padding:12px;color:#94a3b8;font-weight:600;font-size:0.8rem;text-transform:uppercase' + html = (f'
' + f'' + f'' + f'' + f'' + f'' + f'' + f'{table_rows}
IDCity NameLatitudeLongitude
') + st.markdown(html, unsafe_allow_html=True) + else: + st.info("No cities in the graph yet.") + + st.markdown("
", unsafe_allow_html=True) + + # โ”€โ”€ Existing Roads Table โ”€โ”€ + st.markdown( + '
๐Ÿ›ค๏ธ Current Roads
', + unsafe_allow_html=True, + ) + + if roads: + road_rows = "" + for r in roads: + from_name = cities.get(r["from"], {}).get("name", r["from"]) if isinstance(cities.get(r["from"]), dict) else r["from"] + to_name = cities.get(r["to"], {}).get("name", r["to"]) if isinstance(cities.get(r["to"]), dict) else r["to"] + road_rows += (f'' + f'{r["from"]}' + f'{r["to"]}' + f'{from_name} โ†’ {to_name}' + f'{r["distance"]} km' + f'{r["pollution"]}' + f'') + th_style = 'text-align:left;padding:12px;color:#94a3b8;font-weight:600;font-size:0.8rem;text-transform:uppercase' + html = (f'
' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'{road_rows}
FromToRouteDistancePollution
') + st.markdown(html, unsafe_allow_html=True) + else: + st.info("No roads in the graph yet.") + + st.markdown("
", unsafe_allow_html=True) + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # MANAGEMENT SECTION (collapsible) + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + with st.expander("๐Ÿ”ง Advanced Management", expanded=False): + + # โ”€โ”€ Remove City โ”€โ”€ + st.markdown( + '
๐Ÿ—‘๏ธ Remove City
', + unsafe_allow_html=True, + ) + st.markdown( + '

' + 'โš ๏ธ Removing a city also deletes all roads connected to it.

', + unsafe_allow_html=True, + ) + + node_list = sorted(cities.keys()) + if node_list: + city_labels_del = {nid: f"{cities[nid]['name']} ({nid})" for nid in node_list} + with st.form("remove_city_form"): + del_city = st.selectbox( + "Select city to remove", + node_list, + format_func=lambda x: city_labels_del[x], + key="del_city_sel", + ) + del_submitted = st.form_submit_button("๐Ÿ—‘๏ธ Remove City", use_container_width=True) + if del_submitted: + result = remove_city(del_city) + if result.get("status") == "success": + st.success(f"โœ… {result['message']}") + st.rerun() + else: + detail = result.get("detail", result.get("message", "Unknown error")) + st.error(f"โŒ {detail}") + + st.markdown("
", unsafe_allow_html=True) + + # โ”€โ”€ Remove Road โ”€โ”€ + st.markdown( + '
โœ‚๏ธ Remove Road
', + unsafe_allow_html=True, + ) + + if roads: + road_labels = { + i: f"{r['from']} โ†’ {r['to']} ({cities.get(r['from'], {}).get('name', r['from'])} โ†’ {cities.get(r['to'], {}).get('name', r['to'])})" + for i, r in enumerate(roads) + } + with st.form("remove_road_form"): + road_idx = st.selectbox( + "Select road to remove", + list(road_labels.keys()), + format_func=lambda x: road_labels[x], + key="del_road_sel", + ) + road_del_submitted = st.form_submit_button("โœ‚๏ธ Remove Road", use_container_width=True) + if road_del_submitted: + r = roads[road_idx] + result = remove_road(r["from"], r["to"]) + if result.get("status") == "success": + st.success(f"โœ… {result['message']}") + st.rerun() + else: + detail = result.get("detail", result.get("message", "Unknown error")) + st.error(f"โŒ {detail}") + else: + st.info("No roads to remove.") + + st.markdown("
", unsafe_allow_html=True) + + # โ”€โ”€ Add Manual Road โ”€โ”€ + st.markdown( + '
๐Ÿ”— Add Road Manually
', + unsafe_allow_html=True, + ) + + if len(node_list) >= 2: + city_labels = {nid: f"{cities[nid]['name']} ({nid})" for nid in node_list} + with st.form("add_road_form", clear_on_submit=True): + col_r1, col_r2 = st.columns(2) + with col_r1: + road_from = st.selectbox( + "From City", node_list, + format_func=lambda x: city_labels[x], key="road_from", + ) + with col_r2: + road_to = st.selectbox( + "To City", node_list, + format_func=lambda x: city_labels[x], + index=min(1, len(node_list) - 1), key="road_to", + ) + col_r3, col_r4 = st.columns(2) + with col_r3: + road_dist = st.number_input( + "Distance (km)", value=100.0, min_value=0.1, step=10.0, format="%.1f" + ) + with col_r4: + road_poll = st.number_input( + "Pollution (AQI)", value=5.0, min_value=0.0, step=0.5, format="%.1f" + ) + road_submitted = st.form_submit_button("๐Ÿ›ค๏ธ Add Road", use_container_width=True) + if road_submitted: + if road_from == road_to: + st.warning("Cannot create a road from a city to itself.") + else: + result = add_road(road_from, road_to, road_dist, road_poll) + if result.get("status") == "success": + st.success(f"โœ… {result['message']}") + st.rerun() + else: + detail = result.get("detail", result.get("message", "Unknown error")) + st.error(f"โŒ {detail}") + else: + st.info("Add at least 2 cities before creating roads.") + + st.markdown("
", unsafe_allow_html=True) + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # RESET + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + st.markdown( + '
๐Ÿ”„ Reset to Defaults
', + unsafe_allow_html=True, + ) + st.markdown( + '

' + 'This will restore the original 6-city network and remove all custom cities/roads.

', + unsafe_allow_html=True, + ) + + if st.button("๐Ÿ”„ Reset Graph", key="btn_reset_graph", use_container_width=True): + result = reset_graph() + if result.get("status") == "success": + st.success(f"โœ… {result['message']}") + st.rerun() + else: + detail = result.get("detail", result.get("message", "Unknown error")) + st.error(f"โŒ {detail}") + + +def _suggest_next_id(used: list[str]) -> str: + """Suggest the next available single-letter ID.""" + for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + if c not in used: + return c + return "??" diff --git a/apps/frontend/components/environment_manager.py b/apps/frontend/components/environment_manager.py new file mode 100644 index 0000000..311cf62 --- /dev/null +++ b/apps/frontend/components/environment_manager.py @@ -0,0 +1,158 @@ +import random + +import streamlit as st + + +def render_environment_manager(): + """ + Renders the Environment Manager tab where users can manually add, + view, and remove custom places from the routing graph. + """ + st.markdown('
๐ŸŒ Manage Your Environment
', unsafe_allow_html=True) + st.markdown( + '

' + 'Add, view, and remove places that form the routing network. ' + 'These places will be used as waypoints when finding eco-friendly routes.' + '

', + unsafe_allow_html=True, + ) + + # Initialize session state for places + if "custom_places" not in st.session_state: + st.session_state.custom_places = [ + {"name": "Home", "traffic": "Low", "pollution": "Low"}, + {"name": "Downtown", "traffic": "High", "pollution": "High"}, + {"name": "Central Park", "traffic": "Low", "pollution": "Low"}, + {"name": "Industrial Zone", "traffic": "Medium", "pollution": "High"}, + {"name": "Office", "traffic": "Medium", "pollution": "Medium"}, + ] + + # ---- Add Place Form ---- + st.markdown('
โž• Add a New Place
', unsafe_allow_html=True) + + col1, col2, col3 = st.columns([2, 1, 1]) + with col1: + new_name = st.text_input("Place Name", placeholder="e.g. Riverside Park", key="add_place_name") + with col2: + new_traffic = st.selectbox("Base Traffic", ["Low", "Medium", "High"], key="add_place_traffic") + with col3: + new_pollution = st.selectbox("Base Pollution (AQI)", ["Low", "Medium", "High"], key="add_place_pollution") + + add_clicked = st.button("Add Place", key="btn_add_place", use_container_width=True) + if add_clicked: + if new_name.strip(): + existing_names = [p["name"].lower() for p in st.session_state.custom_places] + if new_name.strip().lower() in existing_names: + st.warning(f"โš ๏ธ A place named **{new_name.strip()}** already exists.") + else: + st.session_state.custom_places.append({ + "name": new_name.strip(), + "traffic": new_traffic, + "pollution": new_pollution, + }) + st.success(f"โœ… **{new_name.strip()}** added to the environment!") + st.rerun() + else: + st.warning("Please enter a place name.") + + st.markdown("
", unsafe_allow_html=True) + + # ---- Display Existing Places ---- + st.markdown('
๐Ÿ“ Current Places
', unsafe_allow_html=True) + + if not st.session_state.custom_places: + st.info("No places added yet. Add your first place above!") + else: + for idx, place in enumerate(st.session_state.custom_places): + traffic_color = {"Low": "#10b981", "Medium": "#f59e0b", "High": "#ef4444"}.get(place["traffic"], "#94a3b8") + pollution_color = {"Low": "#10b981", "Medium": "#f59e0b", "High": "#ef4444"}.get(place["pollution"], "#94a3b8") + + place_html = f""" +
+
+
๐Ÿ“ {place['name']}
+
+ โ— Traffic: {place['traffic']} +    + โ— Pollution: {place['pollution']} +
+
+
+ """ + st.markdown(place_html, unsafe_allow_html=True) + + st.markdown("
", unsafe_allow_html=True) + + # Remove place + remove_name = st.selectbox( + "Remove a place", + options=[""] + [p["name"] for p in st.session_state.custom_places], + key="remove_place_select", + ) + if st.button("Remove Selected Place", key="btn_remove_place"): + if remove_name: + st.session_state.custom_places = [ + p for p in st.session_state.custom_places if p["name"] != remove_name + ] + st.success(f"๐Ÿ—‘๏ธ **{remove_name}** removed.") + st.rerun() + + +def get_place_names(): + """Returns the list of user-defined place names for use in routing.""" + if "custom_places" not in st.session_state: + return ["A", "B", "C", "D", "E"] + names = [p["name"] for p in st.session_state.custom_places] + return names if names else ["A", "B", "C", "D", "E"] + + +def build_routes_from_places(source: str, destination: str): + """ + Builds mock routes between source and destination using + intermediate places from the session state for the waypoints. + """ + places = get_place_names() + intermediates = [p for p in places if p != source and p != destination] + + traffic_map = {} + pollution_map = {} + if "custom_places" in st.session_state: + for p in st.session_state.custom_places: + t = {"Low": 2, "Medium": 5, "High": 8}.get(p["traffic"], 3) + q = {"Low": 30, "Medium": 100, "High": 250}.get(p["pollution"], 50) + traffic_map[p["name"]] = t + pollution_map[p["name"]] = q + + routes = [] + # Direct route + avg_traffic = (traffic_map.get(source, 3) + traffic_map.get(destination, 3)) // 2 + routes.append({ + "path": [source, destination], + "distance": random.randint(5, 12), + "traffic": avg_traffic, + "fuel": 0, + }) + + # Routes through single intermediates (up to 4) + random.shuffle(intermediates) + for mid in intermediates[:4]: + avg_t = (traffic_map.get(source, 3) + traffic_map.get(mid, 4) + traffic_map.get(destination, 3)) // 3 + routes.append({ + "path": [source, mid, destination], + "distance": random.randint(8, 20), + "traffic": avg_t, + "fuel": 0, + }) + + # One route through two intermediates (if enough places) + if len(intermediates) >= 2: + pair = random.sample(intermediates[:4], 2) + avg_t = sum(traffic_map.get(n, 4) for n in [source] + pair + [destination]) // 4 + routes.append({ + "path": [source, pair[0], pair[1], destination], + "distance": random.randint(14, 25), + "traffic": avg_t, + "fuel": 0, + }) + + return routes diff --git a/apps/frontend/hero-bg.png b/apps/frontend/hero-bg.png new file mode 100644 index 0000000..c8f386c Binary files /dev/null and b/apps/frontend/hero-bg.png differ diff --git a/apps/frontend/index.html b/apps/frontend/index.html index d6ca799..736ccf7 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -7,7 +7,7 @@ - + @@ -178,19 +178,7 @@

๐Ÿ“ Destination City -
- -
- - -
-
+ @@ -209,43 +197,14 @@

-

๐Ÿ† Route Comparison

+

๐Ÿ† Select Your Route

-
- -
-

- ๐ŸŒฟ Recommended Eco Route -

-
-
๐Ÿ—บ 0 km
-
๐ŸŒซ Exposure: 0
-
๐Ÿ“ˆ Saved: 0%
-
-
- Eco Path: ... -
Grade: ?
-
-
- - -
-

- ๐ŸŽ Direct (Shortest) Path -

-
-
๐Ÿ—บ 0 km
-
๐ŸŒซ Exposure: 0
-
๐Ÿ“ˆ Baseline
-
-
- Shortest Path: ... -
-
+
+
- โ„น๏ธ Comparison complete. Green paths prioritize health; Red paths prioritize speed. + โ„น๏ธ Green paths prioritize health; Red paths prioritize distance; Orange balance both.
@@ -324,15 +283,27 @@

๐Ÿ“Š AQI Comparison Overview