feat: Add operator dashboard, alerts, analytics, and simulator pages
Browse files- Implemented new HTML templates for operator dashboard, alerts, analytics, and simulator functionalities.
- Created a structured layout for displaying alerts and analytics data.
- Added JavaScript files for real-time updates and notifications.
- Established a testing framework with comprehensive tests for API endpoints, authentication, crowd and queue services, and simulator logic.
- Included fixtures for testing with authenticated clients for both attendees and operators.
This view is limited to 50 files because it contains too many changes. ย See raw diff
- .env.example +26 -0
- .gitignore +32 -0
- Dockerfile +26 -0
- app.py +131 -0
- blueprints/__init__.py +1 -0
- blueprints/api.py +359 -0
- blueprints/attendee.py +113 -0
- blueprints/auth.py +139 -0
- blueprints/operator.py +105 -0
- blueprints/sse.py +67 -0
- config.py +71 -0
- implementation_plan.md +535 -0
- models/__init__.py +1 -0
- models/event.py +135 -0
- models/queue.py +132 -0
- models/venue.py +138 -0
- requirements.txt +12 -0
- services/__init__.py +1 -0
- services/crowd_service.py +129 -0
- services/firebase_service.py +220 -0
- services/gemini_service.py +130 -0
- services/maps_service.py +217 -0
- services/notification_service.py +128 -0
- services/queue_service.py +185 -0
- services/simulator.py +364 -0
- services/translation_service.py +97 -0
- static/css/base.css +1276 -0
- static/js/accessibility.js +85 -0
- static/js/app.js +116 -0
- static/js/chatbot.js +133 -0
- static/js/dashboard.js +90 -0
- static/js/heatmap.js +232 -0
- static/js/notifications.js +59 -0
- static/js/queue.js +110 -0
- static/js/simulator-ui.js +79 -0
- static/js/sse-client.js +84 -0
- static/js/wayfinding.js +267 -0
- templates/attendee/heatmap.html +102 -0
- templates/attendee/home.html +163 -0
- templates/attendee/navigate.html +82 -0
- templates/attendee/profile.html +77 -0
- templates/attendee/queues.html +113 -0
- templates/auth/login.html +59 -0
- templates/auth/register.html +64 -0
- templates/base.html +49 -0
- templates/components/chatbot.html +28 -0
- templates/components/nav.html +66 -0
- templates/errors/404.html +12 -0
- templates/errors/500.html +12 -0
- templates/operator/alerts.html +40 -0
.env.example
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Flask
|
| 2 |
+
FLASK_SECRET_KEY=your-secret-key-here
|
| 3 |
+
FLASK_ENV=development
|
| 4 |
+
FLASK_DEBUG=1
|
| 5 |
+
|
| 6 |
+
# Firebase
|
| 7 |
+
FIREBASE_CREDENTIALS_PATH=path/to/serviceAccountKey.json
|
| 8 |
+
FIREBASE_API_KEY=your-firebase-api-key
|
| 9 |
+
FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
| 10 |
+
FIREBASE_PROJECT_ID=your-project-id
|
| 11 |
+
FIREBASE_STORAGE_BUCKET=your-project.appspot.com
|
| 12 |
+
FIREBASE_MESSAGING_SENDER_ID=your-sender-id
|
| 13 |
+
FIREBASE_APP_ID=your-app-id
|
| 14 |
+
|
| 15 |
+
# Google Gemini
|
| 16 |
+
GEMINI_API_KEY=your-gemini-api-key
|
| 17 |
+
|
| 18 |
+
# Google Maps
|
| 19 |
+
GOOGLE_MAPS_API_KEY=your-google-maps-api-key
|
| 20 |
+
|
| 21 |
+
# Google Cloud Translation
|
| 22 |
+
GOOGLE_TRANSLATE_API_KEY=your-translate-api-key
|
| 23 |
+
|
| 24 |
+
# App Config
|
| 25 |
+
USE_MOCK_SERVICES=true
|
| 26 |
+
VENUE_CAPACITY=50000
|
.gitignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment
|
| 2 |
+
.env
|
| 3 |
+
*.env
|
| 4 |
+
serviceAccountKey.json
|
| 5 |
+
|
| 6 |
+
# Python
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*$py.class
|
| 10 |
+
*.so
|
| 11 |
+
.Python
|
| 12 |
+
venv/
|
| 13 |
+
.venv/
|
| 14 |
+
env/
|
| 15 |
+
*.egg-info/
|
| 16 |
+
dist/
|
| 17 |
+
build/
|
| 18 |
+
|
| 19 |
+
# IDE
|
| 20 |
+
.vscode/
|
| 21 |
+
.idea/
|
| 22 |
+
*.swp
|
| 23 |
+
*.swo
|
| 24 |
+
|
| 25 |
+
# OS
|
| 26 |
+
.DS_Store
|
| 27 |
+
Thumbs.db
|
| 28 |
+
|
| 29 |
+
# Testing
|
| 30 |
+
.pytest_cache/
|
| 31 |
+
htmlcov/
|
| 32 |
+
.coverage
|
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Create a user to run the app (Hugging Face Spaces requirement)
|
| 6 |
+
RUN useradd -m -u 1000 user
|
| 7 |
+
|
| 8 |
+
# Install required packages
|
| 9 |
+
COPY requirements.txt /app/
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
+
RUN pip install --no-cache-dir gunicorn
|
| 12 |
+
|
| 13 |
+
# Copy the rest of the application
|
| 14 |
+
COPY . /app/
|
| 15 |
+
|
| 16 |
+
# Set ownership to the non-root user so the app can create and write to game.db
|
| 17 |
+
RUN chown -R user:user /app
|
| 18 |
+
|
| 19 |
+
# Switch to the non-root user
|
| 20 |
+
USER user
|
| 21 |
+
|
| 22 |
+
# Expose port 7860 which is the default for Hugging Face Spaces
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
|
| 25 |
+
# Initialize the database and then start gunicorn
|
| 26 |
+
CMD ["sh", "-c", "python -c 'from app import init_db; init_db()' && gunicorn -b 0.0.0.0:7860 app:app"]
|
app.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VenueFlow โ Flask application factory."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from flask import Flask, redirect, url_for, render_template
|
| 5 |
+
from config import get_config
|
| 6 |
+
|
| 7 |
+
# Configure logging
|
| 8 |
+
logging.basicConfig(
|
| 9 |
+
level=logging.INFO,
|
| 10 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 11 |
+
)
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def create_app(config_class=None):
|
| 16 |
+
"""Application factory."""
|
| 17 |
+
app = Flask(__name__)
|
| 18 |
+
|
| 19 |
+
# Load configuration
|
| 20 |
+
if config_class is None:
|
| 21 |
+
config_class = get_config()
|
| 22 |
+
app.config.from_object(config_class)
|
| 23 |
+
|
| 24 |
+
# โโโ Security Headers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 25 |
+
@app.after_request
|
| 26 |
+
def set_security_headers(response):
|
| 27 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 28 |
+
response.headers["X-Frame-Options"] = "DENY"
|
| 29 |
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
| 30 |
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 31 |
+
if not app.debug:
|
| 32 |
+
response.headers["Strict-Transport-Security"] = (
|
| 33 |
+
"max-age=31536000; includeSubDomains"
|
| 34 |
+
)
|
| 35 |
+
return response
|
| 36 |
+
|
| 37 |
+
# โโโ Initialize Services โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 38 |
+
from services.firebase_service import init_firebase
|
| 39 |
+
from services.gemini_service import init_gemini
|
| 40 |
+
from services.translation_service import init_translation
|
| 41 |
+
|
| 42 |
+
init_firebase(app)
|
| 43 |
+
init_gemini(app)
|
| 44 |
+
init_translation(app)
|
| 45 |
+
|
| 46 |
+
# โโโ Initialize Demo Data โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 47 |
+
from services.simulator import (
|
| 48 |
+
create_demo_venue,
|
| 49 |
+
create_demo_queue_stations,
|
| 50 |
+
create_demo_event,
|
| 51 |
+
Simulator,
|
| 52 |
+
)
|
| 53 |
+
from services.crowd_service import CrowdService
|
| 54 |
+
from services.queue_service import QueueService
|
| 55 |
+
from services.maps_service import MapsService
|
| 56 |
+
from services.notification_service import NotificationService
|
| 57 |
+
|
| 58 |
+
venue = create_demo_venue()
|
| 59 |
+
event = create_demo_event()
|
| 60 |
+
crowd_service = CrowdService()
|
| 61 |
+
queue_service = QueueService()
|
| 62 |
+
maps_service = MapsService(venue)
|
| 63 |
+
notification_service = NotificationService()
|
| 64 |
+
|
| 65 |
+
# Register queue stations
|
| 66 |
+
queue_stations = create_demo_queue_stations()
|
| 67 |
+
queue_service.register_stations(queue_stations)
|
| 68 |
+
|
| 69 |
+
# Create simulator
|
| 70 |
+
simulator = Simulator(venue, queue_service, crowd_service, notification_service, event)
|
| 71 |
+
|
| 72 |
+
# Wire SSE broadcasting
|
| 73 |
+
from blueprints.sse import broadcast_update
|
| 74 |
+
|
| 75 |
+
def on_sim_update():
|
| 76 |
+
broadcast_update({
|
| 77 |
+
"venue": venue.to_dict(),
|
| 78 |
+
"event": event.to_dict(),
|
| 79 |
+
"sim_status": simulator.get_status(),
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
simulator.set_update_callback(on_sim_update)
|
| 83 |
+
|
| 84 |
+
# Store services in app config for blueprint access
|
| 85 |
+
app.config["VENUE_OBJ"] = venue
|
| 86 |
+
app.config["EVENT_OBJ"] = event
|
| 87 |
+
app.config["CROWD_SERVICE"] = crowd_service
|
| 88 |
+
app.config["QUEUE_SERVICE"] = queue_service
|
| 89 |
+
app.config["MAPS_SERVICE"] = maps_service
|
| 90 |
+
app.config["NOTIFICATION_SERVICE"] = notification_service
|
| 91 |
+
app.config["SIMULATOR_OBJ"] = simulator
|
| 92 |
+
|
| 93 |
+
# Run initial tick to populate data
|
| 94 |
+
simulator.tick()
|
| 95 |
+
|
| 96 |
+
# โโโ Register Blueprints โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 97 |
+
from blueprints.auth import auth_bp
|
| 98 |
+
from blueprints.attendee import attendee_bp
|
| 99 |
+
from blueprints.operator import operator_bp
|
| 100 |
+
from blueprints.api import api_bp
|
| 101 |
+
from blueprints.sse import sse_bp
|
| 102 |
+
|
| 103 |
+
app.register_blueprint(auth_bp)
|
| 104 |
+
app.register_blueprint(attendee_bp)
|
| 105 |
+
app.register_blueprint(operator_bp)
|
| 106 |
+
app.register_blueprint(api_bp)
|
| 107 |
+
app.register_blueprint(sse_bp)
|
| 108 |
+
|
| 109 |
+
# โโโ Root Route โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 110 |
+
@app.route("/")
|
| 111 |
+
def index():
|
| 112 |
+
return redirect(url_for("auth.login"))
|
| 113 |
+
|
| 114 |
+
# โโโ Error Handlers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 115 |
+
@app.errorhandler(404)
|
| 116 |
+
def not_found(e):
|
| 117 |
+
return render_template("errors/404.html"), 404
|
| 118 |
+
|
| 119 |
+
@app.errorhandler(500)
|
| 120 |
+
def server_error(e):
|
| 121 |
+
return render_template("errors/500.html"), 500
|
| 122 |
+
|
| 123 |
+
logger.info("๐๏ธ VenueFlow app created successfully")
|
| 124 |
+
return app
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# โโโ Entry Point โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 128 |
+
|
| 129 |
+
if __name__ == "__main__":
|
| 130 |
+
app = create_app()
|
| 131 |
+
app.run(host="0.0.0.0", port=5000, debug=True, threaded=True)
|
blueprints/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Blueprints package."""
|
blueprints/api.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""REST API endpoints for real-time data access."""
|
| 2 |
+
|
| 3 |
+
from flask import Blueprint, jsonify, request, session, current_app
|
| 4 |
+
import bleach
|
| 5 |
+
|
| 6 |
+
api_bp = Blueprint("api", __name__, url_prefix="/api")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _get_services():
|
| 10 |
+
return {
|
| 11 |
+
"venue": current_app.config.get("VENUE_OBJ"),
|
| 12 |
+
"event": current_app.config.get("EVENT_OBJ"),
|
| 13 |
+
"crowd": current_app.config.get("CROWD_SERVICE"),
|
| 14 |
+
"queue": current_app.config.get("QUEUE_SERVICE"),
|
| 15 |
+
"maps": current_app.config.get("MAPS_SERVICE"),
|
| 16 |
+
"notifications": current_app.config.get("NOTIFICATION_SERVICE"),
|
| 17 |
+
"simulator": current_app.config.get("SIMULATOR_OBJ"),
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# โโโ Crowd Data โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@api_bp.route("/crowd/summary")
|
| 25 |
+
def crowd_summary():
|
| 26 |
+
"""Get venue crowd summary."""
|
| 27 |
+
svc = _get_services()
|
| 28 |
+
if not svc["crowd"] or not svc["venue"]:
|
| 29 |
+
return jsonify({"error": "Service unavailable"}), 503
|
| 30 |
+
return jsonify(svc["crowd"].get_venue_summary(svc["venue"]))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@api_bp.route("/crowd/heatmap")
|
| 34 |
+
def crowd_heatmap():
|
| 35 |
+
"""Get heatmap data for visualization."""
|
| 36 |
+
svc = _get_services()
|
| 37 |
+
if not svc["crowd"] or not svc["venue"]:
|
| 38 |
+
return jsonify({"error": "Service unavailable"}), 503
|
| 39 |
+
return jsonify(svc["crowd"].get_crowd_flow_data(svc["venue"]))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@api_bp.route("/crowd/zone/<zone_id>")
|
| 43 |
+
def crowd_zone(zone_id):
|
| 44 |
+
"""Get specific zone data."""
|
| 45 |
+
svc = _get_services()
|
| 46 |
+
venue = svc["venue"]
|
| 47 |
+
if not venue:
|
| 48 |
+
return jsonify({"error": "Service unavailable"}), 503
|
| 49 |
+
zone = venue.get_zone(zone_id)
|
| 50 |
+
if not zone:
|
| 51 |
+
return jsonify({"error": "Zone not found"}), 404
|
| 52 |
+
trend = svc["crowd"].get_zone_trend(zone_id) if svc["crowd"] else []
|
| 53 |
+
return jsonify({"zone": zone.to_dict(), "trend": trend})
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@api_bp.route("/crowd/recommend")
|
| 57 |
+
def crowd_recommend():
|
| 58 |
+
"""Get recommended least crowded zones."""
|
| 59 |
+
svc = _get_services()
|
| 60 |
+
zone_type = request.args.get("type")
|
| 61 |
+
return jsonify(svc["crowd"].get_recommended_zones(svc["venue"], zone_type))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# โโโ Queue Data โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@api_bp.route("/queue/summary")
|
| 68 |
+
def queue_summary():
|
| 69 |
+
"""Get queue overview."""
|
| 70 |
+
svc = _get_services()
|
| 71 |
+
if not svc["queue"]:
|
| 72 |
+
return jsonify({"error": "Service unavailable"}), 503
|
| 73 |
+
return jsonify(svc["queue"].get_queue_summary())
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@api_bp.route("/queue/stations")
|
| 77 |
+
def queue_stations():
|
| 78 |
+
"""Get all queue stations."""
|
| 79 |
+
svc = _get_services()
|
| 80 |
+
category = request.args.get("category")
|
| 81 |
+
if category:
|
| 82 |
+
return jsonify(svc["queue"].get_stations_by_category(category))
|
| 83 |
+
return jsonify(svc["queue"].get_all_stations())
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@api_bp.route("/queue/shortest")
|
| 87 |
+
def queue_shortest():
|
| 88 |
+
"""Find shortest queue."""
|
| 89 |
+
svc = _get_services()
|
| 90 |
+
category = request.args.get("category")
|
| 91 |
+
result = svc["queue"].get_shortest_queue(category)
|
| 92 |
+
if result:
|
| 93 |
+
return jsonify(result)
|
| 94 |
+
return jsonify({"error": "No open stations found"}), 404
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@api_bp.route("/queue/join", methods=["POST"])
|
| 98 |
+
def queue_join():
|
| 99 |
+
"""Join a virtual queue."""
|
| 100 |
+
svc = _get_services()
|
| 101 |
+
user_id = session.get("user_id")
|
| 102 |
+
if not user_id:
|
| 103 |
+
return jsonify({"error": "Not authenticated"}), 401
|
| 104 |
+
|
| 105 |
+
data = request.get_json()
|
| 106 |
+
station_id = data.get("station_id", "") if data else ""
|
| 107 |
+
if not station_id:
|
| 108 |
+
return jsonify({"error": "Station ID required"}), 400
|
| 109 |
+
|
| 110 |
+
ticket = svc["queue"].join_virtual_queue(user_id, station_id)
|
| 111 |
+
if ticket:
|
| 112 |
+
return jsonify(ticket)
|
| 113 |
+
return jsonify({"error": "Could not join queue"}), 400
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@api_bp.route("/queue/tickets")
|
| 117 |
+
def queue_tickets():
|
| 118 |
+
"""Get user's virtual queue tickets."""
|
| 119 |
+
svc = _get_services()
|
| 120 |
+
user_id = session.get("user_id")
|
| 121 |
+
if not user_id:
|
| 122 |
+
return jsonify({"error": "Not authenticated"}), 401
|
| 123 |
+
return jsonify(svc["queue"].get_user_tickets(user_id))
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@api_bp.route("/queue/cancel", methods=["POST"])
|
| 127 |
+
def queue_cancel():
|
| 128 |
+
"""Cancel a virtual queue ticket."""
|
| 129 |
+
svc = _get_services()
|
| 130 |
+
user_id = session.get("user_id")
|
| 131 |
+
if not user_id:
|
| 132 |
+
return jsonify({"error": "Not authenticated"}), 401
|
| 133 |
+
|
| 134 |
+
data = request.get_json()
|
| 135 |
+
ticket_id = data.get("ticket_id", "") if data else ""
|
| 136 |
+
if svc["queue"].cancel_ticket(ticket_id, user_id):
|
| 137 |
+
return jsonify({"success": True})
|
| 138 |
+
return jsonify({"error": "Could not cancel ticket"}), 400
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# โโโ Navigation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@api_bp.route("/navigate/route")
|
| 145 |
+
def navigate_route():
|
| 146 |
+
"""Get route between two zones."""
|
| 147 |
+
svc = _get_services()
|
| 148 |
+
from_zone = request.args.get("from")
|
| 149 |
+
to_zone = request.args.get("to")
|
| 150 |
+
accessible = request.args.get("accessible", "false").lower() == "true"
|
| 151 |
+
|
| 152 |
+
if not from_zone or not to_zone:
|
| 153 |
+
return jsonify({"error": "from and to zone IDs required"}), 400
|
| 154 |
+
|
| 155 |
+
route = svc["maps"].find_route(from_zone, to_zone, accessible)
|
| 156 |
+
if route:
|
| 157 |
+
return jsonify(route)
|
| 158 |
+
return jsonify({"error": "Route not found"}), 404
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@api_bp.route("/navigate/nearest")
|
| 162 |
+
def navigate_nearest():
|
| 163 |
+
"""Find nearest zone of a type."""
|
| 164 |
+
svc = _get_services()
|
| 165 |
+
from_zone = request.args.get("from")
|
| 166 |
+
target_type = request.args.get("type")
|
| 167 |
+
accessible = request.args.get("accessible", "false").lower() == "true"
|
| 168 |
+
|
| 169 |
+
if not from_zone or not target_type:
|
| 170 |
+
return jsonify({"error": "from zone and type required"}), 400
|
| 171 |
+
|
| 172 |
+
result = svc["maps"].find_nearest(from_zone, target_type, accessible)
|
| 173 |
+
if result:
|
| 174 |
+
return jsonify(result)
|
| 175 |
+
return jsonify({"error": "No matching zone found"}), 404
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@api_bp.route("/navigate/pois")
|
| 179 |
+
def navigate_pois():
|
| 180 |
+
"""Get all points of interest."""
|
| 181 |
+
svc = _get_services()
|
| 182 |
+
return jsonify(svc["maps"].get_points_of_interest())
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# โโโ Chatbot โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@api_bp.route("/chat", methods=["POST"])
|
| 189 |
+
def chat():
|
| 190 |
+
"""Send a message to the AI chatbot."""
|
| 191 |
+
from services import gemini_service
|
| 192 |
+
|
| 193 |
+
user_id = session.get("user_id")
|
| 194 |
+
if not user_id:
|
| 195 |
+
return jsonify({"error": "Not authenticated"}), 401
|
| 196 |
+
|
| 197 |
+
data = request.get_json()
|
| 198 |
+
message = bleach.clean(data.get("message", "").strip()) if data else ""
|
| 199 |
+
if not message:
|
| 200 |
+
return jsonify({"error": "Message required"}), 400
|
| 201 |
+
if len(message) > 500:
|
| 202 |
+
return jsonify({"error": "Message too long (max 500 chars)"}), 400
|
| 203 |
+
|
| 204 |
+
# Build venue context for the AI
|
| 205 |
+
svc = _get_services()
|
| 206 |
+
venue_context = None
|
| 207 |
+
if svc["crowd"] and svc["venue"]:
|
| 208 |
+
summary = svc["crowd"].get_venue_summary(svc["venue"])
|
| 209 |
+
queue_summary = svc["queue"].get_queue_summary() if svc["queue"] else {}
|
| 210 |
+
venue_context = {
|
| 211 |
+
"overall_occupancy": summary.get("overall_occupancy"),
|
| 212 |
+
"hotspots": [h["name"] for h in summary.get("hotspots", [])[:3]],
|
| 213 |
+
"queue_categories": queue_summary.get("categories", {}),
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
history = data.get("history", [])
|
| 217 |
+
response = gemini_service.get_chat_response(message, venue_context, history)
|
| 218 |
+
|
| 219 |
+
return jsonify({"response": response})
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@api_bp.route("/chat/suggestions")
|
| 223 |
+
def chat_suggestions():
|
| 224 |
+
"""Get chatbot quick-reply suggestions."""
|
| 225 |
+
from services import gemini_service
|
| 226 |
+
return jsonify(gemini_service.get_quick_suggestions())
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# โโโ Notifications โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@api_bp.route("/notifications")
|
| 233 |
+
def notifications():
|
| 234 |
+
"""Get notifications for the current user."""
|
| 235 |
+
svc = _get_services()
|
| 236 |
+
role = session.get("user_role", "attendee")
|
| 237 |
+
limit = request.args.get("limit", 20, type=int)
|
| 238 |
+
return jsonify(svc["notifications"].get_alerts(role=role, limit=limit))
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
@api_bp.route("/notifications/unread")
|
| 242 |
+
def notifications_unread():
|
| 243 |
+
"""Get unread notification count."""
|
| 244 |
+
svc = _get_services()
|
| 245 |
+
role = session.get("user_role", "attendee")
|
| 246 |
+
return jsonify({"count": svc["notifications"].get_unread_count(role)})
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
@api_bp.route("/notifications/read", methods=["POST"])
|
| 250 |
+
def notifications_read():
|
| 251 |
+
"""Mark a notification as read."""
|
| 252 |
+
svc = _get_services()
|
| 253 |
+
data = request.get_json()
|
| 254 |
+
alert_id = data.get("id", "") if data else ""
|
| 255 |
+
if svc["notifications"].mark_read(alert_id):
|
| 256 |
+
return jsonify({"success": True})
|
| 257 |
+
return jsonify({"error": "Alert not found"}), 404
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# โโโ Simulator Control โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@api_bp.route("/simulator/status")
|
| 264 |
+
def simulator_status():
|
| 265 |
+
"""Get simulator status."""
|
| 266 |
+
svc = _get_services()
|
| 267 |
+
simulator = svc["simulator"]
|
| 268 |
+
if not simulator:
|
| 269 |
+
return jsonify({"error": "Simulator unavailable"}), 503
|
| 270 |
+
return jsonify(simulator.get_status())
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@api_bp.route("/simulator/start", methods=["POST"])
|
| 274 |
+
def simulator_start():
|
| 275 |
+
"""Start the simulation."""
|
| 276 |
+
if session.get("user_role") not in ("operator", "admin"):
|
| 277 |
+
return jsonify({"error": "Operator access required"}), 403
|
| 278 |
+
svc = _get_services()
|
| 279 |
+
svc["simulator"].start()
|
| 280 |
+
return jsonify({"success": True, "status": svc["simulator"].get_status()})
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
@api_bp.route("/simulator/stop", methods=["POST"])
|
| 284 |
+
def simulator_stop():
|
| 285 |
+
"""Stop the simulation."""
|
| 286 |
+
if session.get("user_role") not in ("operator", "admin"):
|
| 287 |
+
return jsonify({"error": "Operator access required"}), 403
|
| 288 |
+
svc = _get_services()
|
| 289 |
+
svc["simulator"].stop()
|
| 290 |
+
return jsonify({"success": True, "status": svc["simulator"].get_status()})
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@api_bp.route("/simulator/phase", methods=["POST"])
|
| 294 |
+
def simulator_phase():
|
| 295 |
+
"""Change simulation phase."""
|
| 296 |
+
if session.get("user_role") not in ("operator", "admin"):
|
| 297 |
+
return jsonify({"error": "Operator access required"}), 403
|
| 298 |
+
svc = _get_services()
|
| 299 |
+
data = request.get_json()
|
| 300 |
+
phase = data.get("phase", "") if data else ""
|
| 301 |
+
svc["simulator"].set_phase(phase)
|
| 302 |
+
return jsonify({"success": True, "status": svc["simulator"].get_status()})
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@api_bp.route("/simulator/speed", methods=["POST"])
|
| 306 |
+
def simulator_speed():
|
| 307 |
+
"""Change simulation speed."""
|
| 308 |
+
if session.get("user_role") not in ("operator", "admin"):
|
| 309 |
+
return jsonify({"error": "Operator access required"}), 403
|
| 310 |
+
svc = _get_services()
|
| 311 |
+
data = request.get_json()
|
| 312 |
+
speed = float(data.get("speed", 1.0)) if data else 1.0
|
| 313 |
+
svc["simulator"].set_speed(speed)
|
| 314 |
+
return jsonify({"success": True, "status": svc["simulator"].get_status()})
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
# โโโ Translation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
@api_bp.route("/translate", methods=["POST"])
|
| 321 |
+
def translate():
|
| 322 |
+
"""Translate text."""
|
| 323 |
+
from services import translation_service
|
| 324 |
+
|
| 325 |
+
data = request.get_json()
|
| 326 |
+
text = data.get("text", "") if data else ""
|
| 327 |
+
target = data.get("target_lang", "en") if data else "en"
|
| 328 |
+
|
| 329 |
+
if not text:
|
| 330 |
+
return jsonify({"error": "Text required"}), 400
|
| 331 |
+
|
| 332 |
+
translated = translation_service.translate_text(text, target)
|
| 333 |
+
return jsonify({"translated": translated, "target_lang": target})
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
@api_bp.route("/languages")
|
| 337 |
+
def languages():
|
| 338 |
+
"""Get supported languages."""
|
| 339 |
+
from services import translation_service
|
| 340 |
+
return jsonify(translation_service.get_supported_languages())
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
# โโโ Event Info โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
@api_bp.route("/event")
|
| 347 |
+
def event_info():
|
| 348 |
+
"""Get current event information."""
|
| 349 |
+
svc = _get_services()
|
| 350 |
+
event = svc["event"]
|
| 351 |
+
return jsonify(event.to_dict() if event else {})
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
@api_bp.route("/venue")
|
| 355 |
+
def venue_info():
|
| 356 |
+
"""Get venue information."""
|
| 357 |
+
svc = _get_services()
|
| 358 |
+
venue = svc["venue"]
|
| 359 |
+
return jsonify(venue.to_dict() if venue else {})
|
blueprints/attendee.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Attendee-facing routes."""
|
| 2 |
+
|
| 3 |
+
from flask import Blueprint, render_template, session, current_app
|
| 4 |
+
|
| 5 |
+
attendee_bp = Blueprint("attendee", __name__, url_prefix="/app")
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _get_services():
|
| 9 |
+
"""Get shared services from app context."""
|
| 10 |
+
return {
|
| 11 |
+
"venue": current_app.config.get("VENUE_OBJ"),
|
| 12 |
+
"event": current_app.config.get("EVENT_OBJ"),
|
| 13 |
+
"crowd": current_app.config.get("CROWD_SERVICE"),
|
| 14 |
+
"queue": current_app.config.get("QUEUE_SERVICE"),
|
| 15 |
+
"maps": current_app.config.get("MAPS_SERVICE"),
|
| 16 |
+
"notifications": current_app.config.get("NOTIFICATION_SERVICE"),
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@attendee_bp.before_request
|
| 21 |
+
def require_login():
|
| 22 |
+
"""Require login for all attendee pages."""
|
| 23 |
+
from blueprints.auth import login_required
|
| 24 |
+
# Check inline to avoid decorator issues with blueprints
|
| 25 |
+
if "user_id" not in session:
|
| 26 |
+
from flask import redirect, url_for, flash
|
| 27 |
+
flash("Please log in to continue.", "warning")
|
| 28 |
+
return redirect(url_for("auth.login"))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@attendee_bp.route("/")
|
| 32 |
+
def home():
|
| 33 |
+
"""Attendee home page with venue overview."""
|
| 34 |
+
svc = _get_services()
|
| 35 |
+
venue = svc["venue"]
|
| 36 |
+
event = svc["event"]
|
| 37 |
+
crowd = svc["crowd"]
|
| 38 |
+
queue = svc["queue"]
|
| 39 |
+
|
| 40 |
+
venue_summary = crowd.get_venue_summary(venue) if crowd else {}
|
| 41 |
+
queue_summary = queue.get_queue_summary() if queue else {}
|
| 42 |
+
|
| 43 |
+
return render_template(
|
| 44 |
+
"attendee/home.html",
|
| 45 |
+
venue=venue.to_dict() if venue else {},
|
| 46 |
+
event=event.to_dict() if event else {},
|
| 47 |
+
venue_summary=venue_summary,
|
| 48 |
+
queue_summary=queue_summary,
|
| 49 |
+
user_name=session.get("user_name", "Guest"),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@attendee_bp.route("/heatmap")
|
| 54 |
+
def heatmap():
|
| 55 |
+
"""Live crowd heatmap page."""
|
| 56 |
+
svc = _get_services()
|
| 57 |
+
venue = svc["venue"]
|
| 58 |
+
crowd = svc["crowd"]
|
| 59 |
+
|
| 60 |
+
heatmap_data = crowd.get_crowd_flow_data(venue) if crowd else {}
|
| 61 |
+
|
| 62 |
+
return render_template(
|
| 63 |
+
"attendee/heatmap.html",
|
| 64 |
+
venue=venue.to_dict() if venue else {},
|
| 65 |
+
heatmap_data=heatmap_data,
|
| 66 |
+
maps_api_key=current_app.config.get("GOOGLE_MAPS_API_KEY", ""),
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@attendee_bp.route("/queues")
|
| 71 |
+
def queues():
|
| 72 |
+
"""Queue status and virtual queue page."""
|
| 73 |
+
svc = _get_services()
|
| 74 |
+
queue = svc["queue"]
|
| 75 |
+
|
| 76 |
+
queue_summary = queue.get_queue_summary() if queue else {}
|
| 77 |
+
user_tickets = queue.get_user_tickets(session.get("user_id", "")) if queue else []
|
| 78 |
+
|
| 79 |
+
return render_template(
|
| 80 |
+
"attendee/queues.html",
|
| 81 |
+
queue_summary=queue_summary,
|
| 82 |
+
user_tickets=user_tickets,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@attendee_bp.route("/navigate")
|
| 87 |
+
def navigate():
|
| 88 |
+
"""Wayfinding and navigation page."""
|
| 89 |
+
svc = _get_services()
|
| 90 |
+
venue = svc["venue"]
|
| 91 |
+
maps_svc = svc["maps"]
|
| 92 |
+
|
| 93 |
+
pois = maps_svc.get_points_of_interest() if maps_svc else []
|
| 94 |
+
|
| 95 |
+
return render_template(
|
| 96 |
+
"attendee/navigate.html",
|
| 97 |
+
venue=venue.to_dict() if venue else {},
|
| 98 |
+
pois=pois,
|
| 99 |
+
maps_api_key=current_app.config.get("GOOGLE_MAPS_API_KEY", ""),
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@attendee_bp.route("/profile")
|
| 104 |
+
def profile():
|
| 105 |
+
"""User profile and accessibility settings."""
|
| 106 |
+
from services.translation_service import get_supported_languages
|
| 107 |
+
|
| 108 |
+
return render_template(
|
| 109 |
+
"attendee/profile.html",
|
| 110 |
+
user_name=session.get("user_name", "Guest"),
|
| 111 |
+
user_email=session.get("user_email", ""),
|
| 112 |
+
languages=get_supported_languages(),
|
| 113 |
+
)
|
blueprints/auth.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication blueprint with Firebase Auth (mock fallback)."""
|
| 2 |
+
|
| 3 |
+
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, current_app
|
| 4 |
+
from services import firebase_service
|
| 5 |
+
import bleach
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _sanitize(text: str) -> str:
|
| 12 |
+
"""Sanitize user input."""
|
| 13 |
+
return bleach.clean(text.strip())
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _validate_email(email: str) -> bool:
|
| 17 |
+
"""Basic email validation."""
|
| 18 |
+
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
| 19 |
+
return bool(re.match(pattern, email))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@auth_bp.route("/login", methods=["GET", "POST"])
|
| 23 |
+
def login():
|
| 24 |
+
"""Login page and handler."""
|
| 25 |
+
if request.method == "POST":
|
| 26 |
+
email = _sanitize(request.form.get("email", ""))
|
| 27 |
+
password = request.form.get("password", "")
|
| 28 |
+
|
| 29 |
+
if not email or not password:
|
| 30 |
+
flash("Please fill in all fields.", "error")
|
| 31 |
+
return render_template("auth/login.html")
|
| 32 |
+
|
| 33 |
+
if not _validate_email(email):
|
| 34 |
+
flash("Please enter a valid email address.", "error")
|
| 35 |
+
return render_template("auth/login.html")
|
| 36 |
+
|
| 37 |
+
# Authenticate
|
| 38 |
+
if current_app.config.get("USE_MOCK_SERVICES"):
|
| 39 |
+
user = firebase_service.verify_mock_login(email, password)
|
| 40 |
+
else:
|
| 41 |
+
# TODO: Implement Firebase Auth token verification
|
| 42 |
+
user = firebase_service.verify_mock_login(email, password)
|
| 43 |
+
|
| 44 |
+
if user:
|
| 45 |
+
session["user_id"] = user["uid"]
|
| 46 |
+
session["user_email"] = user["email"]
|
| 47 |
+
session["user_name"] = user["display_name"]
|
| 48 |
+
session["user_role"] = user["role"]
|
| 49 |
+
|
| 50 |
+
flash(f"Welcome back, {user['display_name']}!", "success")
|
| 51 |
+
|
| 52 |
+
if user["role"] in ("operator", "admin"):
|
| 53 |
+
return redirect(url_for("operator.dashboard"))
|
| 54 |
+
return redirect(url_for("attendee.home"))
|
| 55 |
+
else:
|
| 56 |
+
flash("Invalid email or password.", "error")
|
| 57 |
+
|
| 58 |
+
return render_template("auth/login.html")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@auth_bp.route("/register", methods=["GET", "POST"])
|
| 62 |
+
def register():
|
| 63 |
+
"""Registration page and handler."""
|
| 64 |
+
if request.method == "POST":
|
| 65 |
+
email = _sanitize(request.form.get("email", ""))
|
| 66 |
+
password = request.form.get("password", "")
|
| 67 |
+
confirm_password = request.form.get("confirm_password", "")
|
| 68 |
+
display_name = _sanitize(request.form.get("display_name", ""))
|
| 69 |
+
|
| 70 |
+
errors = []
|
| 71 |
+
if not email or not password or not display_name:
|
| 72 |
+
errors.append("Please fill in all fields.")
|
| 73 |
+
if not _validate_email(email):
|
| 74 |
+
errors.append("Please enter a valid email address.")
|
| 75 |
+
if len(password) < 6:
|
| 76 |
+
errors.append("Password must be at least 6 characters.")
|
| 77 |
+
if password != confirm_password:
|
| 78 |
+
errors.append("Passwords do not match.")
|
| 79 |
+
|
| 80 |
+
if errors:
|
| 81 |
+
for err in errors:
|
| 82 |
+
flash(err, "error")
|
| 83 |
+
return render_template("auth/register.html")
|
| 84 |
+
|
| 85 |
+
# Create user
|
| 86 |
+
user = firebase_service.create_mock_user(email, password, display_name)
|
| 87 |
+
if user:
|
| 88 |
+
session["user_id"] = user["uid"]
|
| 89 |
+
session["user_email"] = user["email"]
|
| 90 |
+
session["user_name"] = user["display_name"]
|
| 91 |
+
session["user_role"] = user["role"]
|
| 92 |
+
flash("Account created successfully! Welcome to VenueFlow.", "success")
|
| 93 |
+
return redirect(url_for("attendee.home"))
|
| 94 |
+
else:
|
| 95 |
+
flash("Could not create account. Please try again.", "error")
|
| 96 |
+
|
| 97 |
+
return render_template("auth/register.html")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@auth_bp.route("/logout")
|
| 101 |
+
def logout():
|
| 102 |
+
"""Clear session and redirect to login."""
|
| 103 |
+
session.clear()
|
| 104 |
+
flash("You have been logged out.", "info")
|
| 105 |
+
return redirect(url_for("auth.login"))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# โโโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def login_required(f):
|
| 112 |
+
"""Decorator to require authentication."""
|
| 113 |
+
from functools import wraps
|
| 114 |
+
|
| 115 |
+
@wraps(f)
|
| 116 |
+
def decorated(*args, **kwargs):
|
| 117 |
+
if "user_id" not in session:
|
| 118 |
+
flash("Please log in to continue.", "warning")
|
| 119 |
+
return redirect(url_for("auth.login"))
|
| 120 |
+
return f(*args, **kwargs)
|
| 121 |
+
|
| 122 |
+
return decorated
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def operator_required(f):
|
| 126 |
+
"""Decorator to require operator role."""
|
| 127 |
+
from functools import wraps
|
| 128 |
+
|
| 129 |
+
@wraps(f)
|
| 130 |
+
def decorated(*args, **kwargs):
|
| 131 |
+
if "user_id" not in session:
|
| 132 |
+
flash("Please log in to continue.", "warning")
|
| 133 |
+
return redirect(url_for("auth.login"))
|
| 134 |
+
if session.get("user_role") not in ("operator", "admin"):
|
| 135 |
+
flash("Access denied. Operator privileges required.", "error")
|
| 136 |
+
return redirect(url_for("attendee.home"))
|
| 137 |
+
return f(*args, **kwargs)
|
| 138 |
+
|
| 139 |
+
return decorated
|
blueprints/operator.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Operator dashboard routes."""
|
| 2 |
+
|
| 3 |
+
from flask import Blueprint, render_template, session, current_app
|
| 4 |
+
|
| 5 |
+
operator_bp = Blueprint("operator", __name__, url_prefix="/operator")
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _get_services():
|
| 9 |
+
"""Get shared services from app context."""
|
| 10 |
+
return {
|
| 11 |
+
"venue": current_app.config.get("VENUE_OBJ"),
|
| 12 |
+
"event": current_app.config.get("EVENT_OBJ"),
|
| 13 |
+
"crowd": current_app.config.get("CROWD_SERVICE"),
|
| 14 |
+
"queue": current_app.config.get("QUEUE_SERVICE"),
|
| 15 |
+
"notifications": current_app.config.get("NOTIFICATION_SERVICE"),
|
| 16 |
+
"simulator": current_app.config.get("SIMULATOR_OBJ"),
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@operator_bp.before_request
|
| 21 |
+
def require_operator():
|
| 22 |
+
"""Require operator role for all operator pages."""
|
| 23 |
+
if "user_id" not in session:
|
| 24 |
+
from flask import redirect, url_for, flash
|
| 25 |
+
flash("Please log in to continue.", "warning")
|
| 26 |
+
return redirect(url_for("auth.login"))
|
| 27 |
+
if session.get("user_role") not in ("operator", "admin"):
|
| 28 |
+
from flask import redirect, url_for, flash
|
| 29 |
+
flash("Access denied. Operator privileges required.", "error")
|
| 30 |
+
return redirect(url_for("attendee.home"))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@operator_bp.route("/")
|
| 34 |
+
@operator_bp.route("/dashboard")
|
| 35 |
+
def dashboard():
|
| 36 |
+
"""Main operator dashboard."""
|
| 37 |
+
svc = _get_services()
|
| 38 |
+
venue = svc["venue"]
|
| 39 |
+
event = svc["event"]
|
| 40 |
+
crowd = svc["crowd"]
|
| 41 |
+
queue = svc["queue"]
|
| 42 |
+
simulator = svc["simulator"]
|
| 43 |
+
|
| 44 |
+
venue_summary = crowd.get_venue_summary(venue) if crowd else {}
|
| 45 |
+
queue_summary = queue.get_queue_summary() if queue else {}
|
| 46 |
+
sim_status = simulator.get_status() if simulator else {}
|
| 47 |
+
|
| 48 |
+
return render_template(
|
| 49 |
+
"operator/dashboard.html",
|
| 50 |
+
venue=venue.to_dict() if venue else {},
|
| 51 |
+
event=event.to_dict() if event else {},
|
| 52 |
+
venue_summary=venue_summary,
|
| 53 |
+
queue_summary=queue_summary,
|
| 54 |
+
sim_status=sim_status,
|
| 55 |
+
user_name=session.get("user_name", "Operator"),
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@operator_bp.route("/analytics")
|
| 60 |
+
def analytics():
|
| 61 |
+
"""Historical analytics view."""
|
| 62 |
+
svc = _get_services()
|
| 63 |
+
venue = svc["venue"]
|
| 64 |
+
crowd = svc["crowd"]
|
| 65 |
+
|
| 66 |
+
venue_summary = crowd.get_venue_summary(venue) if crowd else {}
|
| 67 |
+
|
| 68 |
+
return render_template(
|
| 69 |
+
"operator/analytics.html",
|
| 70 |
+
venue_summary=venue_summary,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@operator_bp.route("/alerts")
|
| 75 |
+
def alerts():
|
| 76 |
+
"""Alert management view."""
|
| 77 |
+
svc = _get_services()
|
| 78 |
+
notifications = svc["notifications"]
|
| 79 |
+
|
| 80 |
+
all_alerts = notifications.get_alerts(role="operator", limit=100) if notifications else []
|
| 81 |
+
|
| 82 |
+
return render_template(
|
| 83 |
+
"operator/alerts.html",
|
| 84 |
+
alerts=all_alerts,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@operator_bp.route("/simulator")
|
| 89 |
+
def simulator_view():
|
| 90 |
+
"""Simulation control panel."""
|
| 91 |
+
svc = _get_services()
|
| 92 |
+
simulator = svc["simulator"]
|
| 93 |
+
event = svc["event"]
|
| 94 |
+
|
| 95 |
+
from models.event import EventPhase
|
| 96 |
+
phases = [{"value": p.value, "label": p.label, "desc": p.description} for p in EventPhase]
|
| 97 |
+
|
| 98 |
+
sim_status = simulator.get_status() if simulator else {}
|
| 99 |
+
|
| 100 |
+
return render_template(
|
| 101 |
+
"operator/simulator.html",
|
| 102 |
+
sim_status=sim_status,
|
| 103 |
+
event=event.to_dict() if event else {},
|
| 104 |
+
phases=phases,
|
| 105 |
+
)
|
blueprints/sse.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Server-Sent Events (SSE) endpoint for real-time push updates."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
import queue
|
| 6 |
+
import logging
|
| 7 |
+
from flask import Blueprint, Response, session, current_app
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
sse_bp = Blueprint("sse", __name__, url_prefix="/sse")
|
| 12 |
+
|
| 13 |
+
# Thread-safe message queues for SSE clients
|
| 14 |
+
_client_queues = {}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def broadcast_update(data: dict, event_type: str = "update"):
|
| 18 |
+
"""Send an update to all connected SSE clients."""
|
| 19 |
+
message = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
| 20 |
+
dead_clients = []
|
| 21 |
+
|
| 22 |
+
for client_id, q in _client_queues.items():
|
| 23 |
+
try:
|
| 24 |
+
q.put_nowait(message)
|
| 25 |
+
except queue.Full:
|
| 26 |
+
dead_clients.append(client_id)
|
| 27 |
+
|
| 28 |
+
# Clean up dead clients
|
| 29 |
+
for cid in dead_clients:
|
| 30 |
+
_client_queues.pop(cid, None)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _generate_events(client_id: str):
|
| 34 |
+
"""Generator for SSE events."""
|
| 35 |
+
q = queue.Queue(maxsize=50)
|
| 36 |
+
_client_queues[client_id] = q
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
while True:
|
| 40 |
+
try:
|
| 41 |
+
# Wait for a message (with timeout for heartbeat)
|
| 42 |
+
message = q.get(timeout=15)
|
| 43 |
+
yield message
|
| 44 |
+
except queue.Empty:
|
| 45 |
+
# Send heartbeat to keep connection alive
|
| 46 |
+
yield f"event: heartbeat\ndata: {json.dumps({'time': time.time()})}\n\n"
|
| 47 |
+
except GeneratorExit:
|
| 48 |
+
pass
|
| 49 |
+
finally:
|
| 50 |
+
_client_queues.pop(client_id, None)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@sse_bp.route("/stream")
|
| 54 |
+
def stream():
|
| 55 |
+
"""SSE stream endpoint for real-time updates."""
|
| 56 |
+
user_id = session.get("user_id", "anon")
|
| 57 |
+
client_id = f"{user_id}_{time.time()}"
|
| 58 |
+
|
| 59 |
+
return Response(
|
| 60 |
+
_generate_events(client_id),
|
| 61 |
+
mimetype="text/event-stream",
|
| 62 |
+
headers={
|
| 63 |
+
"Cache-Control": "no-cache",
|
| 64 |
+
"Connection": "keep-alive",
|
| 65 |
+
"X-Accel-Buffering": "no",
|
| 66 |
+
},
|
| 67 |
+
)
|
config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration loaded from environment variables."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Config:
|
| 10 |
+
"""Base configuration."""
|
| 11 |
+
|
| 12 |
+
SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "dev-secret-key-change-in-production")
|
| 13 |
+
WTF_CSRF_ENABLED = True
|
| 14 |
+
|
| 15 |
+
# Firebase
|
| 16 |
+
FIREBASE_CREDENTIALS_PATH = os.getenv("FIREBASE_CREDENTIALS_PATH", "")
|
| 17 |
+
FIREBASE_API_KEY = os.getenv("FIREBASE_API_KEY", "")
|
| 18 |
+
FIREBASE_AUTH_DOMAIN = os.getenv("FIREBASE_AUTH_DOMAIN", "")
|
| 19 |
+
FIREBASE_PROJECT_ID = os.getenv("FIREBASE_PROJECT_ID", "")
|
| 20 |
+
FIREBASE_STORAGE_BUCKET = os.getenv("FIREBASE_STORAGE_BUCKET", "")
|
| 21 |
+
FIREBASE_MESSAGING_SENDER_ID = os.getenv("FIREBASE_MESSAGING_SENDER_ID", "")
|
| 22 |
+
FIREBASE_APP_ID = os.getenv("FIREBASE_APP_ID", "")
|
| 23 |
+
|
| 24 |
+
# Google Gemini
|
| 25 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
| 26 |
+
|
| 27 |
+
# Google Maps
|
| 28 |
+
GOOGLE_MAPS_API_KEY = os.getenv("GOOGLE_MAPS_API_KEY", "")
|
| 29 |
+
|
| 30 |
+
# Google Cloud Translation
|
| 31 |
+
GOOGLE_TRANSLATE_API_KEY = os.getenv("GOOGLE_TRANSLATE_API_KEY", "")
|
| 32 |
+
|
| 33 |
+
# App
|
| 34 |
+
USE_MOCK_SERVICES = os.getenv("USE_MOCK_SERVICES", "true").lower() == "true"
|
| 35 |
+
VENUE_CAPACITY = int(os.getenv("VENUE_CAPACITY", "50000"))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class DevelopmentConfig(Config):
|
| 39 |
+
"""Development configuration."""
|
| 40 |
+
|
| 41 |
+
DEBUG = True
|
| 42 |
+
TESTING = False
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ProductionConfig(Config):
|
| 46 |
+
"""Production configuration."""
|
| 47 |
+
|
| 48 |
+
DEBUG = False
|
| 49 |
+
TESTING = False
|
| 50 |
+
USE_MOCK_SERVICES = False
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class TestingConfig(Config):
|
| 54 |
+
"""Testing configuration."""
|
| 55 |
+
|
| 56 |
+
TESTING = True
|
| 57 |
+
WTF_CSRF_ENABLED = False
|
| 58 |
+
USE_MOCK_SERVICES = True
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
config_map = {
|
| 62 |
+
"development": DevelopmentConfig,
|
| 63 |
+
"production": ProductionConfig,
|
| 64 |
+
"testing": TestingConfig,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def get_config():
|
| 69 |
+
"""Return config class based on FLASK_ENV."""
|
| 70 |
+
env = os.getenv("FLASK_ENV", "development")
|
| 71 |
+
return config_map.get(env, DevelopmentConfig)
|
implementation_plan.md
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# VenueFlow โ Smart Sporting Venue Experience Platform
|
| 2 |
+
|
| 3 |
+
A Flask-based real-time system that transforms the physical event experience at large-scale sporting venues by addressing crowd movement, waiting times, and real-time coordination.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Problem Statement
|
| 8 |
+
|
| 9 |
+
Attendees at large sporting events face:
|
| 10 |
+
- **Crowd congestion** at gates, concourses, and exits
|
| 11 |
+
- **Long queues** at food stalls, merchandise, and restrooms
|
| 12 |
+
- **Poor wayfinding** inside massive, unfamiliar venues
|
| 13 |
+
- **No real-time information** about crowd conditions or wait times
|
| 14 |
+
- **Accessibility barriers** for attendees with disabilities
|
| 15 |
+
|
| 16 |
+
VenueFlow solves these with an intelligent, real-time web platform for both **fans** (mobile-first attendee app) and **operators** (venue management dashboard).
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## Architecture Overview
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 24 |
+
โ FRONTEND (Jinja2 + JS) โ
|
| 25 |
+
โ โโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
|
| 26 |
+
โ โ Attendeeโ โ Operator โ โ Heatmap โ โ Chatbot โ โ
|
| 27 |
+
โ โ App โ โDashboard โ โ View โ โ Widget โ โ
|
| 28 |
+
โ โโโโโโฌโโโโโ โโโโโโฌโโโโโโ โโโโโโฌโโโโโโ โโโโโโฌโโโโโโ โ
|
| 29 |
+
โ โโโโโโโโโโโโโโโดโโโโโโโโโโโโโดโโโโโโโโโโโโโโโ โ
|
| 30 |
+
โ โ SSE / Fetch โ
|
| 31 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
|
| 32 |
+
โ FLASK BACKEND โ
|
| 33 |
+
โ โโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
|
| 34 |
+
โ โ Auth & โ โ Real-time โ โ AI Engine โ โ
|
| 35 |
+
โ โ Security โ โ Engine โ โ (Gemini) โ โ
|
| 36 |
+
โ โโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
|
| 37 |
+
โ โโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
|
| 38 |
+
โ โ Queue โ โ Crowd โ โ Notification โ โ
|
| 39 |
+
โ โ Manager โ โ Analytics โ โ Service โ โ
|
| 40 |
+
โ โโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
|
| 41 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
|
| 42 |
+
โ GOOGLE SERVICES โ
|
| 43 |
+
โ โโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
|
| 44 |
+
โ โ Firebase โ โGoogle Maps โ โ Gemini โ โ
|
| 45 |
+
โ โ Firestoreโ โ JS API โ โ 2.5 API โ โ
|
| 46 |
+
โ โโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
|
| 47 |
+
โ โโโโโโโโโโโโ โโโโโโโโโโโโโโ โ
|
| 48 |
+
โ โ Cloud โ โ Firebase โ โ
|
| 49 |
+
โ โTranslate โ โ Auth โ โ
|
| 50 |
+
โ โโโโโโโโโโโโ โโโโโโโโโโโโโโ โ
|
| 51 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
### Tech Stack
|
| 55 |
+
|
| 56 |
+
| Layer | Technology |
|
| 57 |
+
|-------|-----------|
|
| 58 |
+
| **Backend** | Python 3.11+, Flask, Flask-SocketIO |
|
| 59 |
+
| **Frontend** | Jinja2 templates, Vanilla JS, CSS3 |
|
| 60 |
+
| **Database** | Firebase Firestore (real-time sync) |
|
| 61 |
+
| **Auth** | Firebase Authentication |
|
| 62 |
+
| **Maps** | Google Maps JavaScript API |
|
| 63 |
+
| **AI** | Google Gemini 2.5 Flash API |
|
| 64 |
+
| **Translation** | Google Cloud Translation API |
|
| 65 |
+
| **Real-time** | Server-Sent Events (SSE) + Firebase listeners |
|
| 66 |
+
| **Deployment** | Docker-ready for Cloud Run |
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## User Review Required
|
| 71 |
+
|
| 72 |
+
> [!IMPORTANT]
|
| 73 |
+
> **Google API Keys Required**: You will need to provide API keys / service accounts for:
|
| 74 |
+
> - Firebase project (Firestore + Auth)
|
| 75 |
+
> - Google Maps JavaScript API key
|
| 76 |
+
> - Google Gemini API key
|
| 77 |
+
> - Google Cloud Translation API key
|
| 78 |
+
>
|
| 79 |
+
> These will be stored in a `.env` file (never committed to git).
|
| 80 |
+
|
| 81 |
+
> [!WARNING]
|
| 82 |
+
> **Simulated Sensor Data**: Since we don't have physical IoT sensors, the system will include a **simulation engine** that generates realistic crowd density, queue lengths, and movement patterns. This makes the demo fully functional without hardware.
|
| 83 |
+
|
| 84 |
+
> [!IMPORTANT]
|
| 85 |
+
> **Two User Interfaces**:
|
| 86 |
+
> 1. **Attendee App** (mobile-first) โ For fans at the venue
|
| 87 |
+
> 2. **Operator Dashboard** โ For venue staff/management
|
| 88 |
+
>
|
| 89 |
+
> Both are served from the same Flask app. Please confirm this dual-interface approach.
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## Proposed Changes
|
| 94 |
+
|
| 95 |
+
### Project Structure
|
| 96 |
+
|
| 97 |
+
```
|
| 98 |
+
EventManager/
|
| 99 |
+
โโโ app.py # Flask application factory
|
| 100 |
+
โโโ config.py # Configuration & env management
|
| 101 |
+
โโโ requirements.txt # Python dependencies
|
| 102 |
+
โโโ Dockerfile # Container deployment
|
| 103 |
+
โโโ .env.example # Environment variable template
|
| 104 |
+
โโโ .gitignore
|
| 105 |
+
โ
|
| 106 |
+
โโโ blueprints/
|
| 107 |
+
โ โโโ __init__.py
|
| 108 |
+
โ โโโ auth.py # Authentication routes
|
| 109 |
+
โ โโโ attendee.py # Attendee-facing routes
|
| 110 |
+
โ โโโ operator.py # Operator dashboard routes
|
| 111 |
+
โ โโโ api.py # REST API endpoints
|
| 112 |
+
โ โโโ sse.py # Server-Sent Events stream
|
| 113 |
+
โ
|
| 114 |
+
โโโ services/
|
| 115 |
+
โ โโโ __init__.py
|
| 116 |
+
โ โโโ firebase_service.py # Firebase Firestore & Auth
|
| 117 |
+
โ โโโ crowd_service.py # Crowd density analytics
|
| 118 |
+
โ โโโ queue_service.py # Queue management logic
|
| 119 |
+
โ โโโ gemini_service.py # AI chatbot & recommendations
|
| 120 |
+
โ โโโ maps_service.py # Maps & wayfinding
|
| 121 |
+
โ โโโ translation_service.py # Multi-language support
|
| 122 |
+
โ โโโ notification_service.py # Alert & notification engine
|
| 123 |
+
โ โโโ simulator.py # Realistic data simulation
|
| 124 |
+
โ
|
| 125 |
+
โโโ models/
|
| 126 |
+
โ โโโ __init__.py
|
| 127 |
+
โ โโโ venue.py # Venue, Zone, Gate models
|
| 128 |
+
โ โโโ queue.py # Queue & wait time models
|
| 129 |
+
โ โโโ event.py # Event & attendee models
|
| 130 |
+
โ
|
| 131 |
+
โโโ static/
|
| 132 |
+
โ โโโ css/
|
| 133 |
+
โ โ โโโ base.css # Design system & tokens
|
| 134 |
+
โ โ โโโ attendee.css # Attendee app styles
|
| 135 |
+
โ โ โโโ operator.css # Operator dashboard styles
|
| 136 |
+
โ โ โโโ accessibility.css # A11y overrides
|
| 137 |
+
โ โโโ js/
|
| 138 |
+
โ โ โโโ app.js # Core JS utilities
|
| 139 |
+
โ โ โโโ heatmap.js # Crowd heatmap rendering
|
| 140 |
+
โ โ โโโ queue.js # Queue display & updates
|
| 141 |
+
โ โ โโโ wayfinding.js # Navigation & routing
|
| 142 |
+
โ โ โโโ chatbot.js # Gemini chatbot widget
|
| 143 |
+
โ โ โโโ notifications.js # Real-time notification handler
|
| 144 |
+
โ โ โโโ sse-client.js # SSE connection manager
|
| 145 |
+
โ โ โโโ dashboard.js # Operator dashboard logic
|
| 146 |
+
โ โ โโโ simulator-ui.js # Simulation controls
|
| 147 |
+
โ โ โโโ accessibility.js # A11y toggle & preferences
|
| 148 |
+
โ โโโ images/
|
| 149 |
+
โ โโโ (generated assets)
|
| 150 |
+
โ
|
| 151 |
+
โโโ templates/
|
| 152 |
+
โ โโโ base.html # Base template with a11y
|
| 153 |
+
โ โโโ attendee/
|
| 154 |
+
โ โ โโโ home.html # Fan landing page
|
| 155 |
+
โ โ โโโ heatmap.html # Live crowd heatmap
|
| 156 |
+
โ โ โโโ queues.html # Queue status & booking
|
| 157 |
+
โ โ โโโ navigate.html # Wayfinding page
|
| 158 |
+
โ โ โโโ profile.html # Preferences & a11y settings
|
| 159 |
+
โ โโโ operator/
|
| 160 |
+
โ โ โโโ dashboard.html # Main control dashboard
|
| 161 |
+
โ โ โโโ analytics.html # Historical analytics
|
| 162 |
+
โ โ โโโ alerts.html # Alert management
|
| 163 |
+
โ โ โโโ simulator.html # Simulation controls
|
| 164 |
+
โ โโโ auth/
|
| 165 |
+
โ โ โโโ login.html
|
| 166 |
+
โ โ โโโ register.html
|
| 167 |
+
โ โโโ components/
|
| 168 |
+
โ โโโ chatbot.html # Chatbot widget partial
|
| 169 |
+
โ โโโ notification.html # Notification toast partial
|
| 170 |
+
โ โโโ nav.html # Navigation component
|
| 171 |
+
โ
|
| 172 |
+
โโโ tests/
|
| 173 |
+
โโโ __init__.py
|
| 174 |
+
โโโ conftest.py # Pytest fixtures
|
| 175 |
+
โโโ test_auth.py # Authentication tests
|
| 176 |
+
โโโ test_crowd_service.py # Crowd analytics tests
|
| 177 |
+
โโโ test_queue_service.py # Queue management tests
|
| 178 |
+
โโโ test_api.py # API endpoint tests
|
| 179 |
+
โโโ test_simulator.py # Simulator logic tests
|
| 180 |
+
โโโ test_accessibility.py # Accessibility audit tests
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
### Component 1: Core Application & Configuration
|
| 186 |
+
|
| 187 |
+
#### [NEW] `app.py`
|
| 188 |
+
- Flask application factory pattern with blueprint registration
|
| 189 |
+
- CORS, CSP, and security headers middleware
|
| 190 |
+
- Error handlers (404, 500) with accessible error pages
|
| 191 |
+
- SSE stream endpoint registration
|
| 192 |
+
|
| 193 |
+
#### [NEW] `config.py`
|
| 194 |
+
- Environment-based configuration (dev/staging/prod)
|
| 195 |
+
- Secure loading of all API keys from `.env`
|
| 196 |
+
- Firebase, Gemini, Maps, Translation config constants
|
| 197 |
+
|
| 198 |
+
#### [NEW] `requirements.txt`
|
| 199 |
+
Key dependencies:
|
| 200 |
+
```
|
| 201 |
+
flask>=3.1
|
| 202 |
+
flask-socketio>=5.3
|
| 203 |
+
firebase-admin>=6.5
|
| 204 |
+
google-generativeai>=0.8
|
| 205 |
+
google-cloud-translate>=3.16
|
| 206 |
+
gunicorn>=22.0
|
| 207 |
+
python-dotenv>=1.0
|
| 208 |
+
pytest>=8.0
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
---
|
| 212 |
+
|
| 213 |
+
### Component 2: Authentication (Firebase Auth)
|
| 214 |
+
|
| 215 |
+
#### [NEW] `blueprints/auth.py`
|
| 216 |
+
- Login / Register routes using Firebase Authentication
|
| 217 |
+
- Role-based access: `attendee` vs `operator`
|
| 218 |
+
- Session management with secure HTTP-only cookies
|
| 219 |
+
- CSRF protection on all forms
|
| 220 |
+
|
| 221 |
+
#### [NEW] `services/firebase_service.py`
|
| 222 |
+
- Firebase Admin SDK initialization (singleton)
|
| 223 |
+
- Firestore CRUD helpers with connection pooling
|
| 224 |
+
- Real-time listener setup for zone/queue collections
|
| 225 |
+
- Token verification for authenticated API calls
|
| 226 |
+
|
| 227 |
+
---
|
| 228 |
+
|
| 229 |
+
### Component 3: Real-Time Crowd Heatmap
|
| 230 |
+
|
| 231 |
+
#### [NEW] `services/crowd_service.py`
|
| 232 |
+
- Processes zone density data from Firestore
|
| 233 |
+
- Calculates crowd density levels: ๐ข Low / ๐ก Moderate / ๐ด High / โซ Critical
|
| 234 |
+
- Threshold-based alerting triggers
|
| 235 |
+
- Historical trend computation
|
| 236 |
+
|
| 237 |
+
#### [NEW] `static/js/heatmap.js`
|
| 238 |
+
- Renders interactive venue map overlay using **Google Maps JavaScript API**
|
| 239 |
+
- Real-time heatmap layer with color-coded zones
|
| 240 |
+
- Click-to-inspect zone details (capacity, current count, trend)
|
| 241 |
+
- Auto-refresh via SSE stream
|
| 242 |
+
|
| 243 |
+
#### [NEW] `templates/attendee/heatmap.html`
|
| 244 |
+
- Mobile-first responsive layout
|
| 245 |
+
- Legend with WCAG-compliant color indicators + text labels
|
| 246 |
+
- "Best route" suggestion panel
|
| 247 |
+
- Accessible alternative: tabular zone status view
|
| 248 |
+
|
| 249 |
+
---
|
| 250 |
+
|
| 251 |
+
### Component 4: Queue Management System
|
| 252 |
+
|
| 253 |
+
#### [NEW] `services/queue_service.py`
|
| 254 |
+
- Manages virtual queues for food, merchandise, restrooms
|
| 255 |
+
- Estimated wait time calculation (moving average algorithm)
|
| 256 |
+
- "Book your spot" โ virtual queue reservation
|
| 257 |
+
- Queue position tracking and SMS/notification alerts
|
| 258 |
+
|
| 259 |
+
#### [NEW] `static/js/queue.js`
|
| 260 |
+
- Live queue dashboard with animated progress bars
|
| 261 |
+
- Sort/filter by wait time, category, proximity
|
| 262 |
+
- "Join Queue" button with confirmation modal
|
| 263 |
+
- Real-time position counter
|
| 264 |
+
|
| 265 |
+
#### [NEW] `templates/attendee/queues.html`
|
| 266 |
+
- Card-based queue listings with status indicators
|
| 267 |
+
- Category filters (๐ Food, ๐ Merch, ๐ป Restrooms)
|
| 268 |
+
- Estimated wait time with confidence indicator
|
| 269 |
+
- Virtual queue ticket with QR code
|
| 270 |
+
|
| 271 |
+
---
|
| 272 |
+
|
| 273 |
+
### Component 5: Wayfinding & Navigation
|
| 274 |
+
|
| 275 |
+
#### [NEW] `services/maps_service.py`
|
| 276 |
+
- Google Maps integration for venue layout
|
| 277 |
+
- Shortest-path routing between venue zones
|
| 278 |
+
- Accessibility-aware routing (elevators, ramps)
|
| 279 |
+
- Points-of-interest data management
|
| 280 |
+
|
| 281 |
+
#### [NEW] `static/js/wayfinding.js`
|
| 282 |
+
- Interactive venue map with Google Maps JS API
|
| 283 |
+
- Turn-by-turn directions within the venue
|
| 284 |
+
- "Navigate to nearest [restroom/food/exit]" functionality
|
| 285 |
+
- Crowd-aware routing (avoids congested paths)
|
| 286 |
+
|
| 287 |
+
#### [NEW] `templates/attendee/navigate.html`
|
| 288 |
+
- Full-screen map with controls
|
| 289 |
+
- Quick-action buttons: "Nearest Exit", "My Seat", "Food Court"
|
| 290 |
+
- Step-by-step accessible text directions
|
| 291 |
+
- Estimated walking time display
|
| 292 |
+
|
| 293 |
+
---
|
| 294 |
+
|
| 295 |
+
### Component 6: AI Chatbot (Google Gemini)
|
| 296 |
+
|
| 297 |
+
#### [NEW] `services/gemini_service.py`
|
| 298 |
+
- Gemini 2.5 Flash integration for conversational AI
|
| 299 |
+
- Context-aware: knows venue layout, current crowd data, queue times
|
| 300 |
+
- Multi-language support via Cloud Translation API
|
| 301 |
+
- Safety filters and responsible AI guardrails
|
| 302 |
+
- Prompts:
|
| 303 |
+
- "Where's the shortest food queue?"
|
| 304 |
+
- "How do I get to Gate 7?"
|
| 305 |
+
- "Is Section B crowded right now?"
|
| 306 |
+
|
| 307 |
+
#### [NEW] `static/js/chatbot.js`
|
| 308 |
+
- Floating chat widget (bottom-right)
|
| 309 |
+
- Message bubbles with typing indicator
|
| 310 |
+
- Quick-reply suggestion chips
|
| 311 |
+
- Language selector for real-time translation
|
| 312 |
+
- ARIA live region for screen reader announcements
|
| 313 |
+
|
| 314 |
+
#### [NEW] `templates/components/chatbot.html`
|
| 315 |
+
- Accessible chat interface with proper ARIA roles
|
| 316 |
+
- Resizable/minimizable widget
|
| 317 |
+
- Keyboard-navigable message list
|
| 318 |
+
|
| 319 |
+
---
|
| 320 |
+
|
| 321 |
+
### Component 7: Operator Dashboard
|
| 322 |
+
|
| 323 |
+
#### [NEW] `blueprints/operator.py`
|
| 324 |
+
- Protected routes (operator role required)
|
| 325 |
+
- Dashboard, analytics, alerts, and simulator views
|
| 326 |
+
- API endpoints for crowd threshold management
|
| 327 |
+
|
| 328 |
+
#### [NEW] `static/js/dashboard.js`
|
| 329 |
+
- Real-time KPI cards (total attendance, avg wait, alerts)
|
| 330 |
+
- Zone-by-zone capacity meters
|
| 331 |
+
- Alert timeline with severity levels
|
| 332 |
+
- Staff deployment recommendations
|
| 333 |
+
|
| 334 |
+
#### [NEW] `templates/operator/dashboard.html`
|
| 335 |
+
- Grid layout with responsive breakpoints
|
| 336 |
+
- Live charts (crowd trends, queue throughput)
|
| 337 |
+
- Draggable alert cards
|
| 338 |
+
- Emergency broadcast panel
|
| 339 |
+
|
| 340 |
+
---
|
| 341 |
+
|
| 342 |
+
### Component 8: Notification & Alert System
|
| 343 |
+
|
| 344 |
+
#### [NEW] `services/notification_service.py`
|
| 345 |
+
- Multi-channel: in-app toasts, SSE push, email
|
| 346 |
+
- Priority levels: Info / Warning / Critical / Emergency
|
| 347 |
+
- Geo-targeted: alerts to specific venue zones
|
| 348 |
+
- Automatic triggers from crowd density thresholds
|
| 349 |
+
|
| 350 |
+
#### [NEW] `blueprints/sse.py`
|
| 351 |
+
- Server-Sent Events endpoint for real-time push
|
| 352 |
+
- Per-user event filtering (zone, role)
|
| 353 |
+
- Heartbeat keep-alive for connection stability
|
| 354 |
+
- Graceful reconnection handling
|
| 355 |
+
|
| 356 |
+
#### [NEW] `static/js/notifications.js`
|
| 357 |
+
- Toast notification stack (top-right)
|
| 358 |
+
- Sound alerts for critical notifications
|
| 359 |
+
- Notification center with history
|
| 360 |
+
- "Do Not Disturb" mode for operators
|
| 361 |
+
|
| 362 |
+
---
|
| 363 |
+
|
| 364 |
+
### Component 9: Simulation Engine
|
| 365 |
+
|
| 366 |
+
#### [NEW] `services/simulator.py`
|
| 367 |
+
- Generates realistic crowd movement patterns
|
| 368 |
+
- Simulates event lifecycle: pre-event โ kickoff โ halftime โ post-event
|
| 369 |
+
- Queue fluctuation with realistic distributions
|
| 370 |
+
- Configurable parameters: venue capacity, event type, weather
|
| 371 |
+
- Writes simulated data to Firestore in real-time
|
| 372 |
+
|
| 373 |
+
#### [NEW] `templates/operator/simulator.html`
|
| 374 |
+
- Simulation control panel (start/stop/speed)
|
| 375 |
+
- Event phase selector
|
| 376 |
+
- Parameter sliders (crowd size, arrival rate)
|
| 377 |
+
- Live preview of simulated data
|
| 378 |
+
|
| 379 |
+
---
|
| 380 |
+
|
| 381 |
+
### Component 10: Accessibility & Internationalization
|
| 382 |
+
|
| 383 |
+
#### [NEW] `static/css/accessibility.css`
|
| 384 |
+
- High-contrast mode styles
|
| 385 |
+
- Large text mode (150% scaling)
|
| 386 |
+
- Reduced motion preference support
|
| 387 |
+
- Focus indicator styles (3px solid, 3:1 contrast)
|
| 388 |
+
|
| 389 |
+
#### [NEW] `static/js/accessibility.js`
|
| 390 |
+
- Accessibility preferences panel
|
| 391 |
+
- Toggle: high contrast, large text, reduced motion
|
| 392 |
+
- Persisted via localStorage
|
| 393 |
+
- Screen reader announcement utility
|
| 394 |
+
|
| 395 |
+
#### [NEW] `services/translation_service.py`
|
| 396 |
+
- Google Cloud Translation API integration
|
| 397 |
+
- Auto-detect user language from browser
|
| 398 |
+
- On-demand translation of UI strings and notifications
|
| 399 |
+
- Cached translations for performance
|
| 400 |
+
|
| 401 |
+
---
|
| 402 |
+
|
| 403 |
+
## Google Services Integration Summary
|
| 404 |
+
|
| 405 |
+
| Google Service | Usage | Why |
|
| 406 |
+
|---|---|---|
|
| 407 |
+
| **Firebase Firestore** | Real-time database for crowd, queue, and event data | Sub-second sync, offline support, scales automatically |
|
| 408 |
+
| **Firebase Auth** | User authentication (email/social login) | Secure, supports role-based access, easy integration |
|
| 409 |
+
| **Google Maps JS API** | Venue map, heatmap overlay, wayfinding routes | Industry standard mapping, custom overlays, directions |
|
| 410 |
+
| **Gemini 2.5 Flash** | AI chatbot for attendee assistance | Context-aware responses, fast inference, multi-turn |
|
| 411 |
+
| **Cloud Translation** | Multi-language support | Real-time translation for international events |
|
| 412 |
+
|
| 413 |
+
---
|
| 414 |
+
|
| 415 |
+
## Security Implementation
|
| 416 |
+
|
| 417 |
+
1. **Authentication**: Firebase Auth with JWT token verification
|
| 418 |
+
2. **Authorization**: Role-based access control (attendee/operator/admin)
|
| 419 |
+
3. **CSRF**: Flask-WTF CSRF tokens on all forms
|
| 420 |
+
4. **CSP**: Content Security Policy headers
|
| 421 |
+
5. **Input Validation**: Server-side validation on all endpoints
|
| 422 |
+
6. **Rate Limiting**: API rate limiting to prevent abuse
|
| 423 |
+
7. **Secrets Management**: All keys in `.env`, never in source
|
| 424 |
+
8. **HTTPS**: Enforced in production via Dockerfile/Cloud Run
|
| 425 |
+
9. **XSS Protection**: Jinja2 auto-escaping enabled
|
| 426 |
+
10. **SQL Injection**: N/A (NoSQL Firestore) โ Firestore Security Rules used
|
| 427 |
+
|
| 428 |
+
---
|
| 429 |
+
|
| 430 |
+
## Accessibility Compliance (WCAG 2.2 AA)
|
| 431 |
+
|
| 432 |
+
- **Contrast**: All text โฅ 4.5:1 ratio, UI components โฅ 3:1
|
| 433 |
+
- **Target Size**: All interactive elements โฅ 44ร44 CSS pixels
|
| 434 |
+
- **Keyboard Navigation**: Full keyboard support with visible focus indicators
|
| 435 |
+
- **Screen Readers**: ARIA landmarks, live regions, proper heading hierarchy
|
| 436 |
+
- **Reduced Motion**: `prefers-reduced-motion` media query support
|
| 437 |
+
- **High Contrast Mode**: Toggle-able high contrast theme
|
| 438 |
+
- **Skip Links**: "Skip to content" link on every page
|
| 439 |
+
- **Semantic HTML**: Proper use of `<nav>`, `<main>`, `<aside>`, `<section>`
|
| 440 |
+
- **Alt Text**: All images have descriptive alt text
|
| 441 |
+
- **Language**: `lang` attribute on `<html>`, translated content marked
|
| 442 |
+
|
| 443 |
+
---
|
| 444 |
+
|
| 445 |
+
## Testing Strategy
|
| 446 |
+
|
| 447 |
+
### Unit Tests (pytest)
|
| 448 |
+
```
|
| 449 |
+
tests/
|
| 450 |
+
โโโ test_crowd_service.py # Density calculation, threshold logic
|
| 451 |
+
โโโ test_queue_service.py # Wait time estimation, virtual queue
|
| 452 |
+
โโโ test_auth.py # Login, registration, role checks
|
| 453 |
+
โโโ test_api.py # API endpoint responses
|
| 454 |
+
โโโ test_simulator.py # Simulation output validation
|
| 455 |
+
```
|
| 456 |
+
|
| 457 |
+
### Integration Tests
|
| 458 |
+
- Firebase Firestore read/write operations
|
| 459 |
+
- Gemini API response processing
|
| 460 |
+
- SSE event delivery end-to-end
|
| 461 |
+
|
| 462 |
+
### Accessibility Tests
|
| 463 |
+
- Automated: axe-core audit via browser subagent
|
| 464 |
+
- Manual: keyboard navigation flow verification
|
| 465 |
+
- Contrast ratio verification on all pages
|
| 466 |
+
|
| 467 |
+
### Browser Testing
|
| 468 |
+
- Mobile viewport (375px) responsive validation
|
| 469 |
+
- Desktop viewport (1440px) dashboard verification
|
| 470 |
+
- Cross-browser visual spot checks
|
| 471 |
+
|
| 472 |
+
---
|
| 473 |
+
|
| 474 |
+
## Verification Plan
|
| 475 |
+
|
| 476 |
+
### Automated Tests
|
| 477 |
+
1. `pytest tests/ -v` โ Run full test suite
|
| 478 |
+
2. `flask run` โ Verify app starts without errors
|
| 479 |
+
3. Browser subagent: Navigate all pages, verify rendering
|
| 480 |
+
4. Accessibility audit via axe-core integration
|
| 481 |
+
|
| 482 |
+
### Manual Verification
|
| 483 |
+
1. Start simulation โ observe real-time heatmap updates
|
| 484 |
+
2. Join virtual queue โ verify position tracking
|
| 485 |
+
3. Ask chatbot questions โ verify Gemini responses
|
| 486 |
+
4. Toggle accessibility modes โ verify contrast/text changes
|
| 487 |
+
5. Switch languages โ verify translation
|
| 488 |
+
|
| 489 |
+
---
|
| 490 |
+
|
| 491 |
+
## Execution Phases
|
| 492 |
+
|
| 493 |
+
### Phase 1: Foundation (Core + Auth + Database)
|
| 494 |
+
- `app.py`, `config.py`, `requirements.txt`
|
| 495 |
+
- Firebase service, Auth blueprints
|
| 496 |
+
- Base templates with design system
|
| 497 |
+
- Docker setup
|
| 498 |
+
|
| 499 |
+
### Phase 2: Real-Time Core (Heatmap + Queues + SSE)
|
| 500 |
+
- Crowd service + heatmap visualization
|
| 501 |
+
- Queue management system
|
| 502 |
+
- SSE real-time push infrastructure
|
| 503 |
+
- Simulation engine
|
| 504 |
+
|
| 505 |
+
### Phase 3: Intelligence (AI + Navigation + Notifications)
|
| 506 |
+
- Gemini chatbot integration
|
| 507 |
+
- Google Maps wayfinding
|
| 508 |
+
- Notification/alert system
|
| 509 |
+
- Cloud Translation integration
|
| 510 |
+
|
| 511 |
+
### Phase 4: Operations (Dashboard + Analytics)
|
| 512 |
+
- Operator dashboard with live KPIs
|
| 513 |
+
- Historical analytics views
|
| 514 |
+
- Alert management interface
|
| 515 |
+
- Simulation control panel
|
| 516 |
+
|
| 517 |
+
### Phase 5: Polish (A11y + Testing + Security)
|
| 518 |
+
- Accessibility features & compliance
|
| 519 |
+
- Comprehensive test suite
|
| 520 |
+
- Security hardening
|
| 521 |
+
- Performance optimization
|
| 522 |
+
- Final UI polish
|
| 523 |
+
|
| 524 |
+
---
|
| 525 |
+
|
| 526 |
+
## Open Questions
|
| 527 |
+
|
| 528 |
+
> [!IMPORTANT]
|
| 529 |
+
> 1. **Do you have Google Cloud / Firebase API keys ready?** If not, I can set up the project with mock services that can be swapped for real ones later.
|
| 530 |
+
|
| 531 |
+
> [!IMPORTANT]
|
| 532 |
+
> 2. **Venue type preference?** Should the demo simulate a specific type of venue (cricket stadium, football stadium, Olympic arena)? This affects the venue map layout.
|
| 533 |
+
|
| 534 |
+
> [!NOTE]
|
| 535 |
+
> 3. **Should we include a mobile app wrapper** or keep this strictly as a responsive web app? (I recommend responsive web โ works on all devices without installation.)
|
models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Data models package."""
|
models/event.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Event and attendee models."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
from enum import Enum
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class EventPhase(Enum):
|
| 10 |
+
"""Current phase of the event lifecycle."""
|
| 11 |
+
|
| 12 |
+
PRE_EVENT = "pre_event"
|
| 13 |
+
GATES_OPEN = "gates_open"
|
| 14 |
+
FILLING = "filling"
|
| 15 |
+
EVENT_START = "event_start"
|
| 16 |
+
FIRST_HALF = "first_half"
|
| 17 |
+
HALFTIME = "halftime"
|
| 18 |
+
SECOND_HALF = "second_half"
|
| 19 |
+
EVENT_END = "event_end"
|
| 20 |
+
EXITING = "exiting"
|
| 21 |
+
POST_EVENT = "post_event"
|
| 22 |
+
|
| 23 |
+
@property
|
| 24 |
+
def label(self):
|
| 25 |
+
return {
|
| 26 |
+
"pre_event": "Pre-Event",
|
| 27 |
+
"gates_open": "Gates Open",
|
| 28 |
+
"filling": "Venue Filling",
|
| 29 |
+
"event_start": "Event Started",
|
| 30 |
+
"first_half": "1st Half / Session",
|
| 31 |
+
"halftime": "Half-Time / Break",
|
| 32 |
+
"second_half": "2nd Half / Session",
|
| 33 |
+
"event_end": "Event Ended",
|
| 34 |
+
"exiting": "Attendees Exiting",
|
| 35 |
+
"post_event": "Post-Event",
|
| 36 |
+
}[self.value]
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def description(self):
|
| 40 |
+
return {
|
| 41 |
+
"pre_event": "Venue preparation underway",
|
| 42 |
+
"gates_open": "Gates are open, attendees arriving",
|
| 43 |
+
"filling": "Venue is filling up rapidly",
|
| 44 |
+
"event_start": "The event has begun",
|
| 45 |
+
"first_half": "First session in progress",
|
| 46 |
+
"halftime": "Break time โ high food/restroom traffic expected",
|
| 47 |
+
"second_half": "Second session in progress",
|
| 48 |
+
"event_end": "Event concluded โ exit rush expected",
|
| 49 |
+
"exiting": "Attendees leaving the venue",
|
| 50 |
+
"post_event": "Venue cleared",
|
| 51 |
+
}[self.value]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class Event:
|
| 56 |
+
"""Represents a sporting event."""
|
| 57 |
+
|
| 58 |
+
id: str
|
| 59 |
+
name: str
|
| 60 |
+
sport: str
|
| 61 |
+
venue_id: str
|
| 62 |
+
date: datetime
|
| 63 |
+
home_team: str = ""
|
| 64 |
+
away_team: str = ""
|
| 65 |
+
current_phase: str = "pre_event"
|
| 66 |
+
expected_attendance: int = 0
|
| 67 |
+
actual_attendance: int = 0
|
| 68 |
+
weather: str = "clear"
|
| 69 |
+
temperature_c: float = 28.0
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def phase(self) -> EventPhase:
|
| 73 |
+
return EventPhase(self.current_phase)
|
| 74 |
+
|
| 75 |
+
def to_dict(self) -> dict:
|
| 76 |
+
return {
|
| 77 |
+
"id": self.id,
|
| 78 |
+
"name": self.name,
|
| 79 |
+
"sport": self.sport,
|
| 80 |
+
"venue_id": self.venue_id,
|
| 81 |
+
"date": self.date.isoformat(),
|
| 82 |
+
"home_team": self.home_team,
|
| 83 |
+
"away_team": self.away_team,
|
| 84 |
+
"current_phase": self.current_phase,
|
| 85 |
+
"phase_label": self.phase.label,
|
| 86 |
+
"phase_description": self.phase.description,
|
| 87 |
+
"expected_attendance": self.expected_attendance,
|
| 88 |
+
"actual_attendance": self.actual_attendance,
|
| 89 |
+
"weather": self.weather,
|
| 90 |
+
"temperature_c": self.temperature_c,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@dataclass
|
| 95 |
+
class Attendee:
|
| 96 |
+
"""An event attendee / user profile."""
|
| 97 |
+
|
| 98 |
+
id: str
|
| 99 |
+
display_name: str
|
| 100 |
+
email: str
|
| 101 |
+
role: str = "attendee" # attendee, operator, admin
|
| 102 |
+
language: str = "en"
|
| 103 |
+
accessibility_needs: List[str] = field(default_factory=list)
|
| 104 |
+
current_zone_id: Optional[str] = None
|
| 105 |
+
seat_section: str = ""
|
| 106 |
+
seat_row: str = ""
|
| 107 |
+
seat_number: str = ""
|
| 108 |
+
preferences: dict = field(default_factory=dict)
|
| 109 |
+
|
| 110 |
+
@property
|
| 111 |
+
def is_operator(self) -> bool:
|
| 112 |
+
return self.role in ("operator", "admin")
|
| 113 |
+
|
| 114 |
+
@property
|
| 115 |
+
def seat_display(self) -> str:
|
| 116 |
+
parts = []
|
| 117 |
+
if self.seat_section:
|
| 118 |
+
parts.append(f"Section {self.seat_section}")
|
| 119 |
+
if self.seat_row:
|
| 120 |
+
parts.append(f"Row {self.seat_row}")
|
| 121 |
+
if self.seat_number:
|
| 122 |
+
parts.append(f"Seat {self.seat_number}")
|
| 123 |
+
return ", ".join(parts) if parts else "General Admission"
|
| 124 |
+
|
| 125 |
+
def to_dict(self) -> dict:
|
| 126 |
+
return {
|
| 127 |
+
"id": self.id,
|
| 128 |
+
"display_name": self.display_name,
|
| 129 |
+
"email": self.email,
|
| 130 |
+
"role": self.role,
|
| 131 |
+
"language": self.language,
|
| 132 |
+
"accessibility_needs": self.accessibility_needs,
|
| 133 |
+
"current_zone_id": self.current_zone_id,
|
| 134 |
+
"seat_display": self.seat_display,
|
| 135 |
+
}
|
models/queue.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Queue and wait-time models."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from enum import Enum
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class QueueCategory(Enum):
|
| 11 |
+
"""Category of service queue."""
|
| 12 |
+
|
| 13 |
+
FOOD = "food"
|
| 14 |
+
MERCHANDISE = "merch"
|
| 15 |
+
RESTROOM = "restroom"
|
| 16 |
+
ENTRY = "entry"
|
| 17 |
+
EXIT = "exit"
|
| 18 |
+
TICKET = "ticket"
|
| 19 |
+
|
| 20 |
+
@property
|
| 21 |
+
def icon(self):
|
| 22 |
+
return {
|
| 23 |
+
"food": "๐",
|
| 24 |
+
"merch": "๐๏ธ",
|
| 25 |
+
"restroom": "๐ป",
|
| 26 |
+
"entry": "๐ช",
|
| 27 |
+
"exit": "๐ถ",
|
| 28 |
+
"ticket": "๐ซ",
|
| 29 |
+
}[self.value]
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def label(self):
|
| 33 |
+
return {
|
| 34 |
+
"food": "Food & Beverages",
|
| 35 |
+
"merch": "Merchandise",
|
| 36 |
+
"restroom": "Restrooms",
|
| 37 |
+
"entry": "Entry Gates",
|
| 38 |
+
"exit": "Exit Gates",
|
| 39 |
+
"ticket": "Ticket Counter",
|
| 40 |
+
}[self.value]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class QueueStation:
|
| 45 |
+
"""A single service station (e.g., one food stall)."""
|
| 46 |
+
|
| 47 |
+
id: str
|
| 48 |
+
name: str
|
| 49 |
+
category: str # QueueCategory value
|
| 50 |
+
zone_id: str
|
| 51 |
+
current_length: int = 0
|
| 52 |
+
avg_service_time_sec: float = 45.0
|
| 53 |
+
is_open: bool = True
|
| 54 |
+
lat: float = 0.0
|
| 55 |
+
lng: float = 0.0
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def estimated_wait_minutes(self) -> float:
|
| 59 |
+
if not self.is_open or self.current_length == 0:
|
| 60 |
+
return 0.0
|
| 61 |
+
return round((self.current_length * self.avg_service_time_sec) / 60, 1)
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def wait_level(self) -> str:
|
| 65 |
+
wait = self.estimated_wait_minutes
|
| 66 |
+
if wait < 5:
|
| 67 |
+
return "short"
|
| 68 |
+
elif wait < 15:
|
| 69 |
+
return "moderate"
|
| 70 |
+
elif wait < 30:
|
| 71 |
+
return "long"
|
| 72 |
+
else:
|
| 73 |
+
return "very_long"
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def wait_color(self) -> str:
|
| 77 |
+
return {
|
| 78 |
+
"short": "#22c55e",
|
| 79 |
+
"moderate": "#f59e0b",
|
| 80 |
+
"long": "#ef4444",
|
| 81 |
+
"very_long": "#18181b",
|
| 82 |
+
}[self.wait_level]
|
| 83 |
+
|
| 84 |
+
def to_dict(self) -> dict:
|
| 85 |
+
return {
|
| 86 |
+
"id": self.id,
|
| 87 |
+
"name": self.name,
|
| 88 |
+
"category": self.category,
|
| 89 |
+
"category_icon": QueueCategory(self.category).icon,
|
| 90 |
+
"category_label": QueueCategory(self.category).label,
|
| 91 |
+
"zone_id": self.zone_id,
|
| 92 |
+
"current_length": self.current_length,
|
| 93 |
+
"estimated_wait_minutes": self.estimated_wait_minutes,
|
| 94 |
+
"wait_level": self.wait_level,
|
| 95 |
+
"wait_color": self.wait_color,
|
| 96 |
+
"is_open": self.is_open,
|
| 97 |
+
"lat": self.lat,
|
| 98 |
+
"lng": self.lng,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@dataclass
|
| 103 |
+
class VirtualQueueTicket:
|
| 104 |
+
"""A virtual queue reservation for an attendee."""
|
| 105 |
+
|
| 106 |
+
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8].upper())
|
| 107 |
+
user_id: str = ""
|
| 108 |
+
station_id: str = ""
|
| 109 |
+
station_name: str = ""
|
| 110 |
+
category: str = ""
|
| 111 |
+
position: int = 0
|
| 112 |
+
estimated_ready_time: Optional[datetime] = None
|
| 113 |
+
status: str = "waiting" # waiting, ready, served, expired
|
| 114 |
+
created_at: datetime = field(default_factory=datetime.utcnow)
|
| 115 |
+
|
| 116 |
+
def to_dict(self) -> dict:
|
| 117 |
+
return {
|
| 118 |
+
"id": self.id,
|
| 119 |
+
"user_id": self.user_id,
|
| 120 |
+
"station_id": self.station_id,
|
| 121 |
+
"station_name": self.station_name,
|
| 122 |
+
"category": self.category,
|
| 123 |
+
"category_icon": QueueCategory(self.category).icon if self.category else "",
|
| 124 |
+
"position": self.position,
|
| 125 |
+
"estimated_ready_time": (
|
| 126 |
+
self.estimated_ready_time.isoformat()
|
| 127 |
+
if self.estimated_ready_time
|
| 128 |
+
else None
|
| 129 |
+
),
|
| 130 |
+
"status": self.status,
|
| 131 |
+
"created_at": self.created_at.isoformat(),
|
| 132 |
+
}
|
models/venue.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Venue and zone models for the stadium layout."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
from enum import Enum
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class DensityLevel(Enum):
|
| 9 |
+
"""Crowd density classification."""
|
| 10 |
+
|
| 11 |
+
LOW = "low"
|
| 12 |
+
MODERATE = "moderate"
|
| 13 |
+
HIGH = "high"
|
| 14 |
+
CRITICAL = "critical"
|
| 15 |
+
|
| 16 |
+
@property
|
| 17 |
+
def color(self):
|
| 18 |
+
return {
|
| 19 |
+
"low": "#22c55e",
|
| 20 |
+
"moderate": "#f59e0b",
|
| 21 |
+
"high": "#ef4444",
|
| 22 |
+
"critical": "#18181b",
|
| 23 |
+
}[self.value]
|
| 24 |
+
|
| 25 |
+
@property
|
| 26 |
+
def label(self):
|
| 27 |
+
return {
|
| 28 |
+
"low": "๐ข Low",
|
| 29 |
+
"moderate": "๐ก Moderate",
|
| 30 |
+
"high": "๐ด High",
|
| 31 |
+
"critical": "โซ Critical",
|
| 32 |
+
}[self.value]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class Zone:
|
| 37 |
+
"""A distinct area within the venue."""
|
| 38 |
+
|
| 39 |
+
id: str
|
| 40 |
+
name: str
|
| 41 |
+
zone_type: str # 'stand', 'concourse', 'gate', 'food_court', 'restroom', 'parking'
|
| 42 |
+
capacity: int
|
| 43 |
+
current_count: int = 0
|
| 44 |
+
lat: float = 0.0
|
| 45 |
+
lng: float = 0.0
|
| 46 |
+
polygon_coords: List[dict] = field(default_factory=list)
|
| 47 |
+
amenities: List[str] = field(default_factory=list)
|
| 48 |
+
is_accessible: bool = True
|
| 49 |
+
floor_level: int = 0
|
| 50 |
+
|
| 51 |
+
@property
|
| 52 |
+
def occupancy_rate(self) -> float:
|
| 53 |
+
if self.capacity == 0:
|
| 54 |
+
return 0.0
|
| 55 |
+
return min(self.current_count / self.capacity, 1.0)
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def density_level(self) -> DensityLevel:
|
| 59 |
+
rate = self.occupancy_rate
|
| 60 |
+
if rate < 0.4:
|
| 61 |
+
return DensityLevel.LOW
|
| 62 |
+
elif rate < 0.7:
|
| 63 |
+
return DensityLevel.MODERATE
|
| 64 |
+
elif rate < 0.9:
|
| 65 |
+
return DensityLevel.HIGH
|
| 66 |
+
else:
|
| 67 |
+
return DensityLevel.CRITICAL
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def available_capacity(self) -> int:
|
| 71 |
+
return max(0, self.capacity - self.current_count)
|
| 72 |
+
|
| 73 |
+
def to_dict(self) -> dict:
|
| 74 |
+
return {
|
| 75 |
+
"id": self.id,
|
| 76 |
+
"name": self.name,
|
| 77 |
+
"zone_type": self.zone_type,
|
| 78 |
+
"capacity": self.capacity,
|
| 79 |
+
"current_count": self.current_count,
|
| 80 |
+
"occupancy_rate": round(self.occupancy_rate * 100, 1),
|
| 81 |
+
"density_level": self.density_level.value,
|
| 82 |
+
"density_color": self.density_level.color,
|
| 83 |
+
"density_label": self.density_level.label,
|
| 84 |
+
"available_capacity": self.available_capacity,
|
| 85 |
+
"lat": self.lat,
|
| 86 |
+
"lng": self.lng,
|
| 87 |
+
"polygon_coords": self.polygon_coords,
|
| 88 |
+
"amenities": self.amenities,
|
| 89 |
+
"is_accessible": self.is_accessible,
|
| 90 |
+
"floor_level": self.floor_level,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@dataclass
|
| 95 |
+
class Venue:
|
| 96 |
+
"""Represents the entire sporting venue."""
|
| 97 |
+
|
| 98 |
+
id: str
|
| 99 |
+
name: str
|
| 100 |
+
city: str
|
| 101 |
+
total_capacity: int
|
| 102 |
+
zones: List[Zone] = field(default_factory=list)
|
| 103 |
+
center_lat: float = 0.0
|
| 104 |
+
center_lng: float = 0.0
|
| 105 |
+
map_zoom: int = 17
|
| 106 |
+
|
| 107 |
+
@property
|
| 108 |
+
def total_current(self) -> int:
|
| 109 |
+
return sum(z.current_count for z in self.zones)
|
| 110 |
+
|
| 111 |
+
@property
|
| 112 |
+
def overall_occupancy(self) -> float:
|
| 113 |
+
if self.total_capacity == 0:
|
| 114 |
+
return 0.0
|
| 115 |
+
return min(self.total_current / self.total_capacity, 1.0)
|
| 116 |
+
|
| 117 |
+
def get_zone(self, zone_id: str) -> Optional[Zone]:
|
| 118 |
+
for zone in self.zones:
|
| 119 |
+
if zone.id == zone_id:
|
| 120 |
+
return zone
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
def get_zones_by_type(self, zone_type: str) -> List[Zone]:
|
| 124 |
+
return [z for z in self.zones if z.zone_type == zone_type]
|
| 125 |
+
|
| 126 |
+
def to_dict(self) -> dict:
|
| 127 |
+
return {
|
| 128 |
+
"id": self.id,
|
| 129 |
+
"name": self.name,
|
| 130 |
+
"city": self.city,
|
| 131 |
+
"total_capacity": self.total_capacity,
|
| 132 |
+
"total_current": self.total_current,
|
| 133 |
+
"overall_occupancy": round(self.overall_occupancy * 100, 1),
|
| 134 |
+
"center_lat": self.center_lat,
|
| 135 |
+
"center_lng": self.center_lng,
|
| 136 |
+
"map_zoom": self.map_zoom,
|
| 137 |
+
"zones": [z.to_dict() for z in self.zones],
|
| 138 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask>=3.1
|
| 2 |
+
flask-socketio>=5.3
|
| 3 |
+
flask-wtf>=1.2
|
| 4 |
+
firebase-admin>=6.5
|
| 5 |
+
google-generativeai>=0.8
|
| 6 |
+
google-cloud-translate>=3.16
|
| 7 |
+
gunicorn>=22.0
|
| 8 |
+
python-dotenv>=1.0
|
| 9 |
+
pytest>=8.0
|
| 10 |
+
pytest-flask>=1.3
|
| 11 |
+
requests>=2.31
|
| 12 |
+
bleach>=6.1
|
services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Services package."""
|
services/crowd_service.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Crowd density analytics service."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import List, Dict, Optional
|
| 5 |
+
from models.venue import Venue, Zone, DensityLevel
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class CrowdService:
|
| 11 |
+
"""Manages crowd density analytics and alerts."""
|
| 12 |
+
|
| 13 |
+
# Thresholds for triggering alerts (occupancy rate)
|
| 14 |
+
THRESHOLDS = {
|
| 15 |
+
"warning": 0.7,
|
| 16 |
+
"critical": 0.9,
|
| 17 |
+
"emergency": 0.95,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
self._alert_callbacks = []
|
| 22 |
+
self._history: List[Dict] = []
|
| 23 |
+
|
| 24 |
+
def register_alert_callback(self, callback):
|
| 25 |
+
"""Register a function to call when density thresholds are breached."""
|
| 26 |
+
self._alert_callbacks.append(callback)
|
| 27 |
+
|
| 28 |
+
def update_zone_count(self, venue: Venue, zone_id: str, new_count: int) -> Optional[Dict]:
|
| 29 |
+
"""Update a zone's crowd count and check thresholds."""
|
| 30 |
+
zone = venue.get_zone(zone_id)
|
| 31 |
+
if not zone:
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
old_level = zone.density_level
|
| 35 |
+
zone.current_count = max(0, min(new_count, zone.capacity))
|
| 36 |
+
new_level = zone.density_level
|
| 37 |
+
|
| 38 |
+
# Record history
|
| 39 |
+
self._history.append({
|
| 40 |
+
"zone_id": zone_id,
|
| 41 |
+
"count": zone.current_count,
|
| 42 |
+
"occupancy": zone.occupancy_rate,
|
| 43 |
+
"level": new_level.value,
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
# Keep history manageable (last 1000 entries)
|
| 47 |
+
if len(self._history) > 1000:
|
| 48 |
+
self._history = self._history[-500:]
|
| 49 |
+
|
| 50 |
+
# Check if we need to fire alerts
|
| 51 |
+
alert = None
|
| 52 |
+
if new_level != old_level and new_level in (DensityLevel.HIGH, DensityLevel.CRITICAL):
|
| 53 |
+
alert = {
|
| 54 |
+
"type": "crowd_density",
|
| 55 |
+
"severity": "critical" if new_level == DensityLevel.CRITICAL else "warning",
|
| 56 |
+
"zone_id": zone_id,
|
| 57 |
+
"zone_name": zone.name,
|
| 58 |
+
"occupancy_rate": round(zone.occupancy_rate * 100, 1),
|
| 59 |
+
"density_level": new_level.value,
|
| 60 |
+
"message": f"โ ๏ธ {zone.name} is now at {new_level.label} density ({round(zone.occupancy_rate * 100)}% capacity)",
|
| 61 |
+
}
|
| 62 |
+
for callback in self._alert_callbacks:
|
| 63 |
+
try:
|
| 64 |
+
callback(alert)
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Alert callback error: {e}")
|
| 67 |
+
|
| 68 |
+
return zone.to_dict()
|
| 69 |
+
|
| 70 |
+
def get_venue_summary(self, venue: Venue) -> Dict:
|
| 71 |
+
"""Get a summary of crowd conditions across the venue."""
|
| 72 |
+
zones_data = [z.to_dict() for z in venue.zones]
|
| 73 |
+
|
| 74 |
+
# Count zones by density level
|
| 75 |
+
density_counts = {}
|
| 76 |
+
for level in DensityLevel:
|
| 77 |
+
density_counts[level.value] = sum(
|
| 78 |
+
1 for z in venue.zones if z.density_level == level
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Find hotspots (highest occupancy zones)
|
| 82 |
+
hotspots = sorted(venue.zones, key=lambda z: z.occupancy_rate, reverse=True)[:5]
|
| 83 |
+
|
| 84 |
+
# Find available zones (lowest occupancy)
|
| 85 |
+
available = sorted(venue.zones, key=lambda z: z.occupancy_rate)[:5]
|
| 86 |
+
|
| 87 |
+
return {
|
| 88 |
+
"total_capacity": venue.total_capacity,
|
| 89 |
+
"total_current": venue.total_current,
|
| 90 |
+
"overall_occupancy": round(venue.overall_occupancy * 100, 1),
|
| 91 |
+
"density_counts": density_counts,
|
| 92 |
+
"hotspots": [z.to_dict() for z in hotspots],
|
| 93 |
+
"most_available": [z.to_dict() for z in available],
|
| 94 |
+
"zones": zones_data,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
def get_zone_trend(self, zone_id: str, last_n: int = 20) -> List[Dict]:
|
| 98 |
+
"""Get recent occupancy trend for a zone."""
|
| 99 |
+
zone_history = [h for h in self._history if h["zone_id"] == zone_id]
|
| 100 |
+
return zone_history[-last_n:]
|
| 101 |
+
|
| 102 |
+
def get_recommended_zones(self, venue: Venue, zone_type: str = None) -> List[Dict]:
|
| 103 |
+
"""Recommend least crowded zones, optionally filtered by type."""
|
| 104 |
+
zones = venue.zones
|
| 105 |
+
if zone_type:
|
| 106 |
+
zones = [z for z in zones if z.zone_type == zone_type]
|
| 107 |
+
|
| 108 |
+
sorted_zones = sorted(zones, key=lambda z: z.occupancy_rate)
|
| 109 |
+
return [z.to_dict() for z in sorted_zones[:5]]
|
| 110 |
+
|
| 111 |
+
def get_crowd_flow_data(self, venue: Venue) -> Dict:
|
| 112 |
+
"""Get data formatted for heatmap visualization."""
|
| 113 |
+
heatmap_points = []
|
| 114 |
+
for zone in venue.zones:
|
| 115 |
+
if zone.lat and zone.lng:
|
| 116 |
+
heatmap_points.append({
|
| 117 |
+
"lat": zone.lat,
|
| 118 |
+
"lng": zone.lng,
|
| 119 |
+
"weight": zone.occupancy_rate,
|
| 120 |
+
"zone_id": zone.id,
|
| 121 |
+
"zone_name": zone.name,
|
| 122 |
+
"density_color": zone.density_level.color,
|
| 123 |
+
})
|
| 124 |
+
|
| 125 |
+
return {
|
| 126 |
+
"heatmap_points": heatmap_points,
|
| 127 |
+
"zones": [z.to_dict() for z in venue.zones],
|
| 128 |
+
"overall_occupancy": round(venue.overall_occupancy * 100, 1),
|
| 129 |
+
}
|
services/firebase_service.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Firebase Firestore & Auth service with mock fallback."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
# In-memory store for mock mode
|
| 12 |
+
_mock_store = {}
|
| 13 |
+
_firebase_app = None
|
| 14 |
+
_firestore_client = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def init_firebase(app):
|
| 18 |
+
"""Initialize Firebase Admin SDK or fall back to mock mode."""
|
| 19 |
+
global _firebase_app, _firestore_client
|
| 20 |
+
|
| 21 |
+
if app.config.get("USE_MOCK_SERVICES"):
|
| 22 |
+
logger.info("๐ถ Firebase running in MOCK mode")
|
| 23 |
+
return
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
import firebase_admin
|
| 27 |
+
from firebase_admin import credentials, firestore
|
| 28 |
+
|
| 29 |
+
cred_path = app.config.get("FIREBASE_CREDENTIALS_PATH", "")
|
| 30 |
+
if cred_path and os.path.exists(cred_path):
|
| 31 |
+
cred = credentials.Certificate(cred_path)
|
| 32 |
+
_firebase_app = firebase_admin.initialize_app(cred)
|
| 33 |
+
_firestore_client = firestore.client()
|
| 34 |
+
logger.info("โ
Firebase initialized successfully")
|
| 35 |
+
else:
|
| 36 |
+
logger.warning("โ ๏ธ Firebase credentials not found, using mock mode")
|
| 37 |
+
app.config["USE_MOCK_SERVICES"] = True
|
| 38 |
+
except Exception as e:
|
| 39 |
+
logger.error(f"โ Firebase init failed: {e}, falling back to mock mode")
|
| 40 |
+
app.config["USE_MOCK_SERVICES"] = True
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _is_mock():
|
| 44 |
+
"""Check if running in mock mode."""
|
| 45 |
+
return _firestore_client is None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# โโโ Firestore CRUD Operations โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def set_document(collection: str, doc_id: str, data: dict) -> bool:
|
| 52 |
+
"""Create or overwrite a document."""
|
| 53 |
+
try:
|
| 54 |
+
if _is_mock():
|
| 55 |
+
if collection not in _mock_store:
|
| 56 |
+
_mock_store[collection] = {}
|
| 57 |
+
_mock_store[collection][doc_id] = {
|
| 58 |
+
**data,
|
| 59 |
+
"_updated_at": datetime.utcnow().isoformat(),
|
| 60 |
+
}
|
| 61 |
+
return True
|
| 62 |
+
|
| 63 |
+
_firestore_client.collection(collection).document(doc_id).set(data)
|
| 64 |
+
return True
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Firestore set_document error: {e}")
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def get_document(collection: str, doc_id: str) -> Optional[dict]:
|
| 71 |
+
"""Retrieve a single document."""
|
| 72 |
+
try:
|
| 73 |
+
if _is_mock():
|
| 74 |
+
return _mock_store.get(collection, {}).get(doc_id)
|
| 75 |
+
|
| 76 |
+
doc = _firestore_client.collection(collection).document(doc_id).get()
|
| 77 |
+
return doc.to_dict() if doc.exists else None
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error(f"Firestore get_document error: {e}")
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def get_collection(collection: str) -> list:
|
| 84 |
+
"""Retrieve all documents in a collection."""
|
| 85 |
+
try:
|
| 86 |
+
if _is_mock():
|
| 87 |
+
store = _mock_store.get(collection, {})
|
| 88 |
+
return [{"id": k, **v} for k, v in store.items()]
|
| 89 |
+
|
| 90 |
+
docs = _firestore_client.collection(collection).stream()
|
| 91 |
+
return [{"id": doc.id, **doc.to_dict()} for doc in docs]
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"Firestore get_collection error: {e}")
|
| 94 |
+
return []
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def update_document(collection: str, doc_id: str, data: dict) -> bool:
|
| 98 |
+
"""Update fields in a document."""
|
| 99 |
+
try:
|
| 100 |
+
if _is_mock():
|
| 101 |
+
if collection not in _mock_store:
|
| 102 |
+
_mock_store[collection] = {}
|
| 103 |
+
if doc_id not in _mock_store[collection]:
|
| 104 |
+
_mock_store[collection][doc_id] = {}
|
| 105 |
+
_mock_store[collection][doc_id].update(data)
|
| 106 |
+
_mock_store[collection][doc_id]["_updated_at"] = (
|
| 107 |
+
datetime.utcnow().isoformat()
|
| 108 |
+
)
|
| 109 |
+
return True
|
| 110 |
+
|
| 111 |
+
_firestore_client.collection(collection).document(doc_id).update(data)
|
| 112 |
+
return True
|
| 113 |
+
except Exception as e:
|
| 114 |
+
logger.error(f"Firestore update_document error: {e}")
|
| 115 |
+
return False
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def delete_document(collection: str, doc_id: str) -> bool:
|
| 119 |
+
"""Delete a document."""
|
| 120 |
+
try:
|
| 121 |
+
if _is_mock():
|
| 122 |
+
if collection in _mock_store and doc_id in _mock_store[collection]:
|
| 123 |
+
del _mock_store[collection][doc_id]
|
| 124 |
+
return True
|
| 125 |
+
|
| 126 |
+
_firestore_client.collection(collection).document(doc_id).delete()
|
| 127 |
+
return True
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"Firestore delete_document error: {e}")
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def query_documents(collection: str, field: str, op: str, value) -> list:
|
| 134 |
+
"""Query documents with a filter."""
|
| 135 |
+
try:
|
| 136 |
+
if _is_mock():
|
| 137 |
+
results = []
|
| 138 |
+
for doc_id, doc_data in _mock_store.get(collection, {}).items():
|
| 139 |
+
doc_val = doc_data.get(field)
|
| 140 |
+
if op == "==" and doc_val == value:
|
| 141 |
+
results.append({"id": doc_id, **doc_data})
|
| 142 |
+
elif op == ">" and doc_val is not None and doc_val > value:
|
| 143 |
+
results.append({"id": doc_id, **doc_data})
|
| 144 |
+
elif op == "<" and doc_val is not None and doc_val < value:
|
| 145 |
+
results.append({"id": doc_id, **doc_data})
|
| 146 |
+
elif op == ">=" and doc_val is not None and doc_val >= value:
|
| 147 |
+
results.append({"id": doc_id, **doc_data})
|
| 148 |
+
elif op == "<=" and doc_val is not None and doc_val <= value:
|
| 149 |
+
results.append({"id": doc_id, **doc_data})
|
| 150 |
+
return results
|
| 151 |
+
|
| 152 |
+
docs = (
|
| 153 |
+
_firestore_client.collection(collection)
|
| 154 |
+
.where(field, op, value)
|
| 155 |
+
.stream()
|
| 156 |
+
)
|
| 157 |
+
return [{"id": doc.id, **doc.to_dict()} for doc in docs]
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(f"Firestore query error: {e}")
|
| 160 |
+
return []
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# โโโ Auth Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 164 |
+
|
| 165 |
+
# Mock user store
|
| 166 |
+
_mock_users = {
|
| 167 |
+
"fan1": {
|
| 168 |
+
"uid": "fan1",
|
| 169 |
+
"email": "fan@venueflow.demo",
|
| 170 |
+
"display_name": "Demo Fan",
|
| 171 |
+
"role": "attendee",
|
| 172 |
+
"password": "demo123",
|
| 173 |
+
},
|
| 174 |
+
"op1": {
|
| 175 |
+
"uid": "op1",
|
| 176 |
+
"email": "operator@venueflow.demo",
|
| 177 |
+
"display_name": "Demo Operator",
|
| 178 |
+
"role": "operator",
|
| 179 |
+
"password": "demo123",
|
| 180 |
+
},
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def verify_mock_login(email: str, password: str) -> Optional[dict]:
|
| 185 |
+
"""Verify login credentials in mock mode."""
|
| 186 |
+
for uid, user in _mock_users.items():
|
| 187 |
+
if user["email"] == email and user["password"] == password:
|
| 188 |
+
return {
|
| 189 |
+
"uid": uid,
|
| 190 |
+
"email": user["email"],
|
| 191 |
+
"display_name": user["display_name"],
|
| 192 |
+
"role": user["role"],
|
| 193 |
+
}
|
| 194 |
+
return None
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def create_mock_user(email: str, password: str, display_name: str, role: str = "attendee") -> Optional[dict]:
|
| 198 |
+
"""Create a user in mock mode."""
|
| 199 |
+
uid = f"user_{len(_mock_users) + 1}"
|
| 200 |
+
_mock_users[uid] = {
|
| 201 |
+
"uid": uid,
|
| 202 |
+
"email": email,
|
| 203 |
+
"display_name": display_name,
|
| 204 |
+
"role": role,
|
| 205 |
+
"password": password,
|
| 206 |
+
}
|
| 207 |
+
return {"uid": uid, "email": email, "display_name": display_name, "role": role}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def get_mock_user(uid: str) -> Optional[dict]:
|
| 211 |
+
"""Get a user by UID in mock mode."""
|
| 212 |
+
user = _mock_users.get(uid)
|
| 213 |
+
if user:
|
| 214 |
+
return {
|
| 215 |
+
"uid": uid,
|
| 216 |
+
"email": user["email"],
|
| 217 |
+
"display_name": user["display_name"],
|
| 218 |
+
"role": user["role"],
|
| 219 |
+
}
|
| 220 |
+
return None
|
services/gemini_service.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini AI chatbot service with mock fallback."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import List, Dict, Optional
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
_genai_model = None
|
| 9 |
+
_is_mock = True
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def init_gemini(app):
|
| 13 |
+
"""Initialize Google Gemini API or fall back to mock."""
|
| 14 |
+
global _genai_model, _is_mock
|
| 15 |
+
|
| 16 |
+
if app.config.get("USE_MOCK_SERVICES"):
|
| 17 |
+
logger.info("๐ถ Gemini running in MOCK mode")
|
| 18 |
+
return
|
| 19 |
+
|
| 20 |
+
api_key = app.config.get("GEMINI_API_KEY", "")
|
| 21 |
+
if not api_key:
|
| 22 |
+
logger.warning("โ ๏ธ No Gemini API key, using mock mode")
|
| 23 |
+
return
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
import google.generativeai as genai
|
| 27 |
+
|
| 28 |
+
genai.configure(api_key=api_key)
|
| 29 |
+
_genai_model = genai.GenerativeModel("gemini-2.5-flash")
|
| 30 |
+
_is_mock = False
|
| 31 |
+
logger.info("โ
Gemini AI initialized successfully")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"โ Gemini init failed: {e}, using mock mode")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
SYSTEM_PROMPT = """You are VenueFlow Assistant, an AI concierge for a large sporting venue.
|
| 37 |
+
You help attendees with:
|
| 38 |
+
- Finding the shortest queues for food, merchandise, and restrooms
|
| 39 |
+
- Navigating to different areas of the venue
|
| 40 |
+
- Checking crowd density in different zones
|
| 41 |
+
- General event information and recommendations
|
| 42 |
+
|
| 43 |
+
Be concise, friendly, and helpful. Use emojis sparingly for clarity.
|
| 44 |
+
When providing directions, be specific about zone names and landmarks.
|
| 45 |
+
Always prioritize attendee safety and comfort."""
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# Smart mock responses based on keywords
|
| 49 |
+
MOCK_RESPONSES = {
|
| 50 |
+
"queue": "Based on current data, the **North Food Court** (Gate 3 area) has the shortest wait at ~4 minutes ๐. The South Food Court is busier at ~12 minutes. Would you like me to add you to the virtual queue?",
|
| 51 |
+
"food": "Here are the least crowded food options right now:\n\n1. **Stall N3** - North Food Court (4 min wait)\n2. **Stall W1** - West Concourse (6 min wait)\n3. **Stall E2** - East Pavilion (8 min wait)\n\nWould you like directions to any of these?",
|
| 52 |
+
"restroom": "The nearest restroom with the shortest queue is at **West Concourse Level 1** (~2 min wait) ๐ป. The restrooms near Gate 5 are currently busy (~10 min wait). Want me to navigate you there?",
|
| 53 |
+
"crowd": "Current venue status:\n\n๐ข **Low density**: North Stand, West Pavilion\n๐ก **Moderate**: East Stand, South Concourse\n๐ด **High**: Main Pavilion, VIP Area\n\nI recommend the North Stand concourse for a more comfortable experience.",
|
| 54 |
+
"seat": "To find your seat, head through **Gate 3** and follow the signs for your section. Staff members at each entry point can help direct you. Would you like turn-by-turn directions?",
|
| 55 |
+
"exit": "The nearest exit from your area is **Gate 5 (West Exit)**. Current crowd density at this exit is ๐ข Low. For the fastest exit after the event, I recommend **Gate 7 (North Exit)** โ it typically has 30% less congestion.",
|
| 56 |
+
"help": "I can help you with:\n\n๐ **Food & Drinks** โ Find shortest queues\n๐ป **Restrooms** โ Locate nearest available\n๐บ๏ธ **Navigation** โ Get directions anywhere\n๐ **Crowd Info** โ Check zone density\n๐ช **Exits** โ Find fastest exit route\n\nWhat would you like to know?",
|
| 57 |
+
"navigate": "I can help you navigate! Which zone are you heading to? You can say things like:\n- 'Navigate to Gate 5'\n- 'Find nearest food stall'\n- 'How do I get to my seat in Section B?'",
|
| 58 |
+
"weather": "Current conditions: โ๏ธ Clear skies, 28ยฐC. It's a great day for the match! Don't forget to stay hydrated โ the nearest water station is at the **North Concourse**.",
|
| 59 |
+
"emergency": "๐จ **In case of emergency:**\n1. Stay calm and follow staff directions\n2. Move to the nearest marked exit\n3. Do not use elevators\n4. Emergency exits are marked with green signs\n5. First aid stations are at Gates 1, 4, and 7\n\nIf you need immediate help, call venue security or alert the nearest staff member.",
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
DEFAULT_MOCK_RESPONSE = "I'm your VenueFlow assistant! I can help with finding food, restrooms, navigating the venue, checking crowd levels, and more. What would you like to know? ๐"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def get_chat_response(
|
| 66 |
+
message: str,
|
| 67 |
+
venue_context: Dict = None,
|
| 68 |
+
conversation_history: List[Dict] = None,
|
| 69 |
+
) -> str:
|
| 70 |
+
"""Get a response from the AI chatbot."""
|
| 71 |
+
if _is_mock:
|
| 72 |
+
return _get_mock_response(message)
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
# Build context-aware prompt
|
| 76 |
+
context_parts = [SYSTEM_PROMPT]
|
| 77 |
+
|
| 78 |
+
if venue_context:
|
| 79 |
+
context_parts.append(f"\nCurrent venue data: {venue_context}")
|
| 80 |
+
|
| 81 |
+
if conversation_history:
|
| 82 |
+
for msg in conversation_history[-6:]: # Last 6 messages
|
| 83 |
+
role = "user" if msg.get("role") == "user" else "model"
|
| 84 |
+
context_parts.append(f"{role}: {msg.get('content', '')}")
|
| 85 |
+
|
| 86 |
+
full_prompt = "\n".join(context_parts) + f"\n\nuser: {message}\nassistant:"
|
| 87 |
+
|
| 88 |
+
response = _genai_model.generate_content(
|
| 89 |
+
full_prompt,
|
| 90 |
+
generation_config={
|
| 91 |
+
"temperature": 0.7,
|
| 92 |
+
"max_output_tokens": 500,
|
| 93 |
+
"top_p": 0.9,
|
| 94 |
+
},
|
| 95 |
+
safety_settings={
|
| 96 |
+
"HARM_CATEGORY_HARASSMENT": "BLOCK_MEDIUM_AND_ABOVE",
|
| 97 |
+
"HARM_CATEGORY_HATE_SPEECH": "BLOCK_MEDIUM_AND_ABOVE",
|
| 98 |
+
"HARM_CATEGORY_SEXUALLY_EXPLICIT": "BLOCK_MEDIUM_AND_ABOVE",
|
| 99 |
+
"HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_MEDIUM_AND_ABOVE",
|
| 100 |
+
},
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
return response.text.strip()
|
| 104 |
+
|
| 105 |
+
except Exception as e:
|
| 106 |
+
logger.error(f"Gemini API error: {e}")
|
| 107 |
+
return "I'm having trouble connecting right now. Please try again in a moment, or ask a venue staff member for help."
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _get_mock_response(message: str) -> str:
|
| 111 |
+
"""Generate a contextual mock response based on keywords."""
|
| 112 |
+
msg_lower = message.lower()
|
| 113 |
+
|
| 114 |
+
for keyword, response in MOCK_RESPONSES.items():
|
| 115 |
+
if keyword in msg_lower:
|
| 116 |
+
return response
|
| 117 |
+
|
| 118 |
+
return DEFAULT_MOCK_RESPONSE
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def get_quick_suggestions() -> List[str]:
|
| 122 |
+
"""Return suggested quick-reply prompts for the chatbot."""
|
| 123 |
+
return [
|
| 124 |
+
"Where's the shortest food queue?",
|
| 125 |
+
"Find nearest restroom",
|
| 126 |
+
"How crowded is it?",
|
| 127 |
+
"Navigate to my seat",
|
| 128 |
+
"Where's the nearest exit?",
|
| 129 |
+
"What's the weather like?",
|
| 130 |
+
]
|
services/maps_service.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Maps and wayfinding service."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import math
|
| 5 |
+
from typing import List, Dict, Optional, Tuple
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class MapsService:
|
| 11 |
+
"""Venue navigation and wayfinding service."""
|
| 12 |
+
|
| 13 |
+
def __init__(self, venue=None):
|
| 14 |
+
self.venue = venue
|
| 15 |
+
# Pre-built adjacency for routing between zones
|
| 16 |
+
self._adjacency: Dict[str, List[str]] = {}
|
| 17 |
+
self._walking_speeds = {
|
| 18 |
+
"normal": 1.2, # m/s
|
| 19 |
+
"crowded": 0.6, # m/s in dense areas
|
| 20 |
+
"accessible": 0.8, # m/s for accessible routes
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
def set_venue(self, venue):
|
| 24 |
+
"""Set the active venue and build routing graph."""
|
| 25 |
+
self.venue = venue
|
| 26 |
+
self._build_adjacency()
|
| 27 |
+
|
| 28 |
+
def _build_adjacency(self):
|
| 29 |
+
"""Build zone adjacency graph based on proximity."""
|
| 30 |
+
if not self.venue:
|
| 31 |
+
return
|
| 32 |
+
|
| 33 |
+
self._adjacency = {}
|
| 34 |
+
zones = self.venue.zones
|
| 35 |
+
|
| 36 |
+
for z in zones:
|
| 37 |
+
self._adjacency[z.id] = []
|
| 38 |
+
for other in zones:
|
| 39 |
+
if z.id != other.id:
|
| 40 |
+
dist = self._haversine(z.lat, z.lng, other.lat, other.lng)
|
| 41 |
+
if dist < 300: # Within 300 meters = adjacent
|
| 42 |
+
self._adjacency[z.id].append(other.id)
|
| 43 |
+
|
| 44 |
+
def _haversine(self, lat1, lon1, lat2, lon2) -> float:
|
| 45 |
+
"""Calculate distance between two points in meters."""
|
| 46 |
+
R = 6371000 # Earth's radius in meters
|
| 47 |
+
phi1 = math.radians(lat1)
|
| 48 |
+
phi2 = math.radians(lat2)
|
| 49 |
+
dphi = math.radians(lat2 - lat1)
|
| 50 |
+
dlambda = math.radians(lon2 - lon1)
|
| 51 |
+
|
| 52 |
+
a = (
|
| 53 |
+
math.sin(dphi / 2) ** 2
|
| 54 |
+
+ math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
| 55 |
+
)
|
| 56 |
+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
| 57 |
+
return R * c
|
| 58 |
+
|
| 59 |
+
def find_route(
|
| 60 |
+
self,
|
| 61 |
+
from_zone_id: str,
|
| 62 |
+
to_zone_id: str,
|
| 63 |
+
accessible: bool = False,
|
| 64 |
+
) -> Optional[Dict]:
|
| 65 |
+
"""Find a route between two zones using BFS (shortest path)."""
|
| 66 |
+
if not self.venue:
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
from_zone = self.venue.get_zone(from_zone_id)
|
| 70 |
+
to_zone = self.venue.get_zone(to_zone_id)
|
| 71 |
+
if not from_zone or not to_zone:
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
# BFS for shortest path
|
| 75 |
+
visited = {from_zone_id}
|
| 76 |
+
queue = [(from_zone_id, [from_zone_id])]
|
| 77 |
+
path = None
|
| 78 |
+
|
| 79 |
+
while queue:
|
| 80 |
+
current, current_path = queue.pop(0)
|
| 81 |
+
if current == to_zone_id:
|
| 82 |
+
path = current_path
|
| 83 |
+
break
|
| 84 |
+
|
| 85 |
+
for neighbor in self._adjacency.get(current, []):
|
| 86 |
+
if neighbor not in visited:
|
| 87 |
+
# Skip non-accessible zones if accessible routing requested
|
| 88 |
+
nzone = self.venue.get_zone(neighbor)
|
| 89 |
+
if accessible and nzone and not nzone.is_accessible:
|
| 90 |
+
continue
|
| 91 |
+
visited.add(neighbor)
|
| 92 |
+
queue.append((neighbor, current_path + [neighbor]))
|
| 93 |
+
|
| 94 |
+
if not path:
|
| 95 |
+
# Fallback: direct route
|
| 96 |
+
path = [from_zone_id, to_zone_id]
|
| 97 |
+
|
| 98 |
+
# Build route details
|
| 99 |
+
steps = []
|
| 100 |
+
total_distance = 0
|
| 101 |
+
for i in range(len(path) - 1):
|
| 102 |
+
z1 = self.venue.get_zone(path[i])
|
| 103 |
+
z2 = self.venue.get_zone(path[i + 1])
|
| 104 |
+
if z1 and z2:
|
| 105 |
+
dist = self._haversine(z1.lat, z1.lng, z2.lat, z2.lng)
|
| 106 |
+
total_distance += dist
|
| 107 |
+
steps.append({
|
| 108 |
+
"from": z1.name,
|
| 109 |
+
"to": z2.name,
|
| 110 |
+
"distance_m": round(dist),
|
| 111 |
+
"instruction": f"Head towards {z2.name}",
|
| 112 |
+
"from_coords": {"lat": z1.lat, "lng": z1.lng},
|
| 113 |
+
"to_coords": {"lat": z2.lat, "lng": z2.lng},
|
| 114 |
+
})
|
| 115 |
+
|
| 116 |
+
speed = (
|
| 117 |
+
self._walking_speeds["accessible"]
|
| 118 |
+
if accessible
|
| 119 |
+
else self._walking_speeds["normal"]
|
| 120 |
+
)
|
| 121 |
+
est_time_sec = total_distance / speed if speed > 0 else 0
|
| 122 |
+
|
| 123 |
+
return {
|
| 124 |
+
"from": from_zone.name,
|
| 125 |
+
"to": to_zone.name,
|
| 126 |
+
"path": path,
|
| 127 |
+
"steps": steps,
|
| 128 |
+
"total_distance_m": round(total_distance),
|
| 129 |
+
"estimated_time_sec": round(est_time_sec),
|
| 130 |
+
"estimated_time_display": self._format_time(est_time_sec),
|
| 131 |
+
"accessible": accessible,
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
def find_nearest(
|
| 135 |
+
self,
|
| 136 |
+
from_zone_id: str,
|
| 137 |
+
target_type: str,
|
| 138 |
+
accessible: bool = False,
|
| 139 |
+
) -> Optional[Dict]:
|
| 140 |
+
"""Find the nearest zone of a specific type."""
|
| 141 |
+
if not self.venue:
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
from_zone = self.venue.get_zone(from_zone_id)
|
| 145 |
+
if not from_zone:
|
| 146 |
+
return None
|
| 147 |
+
|
| 148 |
+
targets = self.venue.get_zones_by_type(target_type)
|
| 149 |
+
if accessible:
|
| 150 |
+
targets = [z for z in targets if z.is_accessible]
|
| 151 |
+
|
| 152 |
+
if not targets:
|
| 153 |
+
return None
|
| 154 |
+
|
| 155 |
+
# Sort by distance and choose nearest with lowest crowd
|
| 156 |
+
scored = []
|
| 157 |
+
for t in targets:
|
| 158 |
+
dist = self._haversine(from_zone.lat, from_zone.lng, t.lat, t.lng)
|
| 159 |
+
# Penalize crowded zones
|
| 160 |
+
crowd_penalty = t.occupancy_rate * 100
|
| 161 |
+
score = dist + crowd_penalty
|
| 162 |
+
scored.append((t, dist, score))
|
| 163 |
+
|
| 164 |
+
scored.sort(key=lambda x: x[2])
|
| 165 |
+
best = scored[0]
|
| 166 |
+
|
| 167 |
+
route = self.find_route(from_zone_id, best[0].id, accessible)
|
| 168 |
+
|
| 169 |
+
return {
|
| 170 |
+
"target_zone": best[0].to_dict(),
|
| 171 |
+
"distance_m": round(best[1]),
|
| 172 |
+
"route": route,
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
def get_points_of_interest(self) -> List[Dict]:
|
| 176 |
+
"""Get all points of interest for the map."""
|
| 177 |
+
if not self.venue:
|
| 178 |
+
return []
|
| 179 |
+
|
| 180 |
+
pois = []
|
| 181 |
+
type_icons = {
|
| 182 |
+
"food_court": "๐",
|
| 183 |
+
"restroom": "๐ป",
|
| 184 |
+
"gate": "๐ช",
|
| 185 |
+
"stand": "๐๏ธ",
|
| 186 |
+
"concourse": "๐ถ",
|
| 187 |
+
"parking": "๐
ฟ๏ธ",
|
| 188 |
+
"first_aid": "๐ฅ",
|
| 189 |
+
"merchandise": "๐๏ธ",
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
for zone in self.venue.zones:
|
| 193 |
+
pois.append({
|
| 194 |
+
"id": zone.id,
|
| 195 |
+
"name": zone.name,
|
| 196 |
+
"type": zone.zone_type,
|
| 197 |
+
"icon": type_icons.get(zone.zone_type, "๐"),
|
| 198 |
+
"lat": zone.lat,
|
| 199 |
+
"lng": zone.lng,
|
| 200 |
+
"is_accessible": zone.is_accessible,
|
| 201 |
+
"density_level": zone.density_level.value,
|
| 202 |
+
"density_color": zone.density_level.color,
|
| 203 |
+
})
|
| 204 |
+
|
| 205 |
+
return pois
|
| 206 |
+
|
| 207 |
+
def _format_time(self, seconds: float) -> str:
|
| 208 |
+
"""Format seconds into a readable time string."""
|
| 209 |
+
if seconds < 60:
|
| 210 |
+
return f"{int(seconds)} sec"
|
| 211 |
+
minutes = int(seconds / 60)
|
| 212 |
+
remaining_sec = int(seconds % 60)
|
| 213 |
+
if minutes < 60:
|
| 214 |
+
return f"{minutes} min" + (f" {remaining_sec}s" if remaining_sec > 0 else "")
|
| 215 |
+
hours = int(minutes / 60)
|
| 216 |
+
remaining_min = int(minutes % 60)
|
| 217 |
+
return f"{hours}h {remaining_min}m"
|
services/notification_service.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Notification and alert service."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import List, Dict, Optional
|
| 6 |
+
from enum import Enum
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AlertSeverity(Enum):
|
| 13 |
+
INFO = "info"
|
| 14 |
+
WARNING = "warning"
|
| 15 |
+
CRITICAL = "critical"
|
| 16 |
+
EMERGENCY = "emergency"
|
| 17 |
+
|
| 18 |
+
@property
|
| 19 |
+
def color(self):
|
| 20 |
+
return {
|
| 21 |
+
"info": "#3b82f6",
|
| 22 |
+
"warning": "#f59e0b",
|
| 23 |
+
"critical": "#ef4444",
|
| 24 |
+
"emergency": "#dc2626",
|
| 25 |
+
}[self.value]
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def icon(self):
|
| 29 |
+
return {
|
| 30 |
+
"info": "โน๏ธ",
|
| 31 |
+
"warning": "โ ๏ธ",
|
| 32 |
+
"critical": "๐ด",
|
| 33 |
+
"emergency": "๐จ",
|
| 34 |
+
}[self.value]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class NotificationService:
|
| 38 |
+
"""Manages alerts and notifications for both attendees and operators."""
|
| 39 |
+
|
| 40 |
+
def __init__(self):
|
| 41 |
+
self._alerts: List[Dict] = []
|
| 42 |
+
self._subscribers: List[callable] = []
|
| 43 |
+
self._max_alerts = 500
|
| 44 |
+
|
| 45 |
+
def subscribe(self, callback):
|
| 46 |
+
"""Register a callback for new alerts."""
|
| 47 |
+
self._subscribers.append(callback)
|
| 48 |
+
|
| 49 |
+
def create_alert(
|
| 50 |
+
self,
|
| 51 |
+
title: str,
|
| 52 |
+
message: str,
|
| 53 |
+
severity: str = "info",
|
| 54 |
+
zone_id: str = None,
|
| 55 |
+
target_role: str = "all",
|
| 56 |
+
) -> Dict:
|
| 57 |
+
"""Create and broadcast a new alert."""
|
| 58 |
+
alert = {
|
| 59 |
+
"id": str(uuid.uuid4())[:8],
|
| 60 |
+
"title": title,
|
| 61 |
+
"message": message,
|
| 62 |
+
"severity": severity,
|
| 63 |
+
"severity_color": AlertSeverity(severity).color,
|
| 64 |
+
"severity_icon": AlertSeverity(severity).icon,
|
| 65 |
+
"zone_id": zone_id,
|
| 66 |
+
"target_role": target_role,
|
| 67 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 68 |
+
"read": False,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
self._alerts.insert(0, alert)
|
| 72 |
+
|
| 73 |
+
# Trim old alerts
|
| 74 |
+
if len(self._alerts) > self._max_alerts:
|
| 75 |
+
self._alerts = self._alerts[: self._max_alerts]
|
| 76 |
+
|
| 77 |
+
# Notify subscribers
|
| 78 |
+
for callback in self._subscribers:
|
| 79 |
+
try:
|
| 80 |
+
callback(alert)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"Notification callback error: {e}")
|
| 83 |
+
|
| 84 |
+
return alert
|
| 85 |
+
|
| 86 |
+
def get_alerts(
|
| 87 |
+
self,
|
| 88 |
+
role: str = "all",
|
| 89 |
+
severity: str = None,
|
| 90 |
+
zone_id: str = None,
|
| 91 |
+
limit: int = 50,
|
| 92 |
+
) -> List[Dict]:
|
| 93 |
+
"""Get filtered alerts."""
|
| 94 |
+
filtered = self._alerts
|
| 95 |
+
|
| 96 |
+
if role != "all":
|
| 97 |
+
filtered = [
|
| 98 |
+
a for a in filtered
|
| 99 |
+
if a["target_role"] in ("all", role)
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
if severity:
|
| 103 |
+
filtered = [a for a in filtered if a["severity"] == severity]
|
| 104 |
+
|
| 105 |
+
if zone_id:
|
| 106 |
+
filtered = [
|
| 107 |
+
a for a in filtered
|
| 108 |
+
if a.get("zone_id") is None or a["zone_id"] == zone_id
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
return filtered[:limit]
|
| 112 |
+
|
| 113 |
+
def mark_read(self, alert_id: str) -> bool:
|
| 114 |
+
"""Mark an alert as read."""
|
| 115 |
+
for alert in self._alerts:
|
| 116 |
+
if alert["id"] == alert_id:
|
| 117 |
+
alert["read"] = True
|
| 118 |
+
return True
|
| 119 |
+
return False
|
| 120 |
+
|
| 121 |
+
def get_unread_count(self, role: str = "all") -> int:
|
| 122 |
+
"""Count unread alerts for a role."""
|
| 123 |
+
alerts = self.get_alerts(role=role)
|
| 124 |
+
return sum(1 for a in alerts if not a["read"])
|
| 125 |
+
|
| 126 |
+
def clear_all(self):
|
| 127 |
+
"""Clear all alerts."""
|
| 128 |
+
self._alerts = []
|
services/queue_service.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Queue management service with virtual queue support."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from typing import List, Dict, Optional
|
| 6 |
+
from models.queue import QueueStation, VirtualQueueTicket
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class QueueService:
|
| 12 |
+
"""Manages queues, wait times, and virtual queue reservations."""
|
| 13 |
+
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self._stations: Dict[str, QueueStation] = {}
|
| 16 |
+
self._virtual_tickets: Dict[str, VirtualQueueTicket] = {}
|
| 17 |
+
self._station_history: Dict[str, List[Dict]] = {}
|
| 18 |
+
|
| 19 |
+
def register_station(self, station: QueueStation):
|
| 20 |
+
"""Register a queue station."""
|
| 21 |
+
self._stations[station.id] = station
|
| 22 |
+
|
| 23 |
+
def register_stations(self, stations: List[QueueStation]):
|
| 24 |
+
"""Register multiple queue stations."""
|
| 25 |
+
for s in stations:
|
| 26 |
+
self._stations[s.id] = s
|
| 27 |
+
|
| 28 |
+
def get_station(self, station_id: str) -> Optional[QueueStation]:
|
| 29 |
+
"""Get a station by ID."""
|
| 30 |
+
return self._stations.get(station_id)
|
| 31 |
+
|
| 32 |
+
def get_all_stations(self) -> List[Dict]:
|
| 33 |
+
"""Get all stations as dicts."""
|
| 34 |
+
return [s.to_dict() for s in self._stations.values()]
|
| 35 |
+
|
| 36 |
+
def get_stations_by_category(self, category: str) -> List[Dict]:
|
| 37 |
+
"""Get stations filtered by category."""
|
| 38 |
+
return [
|
| 39 |
+
s.to_dict()
|
| 40 |
+
for s in self._stations.values()
|
| 41 |
+
if s.category == category and s.is_open
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
def get_stations_by_zone(self, zone_id: str) -> List[Dict]:
|
| 45 |
+
"""Get stations in a specific zone."""
|
| 46 |
+
return [
|
| 47 |
+
s.to_dict()
|
| 48 |
+
for s in self._stations.values()
|
| 49 |
+
if s.zone_id == zone_id
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
def update_queue_length(self, station_id: str, new_length: int) -> Optional[Dict]:
|
| 53 |
+
"""Update the queue length for a station."""
|
| 54 |
+
station = self._stations.get(station_id)
|
| 55 |
+
if not station:
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
station.current_length = max(0, new_length)
|
| 59 |
+
|
| 60 |
+
# Track history
|
| 61 |
+
if station_id not in self._station_history:
|
| 62 |
+
self._station_history[station_id] = []
|
| 63 |
+
self._station_history[station_id].append({
|
| 64 |
+
"length": station.current_length,
|
| 65 |
+
"wait_min": station.estimated_wait_minutes,
|
| 66 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
# Trim history
|
| 70 |
+
if len(self._station_history[station_id]) > 100:
|
| 71 |
+
self._station_history[station_id] = self._station_history[station_id][-50:]
|
| 72 |
+
|
| 73 |
+
return station.to_dict()
|
| 74 |
+
|
| 75 |
+
def get_shortest_queue(self, category: str = None) -> Optional[Dict]:
|
| 76 |
+
"""Find the station with the shortest wait time."""
|
| 77 |
+
stations = [
|
| 78 |
+
s for s in self._stations.values()
|
| 79 |
+
if s.is_open and (category is None or s.category == category)
|
| 80 |
+
]
|
| 81 |
+
if not stations:
|
| 82 |
+
return None
|
| 83 |
+
shortest = min(stations, key=lambda s: s.estimated_wait_minutes)
|
| 84 |
+
return shortest.to_dict()
|
| 85 |
+
|
| 86 |
+
def get_queue_summary(self) -> Dict:
|
| 87 |
+
"""Get an overview of all queues."""
|
| 88 |
+
open_stations = [s for s in self._stations.values() if s.is_open]
|
| 89 |
+
categories = {}
|
| 90 |
+
|
| 91 |
+
for station in open_stations:
|
| 92 |
+
cat = station.category
|
| 93 |
+
if cat not in categories:
|
| 94 |
+
categories[cat] = {
|
| 95 |
+
"category": cat,
|
| 96 |
+
"total_stations": 0,
|
| 97 |
+
"total_in_queue": 0,
|
| 98 |
+
"avg_wait_minutes": 0.0,
|
| 99 |
+
"shortest_wait": float("inf"),
|
| 100 |
+
"shortest_station": None,
|
| 101 |
+
}
|
| 102 |
+
categories[cat]["total_stations"] += 1
|
| 103 |
+
categories[cat]["total_in_queue"] += station.current_length
|
| 104 |
+
|
| 105 |
+
wait = station.estimated_wait_minutes
|
| 106 |
+
if wait < categories[cat]["shortest_wait"]:
|
| 107 |
+
categories[cat]["shortest_wait"] = wait
|
| 108 |
+
categories[cat]["shortest_station"] = station.name
|
| 109 |
+
|
| 110 |
+
# Calculate averages
|
| 111 |
+
for cat_data in categories.values():
|
| 112 |
+
if cat_data["total_stations"] > 0:
|
| 113 |
+
cat_data["avg_wait_minutes"] = round(
|
| 114 |
+
sum(
|
| 115 |
+
s.estimated_wait_minutes
|
| 116 |
+
for s in open_stations
|
| 117 |
+
if s.category == cat_data["category"]
|
| 118 |
+
) / cat_data["total_stations"],
|
| 119 |
+
1,
|
| 120 |
+
)
|
| 121 |
+
if cat_data["shortest_wait"] == float("inf"):
|
| 122 |
+
cat_data["shortest_wait"] = 0
|
| 123 |
+
|
| 124 |
+
return {
|
| 125 |
+
"total_stations": len(open_stations),
|
| 126 |
+
"total_people_waiting": sum(s.current_length for s in open_stations),
|
| 127 |
+
"categories": categories,
|
| 128 |
+
"stations": [s.to_dict() for s in open_stations],
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
# โโโ Virtual Queue โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 132 |
+
|
| 133 |
+
def join_virtual_queue(self, user_id: str, station_id: str) -> Optional[Dict]:
|
| 134 |
+
"""Create a virtual queue reservation."""
|
| 135 |
+
station = self._stations.get(station_id)
|
| 136 |
+
if not station or not station.is_open:
|
| 137 |
+
return None
|
| 138 |
+
|
| 139 |
+
# Check if user already has a ticket for this station
|
| 140 |
+
existing = [
|
| 141 |
+
t for t in self._virtual_tickets.values()
|
| 142 |
+
if t.user_id == user_id and t.station_id == station_id and t.status == "waiting"
|
| 143 |
+
]
|
| 144 |
+
if existing:
|
| 145 |
+
return existing[0].to_dict()
|
| 146 |
+
|
| 147 |
+
# Count waiting tickets for this station
|
| 148 |
+
waiting_count = sum(
|
| 149 |
+
1 for t in self._virtual_tickets.values()
|
| 150 |
+
if t.station_id == station_id and t.status == "waiting"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
ticket = VirtualQueueTicket(
|
| 154 |
+
user_id=user_id,
|
| 155 |
+
station_id=station_id,
|
| 156 |
+
station_name=station.name,
|
| 157 |
+
category=station.category,
|
| 158 |
+
position=waiting_count + 1,
|
| 159 |
+
estimated_ready_time=datetime.utcnow() + timedelta(
|
| 160 |
+
seconds=station.avg_service_time_sec * (waiting_count + 1)
|
| 161 |
+
),
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
self._virtual_tickets[ticket.id] = ticket
|
| 165 |
+
return ticket.to_dict()
|
| 166 |
+
|
| 167 |
+
def get_user_tickets(self, user_id: str) -> List[Dict]:
|
| 168 |
+
"""Get all virtual tickets for a user."""
|
| 169 |
+
return [
|
| 170 |
+
t.to_dict()
|
| 171 |
+
for t in self._virtual_tickets.values()
|
| 172 |
+
if t.user_id == user_id
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
def cancel_ticket(self, ticket_id: str, user_id: str) -> bool:
|
| 176 |
+
"""Cancel a virtual queue ticket."""
|
| 177 |
+
ticket = self._virtual_tickets.get(ticket_id)
|
| 178 |
+
if ticket and ticket.user_id == user_id and ticket.status == "waiting":
|
| 179 |
+
ticket.status = "expired"
|
| 180 |
+
return True
|
| 181 |
+
return False
|
| 182 |
+
|
| 183 |
+
def get_station_history(self, station_id: str, last_n: int = 20) -> List[Dict]:
|
| 184 |
+
"""Get queue length history for a station."""
|
| 185 |
+
return self._station_history.get(station_id, [])[-last_n:]
|
services/simulator.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Realistic event simulation engine for demo/testing."""
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
import math
|
| 5 |
+
import logging
|
| 6 |
+
import threading
|
| 7 |
+
import time
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Dict, Optional, Callable
|
| 10 |
+
from models.venue import Venue, Zone
|
| 11 |
+
from models.queue import QueueStation
|
| 12 |
+
from models.event import Event, EventPhase
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def create_demo_venue() -> Venue:
|
| 18 |
+
"""Create a realistic cricket stadium layout (based on a typical Indian cricket ground)."""
|
| 19 |
+
# Center coordinates (representative sports venue in Mumbai)
|
| 20 |
+
center_lat = 18.9388
|
| 21 |
+
center_lng = 72.8255
|
| 22 |
+
|
| 23 |
+
zones = [
|
| 24 |
+
# Stands
|
| 25 |
+
Zone(id="north_stand", name="North Stand", zone_type="stand", capacity=8000,
|
| 26 |
+
lat=center_lat + 0.0015, lng=center_lng, amenities=["seating", "shade"]),
|
| 27 |
+
Zone(id="south_stand", name="South Stand", zone_type="stand", capacity=8000,
|
| 28 |
+
lat=center_lat - 0.0015, lng=center_lng, amenities=["seating", "shade"]),
|
| 29 |
+
Zone(id="east_pavilion", name="East Pavilion", zone_type="stand", capacity=6000,
|
| 30 |
+
lat=center_lat, lng=center_lng + 0.0015, amenities=["seating", "premium"]),
|
| 31 |
+
Zone(id="west_pavilion", name="West Pavilion", zone_type="stand", capacity=6000,
|
| 32 |
+
lat=center_lat, lng=center_lng - 0.0015, amenities=["seating", "premium"]),
|
| 33 |
+
Zone(id="main_pavilion", name="Main Pavilion (VIP)", zone_type="stand", capacity=3000,
|
| 34 |
+
lat=center_lat + 0.001, lng=center_lng + 0.001, amenities=["seating", "vip", "lounge"]),
|
| 35 |
+
|
| 36 |
+
# Concourses
|
| 37 |
+
Zone(id="north_concourse", name="North Concourse", zone_type="concourse", capacity=4000,
|
| 38 |
+
lat=center_lat + 0.002, lng=center_lng, amenities=["walking"]),
|
| 39 |
+
Zone(id="south_concourse", name="South Concourse", zone_type="concourse", capacity=4000,
|
| 40 |
+
lat=center_lat - 0.002, lng=center_lng, amenities=["walking"]),
|
| 41 |
+
Zone(id="east_concourse", name="East Concourse", zone_type="concourse", capacity=3000,
|
| 42 |
+
lat=center_lat, lng=center_lng + 0.002, amenities=["walking"]),
|
| 43 |
+
Zone(id="west_concourse", name="West Concourse", zone_type="concourse", capacity=3000,
|
| 44 |
+
lat=center_lat, lng=center_lng - 0.002, amenities=["walking"]),
|
| 45 |
+
|
| 46 |
+
# Gates
|
| 47 |
+
Zone(id="gate_1", name="Gate 1 (Main)", zone_type="gate", capacity=2000,
|
| 48 |
+
lat=center_lat + 0.0025, lng=center_lng - 0.001, amenities=["entry", "security"]),
|
| 49 |
+
Zone(id="gate_3", name="Gate 3 (North)", zone_type="gate", capacity=1500,
|
| 50 |
+
lat=center_lat + 0.0025, lng=center_lng + 0.001, amenities=["entry", "security"]),
|
| 51 |
+
Zone(id="gate_5", name="Gate 5 (West)", zone_type="gate", capacity=1500,
|
| 52 |
+
lat=center_lat, lng=center_lng - 0.0025, amenities=["entry", "security"]),
|
| 53 |
+
Zone(id="gate_7", name="Gate 7 (South)", zone_type="gate", capacity=1500,
|
| 54 |
+
lat=center_lat - 0.0025, lng=center_lng, amenities=["entry", "security"]),
|
| 55 |
+
|
| 56 |
+
# Food Courts
|
| 57 |
+
Zone(id="food_north", name="North Food Court", zone_type="food_court", capacity=1500,
|
| 58 |
+
lat=center_lat + 0.0018, lng=center_lng + 0.0005, amenities=["food", "beverages"]),
|
| 59 |
+
Zone(id="food_south", name="South Food Court", zone_type="food_court", capacity=1500,
|
| 60 |
+
lat=center_lat - 0.0018, lng=center_lng - 0.0005, amenities=["food", "beverages"]),
|
| 61 |
+
Zone(id="food_west", name="West Food Stalls", zone_type="food_court", capacity=800,
|
| 62 |
+
lat=center_lat - 0.0005, lng=center_lng - 0.0018, amenities=["food"]),
|
| 63 |
+
|
| 64 |
+
# Restrooms
|
| 65 |
+
Zone(id="restroom_n1", name="Restrooms N1", zone_type="restroom", capacity=200,
|
| 66 |
+
lat=center_lat + 0.0017, lng=center_lng - 0.0008, amenities=["restroom", "accessible"]),
|
| 67 |
+
Zone(id="restroom_s1", name="Restrooms S1", zone_type="restroom", capacity=200,
|
| 68 |
+
lat=center_lat - 0.0017, lng=center_lng + 0.0008, amenities=["restroom", "accessible"]),
|
| 69 |
+
Zone(id="restroom_e1", name="Restrooms E1", zone_type="restroom", capacity=150,
|
| 70 |
+
lat=center_lat + 0.0005, lng=center_lng + 0.0017, amenities=["restroom"]),
|
| 71 |
+
Zone(id="restroom_w1", name="Restrooms W1", zone_type="restroom", capacity=150,
|
| 72 |
+
lat=center_lat - 0.0005, lng=center_lng - 0.0017, amenities=["restroom"]),
|
| 73 |
+
|
| 74 |
+
# Parking
|
| 75 |
+
Zone(id="parking_a", name="Parking Lot A", zone_type="parking", capacity=3000,
|
| 76 |
+
lat=center_lat + 0.003, lng=center_lng, amenities=["parking"]),
|
| 77 |
+
Zone(id="parking_b", name="Parking Lot B", zone_type="parking", capacity=2000,
|
| 78 |
+
lat=center_lat, lng=center_lng - 0.003, amenities=["parking"]),
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
venue = Venue(
|
| 82 |
+
id="wankhede",
|
| 83 |
+
name="Wankhede Stadium",
|
| 84 |
+
city="Mumbai",
|
| 85 |
+
total_capacity=50000,
|
| 86 |
+
zones=zones,
|
| 87 |
+
center_lat=center_lat,
|
| 88 |
+
center_lng=center_lng,
|
| 89 |
+
map_zoom=16,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
return venue
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def create_demo_queue_stations() -> list:
|
| 96 |
+
"""Create realistic queue stations for the demo venue."""
|
| 97 |
+
stations = [
|
| 98 |
+
# Food stalls
|
| 99 |
+
QueueStation(id="f_n1", name="Chaat Counter", category="food", zone_id="food_north",
|
| 100 |
+
avg_service_time_sec=60, lat=18.9406, lng=72.8260),
|
| 101 |
+
QueueStation(id="f_n2", name="Biryani House", category="food", zone_id="food_north",
|
| 102 |
+
avg_service_time_sec=90, lat=18.9406, lng=72.8258),
|
| 103 |
+
QueueStation(id="f_n3", name="Pizza & Burgers", category="food", zone_id="food_north",
|
| 104 |
+
avg_service_time_sec=75, lat=18.9406, lng=72.8262),
|
| 105 |
+
QueueStation(id="f_s1", name="South Indian Delights", category="food", zone_id="food_south",
|
| 106 |
+
avg_service_time_sec=70, lat=18.9370, lng=72.8250),
|
| 107 |
+
QueueStation(id="f_s2", name="Beverages & Ice Cream", category="food", zone_id="food_south",
|
| 108 |
+
avg_service_time_sec=45, lat=18.9370, lng=72.8252),
|
| 109 |
+
QueueStation(id="f_w1", name="Quick Bites", category="food", zone_id="food_west",
|
| 110 |
+
avg_service_time_sec=40, lat=18.9385, lng=72.8237),
|
| 111 |
+
|
| 112 |
+
# Merchandise
|
| 113 |
+
QueueStation(id="m_1", name="Team Jersey Shop", category="merch", zone_id="north_concourse",
|
| 114 |
+
avg_service_time_sec=120, lat=18.9408, lng=72.8255),
|
| 115 |
+
QueueStation(id="m_2", name="Souvenir Stand", category="merch", zone_id="east_concourse",
|
| 116 |
+
avg_service_time_sec=90, lat=18.9388, lng=72.8275),
|
| 117 |
+
|
| 118 |
+
# Restrooms (physical queues)
|
| 119 |
+
QueueStation(id="r_n1", name="Restrooms N1", category="restroom", zone_id="restroom_n1",
|
| 120 |
+
avg_service_time_sec=120, lat=18.9405, lng=72.8247),
|
| 121 |
+
QueueStation(id="r_s1", name="Restrooms S1", category="restroom", zone_id="restroom_s1",
|
| 122 |
+
avg_service_time_sec=120, lat=18.9371, lng=72.8263),
|
| 123 |
+
QueueStation(id="r_e1", name="Restrooms E1", category="restroom", zone_id="restroom_e1",
|
| 124 |
+
avg_service_time_sec=110, lat=18.9393, lng=72.8272),
|
| 125 |
+
QueueStation(id="r_w1", name="Restrooms W1", category="restroom", zone_id="restroom_w1",
|
| 126 |
+
avg_service_time_sec=110, lat=18.9383, lng=72.8238),
|
| 127 |
+
|
| 128 |
+
# Entry/Exit gates
|
| 129 |
+
QueueStation(id="g_1", name="Gate 1 Entry", category="entry", zone_id="gate_1",
|
| 130 |
+
avg_service_time_sec=15, lat=18.9413, lng=72.8245),
|
| 131 |
+
QueueStation(id="g_3", name="Gate 3 Entry", category="entry", zone_id="gate_3",
|
| 132 |
+
avg_service_time_sec=15, lat=18.9413, lng=72.8265),
|
| 133 |
+
QueueStation(id="g_5", name="Gate 5 Entry", category="entry", zone_id="gate_5",
|
| 134 |
+
avg_service_time_sec=15, lat=18.9388, lng=72.8230),
|
| 135 |
+
QueueStation(id="g_7", name="Gate 7 Entry", category="entry", zone_id="gate_7",
|
| 136 |
+
avg_service_time_sec=15, lat=18.9363, lng=72.8255),
|
| 137 |
+
]
|
| 138 |
+
|
| 139 |
+
return stations
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def create_demo_event() -> Event:
|
| 143 |
+
"""Create a demo cricket match event."""
|
| 144 |
+
return Event(
|
| 145 |
+
id="ipl_2026_01",
|
| 146 |
+
name="IPL 2026 โ Mumbai Indians vs Chennai Super Kings",
|
| 147 |
+
sport="Cricket",
|
| 148 |
+
venue_id="wankhede",
|
| 149 |
+
date=datetime.now(),
|
| 150 |
+
home_team="Mumbai Indians",
|
| 151 |
+
away_team="Chennai Super Kings",
|
| 152 |
+
current_phase="first_half",
|
| 153 |
+
expected_attendance=33000,
|
| 154 |
+
actual_attendance=0,
|
| 155 |
+
weather="clear",
|
| 156 |
+
temperature_c=31.0,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# โโโ Phase-based simulation profiles โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 161 |
+
|
| 162 |
+
PHASE_PROFILES = {
|
| 163 |
+
"pre_event": {
|
| 164 |
+
"stand_fill": 0.0, "concourse_fill": 0.05, "gate_fill": 0.1,
|
| 165 |
+
"food_fill": 0.05, "restroom_fill": 0.05, "parking_fill": 0.3,
|
| 166 |
+
"queue_food": (0, 3), "queue_restroom": (0, 2), "queue_entry": (0, 5),
|
| 167 |
+
},
|
| 168 |
+
"gates_open": {
|
| 169 |
+
"stand_fill": 0.1, "concourse_fill": 0.3, "gate_fill": 0.7,
|
| 170 |
+
"food_fill": 0.15, "restroom_fill": 0.1, "parking_fill": 0.6,
|
| 171 |
+
"queue_food": (2, 8), "queue_restroom": (1, 4), "queue_entry": (10, 40),
|
| 172 |
+
},
|
| 173 |
+
"filling": {
|
| 174 |
+
"stand_fill": 0.4, "concourse_fill": 0.5, "gate_fill": 0.8,
|
| 175 |
+
"food_fill": 0.3, "restroom_fill": 0.2, "parking_fill": 0.8,
|
| 176 |
+
"queue_food": (5, 15), "queue_restroom": (3, 8), "queue_entry": (20, 60),
|
| 177 |
+
},
|
| 178 |
+
"first_half": {
|
| 179 |
+
"stand_fill": 0.8, "concourse_fill": 0.15, "gate_fill": 0.1,
|
| 180 |
+
"food_fill": 0.1, "restroom_fill": 0.1, "parking_fill": 0.9,
|
| 181 |
+
"queue_food": (2, 8), "queue_restroom": (1, 5), "queue_entry": (0, 5),
|
| 182 |
+
},
|
| 183 |
+
"halftime": {
|
| 184 |
+
"stand_fill": 0.5, "concourse_fill": 0.7, "gate_fill": 0.15,
|
| 185 |
+
"food_fill": 0.9, "restroom_fill": 0.8, "parking_fill": 0.9,
|
| 186 |
+
"queue_food": (15, 40), "queue_restroom": (10, 25), "queue_entry": (0, 3),
|
| 187 |
+
},
|
| 188 |
+
"second_half": {
|
| 189 |
+
"stand_fill": 0.75, "concourse_fill": 0.1, "gate_fill": 0.1,
|
| 190 |
+
"food_fill": 0.1, "restroom_fill": 0.1, "parking_fill": 0.9,
|
| 191 |
+
"queue_food": (2, 8), "queue_restroom": (1, 5), "queue_entry": (0, 2),
|
| 192 |
+
},
|
| 193 |
+
"event_end": {
|
| 194 |
+
"stand_fill": 0.3, "concourse_fill": 0.6, "gate_fill": 0.8,
|
| 195 |
+
"food_fill": 0.05, "restroom_fill": 0.3, "parking_fill": 0.9,
|
| 196 |
+
"queue_food": (0, 3), "queue_restroom": (3, 10), "queue_entry": (0, 0),
|
| 197 |
+
},
|
| 198 |
+
"exiting": {
|
| 199 |
+
"stand_fill": 0.1, "concourse_fill": 0.4, "gate_fill": 0.9,
|
| 200 |
+
"food_fill": 0.02, "restroom_fill": 0.2, "parking_fill": 0.7,
|
| 201 |
+
"queue_food": (0, 2), "queue_restroom": (1, 5), "queue_entry": (0, 0),
|
| 202 |
+
},
|
| 203 |
+
"post_event": {
|
| 204 |
+
"stand_fill": 0.0, "concourse_fill": 0.02, "gate_fill": 0.05,
|
| 205 |
+
"food_fill": 0.0, "restroom_fill": 0.02, "parking_fill": 0.2,
|
| 206 |
+
"queue_food": (0, 0), "queue_restroom": (0, 1), "queue_entry": (0, 0),
|
| 207 |
+
},
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
# Map zone types to profile keys
|
| 211 |
+
ZONE_TYPE_MAP = {
|
| 212 |
+
"stand": "stand_fill",
|
| 213 |
+
"concourse": "concourse_fill",
|
| 214 |
+
"gate": "gate_fill",
|
| 215 |
+
"food_court": "food_fill",
|
| 216 |
+
"restroom": "restroom_fill",
|
| 217 |
+
"parking": "parking_fill",
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class Simulator:
|
| 222 |
+
"""Simulates realistic crowd and queue dynamics."""
|
| 223 |
+
|
| 224 |
+
def __init__(self, venue: Venue, queue_service, crowd_service, notification_service, event: Event):
|
| 225 |
+
self.venue = venue
|
| 226 |
+
self.queue_service = queue_service
|
| 227 |
+
self.crowd_service = crowd_service
|
| 228 |
+
self.notification_service = notification_service
|
| 229 |
+
self.event = event
|
| 230 |
+
self._running = False
|
| 231 |
+
self._thread: Optional[threading.Thread] = None
|
| 232 |
+
self._speed = 1.0 # 1x = real-time, 2x = double speed, etc.
|
| 233 |
+
self._tick_count = 0
|
| 234 |
+
self._update_callback: Optional[Callable] = None
|
| 235 |
+
|
| 236 |
+
@property
|
| 237 |
+
def is_running(self) -> bool:
|
| 238 |
+
return self._running
|
| 239 |
+
|
| 240 |
+
def set_update_callback(self, callback: Callable):
|
| 241 |
+
"""Set a callback to fire after each simulation tick."""
|
| 242 |
+
self._update_callback = callback
|
| 243 |
+
|
| 244 |
+
def set_phase(self, phase: str):
|
| 245 |
+
"""Manually set the event phase."""
|
| 246 |
+
if phase in PHASE_PROFILES:
|
| 247 |
+
self.event.current_phase = phase
|
| 248 |
+
logger.info(f"๐ฌ Simulation phase changed to: {phase}")
|
| 249 |
+
|
| 250 |
+
def set_speed(self, speed: float):
|
| 251 |
+
"""Set simulation speed (1.0 = real-time)."""
|
| 252 |
+
self._speed = max(0.1, min(10.0, speed))
|
| 253 |
+
|
| 254 |
+
def start(self):
|
| 255 |
+
"""Start the simulation loop."""
|
| 256 |
+
if self._running:
|
| 257 |
+
return
|
| 258 |
+
|
| 259 |
+
self._running = True
|
| 260 |
+
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
| 261 |
+
self._thread.start()
|
| 262 |
+
logger.info("โถ๏ธ Simulation started")
|
| 263 |
+
|
| 264 |
+
def stop(self):
|
| 265 |
+
"""Stop the simulation loop."""
|
| 266 |
+
self._running = False
|
| 267 |
+
if self._thread:
|
| 268 |
+
self._thread.join(timeout=5)
|
| 269 |
+
logger.info("โน๏ธ Simulation stopped")
|
| 270 |
+
|
| 271 |
+
def tick(self):
|
| 272 |
+
"""Execute one simulation tick โ update all zones and queues."""
|
| 273 |
+
phase = self.event.current_phase
|
| 274 |
+
profile = PHASE_PROFILES.get(phase, PHASE_PROFILES["pre_event"])
|
| 275 |
+
|
| 276 |
+
self._tick_count += 1
|
| 277 |
+
|
| 278 |
+
# Update zone crowd counts
|
| 279 |
+
for zone in self.venue.zones:
|
| 280 |
+
fill_key = ZONE_TYPE_MAP.get(zone.zone_type, "concourse_fill")
|
| 281 |
+
target_fill = profile.get(fill_key, 0.5)
|
| 282 |
+
|
| 283 |
+
# Add randomness (+/- 15%)
|
| 284 |
+
jitter = random.uniform(-0.15, 0.15)
|
| 285 |
+
target_count = int(zone.capacity * max(0, min(1, target_fill + jitter)))
|
| 286 |
+
|
| 287 |
+
# Smooth transition (don't jump instantly)
|
| 288 |
+
diff = target_count - zone.current_count
|
| 289 |
+
step = max(1, abs(diff) // 5)
|
| 290 |
+
if diff > 0:
|
| 291 |
+
new_count = zone.current_count + min(step, diff)
|
| 292 |
+
elif diff < 0:
|
| 293 |
+
new_count = zone.current_count - min(step, abs(diff))
|
| 294 |
+
else:
|
| 295 |
+
new_count = zone.current_count
|
| 296 |
+
|
| 297 |
+
self.crowd_service.update_zone_count(self.venue, zone.id, new_count)
|
| 298 |
+
|
| 299 |
+
# Update queue lengths
|
| 300 |
+
for station_id, station in self.queue_service._stations.items():
|
| 301 |
+
cat = station.category
|
| 302 |
+
queue_range = profile.get(f"queue_{cat}", (0, 5))
|
| 303 |
+
target = random.randint(queue_range[0], queue_range[1])
|
| 304 |
+
|
| 305 |
+
# Smooth transition
|
| 306 |
+
diff = target - station.current_length
|
| 307 |
+
step = max(1, abs(diff) // 3)
|
| 308 |
+
if diff > 0:
|
| 309 |
+
new_len = station.current_length + min(step, diff)
|
| 310 |
+
elif diff < 0:
|
| 311 |
+
new_len = station.current_length - min(step, abs(diff))
|
| 312 |
+
else:
|
| 313 |
+
new_len = station.current_length
|
| 314 |
+
|
| 315 |
+
self.queue_service.update_queue_length(station_id, max(0, new_len))
|
| 316 |
+
|
| 317 |
+
# Update attendance count
|
| 318 |
+
self.event.actual_attendance = self.venue.total_current
|
| 319 |
+
|
| 320 |
+
# Periodic alerts based on conditions
|
| 321 |
+
if self._tick_count % 10 == 0:
|
| 322 |
+
self._check_and_alert()
|
| 323 |
+
|
| 324 |
+
# Fire update callback
|
| 325 |
+
if self._update_callback:
|
| 326 |
+
try:
|
| 327 |
+
self._update_callback()
|
| 328 |
+
except Exception as e:
|
| 329 |
+
logger.error(f"Simulator callback error: {e}")
|
| 330 |
+
|
| 331 |
+
def _check_and_alert(self):
|
| 332 |
+
"""Generate contextual alerts based on current conditions."""
|
| 333 |
+
for zone in self.venue.zones:
|
| 334 |
+
if zone.occupancy_rate > 0.9:
|
| 335 |
+
self.notification_service.create_alert(
|
| 336 |
+
title=f"High Density: {zone.name}",
|
| 337 |
+
message=f"{zone.name} is at {round(zone.occupancy_rate * 100)}% capacity. Consider redirecting foot traffic.",
|
| 338 |
+
severity="warning",
|
| 339 |
+
zone_id=zone.id,
|
| 340 |
+
target_role="operator",
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
def _run_loop(self):
|
| 344 |
+
"""Main simulation loop running in a background thread."""
|
| 345 |
+
while self._running:
|
| 346 |
+
try:
|
| 347 |
+
self.tick()
|
| 348 |
+
time.sleep(2.0 / self._speed)
|
| 349 |
+
except Exception as e:
|
| 350 |
+
logger.error(f"Simulation error: {e}")
|
| 351 |
+
time.sleep(1)
|
| 352 |
+
|
| 353 |
+
def get_status(self) -> Dict:
|
| 354 |
+
"""Get current simulation status."""
|
| 355 |
+
return {
|
| 356 |
+
"running": self._running,
|
| 357 |
+
"speed": self._speed,
|
| 358 |
+
"tick_count": self._tick_count,
|
| 359 |
+
"phase": self.event.current_phase,
|
| 360 |
+
"phase_label": self.event.phase.label,
|
| 361 |
+
"phase_description": self.event.phase.description,
|
| 362 |
+
"attendance": self.event.actual_attendance,
|
| 363 |
+
"venue_occupancy": round(self.venue.overall_occupancy * 100, 1),
|
| 364 |
+
}
|
services/translation_service.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google Cloud Translation service with mock fallback."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
_translate_client = None
|
| 9 |
+
_is_mock = True
|
| 10 |
+
|
| 11 |
+
# Supported languages for the venue
|
| 12 |
+
SUPPORTED_LANGUAGES = {
|
| 13 |
+
"en": "English",
|
| 14 |
+
"hi": "เคนเคฟเคจเฅเคฆเฅ",
|
| 15 |
+
"ta": "เฎคเฎฎเฎฟเฎดเฏ",
|
| 16 |
+
"te": "เฐคเฑเฐฒเฑเฐเฑ",
|
| 17 |
+
"kn": "เฒเฒจเณเฒจเฒก",
|
| 18 |
+
"mr": "เคฎเคฐเคพเค เฅ",
|
| 19 |
+
"bn": "เฆฌเฆพเฆเฆฒเฆพ",
|
| 20 |
+
"es": "Espaรฑol",
|
| 21 |
+
"fr": "Franรงais",
|
| 22 |
+
"de": "Deutsch",
|
| 23 |
+
"ja": "ๆฅๆฌ่ช",
|
| 24 |
+
"zh": "ไธญๆ",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
# Cache for translations
|
| 28 |
+
_translation_cache = {}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def init_translation(app):
|
| 32 |
+
"""Initialize Google Cloud Translation or fall back to mock."""
|
| 33 |
+
global _translate_client, _is_mock
|
| 34 |
+
|
| 35 |
+
if app.config.get("USE_MOCK_SERVICES"):
|
| 36 |
+
logger.info("๐ถ Translation running in MOCK mode")
|
| 37 |
+
return
|
| 38 |
+
|
| 39 |
+
api_key = app.config.get("GOOGLE_TRANSLATE_API_KEY", "")
|
| 40 |
+
if not api_key:
|
| 41 |
+
logger.warning("โ ๏ธ No Translation API key, using mock mode")
|
| 42 |
+
return
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
from google.cloud import translate_v2 as translate
|
| 46 |
+
|
| 47 |
+
_translate_client = translate.Client()
|
| 48 |
+
_is_mock = False
|
| 49 |
+
logger.info("โ
Google Translate initialized successfully")
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.error(f"โ Translation init failed: {e}, using mock mode")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def translate_text(text: str, target_lang: str, source_lang: str = "en") -> str:
|
| 55 |
+
"""Translate text to the target language."""
|
| 56 |
+
if target_lang == source_lang:
|
| 57 |
+
return text
|
| 58 |
+
|
| 59 |
+
# Check cache
|
| 60 |
+
cache_key = f"{source_lang}:{target_lang}:{text}"
|
| 61 |
+
if cache_key in _translation_cache:
|
| 62 |
+
return _translation_cache[cache_key]
|
| 63 |
+
|
| 64 |
+
if _is_mock:
|
| 65 |
+
# In mock mode, return original text with language indicator
|
| 66 |
+
result = f"[{SUPPORTED_LANGUAGES.get(target_lang, target_lang)}] {text}"
|
| 67 |
+
_translation_cache[cache_key] = result
|
| 68 |
+
return result
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
result = _translate_client.translate(
|
| 72 |
+
text, target_language=target_lang, source_language=source_lang
|
| 73 |
+
)
|
| 74 |
+
translated = result["translatedText"]
|
| 75 |
+
_translation_cache[cache_key] = translated
|
| 76 |
+
return translated
|
| 77 |
+
except Exception as e:
|
| 78 |
+
logger.error(f"Translation error: {e}")
|
| 79 |
+
return text
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def detect_language(text: str) -> str:
|
| 83 |
+
"""Detect the language of input text."""
|
| 84 |
+
if _is_mock:
|
| 85 |
+
return "en"
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
result = _translate_client.detect_language(text)
|
| 89 |
+
return result["language"]
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"Language detection error: {e}")
|
| 92 |
+
return "en"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def get_supported_languages() -> dict:
|
| 96 |
+
"""Return supported language options."""
|
| 97 |
+
return SUPPORTED_LANGUAGES
|
static/css/base.css
ADDED
|
@@ -0,0 +1,1276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 2 |
+
VenueFlow Design System โ base.css
|
| 3 |
+
Premium dark-mode sporting venue interface
|
| 4 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 5 |
+
|
| 6 |
+
/* โโโ Google Fonts โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 7 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
|
| 8 |
+
|
| 9 |
+
/* โโโ CSS Custom Properties (Design Tokens) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 10 |
+
:root {
|
| 11 |
+
/* Colors โ Deep stadium night palette */
|
| 12 |
+
--bg-primary: #0a0e1a;
|
| 13 |
+
--bg-secondary: #111827;
|
| 14 |
+
--bg-tertiary: #1a2236;
|
| 15 |
+
--bg-card: #1e2a3a;
|
| 16 |
+
--bg-card-hover: #243347;
|
| 17 |
+
--bg-glass: rgba(30, 42, 58, 0.7);
|
| 18 |
+
--bg-glass-strong: rgba(30, 42, 58, 0.9);
|
| 19 |
+
--bg-overlay: rgba(10, 14, 26, 0.85);
|
| 20 |
+
|
| 21 |
+
/* Accent colors */
|
| 22 |
+
--accent-primary: #6366f1; /* Indigo */
|
| 23 |
+
--accent-primary-light: #818cf8;
|
| 24 |
+
--accent-primary-dark: #4f46e5;
|
| 25 |
+
--accent-secondary: #06b6d4; /* Cyan */
|
| 26 |
+
--accent-secondary-light: #22d3ee;
|
| 27 |
+
--accent-success: #22c55e;
|
| 28 |
+
--accent-success-light: #4ade80;
|
| 29 |
+
--accent-warning: #f59e0b;
|
| 30 |
+
--accent-warning-light: #fbbf24;
|
| 31 |
+
--accent-danger: #ef4444;
|
| 32 |
+
--accent-danger-light: #f87171;
|
| 33 |
+
--accent-critical: #18181b;
|
| 34 |
+
|
| 35 |
+
/* Text */
|
| 36 |
+
--text-primary: #f1f5f9;
|
| 37 |
+
--text-secondary: #94a3b8;
|
| 38 |
+
--text-tertiary: #64748b;
|
| 39 |
+
--text-muted: #475569;
|
| 40 |
+
--text-inverse: #0a0e1a;
|
| 41 |
+
|
| 42 |
+
/* Density colors */
|
| 43 |
+
--density-low: #22c55e;
|
| 44 |
+
--density-moderate: #f59e0b;
|
| 45 |
+
--density-high: #ef4444;
|
| 46 |
+
--density-critical: #991b1b;
|
| 47 |
+
|
| 48 |
+
/* Borders */
|
| 49 |
+
--border-subtle: rgba(148, 163, 184, 0.1);
|
| 50 |
+
--border-medium: rgba(148, 163, 184, 0.2);
|
| 51 |
+
--border-strong: rgba(148, 163, 184, 0.3);
|
| 52 |
+
--border-accent: rgba(99, 102, 241, 0.4);
|
| 53 |
+
|
| 54 |
+
/* Gradients */
|
| 55 |
+
--gradient-primary: linear-gradient(135deg, #6366f1 0%, #06b6d4 100%);
|
| 56 |
+
--gradient-warm: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
|
| 57 |
+
--gradient-cool: linear-gradient(135deg, #06b6d4 0%, #6366f1 100%);
|
| 58 |
+
--gradient-dark: linear-gradient(180deg, #0a0e1a 0%, #111827 100%);
|
| 59 |
+
--gradient-card: linear-gradient(145deg, #1e2a3a 0%, #162030 100%);
|
| 60 |
+
--gradient-glass: linear-gradient(145deg, rgba(30,42,58,0.8) 0%, rgba(22,32,48,0.6) 100%);
|
| 61 |
+
|
| 62 |
+
/* Shadows */
|
| 63 |
+
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
| 64 |
+
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
| 65 |
+
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
| 66 |
+
--shadow-xl: 0 16px 50px rgba(0, 0, 0, 0.6);
|
| 67 |
+
--shadow-glow: 0 0 20px rgba(99, 102, 241, 0.3);
|
| 68 |
+
--shadow-glow-cyan: 0 0 20px rgba(6, 182, 212, 0.3);
|
| 69 |
+
|
| 70 |
+
/* Typography */
|
| 71 |
+
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 72 |
+
--font-mono: 'JetBrains Mono', 'Consolas', monospace;
|
| 73 |
+
--font-size-xs: 0.75rem;
|
| 74 |
+
--font-size-sm: 0.875rem;
|
| 75 |
+
--font-size-base: 1rem;
|
| 76 |
+
--font-size-lg: 1.125rem;
|
| 77 |
+
--font-size-xl: 1.25rem;
|
| 78 |
+
--font-size-2xl: 1.5rem;
|
| 79 |
+
--font-size-3xl: 1.875rem;
|
| 80 |
+
--font-size-4xl: 2.25rem;
|
| 81 |
+
|
| 82 |
+
/* Spacing */
|
| 83 |
+
--space-1: 0.25rem;
|
| 84 |
+
--space-2: 0.5rem;
|
| 85 |
+
--space-3: 0.75rem;
|
| 86 |
+
--space-4: 1rem;
|
| 87 |
+
--space-5: 1.25rem;
|
| 88 |
+
--space-6: 1.5rem;
|
| 89 |
+
--space-8: 2rem;
|
| 90 |
+
--space-10: 2.5rem;
|
| 91 |
+
--space-12: 3rem;
|
| 92 |
+
|
| 93 |
+
/* Radius */
|
| 94 |
+
--radius-sm: 6px;
|
| 95 |
+
--radius-md: 10px;
|
| 96 |
+
--radius-lg: 14px;
|
| 97 |
+
--radius-xl: 20px;
|
| 98 |
+
--radius-full: 9999px;
|
| 99 |
+
|
| 100 |
+
/* Transitions */
|
| 101 |
+
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
| 102 |
+
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
| 103 |
+
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
| 104 |
+
--transition-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
| 105 |
+
|
| 106 |
+
/* Z-index scale */
|
| 107 |
+
--z-base: 1;
|
| 108 |
+
--z-dropdown: 100;
|
| 109 |
+
--z-sticky: 200;
|
| 110 |
+
--z-modal: 500;
|
| 111 |
+
--z-toast: 700;
|
| 112 |
+
--z-chatbot: 800;
|
| 113 |
+
--z-overlay: 900;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/* โโโ High Contrast Mode โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 117 |
+
[data-high-contrast="true"] {
|
| 118 |
+
--bg-primary: #000000;
|
| 119 |
+
--bg-secondary: #0a0a0a;
|
| 120 |
+
--bg-card: #1a1a1a;
|
| 121 |
+
--text-primary: #ffffff;
|
| 122 |
+
--text-secondary: #e0e0e0;
|
| 123 |
+
--border-subtle: rgba(255, 255, 255, 0.3);
|
| 124 |
+
--border-medium: rgba(255, 255, 255, 0.5);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/* โโโ Reset & Base โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 128 |
+
*, *::before, *::after {
|
| 129 |
+
box-sizing: border-box;
|
| 130 |
+
margin: 0;
|
| 131 |
+
padding: 0;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
html {
|
| 135 |
+
font-size: 16px;
|
| 136 |
+
scroll-behavior: smooth;
|
| 137 |
+
-webkit-text-size-adjust: 100%;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
[data-large-text="true"] html,
|
| 141 |
+
[data-large-text="true"] {
|
| 142 |
+
font-size: 20px;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
body {
|
| 146 |
+
font-family: var(--font-family);
|
| 147 |
+
background: var(--bg-primary);
|
| 148 |
+
color: var(--text-primary);
|
| 149 |
+
line-height: 1.6;
|
| 150 |
+
min-height: 100vh;
|
| 151 |
+
-webkit-font-smoothing: antialiased;
|
| 152 |
+
-moz-osx-font-smoothing: grayscale;
|
| 153 |
+
overflow-x: hidden;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* โโโ Skip to Content (A11y) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 157 |
+
.skip-link {
|
| 158 |
+
position: absolute;
|
| 159 |
+
top: -50px;
|
| 160 |
+
left: var(--space-4);
|
| 161 |
+
background: var(--accent-primary);
|
| 162 |
+
color: #fff;
|
| 163 |
+
padding: var(--space-3) var(--space-6);
|
| 164 |
+
border-radius: var(--radius-md);
|
| 165 |
+
z-index: 10000;
|
| 166 |
+
font-weight: 600;
|
| 167 |
+
text-decoration: none;
|
| 168 |
+
transition: top var(--transition-fast);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.skip-link:focus {
|
| 172 |
+
top: var(--space-4);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* โโโ Focus Indicators (A11y) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 176 |
+
:focus-visible {
|
| 177 |
+
outline: 3px solid var(--accent-primary-light);
|
| 178 |
+
outline-offset: 2px;
|
| 179 |
+
border-radius: var(--radius-sm);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/* โโโ Typography โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 183 |
+
h1, h2, h3, h4, h5, h6 {
|
| 184 |
+
font-weight: 700;
|
| 185 |
+
line-height: 1.2;
|
| 186 |
+
color: var(--text-primary);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
h1 { font-size: var(--font-size-4xl); letter-spacing: -0.02em; }
|
| 190 |
+
h2 { font-size: var(--font-size-3xl); letter-spacing: -0.01em; }
|
| 191 |
+
h3 { font-size: var(--font-size-2xl); }
|
| 192 |
+
h4 { font-size: var(--font-size-xl); }
|
| 193 |
+
h5 { font-size: var(--font-size-lg); }
|
| 194 |
+
|
| 195 |
+
p { color: var(--text-secondary); margin-bottom: var(--space-4); }
|
| 196 |
+
|
| 197 |
+
a {
|
| 198 |
+
color: var(--accent-primary-light);
|
| 199 |
+
text-decoration: none;
|
| 200 |
+
transition: color var(--transition-fast);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
a:hover { color: var(--accent-secondary-light); }
|
| 204 |
+
|
| 205 |
+
/* โโโ Scrollbar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 206 |
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
| 207 |
+
::-webkit-scrollbar-track { background: var(--bg-secondary); }
|
| 208 |
+
::-webkit-scrollbar-thumb {
|
| 209 |
+
background: var(--bg-tertiary);
|
| 210 |
+
border-radius: var(--radius-full);
|
| 211 |
+
}
|
| 212 |
+
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
| 213 |
+
|
| 214 |
+
/* โโโ Layout Containers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 215 |
+
.page-wrapper {
|
| 216 |
+
display: flex;
|
| 217 |
+
min-height: 100vh;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.main-content {
|
| 221 |
+
flex: 1;
|
| 222 |
+
padding: var(--space-6);
|
| 223 |
+
max-width: 1400px;
|
| 224 |
+
margin: 0 auto;
|
| 225 |
+
width: 100%;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.content-grid {
|
| 229 |
+
display: grid;
|
| 230 |
+
gap: var(--space-6);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.grid-2 { grid-template-columns: repeat(2, 1fr); }
|
| 234 |
+
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
| 235 |
+
.grid-4 { grid-template-columns: repeat(4, 1fr); }
|
| 236 |
+
|
| 237 |
+
@media (max-width: 1024px) {
|
| 238 |
+
.grid-4 { grid-template-columns: repeat(2, 1fr); }
|
| 239 |
+
.grid-3 { grid-template-columns: repeat(2, 1fr); }
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
@media (max-width: 640px) {
|
| 243 |
+
.grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
|
| 244 |
+
.main-content { padding: var(--space-4); }
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/* โโโ Navigation Bar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 248 |
+
.navbar {
|
| 249 |
+
background: var(--bg-glass-strong);
|
| 250 |
+
backdrop-filter: blur(20px);
|
| 251 |
+
-webkit-backdrop-filter: blur(20px);
|
| 252 |
+
border-bottom: 1px solid var(--border-subtle);
|
| 253 |
+
padding: var(--space-3) var(--space-6);
|
| 254 |
+
display: flex;
|
| 255 |
+
align-items: center;
|
| 256 |
+
justify-content: space-between;
|
| 257 |
+
position: sticky;
|
| 258 |
+
top: 0;
|
| 259 |
+
z-index: var(--z-sticky);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.navbar-brand {
|
| 263 |
+
display: flex;
|
| 264 |
+
align-items: center;
|
| 265 |
+
gap: var(--space-3);
|
| 266 |
+
font-size: var(--font-size-xl);
|
| 267 |
+
font-weight: 800;
|
| 268 |
+
color: var(--text-primary);
|
| 269 |
+
text-decoration: none;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.navbar-brand .logo-icon {
|
| 273 |
+
font-size: 1.5rem;
|
| 274 |
+
background: var(--gradient-primary);
|
| 275 |
+
-webkit-background-clip: text;
|
| 276 |
+
-webkit-text-fill-color: transparent;
|
| 277 |
+
background-clip: text;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.navbar-nav {
|
| 281 |
+
display: flex;
|
| 282 |
+
align-items: center;
|
| 283 |
+
gap: var(--space-4);
|
| 284 |
+
list-style: none;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
.navbar-nav a {
|
| 288 |
+
display: flex;
|
| 289 |
+
align-items: center;
|
| 290 |
+
gap: var(--space-2);
|
| 291 |
+
color: var(--text-secondary);
|
| 292 |
+
font-weight: 500;
|
| 293 |
+
font-size: var(--font-size-sm);
|
| 294 |
+
padding: var(--space-2) var(--space-4);
|
| 295 |
+
border-radius: var(--radius-md);
|
| 296 |
+
transition: all var(--transition-base);
|
| 297 |
+
text-decoration: none;
|
| 298 |
+
min-height: 44px;
|
| 299 |
+
min-width: 44px;
|
| 300 |
+
justify-content: center;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.navbar-nav a:hover,
|
| 304 |
+
.navbar-nav a.active {
|
| 305 |
+
color: var(--text-primary);
|
| 306 |
+
background: var(--bg-card);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.navbar-nav a.active {
|
| 310 |
+
background: var(--accent-primary);
|
| 311 |
+
color: #fff;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.navbar-user {
|
| 315 |
+
display: flex;
|
| 316 |
+
align-items: center;
|
| 317 |
+
gap: var(--space-3);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.navbar-avatar {
|
| 321 |
+
width: 36px;
|
| 322 |
+
height: 36px;
|
| 323 |
+
border-radius: var(--radius-full);
|
| 324 |
+
background: var(--gradient-primary);
|
| 325 |
+
display: flex;
|
| 326 |
+
align-items: center;
|
| 327 |
+
justify-content: center;
|
| 328 |
+
font-weight: 700;
|
| 329 |
+
font-size: var(--font-size-sm);
|
| 330 |
+
color: #fff;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/* โโโ Mobile Nav โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 334 |
+
.mobile-nav {
|
| 335 |
+
display: none;
|
| 336 |
+
position: fixed;
|
| 337 |
+
bottom: 0;
|
| 338 |
+
left: 0;
|
| 339 |
+
right: 0;
|
| 340 |
+
background: var(--bg-glass-strong);
|
| 341 |
+
backdrop-filter: blur(20px);
|
| 342 |
+
border-top: 1px solid var(--border-subtle);
|
| 343 |
+
padding: var(--space-2) var(--space-4);
|
| 344 |
+
z-index: var(--z-sticky);
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.mobile-nav-items {
|
| 348 |
+
display: flex;
|
| 349 |
+
justify-content: space-around;
|
| 350 |
+
list-style: none;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.mobile-nav-items a {
|
| 354 |
+
display: flex;
|
| 355 |
+
flex-direction: column;
|
| 356 |
+
align-items: center;
|
| 357 |
+
gap: 2px;
|
| 358 |
+
color: var(--text-tertiary);
|
| 359 |
+
font-size: var(--font-size-xs);
|
| 360 |
+
font-weight: 500;
|
| 361 |
+
padding: var(--space-2);
|
| 362 |
+
border-radius: var(--radius-md);
|
| 363 |
+
transition: all var(--transition-fast);
|
| 364 |
+
text-decoration: none;
|
| 365 |
+
min-height: 44px;
|
| 366 |
+
min-width: 44px;
|
| 367 |
+
justify-content: center;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.mobile-nav-items a .nav-icon {
|
| 371 |
+
font-size: 1.25rem;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.mobile-nav-items a:hover,
|
| 375 |
+
.mobile-nav-items a.active {
|
| 376 |
+
color: var(--accent-primary-light);
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
@media (max-width: 768px) {
|
| 380 |
+
.navbar-nav { display: none; }
|
| 381 |
+
.mobile-nav { display: block; }
|
| 382 |
+
.main-content { padding-bottom: 80px; }
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
/* โโโ Cards โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 386 |
+
.card {
|
| 387 |
+
background: var(--gradient-card);
|
| 388 |
+
border: 1px solid var(--border-subtle);
|
| 389 |
+
border-radius: var(--radius-lg);
|
| 390 |
+
padding: var(--space-6);
|
| 391 |
+
transition: all var(--transition-base);
|
| 392 |
+
position: relative;
|
| 393 |
+
overflow: hidden;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.card::before {
|
| 397 |
+
content: '';
|
| 398 |
+
position: absolute;
|
| 399 |
+
top: 0;
|
| 400 |
+
left: 0;
|
| 401 |
+
right: 0;
|
| 402 |
+
height: 2px;
|
| 403 |
+
background: var(--gradient-primary);
|
| 404 |
+
opacity: 0;
|
| 405 |
+
transition: opacity var(--transition-base);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.card:hover {
|
| 409 |
+
border-color: var(--border-medium);
|
| 410 |
+
box-shadow: var(--shadow-md);
|
| 411 |
+
transform: translateY(-1px);
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.card:hover::before {
|
| 415 |
+
opacity: 1;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.card-header {
|
| 419 |
+
display: flex;
|
| 420 |
+
align-items: center;
|
| 421 |
+
justify-content: space-between;
|
| 422 |
+
margin-bottom: var(--space-4);
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
.card-title {
|
| 426 |
+
font-size: var(--font-size-lg);
|
| 427 |
+
font-weight: 600;
|
| 428 |
+
color: var(--text-primary);
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.card-subtitle {
|
| 432 |
+
font-size: var(--font-size-sm);
|
| 433 |
+
color: var(--text-tertiary);
|
| 434 |
+
margin-top: var(--space-1);
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.card-body {
|
| 438 |
+
color: var(--text-secondary);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
/* โโโ KPI Stat Cards โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 442 |
+
.stat-card {
|
| 443 |
+
background: var(--gradient-card);
|
| 444 |
+
border: 1px solid var(--border-subtle);
|
| 445 |
+
border-radius: var(--radius-lg);
|
| 446 |
+
padding: var(--space-5);
|
| 447 |
+
display: flex;
|
| 448 |
+
flex-direction: column;
|
| 449 |
+
gap: var(--space-2);
|
| 450 |
+
position: relative;
|
| 451 |
+
overflow: hidden;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.stat-card .stat-icon {
|
| 455 |
+
font-size: 1.75rem;
|
| 456 |
+
margin-bottom: var(--space-1);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.stat-card .stat-value {
|
| 460 |
+
font-size: var(--font-size-3xl);
|
| 461 |
+
font-weight: 800;
|
| 462 |
+
line-height: 1;
|
| 463 |
+
background: var(--gradient-primary);
|
| 464 |
+
-webkit-background-clip: text;
|
| 465 |
+
-webkit-text-fill-color: transparent;
|
| 466 |
+
background-clip: text;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
.stat-card .stat-value.warning {
|
| 470 |
+
background: var(--gradient-warm);
|
| 471 |
+
-webkit-background-clip: text;
|
| 472 |
+
background-clip: text;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.stat-card .stat-label {
|
| 476 |
+
font-size: var(--font-size-sm);
|
| 477 |
+
color: var(--text-tertiary);
|
| 478 |
+
font-weight: 500;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
.stat-card .stat-change {
|
| 482 |
+
font-size: var(--font-size-xs);
|
| 483 |
+
font-weight: 600;
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
.stat-card .stat-change.positive { color: var(--accent-success); }
|
| 487 |
+
.stat-card .stat-change.negative { color: var(--accent-danger); }
|
| 488 |
+
|
| 489 |
+
/* โโโ Buttons โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 490 |
+
.btn {
|
| 491 |
+
display: inline-flex;
|
| 492 |
+
align-items: center;
|
| 493 |
+
justify-content: center;
|
| 494 |
+
gap: var(--space-2);
|
| 495 |
+
padding: var(--space-3) var(--space-6);
|
| 496 |
+
font-family: var(--font-family);
|
| 497 |
+
font-size: var(--font-size-sm);
|
| 498 |
+
font-weight: 600;
|
| 499 |
+
border: 1px solid transparent;
|
| 500 |
+
border-radius: var(--radius-md);
|
| 501 |
+
cursor: pointer;
|
| 502 |
+
transition: all var(--transition-base);
|
| 503 |
+
text-decoration: none;
|
| 504 |
+
min-height: 44px;
|
| 505 |
+
min-width: 44px;
|
| 506 |
+
white-space: nowrap;
|
| 507 |
+
user-select: none;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
.btn:active { transform: scale(0.97); }
|
| 511 |
+
|
| 512 |
+
.btn-primary {
|
| 513 |
+
background: var(--gradient-primary);
|
| 514 |
+
color: #fff;
|
| 515 |
+
border-color: transparent;
|
| 516 |
+
box-shadow: var(--shadow-sm), 0 0 0 0 rgba(99, 102, 241, 0);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
.btn-primary:hover {
|
| 520 |
+
box-shadow: var(--shadow-md), var(--shadow-glow);
|
| 521 |
+
transform: translateY(-1px);
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
.btn-secondary {
|
| 525 |
+
background: var(--bg-card);
|
| 526 |
+
color: var(--text-secondary);
|
| 527 |
+
border-color: var(--border-medium);
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
.btn-secondary:hover {
|
| 531 |
+
background: var(--bg-card-hover);
|
| 532 |
+
color: var(--text-primary);
|
| 533 |
+
border-color: var(--border-strong);
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.btn-success {
|
| 537 |
+
background: var(--accent-success);
|
| 538 |
+
color: #fff;
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
.btn-success:hover {
|
| 542 |
+
background: var(--accent-success-light);
|
| 543 |
+
box-shadow: 0 0 15px rgba(34, 197, 94, 0.3);
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
.btn-danger {
|
| 547 |
+
background: var(--accent-danger);
|
| 548 |
+
color: #fff;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
.btn-danger:hover {
|
| 552 |
+
background: var(--accent-danger-light);
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
.btn-ghost {
|
| 556 |
+
background: transparent;
|
| 557 |
+
color: var(--text-secondary);
|
| 558 |
+
border-color: transparent;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
.btn-ghost:hover {
|
| 562 |
+
background: var(--bg-card);
|
| 563 |
+
color: var(--text-primary);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
.btn-sm {
|
| 567 |
+
padding: var(--space-2) var(--space-4);
|
| 568 |
+
font-size: var(--font-size-xs);
|
| 569 |
+
min-height: 36px;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.btn-lg {
|
| 573 |
+
padding: var(--space-4) var(--space-8);
|
| 574 |
+
font-size: var(--font-size-base);
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
.btn-block { width: 100%; }
|
| 578 |
+
|
| 579 |
+
.btn:disabled {
|
| 580 |
+
opacity: 0.5;
|
| 581 |
+
cursor: not-allowed;
|
| 582 |
+
transform: none !important;
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
/* โโโ Form Inputs โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 586 |
+
.form-group {
|
| 587 |
+
display: flex;
|
| 588 |
+
flex-direction: column;
|
| 589 |
+
gap: var(--space-2);
|
| 590 |
+
margin-bottom: var(--space-5);
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
.form-label {
|
| 594 |
+
font-size: var(--font-size-sm);
|
| 595 |
+
font-weight: 600;
|
| 596 |
+
color: var(--text-secondary);
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
.form-input {
|
| 600 |
+
padding: var(--space-3) var(--space-4);
|
| 601 |
+
background: var(--bg-secondary);
|
| 602 |
+
border: 1px solid var(--border-medium);
|
| 603 |
+
border-radius: var(--radius-md);
|
| 604 |
+
color: var(--text-primary);
|
| 605 |
+
font-family: var(--font-family);
|
| 606 |
+
font-size: var(--font-size-base);
|
| 607 |
+
transition: all var(--transition-fast);
|
| 608 |
+
min-height: 44px;
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
.form-input:focus {
|
| 612 |
+
outline: none;
|
| 613 |
+
border-color: var(--accent-primary);
|
| 614 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
.form-input::placeholder {
|
| 618 |
+
color: var(--text-muted);
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
/* โโโ Badges โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 622 |
+
.badge {
|
| 623 |
+
display: inline-flex;
|
| 624 |
+
align-items: center;
|
| 625 |
+
gap: var(--space-1);
|
| 626 |
+
padding: var(--space-1) var(--space-3);
|
| 627 |
+
font-size: var(--font-size-xs);
|
| 628 |
+
font-weight: 600;
|
| 629 |
+
border-radius: var(--radius-full);
|
| 630 |
+
white-space: nowrap;
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
.badge-low { background: rgba(34,197,94,0.15); color: var(--density-low); }
|
| 634 |
+
.badge-moderate { background: rgba(245,158,11,0.15); color: var(--density-moderate); }
|
| 635 |
+
.badge-high { background: rgba(239,68,68,0.15); color: var(--density-high); }
|
| 636 |
+
.badge-critical { background: rgba(153,27,27,0.2); color: #fca5a5; }
|
| 637 |
+
.badge-info { background: rgba(59,130,246,0.15); color: #60a5fa; }
|
| 638 |
+
|
| 639 |
+
/* โโโ Progress Bar โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 640 |
+
.progress {
|
| 641 |
+
width: 100%;
|
| 642 |
+
height: 8px;
|
| 643 |
+
background: var(--bg-secondary);
|
| 644 |
+
border-radius: var(--radius-full);
|
| 645 |
+
overflow: hidden;
|
| 646 |
+
position: relative;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.progress-bar {
|
| 650 |
+
height: 100%;
|
| 651 |
+
border-radius: var(--radius-full);
|
| 652 |
+
transition: width var(--transition-slow);
|
| 653 |
+
position: relative;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
.progress-bar.low { background: var(--density-low); }
|
| 657 |
+
.progress-bar.moderate { background: var(--density-moderate); }
|
| 658 |
+
.progress-bar.high { background: var(--density-high); }
|
| 659 |
+
.progress-bar.critical { background: var(--density-critical); }
|
| 660 |
+
|
| 661 |
+
.progress-bar::after {
|
| 662 |
+
content: '';
|
| 663 |
+
position: absolute;
|
| 664 |
+
top: 0;
|
| 665 |
+
left: 0;
|
| 666 |
+
right: 0;
|
| 667 |
+
bottom: 0;
|
| 668 |
+
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
|
| 669 |
+
animation: shimmer 2s infinite;
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
@keyframes shimmer {
|
| 673 |
+
0% { transform: translateX(-100%); }
|
| 674 |
+
100% { transform: translateX(100%); }
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
/* โโโ Toasts / Alerts โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 678 |
+
.toast-container {
|
| 679 |
+
position: fixed;
|
| 680 |
+
top: var(--space-6);
|
| 681 |
+
right: var(--space-6);
|
| 682 |
+
z-index: var(--z-toast);
|
| 683 |
+
display: flex;
|
| 684 |
+
flex-direction: column;
|
| 685 |
+
gap: var(--space-3);
|
| 686 |
+
max-width: 400px;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
.toast {
|
| 690 |
+
background: var(--bg-glass-strong);
|
| 691 |
+
backdrop-filter: blur(20px);
|
| 692 |
+
border: 1px solid var(--border-medium);
|
| 693 |
+
border-radius: var(--radius-lg);
|
| 694 |
+
padding: var(--space-4) var(--space-5);
|
| 695 |
+
display: flex;
|
| 696 |
+
align-items: flex-start;
|
| 697 |
+
gap: var(--space-3);
|
| 698 |
+
animation: slideInRight var(--transition-spring) forwards;
|
| 699 |
+
box-shadow: var(--shadow-lg);
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
.toast.info { border-left: 3px solid #3b82f6; }
|
| 703 |
+
.toast.success { border-left: 3px solid var(--accent-success); }
|
| 704 |
+
.toast.warning { border-left: 3px solid var(--accent-warning); }
|
| 705 |
+
.toast.error { border-left: 3px solid var(--accent-danger); }
|
| 706 |
+
|
| 707 |
+
@keyframes slideInRight {
|
| 708 |
+
from { transform: translateX(100%); opacity: 0; }
|
| 709 |
+
to { transform: translateX(0); opacity: 1; }
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
@keyframes slideOut {
|
| 713 |
+
from { transform: translateX(0); opacity: 1; }
|
| 714 |
+
to { transform: translateX(100%); opacity: 0; }
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.toast-icon { font-size: 1.25rem; flex-shrink: 0; }
|
| 718 |
+
.toast-body { flex: 1; }
|
| 719 |
+
.toast-title { font-weight: 600; font-size: var(--font-size-sm); color: var(--text-primary); }
|
| 720 |
+
.toast-message { font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 2px; }
|
| 721 |
+
|
| 722 |
+
.toast-close {
|
| 723 |
+
background: none;
|
| 724 |
+
border: none;
|
| 725 |
+
color: var(--text-tertiary);
|
| 726 |
+
cursor: pointer;
|
| 727 |
+
font-size: 1.25rem;
|
| 728 |
+
padding: var(--space-1);
|
| 729 |
+
min-height: 44px;
|
| 730 |
+
min-width: 44px;
|
| 731 |
+
display: flex;
|
| 732 |
+
align-items: center;
|
| 733 |
+
justify-content: center;
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
/* โโโ Density Indicator โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 737 |
+
.density-dot {
|
| 738 |
+
width: 12px;
|
| 739 |
+
height: 12px;
|
| 740 |
+
border-radius: var(--radius-full);
|
| 741 |
+
display: inline-block;
|
| 742 |
+
flex-shrink: 0;
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
.density-dot.low { background: var(--density-low); box-shadow: 0 0 8px rgba(34,197,94,0.5); }
|
| 746 |
+
.density-dot.moderate { background: var(--density-moderate); box-shadow: 0 0 8px rgba(245,158,11,0.5); }
|
| 747 |
+
.density-dot.high { background: var(--density-high); box-shadow: 0 0 8px rgba(239,68,68,0.5); animation: pulse-dot 2s infinite; }
|
| 748 |
+
.density-dot.critical { background: var(--density-critical); box-shadow: 0 0 8px rgba(153,27,27,0.5); animation: pulse-dot 1s infinite; }
|
| 749 |
+
|
| 750 |
+
@keyframes pulse-dot {
|
| 751 |
+
0%, 100% { transform: scale(1); opacity: 1; }
|
| 752 |
+
50% { transform: scale(1.3); opacity: 0.7; }
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
/* โโโ Loading Spinner โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 756 |
+
.spinner {
|
| 757 |
+
width: 24px;
|
| 758 |
+
height: 24px;
|
| 759 |
+
border: 3px solid var(--border-medium);
|
| 760 |
+
border-top-color: var(--accent-primary);
|
| 761 |
+
border-radius: var(--radius-full);
|
| 762 |
+
animation: spin 0.8s linear infinite;
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
@keyframes spin {
|
| 766 |
+
to { transform: rotate(360deg); }
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
.loading-overlay {
|
| 770 |
+
position: absolute;
|
| 771 |
+
inset: 0;
|
| 772 |
+
display: flex;
|
| 773 |
+
align-items: center;
|
| 774 |
+
justify-content: center;
|
| 775 |
+
background: var(--bg-overlay);
|
| 776 |
+
border-radius: inherit;
|
| 777 |
+
z-index: var(--z-base);
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
/* โโโ Chatbot Widget โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 781 |
+
.chatbot-toggle {
|
| 782 |
+
position: fixed;
|
| 783 |
+
bottom: 90px;
|
| 784 |
+
right: var(--space-6);
|
| 785 |
+
width: 56px;
|
| 786 |
+
height: 56px;
|
| 787 |
+
border-radius: var(--radius-full);
|
| 788 |
+
background: var(--gradient-primary);
|
| 789 |
+
color: #fff;
|
| 790 |
+
border: none;
|
| 791 |
+
font-size: 1.5rem;
|
| 792 |
+
cursor: pointer;
|
| 793 |
+
box-shadow: var(--shadow-lg), var(--shadow-glow);
|
| 794 |
+
z-index: var(--z-chatbot);
|
| 795 |
+
transition: all var(--transition-base);
|
| 796 |
+
display: flex;
|
| 797 |
+
align-items: center;
|
| 798 |
+
justify-content: center;
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
.chatbot-toggle:hover {
|
| 802 |
+
transform: scale(1.1);
|
| 803 |
+
box-shadow: var(--shadow-xl), 0 0 30px rgba(99, 102, 241, 0.5);
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
.chatbot-window {
|
| 807 |
+
position: fixed;
|
| 808 |
+
bottom: 160px;
|
| 809 |
+
right: var(--space-6);
|
| 810 |
+
width: 380px;
|
| 811 |
+
max-height: 500px;
|
| 812 |
+
background: var(--bg-secondary);
|
| 813 |
+
border: 1px solid var(--border-medium);
|
| 814 |
+
border-radius: var(--radius-xl);
|
| 815 |
+
box-shadow: var(--shadow-xl);
|
| 816 |
+
z-index: var(--z-chatbot);
|
| 817 |
+
display: flex;
|
| 818 |
+
flex-direction: column;
|
| 819 |
+
overflow: hidden;
|
| 820 |
+
opacity: 0;
|
| 821 |
+
transform: translateY(20px) scale(0.95);
|
| 822 |
+
pointer-events: none;
|
| 823 |
+
transition: all var(--transition-spring);
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
.chatbot-window.open {
|
| 827 |
+
opacity: 1;
|
| 828 |
+
transform: translateY(0) scale(1);
|
| 829 |
+
pointer-events: all;
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
.chatbot-header {
|
| 833 |
+
background: var(--gradient-primary);
|
| 834 |
+
padding: var(--space-4) var(--space-5);
|
| 835 |
+
display: flex;
|
| 836 |
+
align-items: center;
|
| 837 |
+
justify-content: space-between;
|
| 838 |
+
color: #fff;
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
.chatbot-header h4 { font-size: var(--font-size-base); color: #fff; }
|
| 842 |
+
|
| 843 |
+
.chatbot-messages {
|
| 844 |
+
flex: 1;
|
| 845 |
+
overflow-y: auto;
|
| 846 |
+
padding: var(--space-4);
|
| 847 |
+
display: flex;
|
| 848 |
+
flex-direction: column;
|
| 849 |
+
gap: var(--space-3);
|
| 850 |
+
min-height: 250px;
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
.chat-message {
|
| 854 |
+
max-width: 85%;
|
| 855 |
+
padding: var(--space-3) var(--space-4);
|
| 856 |
+
border-radius: var(--radius-lg);
|
| 857 |
+
font-size: var(--font-size-sm);
|
| 858 |
+
line-height: 1.5;
|
| 859 |
+
animation: fadeInUp 0.3s ease;
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
@keyframes fadeInUp {
|
| 863 |
+
from { transform: translateY(10px); opacity: 0; }
|
| 864 |
+
to { transform: translateY(0); opacity: 1; }
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.chat-message.user {
|
| 868 |
+
align-self: flex-end;
|
| 869 |
+
background: var(--accent-primary);
|
| 870 |
+
color: #fff;
|
| 871 |
+
border-bottom-right-radius: var(--radius-sm);
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
.chat-message.bot {
|
| 875 |
+
align-self: flex-start;
|
| 876 |
+
background: var(--bg-card);
|
| 877 |
+
color: var(--text-primary);
|
| 878 |
+
border-bottom-left-radius: var(--radius-sm);
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
.chat-suggestions {
|
| 882 |
+
display: flex;
|
| 883 |
+
flex-wrap: wrap;
|
| 884 |
+
gap: var(--space-2);
|
| 885 |
+
padding: var(--space-2) var(--space-4);
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
.chat-suggestion {
|
| 889 |
+
font-size: var(--font-size-xs);
|
| 890 |
+
padding: var(--space-2) var(--space-3);
|
| 891 |
+
background: var(--bg-card);
|
| 892 |
+
border: 1px solid var(--border-subtle);
|
| 893 |
+
border-radius: var(--radius-full);
|
| 894 |
+
cursor: pointer;
|
| 895 |
+
color: var(--text-secondary);
|
| 896 |
+
transition: all var(--transition-fast);
|
| 897 |
+
white-space: nowrap;
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
.chat-suggestion:hover {
|
| 901 |
+
background: var(--accent-primary);
|
| 902 |
+
color: #fff;
|
| 903 |
+
border-color: var(--accent-primary);
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
.chatbot-input {
|
| 907 |
+
display: flex;
|
| 908 |
+
gap: var(--space-2);
|
| 909 |
+
padding: var(--space-3) var(--space-4);
|
| 910 |
+
border-top: 1px solid var(--border-subtle);
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
.chatbot-input input {
|
| 914 |
+
flex: 1;
|
| 915 |
+
padding: var(--space-3);
|
| 916 |
+
background: var(--bg-card);
|
| 917 |
+
border: 1px solid var(--border-subtle);
|
| 918 |
+
border-radius: var(--radius-md);
|
| 919 |
+
color: var(--text-primary);
|
| 920 |
+
font-family: var(--font-family);
|
| 921 |
+
font-size: var(--font-size-sm);
|
| 922 |
+
min-height: 44px;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
.chatbot-input input:focus {
|
| 926 |
+
outline: none;
|
| 927 |
+
border-color: var(--accent-primary);
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
.chatbot-input button {
|
| 931 |
+
width: 44px;
|
| 932 |
+
height: 44px;
|
| 933 |
+
border-radius: var(--radius-md);
|
| 934 |
+
background: var(--accent-primary);
|
| 935 |
+
color: #fff;
|
| 936 |
+
border: none;
|
| 937 |
+
font-size: 1.1rem;
|
| 938 |
+
cursor: pointer;
|
| 939 |
+
transition: all var(--transition-fast);
|
| 940 |
+
display: flex;
|
| 941 |
+
align-items: center;
|
| 942 |
+
justify-content: center;
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
.chatbot-input button:hover {
|
| 946 |
+
background: var(--accent-primary-dark);
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
/* โโโ Typing indicator โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 950 |
+
.typing-indicator {
|
| 951 |
+
display: flex;
|
| 952 |
+
gap: 4px;
|
| 953 |
+
padding: var(--space-3) var(--space-4);
|
| 954 |
+
align-items: center;
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
.typing-indicator span {
|
| 958 |
+
width: 8px;
|
| 959 |
+
height: 8px;
|
| 960 |
+
background: var(--text-tertiary);
|
| 961 |
+
border-radius: var(--radius-full);
|
| 962 |
+
animation: typingBounce 1.4s ease infinite;
|
| 963 |
+
}
|
| 964 |
+
|
| 965 |
+
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
| 966 |
+
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
| 967 |
+
|
| 968 |
+
@keyframes typingBounce {
|
| 969 |
+
0%, 60%, 100% { transform: translateY(0); }
|
| 970 |
+
30% { transform: translateY(-6px); }
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
/* โโโ Map Container โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 974 |
+
.map-container {
|
| 975 |
+
width: 100%;
|
| 976 |
+
height: 500px;
|
| 977 |
+
border-radius: var(--radius-lg);
|
| 978 |
+
overflow: hidden;
|
| 979 |
+
border: 1px solid var(--border-subtle);
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
@media (max-width: 640px) {
|
| 983 |
+
.map-container { height: 350px; }
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
/* โโโ Queue Cards โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 987 |
+
.queue-card {
|
| 988 |
+
background: var(--gradient-card);
|
| 989 |
+
border: 1px solid var(--border-subtle);
|
| 990 |
+
border-radius: var(--radius-lg);
|
| 991 |
+
padding: var(--space-5);
|
| 992 |
+
display: flex;
|
| 993 |
+
align-items: center;
|
| 994 |
+
gap: var(--space-4);
|
| 995 |
+
transition: all var(--transition-base);
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
.queue-card:hover {
|
| 999 |
+
border-color: var(--border-medium);
|
| 1000 |
+
box-shadow: var(--shadow-md);
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
+
.queue-icon {
|
| 1004 |
+
font-size: 2rem;
|
| 1005 |
+
width: 48px;
|
| 1006 |
+
height: 48px;
|
| 1007 |
+
display: flex;
|
| 1008 |
+
align-items: center;
|
| 1009 |
+
justify-content: center;
|
| 1010 |
+
background: var(--bg-secondary);
|
| 1011 |
+
border-radius: var(--radius-md);
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
.queue-details { flex: 1; }
|
| 1015 |
+
.queue-name { font-weight: 600; font-size: var(--font-size-base); color: var(--text-primary); }
|
| 1016 |
+
.queue-meta { font-size: var(--font-size-sm); color: var(--text-tertiary); }
|
| 1017 |
+
.queue-wait {
|
| 1018 |
+
text-align: right;
|
| 1019 |
+
font-weight: 700;
|
| 1020 |
+
font-size: var(--font-size-xl);
|
| 1021 |
+
}
|
| 1022 |
+
|
| 1023 |
+
/* โโโ Phase Indicator โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 1024 |
+
.phase-badge {
|
| 1025 |
+
display: inline-flex;
|
| 1026 |
+
align-items: center;
|
| 1027 |
+
gap: var(--space-2);
|
| 1028 |
+
padding: var(--space-2) var(--space-4);
|
| 1029 |
+
background: var(--bg-card);
|
| 1030 |
+
border: 1px solid var(--border-accent);
|
| 1031 |
+
border-radius: var(--radius-full);
|
| 1032 |
+
font-size: var(--font-size-sm);
|
| 1033 |
+
font-weight: 600;
|
| 1034 |
+
color: var(--accent-primary-light);
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
.phase-badge .phase-dot {
|
| 1038 |
+
width: 8px;
|
| 1039 |
+
height: 8px;
|
| 1040 |
+
border-radius: var(--radius-full);
|
| 1041 |
+
background: var(--accent-primary);
|
| 1042 |
+
animation: pulse-dot 2s infinite;
|
| 1043 |
+
}
|
| 1044 |
+
|
| 1045 |
+
/* โโโ Auth Pages โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 1046 |
+
.auth-page {
|
| 1047 |
+
min-height: 100vh;
|
| 1048 |
+
display: flex;
|
| 1049 |
+
align-items: center;
|
| 1050 |
+
justify-content: center;
|
| 1051 |
+
background: var(--gradient-dark);
|
| 1052 |
+
padding: var(--space-6);
|
| 1053 |
+
position: relative;
|
| 1054 |
+
overflow: hidden;
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
.auth-page::before {
|
| 1058 |
+
content: '';
|
| 1059 |
+
position: absolute;
|
| 1060 |
+
top: -50%;
|
| 1061 |
+
left: -50%;
|
| 1062 |
+
width: 200%;
|
| 1063 |
+
height: 200%;
|
| 1064 |
+
background: radial-gradient(ellipse at 30% 20%, rgba(99,102,241,0.08) 0%, transparent 50%),
|
| 1065 |
+
radial-gradient(ellipse at 70% 80%, rgba(6,182,212,0.06) 0%, transparent 50%);
|
| 1066 |
+
animation: auroraShift 20s ease infinite alternate;
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
@keyframes auroraShift {
|
| 1070 |
+
0% { transform: translate(0, 0) rotate(0deg); }
|
| 1071 |
+
100% { transform: translate(-5%, 5%) rotate(3deg); }
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
.auth-card {
|
| 1075 |
+
background: var(--bg-glass);
|
| 1076 |
+
backdrop-filter: blur(30px);
|
| 1077 |
+
border: 1px solid var(--border-subtle);
|
| 1078 |
+
border-radius: var(--radius-xl);
|
| 1079 |
+
padding: var(--space-10);
|
| 1080 |
+
width: 100%;
|
| 1081 |
+
max-width: 440px;
|
| 1082 |
+
position: relative;
|
| 1083 |
+
z-index: 1;
|
| 1084 |
+
box-shadow: var(--shadow-xl);
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
.auth-card h1 {
|
| 1088 |
+
font-size: var(--font-size-2xl);
|
| 1089 |
+
text-align: center;
|
| 1090 |
+
margin-bottom: var(--space-2);
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
.auth-card .auth-subtitle {
|
| 1094 |
+
text-align: center;
|
| 1095 |
+
color: var(--text-tertiary);
|
| 1096 |
+
margin-bottom: var(--space-8);
|
| 1097 |
+
font-size: var(--font-size-sm);
|
| 1098 |
+
}
|
| 1099 |
+
|
| 1100 |
+
.auth-footer {
|
| 1101 |
+
text-align: center;
|
| 1102 |
+
margin-top: var(--space-6);
|
| 1103 |
+
font-size: var(--font-size-sm);
|
| 1104 |
+
color: var(--text-tertiary);
|
| 1105 |
+
}
|
| 1106 |
+
|
| 1107 |
+
/* โโโ Flash Messages โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 1108 |
+
.flash-messages {
|
| 1109 |
+
margin-bottom: var(--space-4);
|
| 1110 |
+
}
|
| 1111 |
+
|
| 1112 |
+
.flash-msg {
|
| 1113 |
+
padding: var(--space-3) var(--space-4);
|
| 1114 |
+
border-radius: var(--radius-md);
|
| 1115 |
+
font-size: var(--font-size-sm);
|
| 1116 |
+
font-weight: 500;
|
| 1117 |
+
margin-bottom: var(--space-2);
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
.flash-msg.success { background: rgba(34,197,94,0.15); color: var(--accent-success-light); border: 1px solid rgba(34,197,94,0.2); }
|
| 1121 |
+
.flash-msg.error { background: rgba(239,68,68,0.15); color: var(--accent-danger-light); border: 1px solid rgba(239,68,68,0.2); }
|
| 1122 |
+
.flash-msg.warning { background: rgba(245,158,11,0.15); color: var(--accent-warning-light); border: 1px solid rgba(245,158,11,0.2); }
|
| 1123 |
+
.flash-msg.info { background: rgba(59,130,246,0.15); color: #93c5fd; border: 1px solid rgba(59,130,246,0.2); }
|
| 1124 |
+
|
| 1125 |
+
/* โโโ Hero Section (Attendee Home) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 1126 |
+
.hero-banner {
|
| 1127 |
+
background: var(--gradient-primary);
|
| 1128 |
+
border-radius: var(--radius-xl);
|
| 1129 |
+
padding: var(--space-8) var(--space-10);
|
| 1130 |
+
margin-bottom: var(--space-6);
|
| 1131 |
+
position: relative;
|
| 1132 |
+
overflow: hidden;
|
| 1133 |
+
}
|
| 1134 |
+
|
| 1135 |
+
.hero-banner::after {
|
| 1136 |
+
content: '';
|
| 1137 |
+
position: absolute;
|
| 1138 |
+
top: -50%;
|
| 1139 |
+
right: -20%;
|
| 1140 |
+
width: 300px;
|
| 1141 |
+
height: 300px;
|
| 1142 |
+
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
| 1143 |
+
border-radius: 50%;
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
.hero-banner h2 { color: #fff; font-size: var(--font-size-2xl); margin-bottom: var(--space-2); }
|
| 1147 |
+
.hero-banner p { color: rgba(255,255,255,0.8); margin-bottom: 0; }
|
| 1148 |
+
|
| 1149 |
+
.hero-event-info {
|
| 1150 |
+
display: flex;
|
| 1151 |
+
gap: var(--space-6);
|
| 1152 |
+
margin-top: var(--space-4);
|
| 1153 |
+
flex-wrap: wrap;
|
| 1154 |
+
}
|
| 1155 |
+
|
| 1156 |
+
.hero-event-info .event-detail {
|
| 1157 |
+
display: flex;
|
| 1158 |
+
align-items: center;
|
| 1159 |
+
gap: var(--space-2);
|
| 1160 |
+
color: rgba(255,255,255,0.9);
|
| 1161 |
+
font-size: var(--font-size-sm);
|
| 1162 |
+
font-weight: 500;
|
| 1163 |
+
}
|
| 1164 |
+
|
| 1165 |
+
/* โโโ Zone List โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 1166 |
+
.zone-list {
|
| 1167 |
+
display: flex;
|
| 1168 |
+
flex-direction: column;
|
| 1169 |
+
gap: var(--space-3);
|
| 1170 |
+
}
|
| 1171 |
+
|
| 1172 |
+
.zone-item {
|
| 1173 |
+
display: flex;
|
| 1174 |
+
align-items: center;
|
| 1175 |
+
gap: var(--space-4);
|
| 1176 |
+
padding: var(--space-3) var(--space-4);
|
| 1177 |
+
background: var(--bg-card);
|
| 1178 |
+
border-radius: var(--radius-md);
|
| 1179 |
+
border: 1px solid var(--border-subtle);
|
| 1180 |
+
transition: all var(--transition-fast);
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
.zone-item:hover {
|
| 1184 |
+
border-color: var(--border-medium);
|
| 1185 |
+
background: var(--bg-card-hover);
|
| 1186 |
+
}
|
| 1187 |
+
|
| 1188 |
+
.zone-info { flex: 1; }
|
| 1189 |
+
.zone-name { font-weight: 600; font-size: var(--font-size-sm); }
|
| 1190 |
+
.zone-type { font-size: var(--font-size-xs); color: var(--text-tertiary); text-transform: capitalize; }
|
| 1191 |
+
.zone-occupancy { text-align: right; font-size: var(--font-size-sm); font-weight: 600; }
|
| 1192 |
+
|
| 1193 |
+
/* โโโ Table โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 1194 |
+
.data-table {
|
| 1195 |
+
width: 100%;
|
| 1196 |
+
border-collapse: separate;
|
| 1197 |
+
border-spacing: 0;
|
| 1198 |
+
}
|
| 1199 |
+
|
| 1200 |
+
.data-table th {
|
| 1201 |
+
text-align: left;
|
| 1202 |
+
padding: var(--space-3) var(--space-4);
|
| 1203 |
+
font-size: var(--font-size-xs);
|
| 1204 |
+
font-weight: 600;
|
| 1205 |
+
color: var(--text-tertiary);
|
| 1206 |
+
text-transform: uppercase;
|
| 1207 |
+
letter-spacing: 0.05em;
|
| 1208 |
+
border-bottom: 1px solid var(--border-subtle);
|
| 1209 |
+
}
|
| 1210 |
+
|
| 1211 |
+
.data-table td {
|
| 1212 |
+
padding: var(--space-3) var(--space-4);
|
| 1213 |
+
font-size: var(--font-size-sm);
|
| 1214 |
+
border-bottom: 1px solid var(--border-subtle);
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
+
.data-table tr:hover td {
|
| 1218 |
+
background: var(--bg-card);
|
| 1219 |
+
}
|
| 1220 |
+
|
| 1221 |
+
/* โโโ Reduced Motion โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 1222 |
+
@media (prefers-reduced-motion: reduce) {
|
| 1223 |
+
*, *::before, *::after {
|
| 1224 |
+
animation-duration: 0.01ms !important;
|
| 1225 |
+
animation-iteration-count: 1 !important;
|
| 1226 |
+
transition-duration: 0.01ms !important;
|
| 1227 |
+
}
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
[data-reduced-motion="true"] *,
|
| 1231 |
+
[data-reduced-motion="true"] *::before,
|
| 1232 |
+
[data-reduced-motion="true"] *::after {
|
| 1233 |
+
animation-duration: 0.01ms !important;
|
| 1234 |
+
animation-iteration-count: 1 !important;
|
| 1235 |
+
transition-duration: 0.01ms !important;
|
| 1236 |
+
}
|
| 1237 |
+
|
| 1238 |
+
/* โโโ Utilities โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 1239 |
+
.text-center { text-align: center; }
|
| 1240 |
+
.text-right { text-align: right; }
|
| 1241 |
+
.mt-4 { margin-top: var(--space-4); }
|
| 1242 |
+
.mt-6 { margin-top: var(--space-6); }
|
| 1243 |
+
.mb-4 { margin-bottom: var(--space-4); }
|
| 1244 |
+
.mb-6 { margin-bottom: var(--space-6); }
|
| 1245 |
+
.flex { display: flex; }
|
| 1246 |
+
.flex-col { flex-direction: column; }
|
| 1247 |
+
.items-center { align-items: center; }
|
| 1248 |
+
.justify-between { justify-content: space-between; }
|
| 1249 |
+
.gap-2 { gap: var(--space-2); }
|
| 1250 |
+
.gap-3 { gap: var(--space-3); }
|
| 1251 |
+
.gap-4 { gap: var(--space-4); }
|
| 1252 |
+
.gap-6 { gap: var(--space-6); }
|
| 1253 |
+
.sr-only {
|
| 1254 |
+
position: absolute;
|
| 1255 |
+
width: 1px;
|
| 1256 |
+
height: 1px;
|
| 1257 |
+
padding: 0;
|
| 1258 |
+
margin: -1px;
|
| 1259 |
+
overflow: hidden;
|
| 1260 |
+
clip: rect(0,0,0,0);
|
| 1261 |
+
white-space: nowrap;
|
| 1262 |
+
border: 0;
|
| 1263 |
+
}
|
| 1264 |
+
.visually-hidden { composes: sr-only; }
|
| 1265 |
+
|
| 1266 |
+
@media (max-width: 768px) {
|
| 1267 |
+
.chatbot-window {
|
| 1268 |
+
right: var(--space-4);
|
| 1269 |
+
left: var(--space-4);
|
| 1270 |
+
width: auto;
|
| 1271 |
+
bottom: 100px;
|
| 1272 |
+
}
|
| 1273 |
+
.chatbot-toggle {
|
| 1274 |
+
bottom: 76px;
|
| 1275 |
+
}
|
| 1276 |
+
}
|
static/js/accessibility.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* VenueFlow โ Accessibility Preferences
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
const A11Y_STORAGE_KEY = 'venueflow_a11y';
|
| 6 |
+
|
| 7 |
+
function getA11yPrefs() {
|
| 8 |
+
try {
|
| 9 |
+
return JSON.parse(localStorage.getItem(A11Y_STORAGE_KEY) || '{}');
|
| 10 |
+
} catch {
|
| 11 |
+
return {};
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function saveA11yPrefs(prefs) {
|
| 16 |
+
localStorage.setItem(A11Y_STORAGE_KEY, JSON.stringify(prefs));
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function toggleA11y(key) {
|
| 20 |
+
const prefs = getA11yPrefs();
|
| 21 |
+
prefs[key] = !prefs[key];
|
| 22 |
+
saveA11yPrefs(prefs);
|
| 23 |
+
applyA11yPrefs(prefs);
|
| 24 |
+
updateSwitchUI();
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function applyA11yPrefs(prefs) {
|
| 28 |
+
const root = document.documentElement;
|
| 29 |
+
|
| 30 |
+
if (prefs.highContrast) {
|
| 31 |
+
root.dataset.highContrast = 'true';
|
| 32 |
+
} else {
|
| 33 |
+
root.dataset.highContrast = 'false';
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if (prefs.largeText) {
|
| 37 |
+
root.dataset.largeText = 'true';
|
| 38 |
+
} else {
|
| 39 |
+
root.dataset.largeText = 'false';
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
if (prefs.reducedMotion) {
|
| 43 |
+
root.dataset.reducedMotion = 'true';
|
| 44 |
+
} else {
|
| 45 |
+
root.dataset.reducedMotion = 'false';
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function updateSwitchUI() {
|
| 50 |
+
const prefs = getA11yPrefs();
|
| 51 |
+
|
| 52 |
+
const pairs = {
|
| 53 |
+
highContrast: { toggle: 'toggle-contrast', switch: 'switch-contrast' },
|
| 54 |
+
largeText: { toggle: 'toggle-text', switch: 'switch-text' },
|
| 55 |
+
reducedMotion:{ toggle: 'toggle-motion', switch: 'switch-motion' },
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
for (const [key, ids] of Object.entries(pairs)) {
|
| 59 |
+
const toggleEl = document.getElementById(ids.toggle);
|
| 60 |
+
const switchEl = document.getElementById(ids.switch);
|
| 61 |
+
|
| 62 |
+
if (toggleEl) {
|
| 63 |
+
toggleEl.setAttribute('aria-checked', prefs[key] ? 'true' : 'false');
|
| 64 |
+
}
|
| 65 |
+
if (switchEl) {
|
| 66 |
+
switchEl.textContent = prefs[key] ? 'ON' : 'OFF';
|
| 67 |
+
switchEl.style.color = prefs[key] ? 'var(--accent-success)' : 'var(--text-muted)';
|
| 68 |
+
switchEl.style.fontWeight = prefs[key] ? '700' : '500';
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function setLanguage(lang) {
|
| 74 |
+
localStorage.setItem('venueflow_lang', lang);
|
| 75 |
+
showToast('Language Updated', `Preferred language set. Translation will apply to new content.`, 'info');
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// Initialize on load
|
| 79 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 80 |
+
const prefs = getA11yPrefs();
|
| 81 |
+
applyA11yPrefs(prefs);
|
| 82 |
+
updateSwitchUI();
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
console.log('โฟ Accessibility loaded');
|
static/js/app.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* VenueFlow โ Core JavaScript Utilities
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
// โโโ Toast Notification System โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 6 |
+
|
| 7 |
+
function showToast(title, message, type = 'info', duration = 5000) {
|
| 8 |
+
const container = document.getElementById('toast-container');
|
| 9 |
+
if (!container) return;
|
| 10 |
+
|
| 11 |
+
const icons = { info: 'โน๏ธ', success: 'โ
', warning: 'โ ๏ธ', error: 'โ' };
|
| 12 |
+
|
| 13 |
+
const toast = document.createElement('div');
|
| 14 |
+
toast.className = `toast ${type}`;
|
| 15 |
+
toast.setAttribute('role', 'alert');
|
| 16 |
+
toast.innerHTML = `
|
| 17 |
+
<span class="toast-icon">${icons[type] || 'โน๏ธ'}</span>
|
| 18 |
+
<div class="toast-body">
|
| 19 |
+
<div class="toast-title">${escapeHtml(title)}</div>
|
| 20 |
+
<div class="toast-message">${escapeHtml(message)}</div>
|
| 21 |
+
</div>
|
| 22 |
+
<button class="toast-close" onclick="this.parentElement.remove()" aria-label="Dismiss notification">×</button>
|
| 23 |
+
`;
|
| 24 |
+
|
| 25 |
+
container.appendChild(toast);
|
| 26 |
+
|
| 27 |
+
// Auto remove
|
| 28 |
+
setTimeout(() => {
|
| 29 |
+
if (toast.parentElement) {
|
| 30 |
+
toast.style.animation = 'slideOut 0.3s ease forwards';
|
| 31 |
+
setTimeout(() => toast.remove(), 300);
|
| 32 |
+
}
|
| 33 |
+
}, duration);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
// โโโ XSS Prevention โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 38 |
+
|
| 39 |
+
function escapeHtml(str) {
|
| 40 |
+
if (!str) return '';
|
| 41 |
+
const div = document.createElement('div');
|
| 42 |
+
div.textContent = str;
|
| 43 |
+
return div.innerHTML;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
// โโโ API Helper โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 48 |
+
|
| 49 |
+
async function apiCall(url, options = {}) {
|
| 50 |
+
try {
|
| 51 |
+
const defaults = {
|
| 52 |
+
headers: { 'Content-Type': 'application/json' },
|
| 53 |
+
};
|
| 54 |
+
const resp = await fetch(url, { ...defaults, ...options });
|
| 55 |
+
const data = await resp.json();
|
| 56 |
+
|
| 57 |
+
if (!resp.ok) {
|
| 58 |
+
throw new Error(data.error || `HTTP ${resp.status}`);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return data;
|
| 62 |
+
} catch (err) {
|
| 63 |
+
console.error(`API Error [${url}]:`, err);
|
| 64 |
+
throw err;
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
// โโโ Format Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 70 |
+
|
| 71 |
+
function formatNumber(num) {
|
| 72 |
+
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
| 73 |
+
return num.toString();
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function formatWaitTime(minutes) {
|
| 77 |
+
if (minutes < 1) return '< 1 min';
|
| 78 |
+
if (minutes < 60) return Math.round(minutes) + ' min';
|
| 79 |
+
const h = Math.floor(minutes / 60);
|
| 80 |
+
const m = Math.round(minutes % 60);
|
| 81 |
+
return `${h}h ${m}m`;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function getDensityClass(level) {
|
| 85 |
+
return level || 'low';
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
// โโโ Keyboard Accessibility Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 90 |
+
|
| 91 |
+
document.addEventListener('keydown', (e) => {
|
| 92 |
+
// Allow Enter and Space to activate buttons/links for a11y
|
| 93 |
+
if (e.key === 'Enter' || e.key === ' ') {
|
| 94 |
+
const el = document.activeElement;
|
| 95 |
+
if (el && (el.getAttribute('role') === 'button' || el.getAttribute('role') === 'switch')) {
|
| 96 |
+
e.preventDefault();
|
| 97 |
+
el.click();
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
// โโโ Auto-dismiss flash messages โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 104 |
+
|
| 105 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 106 |
+
const flashMsgs = document.querySelectorAll('.flash-msg');
|
| 107 |
+
flashMsgs.forEach((msg, i) => {
|
| 108 |
+
setTimeout(() => {
|
| 109 |
+
msg.style.transition = 'opacity 0.5s';
|
| 110 |
+
msg.style.opacity = '0';
|
| 111 |
+
setTimeout(() => msg.remove(), 500);
|
| 112 |
+
}, 4000 + (i * 1000));
|
| 113 |
+
});
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
console.log('๐๏ธ VenueFlow app.js loaded');
|
static/js/chatbot.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* VenueFlow โ AI Chatbot Widget
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
let chatHistory = [];
|
| 6 |
+
let chatbotOpen = false;
|
| 7 |
+
|
| 8 |
+
function toggleChatbot() {
|
| 9 |
+
const window = document.getElementById('chatbot-window');
|
| 10 |
+
const toggle = document.getElementById('chatbot-toggle');
|
| 11 |
+
|
| 12 |
+
chatbotOpen = !chatbotOpen;
|
| 13 |
+
|
| 14 |
+
if (chatbotOpen) {
|
| 15 |
+
window.classList.add('open');
|
| 16 |
+
window.setAttribute('aria-hidden', 'false');
|
| 17 |
+
toggle.setAttribute('aria-expanded', 'true');
|
| 18 |
+
toggle.innerHTML = 'โ';
|
| 19 |
+
document.getElementById('chat-input').focus();
|
| 20 |
+
loadSuggestions();
|
| 21 |
+
} else {
|
| 22 |
+
window.classList.remove('open');
|
| 23 |
+
window.setAttribute('aria-hidden', 'true');
|
| 24 |
+
toggle.setAttribute('aria-expanded', 'false');
|
| 25 |
+
toggle.innerHTML = '๐ฌ';
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
async function loadSuggestions() {
|
| 30 |
+
try {
|
| 31 |
+
const suggestions = await apiCall('/api/chat/suggestions');
|
| 32 |
+
const container = document.getElementById('chat-suggestions');
|
| 33 |
+
if (container && suggestions) {
|
| 34 |
+
container.innerHTML = suggestions.map(s =>
|
| 35 |
+
`<button class="chat-suggestion" onclick="sendSuggestion('${escapeHtml(s)}')" aria-label="Quick reply: ${escapeHtml(s)}">${escapeHtml(s)}</button>`
|
| 36 |
+
).join('');
|
| 37 |
+
}
|
| 38 |
+
} catch (e) {
|
| 39 |
+
console.error('Failed to load suggestions:', e);
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function sendSuggestion(text) {
|
| 44 |
+
document.getElementById('chat-input').value = text;
|
| 45 |
+
sendChatMessage();
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async function sendChatMessage() {
|
| 49 |
+
const input = document.getElementById('chat-input');
|
| 50 |
+
const message = input.value.trim();
|
| 51 |
+
if (!message) return;
|
| 52 |
+
|
| 53 |
+
// Add user message
|
| 54 |
+
addChatMessage(message, 'user');
|
| 55 |
+
chatHistory.push({ role: 'user', content: message });
|
| 56 |
+
input.value = '';
|
| 57 |
+
|
| 58 |
+
// Show typing indicator
|
| 59 |
+
showTypingIndicator();
|
| 60 |
+
|
| 61 |
+
// Hide suggestions after first message
|
| 62 |
+
const sugContainer = document.getElementById('chat-suggestions');
|
| 63 |
+
if (sugContainer) sugContainer.style.display = 'none';
|
| 64 |
+
|
| 65 |
+
try {
|
| 66 |
+
const data = await apiCall('/api/chat', {
|
| 67 |
+
method: 'POST',
|
| 68 |
+
body: JSON.stringify({
|
| 69 |
+
message: message,
|
| 70 |
+
history: chatHistory.slice(-6),
|
| 71 |
+
}),
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
removeTypingIndicator();
|
| 75 |
+
addChatMessage(data.response, 'bot');
|
| 76 |
+
chatHistory.push({ role: 'assistant', content: data.response });
|
| 77 |
+
} catch (err) {
|
| 78 |
+
removeTypingIndicator();
|
| 79 |
+
addChatMessage('Sorry, I had trouble processing that. Please try again.', 'bot');
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function addChatMessage(text, role) {
|
| 84 |
+
const container = document.getElementById('chat-messages');
|
| 85 |
+
const msg = document.createElement('div');
|
| 86 |
+
msg.className = `chat-message ${role}`;
|
| 87 |
+
|
| 88 |
+
// Support basic markdown-like formatting
|
| 89 |
+
let formatted = escapeHtml(text);
|
| 90 |
+
// Bold
|
| 91 |
+
formatted = formatted.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
| 92 |
+
// Line breaks
|
| 93 |
+
formatted = formatted.replace(/\n/g, '<br>');
|
| 94 |
+
|
| 95 |
+
msg.innerHTML = formatted;
|
| 96 |
+
container.appendChild(msg);
|
| 97 |
+
container.scrollTop = container.scrollHeight;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
function showTypingIndicator() {
|
| 101 |
+
const container = document.getElementById('chat-messages');
|
| 102 |
+
const indicator = document.createElement('div');
|
| 103 |
+
indicator.id = 'typing-indicator';
|
| 104 |
+
indicator.className = 'chat-message bot';
|
| 105 |
+
indicator.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
|
| 106 |
+
container.appendChild(indicator);
|
| 107 |
+
container.scrollTop = container.scrollHeight;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function removeTypingIndicator() {
|
| 111 |
+
const indicator = document.getElementById('typing-indicator');
|
| 112 |
+
if (indicator) indicator.remove();
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// Enter key to send
|
| 116 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 117 |
+
const input = document.getElementById('chat-input');
|
| 118 |
+
if (input) {
|
| 119 |
+
input.addEventListener('keydown', (e) => {
|
| 120 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 121 |
+
e.preventDefault();
|
| 122 |
+
sendChatMessage();
|
| 123 |
+
}
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
const toggle = document.getElementById('chatbot-toggle');
|
| 128 |
+
if (toggle) {
|
| 129 |
+
toggle.addEventListener('click', toggleChatbot);
|
| 130 |
+
}
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
console.log('๐ค Chatbot loaded');
|
static/js/dashboard.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* VenueFlow โ Operator Dashboard Logic
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
// Auto-refresh dashboard data
|
| 6 |
+
let dashboardRefreshInterval = null;
|
| 7 |
+
|
| 8 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 9 |
+
dashboardRefreshInterval = setInterval(refreshDashboard, 4000);
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
async function refreshDashboard() {
|
| 13 |
+
try {
|
| 14 |
+
const [crowdData, queueData] = await Promise.all([
|
| 15 |
+
apiCall('/api/crowd/summary'),
|
| 16 |
+
apiCall('/api/queue/summary'),
|
| 17 |
+
]);
|
| 18 |
+
|
| 19 |
+
// Update KPIs
|
| 20 |
+
updateElement('kpi-attendance', crowdData.total_current || 0);
|
| 21 |
+
updateElement('kpi-occupancy', (crowdData.overall_occupancy || 0) + '%');
|
| 22 |
+
updateElement('kpi-waiting', queueData.total_people_waiting || 0);
|
| 23 |
+
|
| 24 |
+
const highZones = (crowdData.density_counts?.high || 0) + (crowdData.density_counts?.critical || 0);
|
| 25 |
+
updateElement('kpi-alerts', highZones);
|
| 26 |
+
|
| 27 |
+
// Update zone list
|
| 28 |
+
const zoneList = document.getElementById('op-zone-list');
|
| 29 |
+
if (zoneList && crowdData.zones) {
|
| 30 |
+
zoneList.innerHTML = crowdData.zones
|
| 31 |
+
.sort((a, b) => b.occupancy_rate - a.occupancy_rate)
|
| 32 |
+
.map(zone => `
|
| 33 |
+
<div class="zone-item" data-zone-id="${zone.id}">
|
| 34 |
+
<span class="density-dot ${zone.density_level}"></span>
|
| 35 |
+
<div class="zone-info">
|
| 36 |
+
<div class="zone-name">${escapeHtml(zone.name)}</div>
|
| 37 |
+
<div class="zone-type">${zone.zone_type.replace('_', ' ')} โข ${zone.current_count}/${zone.capacity}</div>
|
| 38 |
+
</div>
|
| 39 |
+
<div class="zone-occupancy" style="color:${zone.density_color}">${zone.occupancy_rate}%</div>
|
| 40 |
+
</div>
|
| 41 |
+
`).join('');
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Update queue list
|
| 45 |
+
const queueList = document.getElementById('op-queue-list');
|
| 46 |
+
if (queueList && queueData.stations) {
|
| 47 |
+
queueList.innerHTML = queueData.stations
|
| 48 |
+
.sort((a, b) => b.estimated_wait_minutes - a.estimated_wait_minutes)
|
| 49 |
+
.map(station => `
|
| 50 |
+
<div class="zone-item">
|
| 51 |
+
<span style="font-size:1.25rem;">${station.category_icon}</span>
|
| 52 |
+
<div class="zone-info">
|
| 53 |
+
<div class="zone-name">${escapeHtml(station.name)}</div>
|
| 54 |
+
<div class="zone-type">${station.current_length} in line</div>
|
| 55 |
+
</div>
|
| 56 |
+
<div class="zone-occupancy" style="color:${station.wait_color}">${Math.round(station.estimated_wait_minutes)} min</div>
|
| 57 |
+
</div>
|
| 58 |
+
`).join('');
|
| 59 |
+
}
|
| 60 |
+
} catch (e) {
|
| 61 |
+
// Silent fail for dashboard refresh
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async function startSim() {
|
| 66 |
+
try {
|
| 67 |
+
await apiCall('/api/simulator/start', { method: 'POST' });
|
| 68 |
+
showToast('Simulation Started', 'Crowd data is now being simulated in real-time', 'success');
|
| 69 |
+
setTimeout(() => location.reload(), 1000);
|
| 70 |
+
} catch (err) {
|
| 71 |
+
showToast('Error', err.message || 'Could not start simulation', 'error');
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
async function stopSim() {
|
| 76 |
+
try {
|
| 77 |
+
await apiCall('/api/simulator/stop', { method: 'POST' });
|
| 78 |
+
showToast('Simulation Stopped', 'Data is now frozen', 'info');
|
| 79 |
+
setTimeout(() => location.reload(), 1000);
|
| 80 |
+
} catch (err) {
|
| 81 |
+
showToast('Error', err.message || 'Could not stop simulation', 'error');
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function updateElement(id, value) {
|
| 86 |
+
const el = document.getElementById(id);
|
| 87 |
+
if (el) el.textContent = value;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
console.log('๐ Dashboard loaded');
|
static/js/heatmap.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* VenueFlow โ Canvas-based Crowd Heatmap Visualization
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
let heatmapCanvas, heatmapCtx;
|
| 6 |
+
let heatmapData = [];
|
| 7 |
+
let heatmapAnimFrame = null;
|
| 8 |
+
|
| 9 |
+
// Stadium zone approximate positions (normalized 0-1 for canvas)
|
| 10 |
+
const ZONE_POSITIONS = {
|
| 11 |
+
north_stand: { x: 0.5, y: 0.15, w: 0.35, h: 0.08, label: 'North Stand' },
|
| 12 |
+
south_stand: { x: 0.5, y: 0.85, w: 0.35, h: 0.08, label: 'South Stand' },
|
| 13 |
+
east_pavilion: { x: 0.85, y: 0.5, w: 0.08, h: 0.3, label: 'East Pavilion' },
|
| 14 |
+
west_pavilion: { x: 0.15, y: 0.5, w: 0.08, h: 0.3, label: 'West Pavilion' },
|
| 15 |
+
main_pavilion: { x: 0.72, y: 0.25, w: 0.12, h: 0.1, label: 'VIP' },
|
| 16 |
+
north_concourse: { x: 0.5, y: 0.06, w: 0.4, h: 0.04, label: 'N Concourse' },
|
| 17 |
+
south_concourse: { x: 0.5, y: 0.94, w: 0.4, h: 0.04, label: 'S Concourse' },
|
| 18 |
+
east_concourse: { x: 0.95, y: 0.5, w: 0.04, h: 0.35, label: 'E Concourse' },
|
| 19 |
+
west_concourse: { x: 0.05, y: 0.5, w: 0.04, h: 0.35, label: 'W Concourse' },
|
| 20 |
+
gate_1: { x: 0.35, y: 0.02, w: 0.06, h: 0.03, label: 'Gate 1' },
|
| 21 |
+
gate_3: { x: 0.65, y: 0.02, w: 0.06, h: 0.03, label: 'Gate 3' },
|
| 22 |
+
gate_5: { x: 0.02, y: 0.5, w: 0.03, h: 0.06, label: 'Gate 5' },
|
| 23 |
+
gate_7: { x: 0.5, y: 0.98, w: 0.06, h: 0.03, label: 'Gate 7' },
|
| 24 |
+
food_north: { x: 0.6, y: 0.1, w: 0.06, h: 0.04, label: '๐ Food N' },
|
| 25 |
+
food_south: { x: 0.4, y: 0.9, w: 0.06, h: 0.04, label: '๐ Food S' },
|
| 26 |
+
food_west: { x: 0.1, y: 0.6, w: 0.04, h: 0.06, label: '๐ Food W' },
|
| 27 |
+
restroom_n1: { x: 0.38, y: 0.1, w: 0.04, h: 0.03, label: '๐ป N1' },
|
| 28 |
+
restroom_s1: { x: 0.62, y: 0.9, w: 0.04, h: 0.03, label: '๐ป S1' },
|
| 29 |
+
restroom_e1: { x: 0.9, y: 0.35, w: 0.03, h: 0.04, label: '๐ป E1' },
|
| 30 |
+
restroom_w1: { x: 0.1, y: 0.65, w: 0.03, h: 0.04, label: '๐ป W1' },
|
| 31 |
+
parking_a: { x: 0.5, y: -0.02, w: 0.15, h: 0.03, label: 'Parking A' },
|
| 32 |
+
parking_b: { x: -0.02, y: 0.3, w: 0.03, h: 0.1, label: 'Parking B' },
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const DENSITY_COLORS = {
|
| 36 |
+
low: '#22c55e',
|
| 37 |
+
moderate: '#f59e0b',
|
| 38 |
+
high: '#ef4444',
|
| 39 |
+
critical: '#991b1b',
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
function initHeatmap() {
|
| 43 |
+
heatmapCanvas = document.getElementById('heatmap-canvas');
|
| 44 |
+
if (!heatmapCanvas) return;
|
| 45 |
+
|
| 46 |
+
heatmapCtx = heatmapCanvas.getContext('2d');
|
| 47 |
+
resizeCanvas();
|
| 48 |
+
window.addEventListener('resize', resizeCanvas);
|
| 49 |
+
|
| 50 |
+
// Initial fetch
|
| 51 |
+
fetchHeatmapData();
|
| 52 |
+
|
| 53 |
+
// Auto-refresh every 3 seconds
|
| 54 |
+
setInterval(fetchHeatmapData, 3000);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function resizeCanvas() {
|
| 58 |
+
if (!heatmapCanvas) return;
|
| 59 |
+
const rect = heatmapCanvas.parentElement.getBoundingClientRect();
|
| 60 |
+
heatmapCanvas.width = rect.width;
|
| 61 |
+
heatmapCanvas.height = rect.height;
|
| 62 |
+
drawHeatmap();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async function fetchHeatmapData() {
|
| 66 |
+
try {
|
| 67 |
+
const data = await apiCall('/api/crowd/heatmap');
|
| 68 |
+
heatmapData = data.zones || [];
|
| 69 |
+
drawHeatmap();
|
| 70 |
+
updateZoneCards(heatmapData);
|
| 71 |
+
} catch (e) {
|
| 72 |
+
console.error('Heatmap fetch error:', e);
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function drawHeatmap() {
|
| 77 |
+
if (!heatmapCtx || !heatmapCanvas) return;
|
| 78 |
+
|
| 79 |
+
const W = heatmapCanvas.width;
|
| 80 |
+
const H = heatmapCanvas.height;
|
| 81 |
+
|
| 82 |
+
// Clear
|
| 83 |
+
heatmapCtx.fillStyle = '#111827';
|
| 84 |
+
heatmapCtx.fillRect(0, 0, W, H);
|
| 85 |
+
|
| 86 |
+
// Draw pitch (center ellipse)
|
| 87 |
+
heatmapCtx.save();
|
| 88 |
+
heatmapCtx.beginPath();
|
| 89 |
+
heatmapCtx.ellipse(W * 0.5, H * 0.5, W * 0.25, H * 0.3, 0, 0, Math.PI * 2);
|
| 90 |
+
heatmapCtx.fillStyle = '#1a3a2a';
|
| 91 |
+
heatmapCtx.fill();
|
| 92 |
+
heatmapCtx.strokeStyle = '#2a5a3a';
|
| 93 |
+
heatmapCtx.lineWidth = 2;
|
| 94 |
+
heatmapCtx.stroke();
|
| 95 |
+
heatmapCtx.restore();
|
| 96 |
+
|
| 97 |
+
// Draw pitch circle
|
| 98 |
+
heatmapCtx.beginPath();
|
| 99 |
+
heatmapCtx.ellipse(W * 0.5, H * 0.5, W * 0.08, H * 0.1, 0, 0, Math.PI * 2);
|
| 100 |
+
heatmapCtx.strokeStyle = '#2a5a3a';
|
| 101 |
+
heatmapCtx.lineWidth = 1;
|
| 102 |
+
heatmapCtx.stroke();
|
| 103 |
+
|
| 104 |
+
// Draw zones
|
| 105 |
+
heatmapData.forEach(zone => {
|
| 106 |
+
const pos = ZONE_POSITIONS[zone.id];
|
| 107 |
+
if (!pos) return;
|
| 108 |
+
|
| 109 |
+
const x = pos.x * W;
|
| 110 |
+
const y = pos.y * H;
|
| 111 |
+
const w = pos.w * W;
|
| 112 |
+
const h = pos.h * H;
|
| 113 |
+
|
| 114 |
+
// Zone fill with density color
|
| 115 |
+
const color = DENSITY_COLORS[zone.density_level] || '#22c55e';
|
| 116 |
+
const alpha = 0.3 + (zone.occupancy_rate / 100) * 0.5;
|
| 117 |
+
|
| 118 |
+
heatmapCtx.save();
|
| 119 |
+
|
| 120 |
+
// Glow effect
|
| 121 |
+
heatmapCtx.shadowColor = color;
|
| 122 |
+
heatmapCtx.shadowBlur = 15 * (zone.occupancy_rate / 100);
|
| 123 |
+
|
| 124 |
+
heatmapCtx.fillStyle = hexToRgba(color, alpha);
|
| 125 |
+
heatmapCtx.strokeStyle = hexToRgba(color, 0.6);
|
| 126 |
+
heatmapCtx.lineWidth = 1;
|
| 127 |
+
|
| 128 |
+
const rx = x - w / 2;
|
| 129 |
+
const ry = y - h / 2;
|
| 130 |
+
|
| 131 |
+
// Rounded rect
|
| 132 |
+
roundRect(heatmapCtx, rx, ry, w, h, 4);
|
| 133 |
+
heatmapCtx.fill();
|
| 134 |
+
heatmapCtx.stroke();
|
| 135 |
+
|
| 136 |
+
heatmapCtx.restore();
|
| 137 |
+
|
| 138 |
+
// Label
|
| 139 |
+
heatmapCtx.save();
|
| 140 |
+
heatmapCtx.fillStyle = '#f1f5f9';
|
| 141 |
+
heatmapCtx.font = `${Math.max(9, W * 0.012)}px Inter, sans-serif`;
|
| 142 |
+
heatmapCtx.textAlign = 'center';
|
| 143 |
+
heatmapCtx.textBaseline = 'middle';
|
| 144 |
+
|
| 145 |
+
const label = pos.label || zone.name;
|
| 146 |
+
const occ = `${Math.round(zone.occupancy_rate)}%`;
|
| 147 |
+
|
| 148 |
+
if (h > 25) {
|
| 149 |
+
heatmapCtx.fillText(label, x, y - 6);
|
| 150 |
+
heatmapCtx.font = `bold ${Math.max(10, W * 0.014)}px Inter, sans-serif`;
|
| 151 |
+
heatmapCtx.fillStyle = color;
|
| 152 |
+
heatmapCtx.fillText(occ, x, y + 8);
|
| 153 |
+
} else {
|
| 154 |
+
heatmapCtx.fillText(`${label} ${occ}`, x, y);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
heatmapCtx.restore();
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
// Title
|
| 161 |
+
heatmapCtx.save();
|
| 162 |
+
heatmapCtx.fillStyle = '#94a3b8';
|
| 163 |
+
heatmapCtx.font = `600 ${Math.max(12, W * 0.016)}px Inter, sans-serif`;
|
| 164 |
+
heatmapCtx.textAlign = 'center';
|
| 165 |
+
heatmapCtx.fillText('๐ Wankhede Stadium โ Live Density', W / 2, H * 0.5);
|
| 166 |
+
heatmapCtx.restore();
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function updateZoneCards(zones) {
|
| 170 |
+
zones.forEach(zone => {
|
| 171 |
+
const card = document.querySelector(`[data-zone-id="${zone.id}"]`);
|
| 172 |
+
if (!card) return;
|
| 173 |
+
|
| 174 |
+
const progressBar = card.querySelector('.progress-bar');
|
| 175 |
+
if (progressBar) {
|
| 176 |
+
progressBar.style.width = zone.occupancy_rate + '%';
|
| 177 |
+
progressBar.className = `progress-bar ${zone.density_level}`;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
const dot = card.querySelector('.density-dot');
|
| 181 |
+
if (dot) {
|
| 182 |
+
dot.className = `density-dot ${zone.density_level}`;
|
| 183 |
+
}
|
| 184 |
+
});
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// โโโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 188 |
+
|
| 189 |
+
function hexToRgba(hex, alpha) {
|
| 190 |
+
const r = parseInt(hex.slice(1, 3), 16);
|
| 191 |
+
const g = parseInt(hex.slice(3, 5), 16);
|
| 192 |
+
const b = parseInt(hex.slice(5, 7), 16);
|
| 193 |
+
return `rgba(${r},${g},${b},${alpha})`;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function roundRect(ctx, x, y, w, h, r) {
|
| 197 |
+
ctx.beginPath();
|
| 198 |
+
ctx.moveTo(x + r, y);
|
| 199 |
+
ctx.lineTo(x + w - r, y);
|
| 200 |
+
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
| 201 |
+
ctx.lineTo(x + w, y + h - r);
|
| 202 |
+
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
| 203 |
+
ctx.lineTo(x + r, y + h);
|
| 204 |
+
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
| 205 |
+
ctx.lineTo(x, y + r);
|
| 206 |
+
ctx.quadraticCurveTo(x, y, x + r, y);
|
| 207 |
+
ctx.closePath();
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
// Toggle table view
|
| 211 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 212 |
+
initHeatmap();
|
| 213 |
+
|
| 214 |
+
const toggleBtn = document.getElementById('toggle-table-view');
|
| 215 |
+
if (toggleBtn) {
|
| 216 |
+
toggleBtn.addEventListener('click', () => {
|
| 217 |
+
const visual = document.getElementById('heatmap-visual');
|
| 218 |
+
const table = document.getElementById('heatmap-table');
|
| 219 |
+
if (table.style.display === 'none') {
|
| 220 |
+
table.style.display = 'block';
|
| 221 |
+
visual.style.display = 'none';
|
| 222 |
+
toggleBtn.textContent = '๐บ๏ธ Map View';
|
| 223 |
+
} else {
|
| 224 |
+
table.style.display = 'none';
|
| 225 |
+
visual.style.display = 'block';
|
| 226 |
+
toggleBtn.textContent = '๐ Table View';
|
| 227 |
+
}
|
| 228 |
+
});
|
| 229 |
+
}
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
console.log('๐ฅ Heatmap loaded');
|
static/js/notifications.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* VenueFlow โ Notification Handler
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
let notifCheckInterval = null;
|
| 6 |
+
|
| 7 |
+
async function checkNotifications() {
|
| 8 |
+
try {
|
| 9 |
+
const data = await apiCall('/api/notifications/unread');
|
| 10 |
+
const countEl = document.getElementById('notif-count');
|
| 11 |
+
if (countEl) {
|
| 12 |
+
const count = data.count || 0;
|
| 13 |
+
countEl.textContent = count;
|
| 14 |
+
countEl.style.display = count > 0 ? 'flex' : 'none';
|
| 15 |
+
}
|
| 16 |
+
} catch (e) {
|
| 17 |
+
// Silent fail for notification polling
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
async function loadNotifications() {
|
| 22 |
+
try {
|
| 23 |
+
const notifications = await apiCall('/api/notifications?limit=10');
|
| 24 |
+
if (notifications && notifications.length > 0) {
|
| 25 |
+
// Show the latest unread as a toast
|
| 26 |
+
const unread = notifications.filter(n => !n.read);
|
| 27 |
+
if (unread.length > 0) {
|
| 28 |
+
const latest = unread[0];
|
| 29 |
+
showToast(latest.title, latest.message, latest.severity === 'critical' ? 'error' : latest.severity);
|
| 30 |
+
// Mark as read
|
| 31 |
+
await apiCall('/api/notifications/read', {
|
| 32 |
+
method: 'POST',
|
| 33 |
+
body: JSON.stringify({ id: latest.id }),
|
| 34 |
+
});
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
} catch (e) {
|
| 38 |
+
// Silent fail
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Listen for SSE notifications
|
| 43 |
+
document.addEventListener('venueflow:update', (e) => {
|
| 44 |
+
checkNotifications();
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
// Start periodic check
|
| 48 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 49 |
+
checkNotifications();
|
| 50 |
+
notifCheckInterval = setInterval(checkNotifications, 10000);
|
| 51 |
+
|
| 52 |
+
// Click handler for notification bell
|
| 53 |
+
const bell = document.getElementById('notification-bell');
|
| 54 |
+
if (bell) {
|
| 55 |
+
bell.addEventListener('click', loadNotifications);
|
| 56 |
+
}
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
console.log('๐ Notifications loaded');
|
static/js/queue.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* VenueFlow โ Queue Management UI
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
// Filter queue stations by category
|
| 6 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 7 |
+
const filterBtns = document.querySelectorAll('.queue-filter');
|
| 8 |
+
|
| 9 |
+
filterBtns.forEach(btn => {
|
| 10 |
+
btn.addEventListener('click', () => {
|
| 11 |
+
const category = btn.dataset.category;
|
| 12 |
+
|
| 13 |
+
// Update active state
|
| 14 |
+
filterBtns.forEach(b => {
|
| 15 |
+
b.classList.remove('active');
|
| 16 |
+
b.classList.replace('btn-primary', 'btn-secondary');
|
| 17 |
+
b.setAttribute('aria-selected', 'false');
|
| 18 |
+
});
|
| 19 |
+
btn.classList.add('active');
|
| 20 |
+
btn.classList.replace('btn-secondary', 'btn-primary');
|
| 21 |
+
btn.setAttribute('aria-selected', 'true');
|
| 22 |
+
|
| 23 |
+
// Filter cards
|
| 24 |
+
const cards = document.querySelectorAll('#queue-stations-list .queue-card');
|
| 25 |
+
cards.forEach(card => {
|
| 26 |
+
if (category === 'all' || card.dataset.category === category) {
|
| 27 |
+
card.style.display = 'flex';
|
| 28 |
+
} else {
|
| 29 |
+
card.style.display = 'none';
|
| 30 |
+
}
|
| 31 |
+
});
|
| 32 |
+
});
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
// Auto-refresh queue data
|
| 36 |
+
setInterval(refreshQueues, 5000);
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
async function refreshQueues() {
|
| 40 |
+
try {
|
| 41 |
+
const data = await apiCall('/api/queue/summary');
|
| 42 |
+
if (!data || !data.stations) return;
|
| 43 |
+
|
| 44 |
+
data.stations.forEach(station => {
|
| 45 |
+
const card = document.querySelector(`[data-station-id="${station.id}"]`);
|
| 46 |
+
if (!card) return;
|
| 47 |
+
|
| 48 |
+
const waitEl = card.querySelector('.queue-wait');
|
| 49 |
+
if (waitEl) {
|
| 50 |
+
waitEl.textContent = Math.round(station.estimated_wait_minutes) + ' min';
|
| 51 |
+
waitEl.style.color = station.wait_color;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const metaEl = card.querySelector('.queue-meta');
|
| 55 |
+
if (metaEl) {
|
| 56 |
+
metaEl.textContent = `${station.category_label} โข ${station.current_length} in line`;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const progressBar = card.querySelector('.progress-bar');
|
| 60 |
+
if (progressBar) {
|
| 61 |
+
const w = Math.min(station.estimated_wait_minutes * 3.3, 100);
|
| 62 |
+
progressBar.style.width = w + '%';
|
| 63 |
+
progressBar.className = `progress-bar ${station.wait_level}`;
|
| 64 |
+
}
|
| 65 |
+
});
|
| 66 |
+
} catch (e) {
|
| 67 |
+
// Silent fail for background refresh
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
async function joinQueue(stationId) {
|
| 72 |
+
try {
|
| 73 |
+
const ticket = await apiCall('/api/queue/join', {
|
| 74 |
+
method: 'POST',
|
| 75 |
+
body: JSON.stringify({ station_id: stationId }),
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
if (ticket && ticket.id) {
|
| 79 |
+
showToast(
|
| 80 |
+
'Queue Joined! ๐ซ',
|
| 81 |
+
`Ticket #${ticket.id} โ Position ${ticket.position} at ${ticket.station_name}`,
|
| 82 |
+
'success',
|
| 83 |
+
8000
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
// Refresh the page to show updated tickets
|
| 87 |
+
setTimeout(() => location.reload(), 1500);
|
| 88 |
+
}
|
| 89 |
+
} catch (err) {
|
| 90 |
+
showToast('Could not join queue', err.message || 'Please try again', 'error');
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
async function cancelTicket(ticketId) {
|
| 95 |
+
if (!confirm('Cancel this virtual queue ticket?')) return;
|
| 96 |
+
|
| 97 |
+
try {
|
| 98 |
+
await apiCall('/api/queue/cancel', {
|
| 99 |
+
method: 'POST',
|
| 100 |
+
body: JSON.stringify({ ticket_id: ticketId }),
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
showToast('Ticket Cancelled', 'Your virtual queue spot has been released.', 'info');
|
| 104 |
+
setTimeout(() => location.reload(), 1000);
|
| 105 |
+
} catch (err) {
|
| 106 |
+
showToast('Could not cancel', err.message || 'Please try again', 'error');
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
console.log('โฑ๏ธ Queue UI loaded');
|
static/js/simulator-ui.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* VenueFlow โ Simulator Control UI
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
let simRefreshInterval = null;
|
| 6 |
+
|
| 7 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 8 |
+
simRefreshInterval = setInterval(refreshSimStatus, 3000);
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
async function refreshSimStatus() {
|
| 12 |
+
try {
|
| 13 |
+
const status = await apiCall('/api/simulator/status');
|
| 14 |
+
|
| 15 |
+
updateEl('sim-tick', status.tick_count || 0);
|
| 16 |
+
updateEl('sim-speed', (status.speed || 1) + 'x');
|
| 17 |
+
updateEl('sim-occ', (status.venue_occupancy || 0) + '%');
|
| 18 |
+
|
| 19 |
+
const badge = document.getElementById('sim-status-badge');
|
| 20 |
+
if (badge) {
|
| 21 |
+
badge.textContent = status.running ? '๐ข Running' : 'โธ๏ธ Stopped';
|
| 22 |
+
badge.className = status.running ? 'badge badge-low' : 'badge badge-info';
|
| 23 |
+
}
|
| 24 |
+
} catch (e) {}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
async function startSim() {
|
| 28 |
+
try {
|
| 29 |
+
await apiCall('/api/simulator/start', { method: 'POST' });
|
| 30 |
+
showToast('Simulation Started', 'Real-time data generation is active', 'success');
|
| 31 |
+
refreshSimStatus();
|
| 32 |
+
} catch (err) {
|
| 33 |
+
showToast('Error', err.message, 'error');
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
async function stopSim() {
|
| 38 |
+
try {
|
| 39 |
+
await apiCall('/api/simulator/stop', { method: 'POST' });
|
| 40 |
+
showToast('Simulation Stopped', 'Data generation paused', 'info');
|
| 41 |
+
refreshSimStatus();
|
| 42 |
+
} catch (err) {
|
| 43 |
+
showToast('Error', err.message, 'error');
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
async function setPhase(phase) {
|
| 48 |
+
try {
|
| 49 |
+
await apiCall('/api/simulator/phase', {
|
| 50 |
+
method: 'POST',
|
| 51 |
+
body: JSON.stringify({ phase }),
|
| 52 |
+
});
|
| 53 |
+
showToast('Phase Changed', `Event phase set to: ${phase.replace('_', ' ')}`, 'success');
|
| 54 |
+
refreshSimStatus();
|
| 55 |
+
} catch (err) {
|
| 56 |
+
showToast('Error', err.message, 'error');
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
async function setSpeed(speed) {
|
| 61 |
+
try {
|
| 62 |
+
const label = document.getElementById('speed-label');
|
| 63 |
+
if (label) label.textContent = speed + 'x';
|
| 64 |
+
|
| 65 |
+
await apiCall('/api/simulator/speed', {
|
| 66 |
+
method: 'POST',
|
| 67 |
+
body: JSON.stringify({ speed: parseFloat(speed) }),
|
| 68 |
+
});
|
| 69 |
+
} catch (err) {
|
| 70 |
+
showToast('Error', err.message, 'error');
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function updateEl(id, value) {
|
| 75 |
+
const el = document.getElementById(id);
|
| 76 |
+
if (el) el.textContent = value;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
console.log('๐ฎ Simulator UI loaded');
|
static/js/sse-client.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* VenueFlow โ SSE Client for Real-Time Updates
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
let sseConnection = null;
|
| 6 |
+
let sseReconnectAttempts = 0;
|
| 7 |
+
const SSE_MAX_RECONNECT = 10;
|
| 8 |
+
|
| 9 |
+
function connectSSE() {
|
| 10 |
+
if (sseConnection) {
|
| 11 |
+
sseConnection.close();
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
try {
|
| 15 |
+
sseConnection = new EventSource('/sse/stream');
|
| 16 |
+
|
| 17 |
+
sseConnection.onopen = () => {
|
| 18 |
+
console.log('๐ก SSE connected');
|
| 19 |
+
sseReconnectAttempts = 0;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
sseConnection.addEventListener('update', (event) => {
|
| 23 |
+
try {
|
| 24 |
+
const data = JSON.parse(event.data);
|
| 25 |
+
handleSSEUpdate(data);
|
| 26 |
+
} catch (e) {
|
| 27 |
+
console.error('SSE parse error:', e);
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
sseConnection.addEventListener('heartbeat', () => {
|
| 32 |
+
// Keep-alive, no action needed
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
sseConnection.onerror = () => {
|
| 36 |
+
console.warn('๐ก SSE connection lost');
|
| 37 |
+
sseConnection.close();
|
| 38 |
+
sseConnection = null;
|
| 39 |
+
|
| 40 |
+
// Reconnect with backoff
|
| 41 |
+
if (sseReconnectAttempts < SSE_MAX_RECONNECT) {
|
| 42 |
+
const delay = Math.min(1000 * Math.pow(2, sseReconnectAttempts), 30000);
|
| 43 |
+
sseReconnectAttempts++;
|
| 44 |
+
setTimeout(connectSSE, delay);
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
} catch (e) {
|
| 48 |
+
console.error('SSE connection failed:', e);
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function handleSSEUpdate(data) {
|
| 53 |
+
// Update KPI values if elements exist
|
| 54 |
+
if (data.venue) {
|
| 55 |
+
updateElement('kpi-attendance', data.venue.total_current);
|
| 56 |
+
updateElement('kpi-occupancy', data.venue.overall_occupancy + '%');
|
| 57 |
+
updateElement('stat-occupancy-val', data.venue.overall_occupancy + '%');
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
if (data.sim_status) {
|
| 61 |
+
updateElement('sim-tick', data.sim_status.tick_count);
|
| 62 |
+
updateElement('sim-occ', data.sim_status.venue_occupancy + '%');
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Trigger custom event for page-specific handlers
|
| 66 |
+
document.dispatchEvent(new CustomEvent('venueflow:update', { detail: data }));
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function updateElement(id, value) {
|
| 70 |
+
const el = document.getElementById(id);
|
| 71 |
+
if (el) el.textContent = value;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// Start SSE on page load
|
| 75 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 76 |
+
connectSSE();
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
// Disconnect on page unload
|
| 80 |
+
window.addEventListener('beforeunload', () => {
|
| 81 |
+
if (sseConnection) sseConnection.close();
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
console.log('๐ก SSE client loaded');
|
static/js/wayfinding.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* VenueFlow โ Wayfinding & Navigation
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
let navCanvas, navCtx;
|
| 6 |
+
let currentRoute = null;
|
| 7 |
+
let accessibleMode = false;
|
| 8 |
+
let selectedFromZone = 'gate_1'; // Default starting point
|
| 9 |
+
let pois = [];
|
| 10 |
+
|
| 11 |
+
// Same zone positions as heatmap
|
| 12 |
+
const NAV_ZONE_POSITIONS = {
|
| 13 |
+
north_stand: { x: 0.5, y: 0.15, label: 'North Stand', icon: '๐๏ธ' },
|
| 14 |
+
south_stand: { x: 0.5, y: 0.85, label: 'South Stand', icon: '๐๏ธ' },
|
| 15 |
+
east_pavilion: { x: 0.85, y: 0.5, label: 'East Pavilion', icon: '๐๏ธ' },
|
| 16 |
+
west_pavilion: { x: 0.15, y: 0.5, label: 'West Pavilion', icon: '๐๏ธ' },
|
| 17 |
+
main_pavilion: { x: 0.72, y: 0.25, label: 'VIP Pavilion', icon: 'โญ' },
|
| 18 |
+
north_concourse: { x: 0.5, y: 0.06, label: 'N Concourse', icon: '๐ถ' },
|
| 19 |
+
south_concourse: { x: 0.5, y: 0.94, label: 'S Concourse', icon: '๐ถ' },
|
| 20 |
+
east_concourse: { x: 0.95, y: 0.5, label: 'E Concourse', icon: '๐ถ' },
|
| 21 |
+
west_concourse: { x: 0.05, y: 0.5, label: 'W Concourse', icon: '๐ถ' },
|
| 22 |
+
gate_1: { x: 0.35, y: 0.02, label: 'Gate 1', icon: '๐ช' },
|
| 23 |
+
gate_3: { x: 0.65, y: 0.02, label: 'Gate 3', icon: '๐ช' },
|
| 24 |
+
gate_5: { x: 0.02, y: 0.5, label: 'Gate 5', icon: '๐ช' },
|
| 25 |
+
gate_7: { x: 0.5, y: 0.98, label: 'Gate 7', icon: '๐ช' },
|
| 26 |
+
food_north: { x: 0.6, y: 0.1, label: 'North Food', icon: '๐' },
|
| 27 |
+
food_south: { x: 0.4, y: 0.9, label: 'South Food', icon: '๐' },
|
| 28 |
+
food_west: { x: 0.1, y: 0.6, label: 'West Food', icon: '๐' },
|
| 29 |
+
restroom_n1: { x: 0.38, y: 0.1, label: 'Restroom N1', icon: '๐ป' },
|
| 30 |
+
restroom_s1: { x: 0.62, y: 0.9, label: 'Restroom S1', icon: '๐ป' },
|
| 31 |
+
restroom_e1: { x: 0.9, y: 0.35, label: 'Restroom E1', icon: '๐ป' },
|
| 32 |
+
restroom_w1: { x: 0.1, y: 0.65, label: 'Restroom W1', icon: '๐ป' },
|
| 33 |
+
parking_a: { x: 0.5, y: 0.0, label: 'Parking A', icon: '๐
ฟ๏ธ' },
|
| 34 |
+
parking_b: { x: 0.0, y: 0.3, label: 'Parking B', icon: '๐
ฟ๏ธ' },
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
function initNav() {
|
| 38 |
+
navCanvas = document.getElementById('nav-canvas');
|
| 39 |
+
if (!navCanvas) return;
|
| 40 |
+
|
| 41 |
+
navCtx = navCanvas.getContext('2d');
|
| 42 |
+
resizeNavCanvas();
|
| 43 |
+
window.addEventListener('resize', resizeNavCanvas);
|
| 44 |
+
|
| 45 |
+
// Click handler on canvas
|
| 46 |
+
navCanvas.addEventListener('click', handleCanvasClick);
|
| 47 |
+
|
| 48 |
+
drawNavMap();
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function resizeNavCanvas() {
|
| 52 |
+
if (!navCanvas) return;
|
| 53 |
+
const rect = navCanvas.parentElement.getBoundingClientRect();
|
| 54 |
+
navCanvas.width = rect.width;
|
| 55 |
+
navCanvas.height = rect.height;
|
| 56 |
+
drawNavMap();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function handleCanvasClick(e) {
|
| 60 |
+
const rect = navCanvas.getBoundingClientRect();
|
| 61 |
+
const clickX = (e.clientX - rect.left) / rect.width;
|
| 62 |
+
const clickY = (e.clientY - rect.top) / rect.height;
|
| 63 |
+
|
| 64 |
+
// Find closest zone
|
| 65 |
+
let closest = null;
|
| 66 |
+
let closestDist = Infinity;
|
| 67 |
+
|
| 68 |
+
for (const [id, pos] of Object.entries(NAV_ZONE_POSITIONS)) {
|
| 69 |
+
const dist = Math.sqrt(Math.pow(clickX - pos.x, 2) + Math.pow(clickY - pos.y, 2));
|
| 70 |
+
if (dist < 0.05 && dist < closestDist) {
|
| 71 |
+
closest = id;
|
| 72 |
+
closestDist = dist;
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if (closest) {
|
| 77 |
+
selectPOI(closest);
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function drawNavMap() {
|
| 82 |
+
if (!navCtx || !navCanvas) return;
|
| 83 |
+
|
| 84 |
+
const W = navCanvas.width;
|
| 85 |
+
const H = navCanvas.height;
|
| 86 |
+
|
| 87 |
+
// Background
|
| 88 |
+
navCtx.fillStyle = '#111827';
|
| 89 |
+
navCtx.fillRect(0, 0, W, H);
|
| 90 |
+
|
| 91 |
+
// Cricket pitch
|
| 92 |
+
navCtx.save();
|
| 93 |
+
navCtx.beginPath();
|
| 94 |
+
navCtx.ellipse(W * 0.5, H * 0.5, W * 0.25, H * 0.3, 0, 0, Math.PI * 2);
|
| 95 |
+
navCtx.fillStyle = '#1a3a2a';
|
| 96 |
+
navCtx.fill();
|
| 97 |
+
navCtx.strokeStyle = '#2a5a3a';
|
| 98 |
+
navCtx.lineWidth = 2;
|
| 99 |
+
navCtx.stroke();
|
| 100 |
+
navCtx.restore();
|
| 101 |
+
|
| 102 |
+
// Draw zone markers
|
| 103 |
+
for (const [id, pos] of Object.entries(NAV_ZONE_POSITIONS)) {
|
| 104 |
+
const x = pos.x * W;
|
| 105 |
+
const y = pos.y * H;
|
| 106 |
+
|
| 107 |
+
// Marker circle
|
| 108 |
+
navCtx.save();
|
| 109 |
+
const isFrom = id === selectedFromZone;
|
| 110 |
+
|
| 111 |
+
navCtx.beginPath();
|
| 112 |
+
navCtx.arc(x, y, isFrom ? 14 : 10, 0, Math.PI * 2);
|
| 113 |
+
navCtx.fillStyle = isFrom ? '#6366f1' : '#1e2a3a';
|
| 114 |
+
navCtx.fill();
|
| 115 |
+
navCtx.strokeStyle = isFrom ? '#818cf8' : '#475569';
|
| 116 |
+
navCtx.lineWidth = 2;
|
| 117 |
+
navCtx.stroke();
|
| 118 |
+
|
| 119 |
+
// Icon
|
| 120 |
+
navCtx.font = `${isFrom ? 14 : 11}px serif`;
|
| 121 |
+
navCtx.textAlign = 'center';
|
| 122 |
+
navCtx.textBaseline = 'middle';
|
| 123 |
+
navCtx.fillText(pos.icon, x, y);
|
| 124 |
+
|
| 125 |
+
// Label below
|
| 126 |
+
navCtx.font = `500 ${Math.max(9, W * 0.01)}px Inter, sans-serif`;
|
| 127 |
+
navCtx.fillStyle = '#94a3b8';
|
| 128 |
+
navCtx.fillText(pos.label, x, y + 20);
|
| 129 |
+
|
| 130 |
+
navCtx.restore();
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// Draw route if exists
|
| 134 |
+
if (currentRoute && currentRoute.path) {
|
| 135 |
+
drawRoute(currentRoute.path);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function drawRoute(path) {
|
| 140 |
+
if (!navCtx || path.length < 2) return;
|
| 141 |
+
|
| 142 |
+
const W = navCanvas.width;
|
| 143 |
+
const H = navCanvas.height;
|
| 144 |
+
|
| 145 |
+
navCtx.save();
|
| 146 |
+
navCtx.strokeStyle = '#06b6d4';
|
| 147 |
+
navCtx.lineWidth = 3;
|
| 148 |
+
navCtx.setLineDash([8, 4]);
|
| 149 |
+
navCtx.shadowColor = '#06b6d4';
|
| 150 |
+
navCtx.shadowBlur = 10;
|
| 151 |
+
|
| 152 |
+
navCtx.beginPath();
|
| 153 |
+
for (let i = 0; i < path.length; i++) {
|
| 154 |
+
const pos = NAV_ZONE_POSITIONS[path[i]];
|
| 155 |
+
if (!pos) continue;
|
| 156 |
+
const x = pos.x * W;
|
| 157 |
+
const y = pos.y * H;
|
| 158 |
+
|
| 159 |
+
if (i === 0) navCtx.moveTo(x, y);
|
| 160 |
+
else navCtx.lineTo(x, y);
|
| 161 |
+
}
|
| 162 |
+
navCtx.stroke();
|
| 163 |
+
|
| 164 |
+
// Destination marker
|
| 165 |
+
const dest = NAV_ZONE_POSITIONS[path[path.length - 1]];
|
| 166 |
+
if (dest) {
|
| 167 |
+
navCtx.beginPath();
|
| 168 |
+
navCtx.arc(dest.x * W, dest.y * H, 16, 0, Math.PI * 2);
|
| 169 |
+
navCtx.fillStyle = 'rgba(6, 182, 212, 0.3)';
|
| 170 |
+
navCtx.fill();
|
| 171 |
+
navCtx.strokeStyle = '#06b6d4';
|
| 172 |
+
navCtx.lineWidth = 3;
|
| 173 |
+
navCtx.setLineDash([]);
|
| 174 |
+
navCtx.stroke();
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
navCtx.restore();
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
async function selectPOI(zoneId) {
|
| 181 |
+
try {
|
| 182 |
+
const data = await apiCall(`/api/navigate/route?from=${selectedFromZone}&to=${zoneId}&accessible=${accessibleMode}`);
|
| 183 |
+
if (data) {
|
| 184 |
+
currentRoute = data;
|
| 185 |
+
showRouteDisplay(data);
|
| 186 |
+
drawNavMap();
|
| 187 |
+
}
|
| 188 |
+
} catch (err) {
|
| 189 |
+
showToast('Route Error', err.message || 'Could not find route', 'error');
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
async function findNearest(type) {
|
| 194 |
+
try {
|
| 195 |
+
const data = await apiCall(`/api/navigate/nearest?from=${selectedFromZone}&type=${type}&accessible=${accessibleMode}`);
|
| 196 |
+
if (data && data.route) {
|
| 197 |
+
currentRoute = data.route;
|
| 198 |
+
showRouteDisplay(data.route);
|
| 199 |
+
drawNavMap();
|
| 200 |
+
showToast('Nearest Found', `${data.target_zone.name} โ ${data.distance_m}m away`, 'success');
|
| 201 |
+
}
|
| 202 |
+
} catch (err) {
|
| 203 |
+
showToast('Not Found', `No ${type.replace('_', ' ')} found nearby`, 'warning');
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function showRouteDisplay(route) {
|
| 208 |
+
const display = document.getElementById('route-display');
|
| 209 |
+
if (!display) return;
|
| 210 |
+
|
| 211 |
+
display.style.display = 'block';
|
| 212 |
+
|
| 213 |
+
document.getElementById('route-title').textContent = `${route.from} โ ${route.to}`;
|
| 214 |
+
document.getElementById('route-distance').textContent = route.total_distance_m + 'm';
|
| 215 |
+
document.getElementById('route-time').textContent = route.estimated_time_display;
|
| 216 |
+
|
| 217 |
+
const stepsEl = document.getElementById('route-steps');
|
| 218 |
+
stepsEl.innerHTML = '';
|
| 219 |
+
|
| 220 |
+
if (route.steps && route.steps.length > 0) {
|
| 221 |
+
route.steps.forEach((step, i) => {
|
| 222 |
+
const item = document.createElement('div');
|
| 223 |
+
item.className = 'zone-item';
|
| 224 |
+
item.innerHTML = `
|
| 225 |
+
<span style="font-size:1.25rem;color:var(--accent-secondary);">${i + 1}</span>
|
| 226 |
+
<div class="zone-info">
|
| 227 |
+
<div class="zone-name">${escapeHtml(step.instruction)}</div>
|
| 228 |
+
<div class="zone-type">${step.distance_m}m</div>
|
| 229 |
+
</div>
|
| 230 |
+
<span style="font-size:1rem;">โ</span>
|
| 231 |
+
`;
|
| 232 |
+
stepsEl.appendChild(item);
|
| 233 |
+
});
|
| 234 |
+
} else {
|
| 235 |
+
stepsEl.innerHTML = '<div class="zone-item"><div class="zone-info"><div class="zone-name">Direct route available</div></div></div>';
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
function clearRoute() {
|
| 240 |
+
currentRoute = null;
|
| 241 |
+
const display = document.getElementById('route-display');
|
| 242 |
+
if (display) display.style.display = 'none';
|
| 243 |
+
drawNavMap();
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
function toggleAccessible() {
|
| 247 |
+
accessibleMode = !accessibleMode;
|
| 248 |
+
const btn = document.getElementById('accessible-route-btn');
|
| 249 |
+
if (btn) {
|
| 250 |
+
btn.setAttribute('aria-pressed', accessibleMode.toString());
|
| 251 |
+
if (accessibleMode) {
|
| 252 |
+
btn.classList.replace('btn-ghost', 'btn-primary');
|
| 253 |
+
showToast('Accessible Routing', 'Routes now prioritize elevators, ramps, and accessible paths', 'info');
|
| 254 |
+
} else {
|
| 255 |
+
btn.classList.replace('btn-primary', 'btn-ghost');
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Re-route if there's an active route
|
| 260 |
+
if (currentRoute) {
|
| 261 |
+
selectPOI(currentRoute.path[currentRoute.path.length - 1]);
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
document.addEventListener('DOMContentLoaded', initNav);
|
| 266 |
+
|
| 267 |
+
console.log('๐บ๏ธ Wayfinding loaded');
|
templates/attendee/heatmap.html
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% from "components/nav.html" import attendee_nav %}
|
| 3 |
+
|
| 4 |
+
{% block title %}Live Heatmap{% endblock %}
|
| 5 |
+
|
| 6 |
+
{% block navbar %}{{ attendee_nav('heatmap') }}{% endblock %}
|
| 7 |
+
|
| 8 |
+
{% block content %}
|
| 9 |
+
<div class="main-content">
|
| 10 |
+
<div class="card-header mb-4">
|
| 11 |
+
<div>
|
| 12 |
+
<h2>๐ฅ Live Crowd Heatmap</h2>
|
| 13 |
+
<p class="card-subtitle">Real-time density across the venue โ updated every 2 seconds</p>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="flex gap-2">
|
| 16 |
+
<button id="toggle-table-view" class="btn btn-secondary btn-sm" aria-label="Toggle accessible table view">
|
| 17 |
+
๐ Table View
|
| 18 |
+
</button>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<!-- Heatmap Legend -->
|
| 23 |
+
<div class="flex gap-4 mb-4 items-center" style="flex-wrap:wrap;" role="legend" aria-label="Density legend">
|
| 24 |
+
<div class="flex items-center gap-2"><span class="density-dot low"></span><span style="font-size:var(--font-size-sm);">Low (<40%)</span></div>
|
| 25 |
+
<div class="flex items-center gap-2"><span class="density-dot moderate"></span><span style="font-size:var(--font-size-sm);">Moderate (40-70%)</span></div>
|
| 26 |
+
<div class="flex items-center gap-2"><span class="density-dot high"></span><span style="font-size:var(--font-size-sm);">High (70-90%)</span></div>
|
| 27 |
+
<div class="flex items-center gap-2"><span class="density-dot critical"></span><span style="font-size:var(--font-size-sm);">Critical (>90%)</span></div>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<!-- Visual Heatmap (Canvas-based) -->
|
| 31 |
+
<div id="heatmap-visual" class="card" style="padding:0;overflow:hidden;">
|
| 32 |
+
<div id="venue-map" class="map-container" style="height:520px;position:relative;background:var(--bg-secondary);">
|
| 33 |
+
<canvas id="heatmap-canvas" style="width:100%;height:100%;"></canvas>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
|
| 37 |
+
<!-- Accessible Table View (hidden by default) -->
|
| 38 |
+
<div id="heatmap-table" class="card mt-4" style="display:none;">
|
| 39 |
+
<h3 class="card-title mb-4">Zone Density Data</h3>
|
| 40 |
+
<div style="overflow-x:auto;">
|
| 41 |
+
<table class="data-table" aria-label="Zone crowd density data">
|
| 42 |
+
<thead>
|
| 43 |
+
<tr>
|
| 44 |
+
<th scope="col">Status</th>
|
| 45 |
+
<th scope="col">Zone</th>
|
| 46 |
+
<th scope="col">Type</th>
|
| 47 |
+
<th scope="col">Capacity</th>
|
| 48 |
+
<th scope="col">Current</th>
|
| 49 |
+
<th scope="col">Occupancy</th>
|
| 50 |
+
<th scope="col">Available</th>
|
| 51 |
+
</tr>
|
| 52 |
+
</thead>
|
| 53 |
+
<tbody id="heatmap-table-body">
|
| 54 |
+
{% for zone in heatmap_data.get('zones', []) %}
|
| 55 |
+
<tr>
|
| 56 |
+
<td><span class="density-dot {{ zone.density_level }}" aria-label="{{ zone.density_label }}"></span></td>
|
| 57 |
+
<td><strong>{{ zone.name }}</strong></td>
|
| 58 |
+
<td>{{ zone.zone_type | replace('_', ' ') | title }}</td>
|
| 59 |
+
<td>{{ zone.capacity }}</td>
|
| 60 |
+
<td>{{ zone.current_count }}</td>
|
| 61 |
+
<td>
|
| 62 |
+
<span class="badge badge-{{ zone.density_level }}">{{ zone.occupancy_rate }}%</span>
|
| 63 |
+
</td>
|
| 64 |
+
<td>{{ zone.available_capacity }}</td>
|
| 65 |
+
</tr>
|
| 66 |
+
{% endfor %}
|
| 67 |
+
</tbody>
|
| 68 |
+
</table>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<!-- Zone Detail Cards -->
|
| 73 |
+
<div class="content-grid grid-3 mt-6" id="zone-cards">
|
| 74 |
+
{% for zone in heatmap_data.get('zones', []) %}
|
| 75 |
+
<div class="stat-card" data-zone-id="{{ zone.id }}">
|
| 76 |
+
<div class="flex items-center gap-2">
|
| 77 |
+
<span class="density-dot {{ zone.density_level }}"></span>
|
| 78 |
+
<strong>{{ zone.name }}</strong>
|
| 79 |
+
</div>
|
| 80 |
+
<div class="progress" style="margin: var(--space-2) 0;">
|
| 81 |
+
<div class="progress-bar {{ zone.density_level }}" style="width: {{ zone.occupancy_rate }}%"></div>
|
| 82 |
+
</div>
|
| 83 |
+
<div class="flex justify-between" style="font-size:var(--font-size-xs);color:var(--text-tertiary);">
|
| 84 |
+
<span>{{ zone.current_count }} / {{ zone.capacity }}</span>
|
| 85 |
+
<span style="color:{{ zone.density_color }};font-weight:600;">{{ zone.occupancy_rate }}%</span>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
{% endfor %}
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
{% endblock %}
|
| 92 |
+
|
| 93 |
+
{% block chatbot %}
|
| 94 |
+
{% include "components/chatbot.html" %}
|
| 95 |
+
{% endblock %}
|
| 96 |
+
|
| 97 |
+
{% block extra_js %}
|
| 98 |
+
<script src="{{ url_for('static', filename='js/heatmap.js') }}"></script>
|
| 99 |
+
<script src="{{ url_for('static', filename='js/chatbot.js') }}"></script>
|
| 100 |
+
<script src="{{ url_for('static', filename='js/sse-client.js') }}"></script>
|
| 101 |
+
<script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
|
| 102 |
+
{% endblock %}
|
templates/attendee/home.html
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% from "components/nav.html" import attendee_nav %}
|
| 3 |
+
|
| 4 |
+
{% block title %}Home{% endblock %}
|
| 5 |
+
|
| 6 |
+
{% block navbar %}{{ attendee_nav('home') }}{% endblock %}
|
| 7 |
+
|
| 8 |
+
{% block content %}
|
| 9 |
+
<div class="main-content">
|
| 10 |
+
<!-- Hero Banner -->
|
| 11 |
+
<section class="hero-banner" aria-label="Event information">
|
| 12 |
+
<h2>{{ event.get('name', 'Live Event Today') }}</h2>
|
| 13 |
+
<p>{{ event.get('phase_description', 'Welcome to the venue') }}</p>
|
| 14 |
+
<div class="hero-event-info">
|
| 15 |
+
<div class="event-detail">๐ {{ event.get('sport', 'Cricket') }}</div>
|
| 16 |
+
<div class="event-detail">๐ค๏ธ {{ event.get('temperature_c', 28) }}ยฐC {{ event.get('weather', 'Clear') }}</div>
|
| 17 |
+
<div class="event-detail">
|
| 18 |
+
<span class="phase-badge">
|
| 19 |
+
<span class="phase-dot"></span>
|
| 20 |
+
{{ event.get('phase_label', 'Pre-Event') }}
|
| 21 |
+
</span>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="event-detail">๐ฅ {{ venue_summary.get('total_current', 0) | int }} / {{ venue_summary.get('total_capacity', 50000) | int }}</div>
|
| 24 |
+
</div>
|
| 25 |
+
</section>
|
| 26 |
+
|
| 27 |
+
<!-- Quick Stats -->
|
| 28 |
+
<section aria-label="Quick statistics">
|
| 29 |
+
<div class="content-grid grid-4 mb-6">
|
| 30 |
+
<div class="stat-card" id="stat-occupancy">
|
| 31 |
+
<span class="stat-icon">๐๏ธ</span>
|
| 32 |
+
<span class="stat-value" id="stat-occupancy-val">{{ venue_summary.get('overall_occupancy', 0) }}%</span>
|
| 33 |
+
<span class="stat-label">Venue Occupancy</span>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<div class="stat-card" id="stat-waiting">
|
| 37 |
+
<span class="stat-icon">โฑ๏ธ</span>
|
| 38 |
+
<span class="stat-value">{{ queue_summary.get('total_people_waiting', 0) }}</span>
|
| 39 |
+
<span class="stat-label">People in Queues</span>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div class="stat-card" id="stat-zones-ok">
|
| 43 |
+
<span class="stat-icon">โ
</span>
|
| 44 |
+
<span class="stat-value">{{ venue_summary.get('density_counts', {}).get('low', 0) }}</span>
|
| 45 |
+
<span class="stat-label">Low Density Zones</span>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div class="stat-card" id="stat-zones-busy">
|
| 49 |
+
<span class="stat-icon">๐ด</span>
|
| 50 |
+
<span class="stat-value warning">{{ (venue_summary.get('density_counts', {}).get('high', 0) + venue_summary.get('density_counts', {}).get('critical', 0)) }}</span>
|
| 51 |
+
<span class="stat-label">Busy Zones</span>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
</section>
|
| 55 |
+
|
| 56 |
+
<div class="content-grid grid-2">
|
| 57 |
+
<!-- Crowd Hotspots -->
|
| 58 |
+
<section class="card" aria-label="Busiest zones">
|
| 59 |
+
<div class="card-header">
|
| 60 |
+
<div>
|
| 61 |
+
<h3 class="card-title">๐ฅ Hotspot Zones</h3>
|
| 62 |
+
<p class="card-subtitle">Most crowded areas right now</p>
|
| 63 |
+
</div>
|
| 64 |
+
<a href="{{ url_for('attendee.heatmap') }}" class="btn btn-ghost btn-sm">View Map โ</a>
|
| 65 |
+
</div>
|
| 66 |
+
<div class="zone-list" id="hotspot-list">
|
| 67 |
+
{% for zone in venue_summary.get('hotspots', [])[:5] %}
|
| 68 |
+
<div class="zone-item">
|
| 69 |
+
<span class="density-dot {{ zone.density_level }}" aria-label="{{ zone.density_label }}"></span>
|
| 70 |
+
<div class="zone-info">
|
| 71 |
+
<div class="zone-name">{{ zone.name }}</div>
|
| 72 |
+
<div class="zone-type">{{ zone.zone_type | replace('_', ' ') }}</div>
|
| 73 |
+
</div>
|
| 74 |
+
<div class="zone-occupancy" style="color: {{ zone.density_color }}">
|
| 75 |
+
{{ zone.occupancy_rate }}%
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
{% endfor %}
|
| 79 |
+
</div>
|
| 80 |
+
</section>
|
| 81 |
+
|
| 82 |
+
<!-- Quick Queue Summary -->
|
| 83 |
+
<section class="card" aria-label="Queue overview">
|
| 84 |
+
<div class="card-header">
|
| 85 |
+
<div>
|
| 86 |
+
<h3 class="card-title">โฑ๏ธ Queue Status</h3>
|
| 87 |
+
<p class="card-subtitle">Shortest wait times by category</p>
|
| 88 |
+
</div>
|
| 89 |
+
<a href="{{ url_for('attendee.queues') }}" class="btn btn-ghost btn-sm">All Queues โ</a>
|
| 90 |
+
</div>
|
| 91 |
+
<div class="zone-list" id="queue-overview">
|
| 92 |
+
{% for cat_key, cat_data in queue_summary.get('categories', {}).items() %}
|
| 93 |
+
<div class="zone-item">
|
| 94 |
+
<span class="queue-icon" style="font-size:1.5rem;">
|
| 95 |
+
{% if cat_key == 'food' %}๐{% elif cat_key == 'restroom' %}๐ป{% elif cat_key == 'merch' %}๐๏ธ{% elif cat_key == 'entry' %}๐ช{% else %}๐{% endif %}
|
| 96 |
+
</span>
|
| 97 |
+
<div class="zone-info">
|
| 98 |
+
<div class="zone-name">{{ cat_key | replace('_', ' ') | title }}</div>
|
| 99 |
+
<div class="zone-type">Best: {{ cat_data.shortest_station or 'N/A' }}</div>
|
| 100 |
+
</div>
|
| 101 |
+
<div class="zone-occupancy" style="color: {% if cat_data.shortest_wait < 5 %}var(--density-low){% elif cat_data.shortest_wait < 15 %}var(--density-moderate){% else %}var(--density-high){% endif %}">
|
| 102 |
+
{{ cat_data.shortest_wait | round(0) | int }} min
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
{% endfor %}
|
| 106 |
+
</div>
|
| 107 |
+
</section>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<!-- Recommended Zones -->
|
| 111 |
+
<section class="card mt-6" aria-label="Recommended quiet zones">
|
| 112 |
+
<div class="card-header">
|
| 113 |
+
<div>
|
| 114 |
+
<h3 class="card-title">๐ข Quiet Zones</h3>
|
| 115 |
+
<p class="card-subtitle">Least crowded areas โ recommended for a comfortable experience</p>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
<div class="content-grid grid-3">
|
| 119 |
+
{% for zone in venue_summary.get('most_available', [])[:3] %}
|
| 120 |
+
<div class="stat-card">
|
| 121 |
+
<span class="stat-icon">
|
| 122 |
+
{% if zone.zone_type == 'food_court' %}๐{% elif zone.zone_type == 'restroom' %}๐ป{% elif zone.zone_type == 'concourse' %}๐ถ{% elif zone.zone_type == 'stand' %}๐๏ธ{% else %}๐{% endif %}
|
| 123 |
+
</span>
|
| 124 |
+
<div>
|
| 125 |
+
<div style="font-weight:600;margin-bottom:4px;">{{ zone.name }}</div>
|
| 126 |
+
<div style="font-size:var(--font-size-sm);color:var(--text-tertiary);">{{ zone.zone_type | replace('_', ' ') | title }}</div>
|
| 127 |
+
</div>
|
| 128 |
+
<div class="flex items-center gap-2">
|
| 129 |
+
<span class="density-dot {{ zone.density_level }}"></span>
|
| 130 |
+
<span style="color:{{ zone.density_color }};font-weight:600;">{{ zone.occupancy_rate }}% full</span>
|
| 131 |
+
</div>
|
| 132 |
+
<div class="progress">
|
| 133 |
+
<div class="progress-bar {{ zone.density_level }}" style="width: {{ zone.occupancy_rate }}%"></div>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
{% endfor %}
|
| 137 |
+
</div>
|
| 138 |
+
</section>
|
| 139 |
+
</div>
|
| 140 |
+
{% endblock %}
|
| 141 |
+
|
| 142 |
+
{% block chatbot %}
|
| 143 |
+
{% include "components/chatbot.html" %}
|
| 144 |
+
{% endblock %}
|
| 145 |
+
|
| 146 |
+
{% block extra_js %}
|
| 147 |
+
<script src="{{ url_for('static', filename='js/sse-client.js') }}"></script>
|
| 148 |
+
<script src="{{ url_for('static', filename='js/chatbot.js') }}"></script>
|
| 149 |
+
<script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
|
| 150 |
+
<script>
|
| 151 |
+
// Auto-refresh data every 5 seconds
|
| 152 |
+
setInterval(async () => {
|
| 153 |
+
try {
|
| 154 |
+
const resp = await fetch('/api/crowd/summary');
|
| 155 |
+
if (resp.ok) {
|
| 156 |
+
const data = await resp.json();
|
| 157 |
+
const occEl = document.getElementById('stat-occupancy-val');
|
| 158 |
+
if (occEl) occEl.textContent = data.overall_occupancy + '%';
|
| 159 |
+
}
|
| 160 |
+
} catch(e) {}
|
| 161 |
+
}, 5000);
|
| 162 |
+
</script>
|
| 163 |
+
{% endblock %}
|
templates/attendee/navigate.html
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% from "components/nav.html" import attendee_nav %}
|
| 3 |
+
|
| 4 |
+
{% block title %}Navigate{% endblock %}
|
| 5 |
+
|
| 6 |
+
{% block navbar %}{{ attendee_nav('navigate') }}{% endblock %}
|
| 7 |
+
|
| 8 |
+
{% block content %}
|
| 9 |
+
<div class="main-content">
|
| 10 |
+
<div class="card-header mb-4">
|
| 11 |
+
<div>
|
| 12 |
+
<h2>๐บ๏ธ Venue Navigation</h2>
|
| 13 |
+
<p class="card-subtitle">Find your way around โ crowd-aware routing</p>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<!-- Quick Navigation Buttons -->
|
| 18 |
+
<div class="flex gap-3 mb-6" style="flex-wrap:wrap;">
|
| 19 |
+
<button class="btn btn-primary" onclick="findNearest('food_court')" id="find-food-btn">๐ Nearest Food</button>
|
| 20 |
+
<button class="btn btn-secondary" onclick="findNearest('restroom')" id="find-restroom-btn">๐ป Nearest Restroom</button>
|
| 21 |
+
<button class="btn btn-secondary" onclick="findNearest('gate')" id="find-exit-btn">๐ช Nearest Exit</button>
|
| 22 |
+
<button class="btn btn-ghost" onclick="toggleAccessible()" id="accessible-route-btn" aria-pressed="false">โฟ Accessible Routes</button>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<!-- Venue Map -->
|
| 26 |
+
<div class="card mb-6" style="padding:0;overflow:hidden;">
|
| 27 |
+
<div id="nav-map" class="map-container" style="height:480px;position:relative;background:var(--bg-secondary);">
|
| 28 |
+
<canvas id="nav-canvas" style="width:100%;height:100%;"></canvas>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<!-- Route Display -->
|
| 33 |
+
<div id="route-display" class="card mb-6" style="display:none;">
|
| 34 |
+
<div class="card-header">
|
| 35 |
+
<h3 class="card-title" id="route-title">Route</h3>
|
| 36 |
+
<button class="btn btn-ghost btn-sm" onclick="clearRoute()" aria-label="Clear route">โ Clear</button>
|
| 37 |
+
</div>
|
| 38 |
+
<div class="flex gap-4 mb-4">
|
| 39 |
+
<div class="stat-card" style="flex:1;">
|
| 40 |
+
<span class="stat-icon">๐</span>
|
| 41 |
+
<span class="stat-value" id="route-distance">โ</span>
|
| 42 |
+
<span class="stat-label">Distance</span>
|
| 43 |
+
</div>
|
| 44 |
+
<div class="stat-card" style="flex:1;">
|
| 45 |
+
<span class="stat-icon">โฑ๏ธ</span>
|
| 46 |
+
<span class="stat-value" id="route-time">โ</span>
|
| 47 |
+
<span class="stat-label">Est. Walking Time</span>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
<div id="route-steps" class="zone-list" role="list" aria-label="Navigation steps"></div>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<!-- Points of Interest -->
|
| 54 |
+
<section class="card" aria-label="Points of interest">
|
| 55 |
+
<h3 class="card-title mb-4">๐ Points of Interest</h3>
|
| 56 |
+
<div class="content-grid grid-3" id="poi-grid">
|
| 57 |
+
{% for poi in pois %}
|
| 58 |
+
<div class="zone-item" style="cursor:pointer;" onclick="selectPOI('{{ poi.id }}')"
|
| 59 |
+
role="button" tabindex="0" aria-label="Navigate to {{ poi.name }}">
|
| 60 |
+
<span style="font-size:1.5rem;">{{ poi.icon }}</span>
|
| 61 |
+
<div class="zone-info">
|
| 62 |
+
<div class="zone-name">{{ poi.name }}</div>
|
| 63 |
+
<div class="zone-type">{{ poi.type | replace('_', ' ') | title }}</div>
|
| 64 |
+
</div>
|
| 65 |
+
<span class="density-dot {{ poi.density_level }}"></span>
|
| 66 |
+
</div>
|
| 67 |
+
{% endfor %}
|
| 68 |
+
</div>
|
| 69 |
+
</section>
|
| 70 |
+
</div>
|
| 71 |
+
{% endblock %}
|
| 72 |
+
|
| 73 |
+
{% block chatbot %}
|
| 74 |
+
{% include "components/chatbot.html" %}
|
| 75 |
+
{% endblock %}
|
| 76 |
+
|
| 77 |
+
{% block extra_js %}
|
| 78 |
+
<script src="{{ url_for('static', filename='js/wayfinding.js') }}"></script>
|
| 79 |
+
<script src="{{ url_for('static', filename='js/chatbot.js') }}"></script>
|
| 80 |
+
<script src="{{ url_for('static', filename='js/sse-client.js') }}"></script>
|
| 81 |
+
<script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
|
| 82 |
+
{% endblock %}
|
templates/attendee/profile.html
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% from "components/nav.html" import attendee_nav %}
|
| 3 |
+
|
| 4 |
+
{% block title %}Profile & Settings{% endblock %}
|
| 5 |
+
|
| 6 |
+
{% block navbar %}{{ attendee_nav('profile') }}{% endblock %}
|
| 7 |
+
|
| 8 |
+
{% block content %}
|
| 9 |
+
<div class="main-content" style="max-width:700px;">
|
| 10 |
+
<h2 class="mb-6">โ๏ธ Settings & Accessibility</h2>
|
| 11 |
+
|
| 12 |
+
<!-- Profile Info -->
|
| 13 |
+
<section class="card mb-6" aria-label="Profile information">
|
| 14 |
+
<h3 class="card-title mb-4">๐ค Profile</h3>
|
| 15 |
+
<div class="form-group">
|
| 16 |
+
<label class="form-label">Display Name</label>
|
| 17 |
+
<input type="text" class="form-input" value="{{ user_name }}" disabled>
|
| 18 |
+
</div>
|
| 19 |
+
<div class="form-group">
|
| 20 |
+
<label class="form-label">Email</label>
|
| 21 |
+
<input type="email" class="form-input" value="{{ user_email }}" disabled>
|
| 22 |
+
</div>
|
| 23 |
+
</section>
|
| 24 |
+
|
| 25 |
+
<!-- Accessibility Settings -->
|
| 26 |
+
<section class="card mb-6" aria-label="Accessibility settings">
|
| 27 |
+
<h3 class="card-title mb-4">โฟ Accessibility</h3>
|
| 28 |
+
<p class="card-subtitle" style="margin-bottom:var(--space-6);">Customize your experience for comfort and accessibility</p>
|
| 29 |
+
|
| 30 |
+
<div class="zone-list">
|
| 31 |
+
<div class="zone-item" style="cursor:pointer;" onclick="toggleA11y('highContrast')" role="switch" aria-checked="false" tabindex="0" id="toggle-contrast">
|
| 32 |
+
<span style="font-size:1.5rem;">๐ฒ</span>
|
| 33 |
+
<div class="zone-info">
|
| 34 |
+
<div class="zone-name">High Contrast Mode</div>
|
| 35 |
+
<div class="zone-type">Increase text and element contrast for better visibility</div>
|
| 36 |
+
</div>
|
| 37 |
+
<div class="a11y-switch" id="switch-contrast" aria-hidden="true">OFF</div>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="zone-item" style="cursor:pointer;" onclick="toggleA11y('largeText')" role="switch" aria-checked="false" tabindex="0" id="toggle-text">
|
| 41 |
+
<span style="font-size:1.5rem;">๐ค</span>
|
| 42 |
+
<div class="zone-info">
|
| 43 |
+
<div class="zone-name">Large Text</div>
|
| 44 |
+
<div class="zone-type">Increase font size to 125% for easier reading</div>
|
| 45 |
+
</div>
|
| 46 |
+
<div class="a11y-switch" id="switch-text" aria-hidden="true">OFF</div>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div class="zone-item" style="cursor:pointer;" onclick="toggleA11y('reducedMotion')" role="switch" aria-checked="false" tabindex="0" id="toggle-motion">
|
| 50 |
+
<span style="font-size:1.5rem;">๐๏ธ</span>
|
| 51 |
+
<div class="zone-info">
|
| 52 |
+
<div class="zone-name">Reduced Motion</div>
|
| 53 |
+
<div class="zone-type">Disable animations and transitions</div>
|
| 54 |
+
</div>
|
| 55 |
+
<div class="a11y-switch" id="switch-motion" aria-hidden="true">OFF</div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
</section>
|
| 59 |
+
|
| 60 |
+
<!-- Language -->
|
| 61 |
+
<section class="card mb-6" aria-label="Language settings">
|
| 62 |
+
<h3 class="card-title mb-4">๐ Language</h3>
|
| 63 |
+
<div class="form-group">
|
| 64 |
+
<label for="language-select" class="form-label">Preferred Language</label>
|
| 65 |
+
<select id="language-select" class="form-input" onchange="setLanguage(this.value)">
|
| 66 |
+
{% for code, name in languages.items() %}
|
| 67 |
+
<option value="{{ code }}">{{ name }}</option>
|
| 68 |
+
{% endfor %}
|
| 69 |
+
</select>
|
| 70 |
+
</div>
|
| 71 |
+
</section>
|
| 72 |
+
</div>
|
| 73 |
+
{% endblock %}
|
| 74 |
+
|
| 75 |
+
{% block extra_js %}
|
| 76 |
+
<script src="{{ url_for('static', filename='js/accessibility.js') }}"></script>
|
| 77 |
+
{% endblock %}
|
templates/attendee/queues.html
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% from "components/nav.html" import attendee_nav %}
|
| 3 |
+
|
| 4 |
+
{% block title %}Queue Status{% endblock %}
|
| 5 |
+
|
| 6 |
+
{% block navbar %}{{ attendee_nav('queues') }}{% endblock %}
|
| 7 |
+
|
| 8 |
+
{% block content %}
|
| 9 |
+
<div class="main-content">
|
| 10 |
+
<div class="card-header mb-4">
|
| 11 |
+
<div>
|
| 12 |
+
<h2>โฑ๏ธ Queue Status & Virtual Queue</h2>
|
| 13 |
+
<p class="card-subtitle">Skip physical lines โ book your spot in virtual queues</p>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<!-- Filter Tabs -->
|
| 18 |
+
<div class="flex gap-2 mb-6" style="flex-wrap:wrap;" role="tablist" aria-label="Queue category filter">
|
| 19 |
+
<button class="btn btn-primary btn-sm queue-filter active" data-category="all" role="tab" aria-selected="true">All</button>
|
| 20 |
+
<button class="btn btn-secondary btn-sm queue-filter" data-category="food" role="tab" aria-selected="false">๐ Food</button>
|
| 21 |
+
<button class="btn btn-secondary btn-sm queue-filter" data-category="restroom" role="tab" aria-selected="false">๐ป Restrooms</button>
|
| 22 |
+
<button class="btn btn-secondary btn-sm queue-filter" data-category="merch" role="tab" aria-selected="false">๐๏ธ Merchandise</button>
|
| 23 |
+
<button class="btn btn-secondary btn-sm queue-filter" data-category="entry" role="tab" aria-selected="false">๐ช Gates</button>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<!-- Active Virtual Tickets -->
|
| 27 |
+
{% if user_tickets %}
|
| 28 |
+
<section class="card mb-6" aria-label="Your virtual queue tickets">
|
| 29 |
+
<h3 class="card-title mb-4">๐ซ Your Virtual Tickets</h3>
|
| 30 |
+
<div class="content-grid grid-2" id="my-tickets">
|
| 31 |
+
{% for ticket in user_tickets %}
|
| 32 |
+
{% if ticket.status == 'waiting' %}
|
| 33 |
+
<div class="queue-card" style="border-left:3px solid var(--accent-primary);">
|
| 34 |
+
<div class="queue-icon">{{ ticket.category_icon }}</div>
|
| 35 |
+
<div class="queue-details">
|
| 36 |
+
<div class="queue-name">{{ ticket.station_name }}</div>
|
| 37 |
+
<div class="queue-meta">Ticket #{{ ticket.id }} โข Position {{ ticket.position }}</div>
|
| 38 |
+
</div>
|
| 39 |
+
<div>
|
| 40 |
+
<div class="queue-wait" style="color:var(--accent-primary);">#{{ ticket.position }}</div>
|
| 41 |
+
<button class="btn btn-ghost btn-sm" onclick="cancelTicket('{{ ticket.id }}')" aria-label="Cancel ticket {{ ticket.id }}">Cancel</button>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
{% endif %}
|
| 45 |
+
{% endfor %}
|
| 46 |
+
</div>
|
| 47 |
+
</section>
|
| 48 |
+
{% endif %}
|
| 49 |
+
|
| 50 |
+
<!-- Queue Summary Stats -->
|
| 51 |
+
<div class="content-grid grid-3 mb-6">
|
| 52 |
+
<div class="stat-card">
|
| 53 |
+
<span class="stat-icon">๐</span>
|
| 54 |
+
<span class="stat-value">{{ queue_summary.get('total_stations', 0) }}</span>
|
| 55 |
+
<span class="stat-label">Open Stations</span>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="stat-card">
|
| 58 |
+
<span class="stat-icon">๐ฅ</span>
|
| 59 |
+
<span class="stat-value warning">{{ queue_summary.get('total_people_waiting', 0) }}</span>
|
| 60 |
+
<span class="stat-label">Total People Waiting</span>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="stat-card">
|
| 63 |
+
<span class="stat-icon">โก</span>
|
| 64 |
+
<span class="stat-value" id="shortest-wait-val">
|
| 65 |
+
{% set min_wait = namespace(val=999) %}
|
| 66 |
+
{% for s in queue_summary.get('stations', []) %}
|
| 67 |
+
{% if s.estimated_wait_minutes < min_wait.val %}
|
| 68 |
+
{% set min_wait.val = s.estimated_wait_minutes %}
|
| 69 |
+
{% endif %}
|
| 70 |
+
{% endfor %}
|
| 71 |
+
{{ min_wait.val | round(0) | int if min_wait.val < 999 else 0 }} min
|
| 72 |
+
</span>
|
| 73 |
+
<span class="stat-label">Shortest Wait</span>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<!-- All Queue Stations -->
|
| 78 |
+
<section aria-label="All queue stations">
|
| 79 |
+
<div class="content-grid grid-2" id="queue-stations-list">
|
| 80 |
+
{% for station in queue_summary.get('stations', []) %}
|
| 81 |
+
<div class="queue-card" data-category="{{ station.category }}" data-station-id="{{ station.id }}">
|
| 82 |
+
<div class="queue-icon">{{ station.category_icon }}</div>
|
| 83 |
+
<div class="queue-details">
|
| 84 |
+
<div class="queue-name">{{ station.name }}</div>
|
| 85 |
+
<div class="queue-meta">{{ station.category_label }} โข {{ station.current_length }} in line</div>
|
| 86 |
+
<div class="progress" style="margin-top:var(--space-2);max-width:200px;">
|
| 87 |
+
<div class="progress-bar {{ station.wait_level }}" style="width: {{ [station.estimated_wait_minutes * 3.3, 100] | min }}%"></div>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
<div style="text-align:right;">
|
| 91 |
+
<div class="queue-wait" style="color:{{ station.wait_color }};">{{ station.estimated_wait_minutes | round(0) | int }} min</div>
|
| 92 |
+
<button class="btn btn-primary btn-sm mt-4" onclick="joinQueue('{{ station.id }}')"
|
| 93 |
+
aria-label="Join virtual queue for {{ station.name }}">
|
| 94 |
+
Join Queue
|
| 95 |
+
</button>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
{% endfor %}
|
| 99 |
+
</div>
|
| 100 |
+
</section>
|
| 101 |
+
</div>
|
| 102 |
+
{% endblock %}
|
| 103 |
+
|
| 104 |
+
{% block chatbot %}
|
| 105 |
+
{% include "components/chatbot.html" %}
|
| 106 |
+
{% endblock %}
|
| 107 |
+
|
| 108 |
+
{% block extra_js %}
|
| 109 |
+
<script src="{{ url_for('static', filename='js/queue.js') }}"></script>
|
| 110 |
+
<script src="{{ url_for('static', filename='js/chatbot.js') }}"></script>
|
| 111 |
+
<script src="{{ url_for('static', filename='js/sse-client.js') }}"></script>
|
| 112 |
+
<script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
|
| 113 |
+
{% endblock %}
|
templates/auth/login.html
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Login{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="auth-page">
|
| 7 |
+
<div class="auth-card">
|
| 8 |
+
<div class="text-center mb-6">
|
| 9 |
+
<div class="navbar-brand" style="justify-content: center; margin-bottom: var(--space-4);">
|
| 10 |
+
<span class="logo-icon">๐๏ธ</span>
|
| 11 |
+
<span>VenueFlow</span>
|
| 12 |
+
</div>
|
| 13 |
+
<h1>Welcome Back</h1>
|
| 14 |
+
<p class="auth-subtitle">Sign in to access your venue experience</p>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 18 |
+
{% if messages %}
|
| 19 |
+
<div class="flash-messages" role="alert">
|
| 20 |
+
{% for category, message in messages %}
|
| 21 |
+
<div class="flash-msg {{ category }}" role="alert">{{ message }}</div>
|
| 22 |
+
{% endfor %}
|
| 23 |
+
</div>
|
| 24 |
+
{% endif %}
|
| 25 |
+
{% endwith %}
|
| 26 |
+
|
| 27 |
+
<form method="POST" action="{{ url_for('auth.login') }}" novalidate>
|
| 28 |
+
<div class="form-group">
|
| 29 |
+
<label for="email" class="form-label">Email Address</label>
|
| 30 |
+
<input type="email" id="email" name="email" class="form-input"
|
| 31 |
+
placeholder="you@example.com" required autocomplete="email"
|
| 32 |
+
aria-required="true">
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<div class="form-group">
|
| 36 |
+
<label for="password" class="form-label">Password</label>
|
| 37 |
+
<input type="password" id="password" name="password" class="form-input"
|
| 38 |
+
placeholder="Enter your password" required autocomplete="current-password"
|
| 39 |
+
aria-required="true">
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<button type="submit" id="login-btn" class="btn btn-primary btn-block btn-lg">
|
| 43 |
+
Sign In
|
| 44 |
+
</button>
|
| 45 |
+
</form>
|
| 46 |
+
|
| 47 |
+
<div class="auth-footer">
|
| 48 |
+
<p style="margin-bottom: var(--space-3);">Don't have an account? <a href="{{ url_for('auth.register') }}">Create one</a></p>
|
| 49 |
+
<div style="border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); margin-top: var(--space-4);">
|
| 50 |
+
<p style="font-size: var(--font-size-xs); color: var(--text-muted); margin-bottom: var(--space-2);">Demo Accounts</p>
|
| 51 |
+
<p style="font-size: var(--font-size-xs); color: var(--text-tertiary);">
|
| 52 |
+
<strong>Fan:</strong> fan@venueflow.demo / demo123<br>
|
| 53 |
+
<strong>Operator:</strong> operator@venueflow.demo / demo123
|
| 54 |
+
</p>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
{% endblock %}
|
templates/auth/register.html
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Register{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="auth-page">
|
| 7 |
+
<div class="auth-card">
|
| 8 |
+
<div class="text-center mb-6">
|
| 9 |
+
<div class="navbar-brand" style="justify-content: center; margin-bottom: var(--space-4);">
|
| 10 |
+
<span class="logo-icon">๐๏ธ</span>
|
| 11 |
+
<span>VenueFlow</span>
|
| 12 |
+
</div>
|
| 13 |
+
<h1>Create Account</h1>
|
| 14 |
+
<p class="auth-subtitle">Join VenueFlow for a smarter venue experience</p>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 18 |
+
{% if messages %}
|
| 19 |
+
<div class="flash-messages" role="alert">
|
| 20 |
+
{% for category, message in messages %}
|
| 21 |
+
<div class="flash-msg {{ category }}">{{ message }}</div>
|
| 22 |
+
{% endfor %}
|
| 23 |
+
</div>
|
| 24 |
+
{% endif %}
|
| 25 |
+
{% endwith %}
|
| 26 |
+
|
| 27 |
+
<form method="POST" action="{{ url_for('auth.register') }}" novalidate>
|
| 28 |
+
<div class="form-group">
|
| 29 |
+
<label for="display_name" class="form-label">Display Name</label>
|
| 30 |
+
<input type="text" id="display_name" name="display_name" class="form-input"
|
| 31 |
+
placeholder="Your name" required aria-required="true">
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div class="form-group">
|
| 35 |
+
<label for="email" class="form-label">Email Address</label>
|
| 36 |
+
<input type="email" id="email" name="email" class="form-input"
|
| 37 |
+
placeholder="you@example.com" required autocomplete="email" aria-required="true">
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="form-group">
|
| 41 |
+
<label for="password" class="form-label">Password</label>
|
| 42 |
+
<input type="password" id="password" name="password" class="form-input"
|
| 43 |
+
placeholder="At least 6 characters" required autocomplete="new-password"
|
| 44 |
+
aria-required="true" minlength="6">
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div class="form-group">
|
| 48 |
+
<label for="confirm_password" class="form-label">Confirm Password</label>
|
| 49 |
+
<input type="password" id="confirm_password" name="confirm_password" class="form-input"
|
| 50 |
+
placeholder="Repeat your password" required autocomplete="new-password"
|
| 51 |
+
aria-required="true">
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<button type="submit" id="register-btn" class="btn btn-primary btn-block btn-lg">
|
| 55 |
+
Create Account
|
| 56 |
+
</button>
|
| 57 |
+
</form>
|
| 58 |
+
|
| 59 |
+
<div class="auth-footer">
|
| 60 |
+
<p>Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></p>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
{% endblock %}
|
templates/base.html
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" data-high-contrast="false" data-large-text="false" data-reduced-motion="false">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<meta name="description" content="VenueFlow โ Smart crowd management for sporting venues. Real-time heatmaps, virtual queues, and AI-powered navigation.">
|
| 7 |
+
<meta name="theme-color" content="#0a0e1a">
|
| 8 |
+
<title>{% block title %}VenueFlow{% endblock %} โ Smart Venue Experience</title>
|
| 9 |
+
|
| 10 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}">
|
| 11 |
+
{% block extra_css %}{% endblock %}
|
| 12 |
+
|
| 13 |
+
<!-- Preload critical font -->
|
| 14 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 15 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 16 |
+
</head>
|
| 17 |
+
<body>
|
| 18 |
+
<!-- Skip to Content (A11y) -->
|
| 19 |
+
<a href="#main-content" class="skip-link">Skip to main content</a>
|
| 20 |
+
|
| 21 |
+
{% block navbar %}{% endblock %}
|
| 22 |
+
|
| 23 |
+
<main id="main-content" role="main">
|
| 24 |
+
{% block content %}{% endblock %}
|
| 25 |
+
</main>
|
| 26 |
+
|
| 27 |
+
<!-- Toast Notification Container -->
|
| 28 |
+
<div id="toast-container" class="toast-container" role="alert" aria-live="polite" aria-label="Notifications"></div>
|
| 29 |
+
|
| 30 |
+
<!-- Chatbot Widget -->
|
| 31 |
+
{% block chatbot %}{% endblock %}
|
| 32 |
+
|
| 33 |
+
<!-- Mobile Bottom Nav -->
|
| 34 |
+
{% block mobile_nav %}{% endblock %}
|
| 35 |
+
|
| 36 |
+
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
| 37 |
+
{% block extra_js %}{% endblock %}
|
| 38 |
+
|
| 39 |
+
<script>
|
| 40 |
+
// Load accessibility preferences from localStorage
|
| 41 |
+
(function() {
|
| 42 |
+
const prefs = JSON.parse(localStorage.getItem('venueflow_a11y') || '{}');
|
| 43 |
+
if (prefs.highContrast) document.documentElement.dataset.highContrast = 'true';
|
| 44 |
+
if (prefs.largeText) document.documentElement.dataset.largeText = 'true';
|
| 45 |
+
if (prefs.reducedMotion) document.documentElement.dataset.reducedMotion = 'true';
|
| 46 |
+
})();
|
| 47 |
+
</script>
|
| 48 |
+
</body>
|
| 49 |
+
</html>
|
templates/components/chatbot.html
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- Chatbot Widget Component -->
|
| 2 |
+
<button class="chatbot-toggle" id="chatbot-toggle" aria-label="Open AI assistant" aria-expanded="false">
|
| 3 |
+
๐ฌ
|
| 4 |
+
</button>
|
| 5 |
+
|
| 6 |
+
<div class="chatbot-window" id="chatbot-window" role="dialog" aria-label="AI Assistant" aria-hidden="true">
|
| 7 |
+
<div class="chatbot-header">
|
| 8 |
+
<h4>๐ค VenueFlow Assistant</h4>
|
| 9 |
+
<button class="btn btn-ghost btn-sm" onclick="toggleChatbot()" aria-label="Close chat" style="color:#fff;font-size:1.25rem;min-width:44px;min-height:44px;">โ</button>
|
| 10 |
+
</div>
|
| 11 |
+
|
| 12 |
+
<div class="chatbot-messages" id="chat-messages" aria-live="polite" aria-label="Chat messages">
|
| 13 |
+
<div class="chat-message bot">
|
| 14 |
+
๐ Hi! I'm your venue assistant. I can help you find food, restrooms, check crowd levels, and navigate the venue. What do you need?
|
| 15 |
+
</div>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<div class="chat-suggestions" id="chat-suggestions">
|
| 19 |
+
<!-- Populated by JS -->
|
| 20 |
+
</div>
|
| 21 |
+
|
| 22 |
+
<div class="chatbot-input">
|
| 23 |
+
<label for="chat-input" class="sr-only">Type your message</label>
|
| 24 |
+
<input type="text" id="chat-input" placeholder="Ask me anything..."
|
| 25 |
+
aria-label="Type your message" autocomplete="off" maxlength="500">
|
| 26 |
+
<button id="chat-send" onclick="sendChatMessage()" aria-label="Send message">โค</button>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
templates/components/nav.html
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- Reusable navigation component -->
|
| 2 |
+
<!-- Attendee Navigation -->
|
| 3 |
+
{% macro attendee_nav(active='home') %}
|
| 4 |
+
<nav class="navbar" role="navigation" aria-label="Main navigation">
|
| 5 |
+
<a href="{{ url_for('attendee.home') }}" class="navbar-brand">
|
| 6 |
+
<span class="logo-icon">๐๏ธ</span>
|
| 7 |
+
<span>VenueFlow</span>
|
| 8 |
+
</a>
|
| 9 |
+
|
| 10 |
+
<ul class="navbar-nav" role="menubar">
|
| 11 |
+
<li role="none"><a href="{{ url_for('attendee.home') }}" role="menuitem" class="{{ 'active' if active == 'home' }}" id="nav-home">๐ Home</a></li>
|
| 12 |
+
<li role="none"><a href="{{ url_for('attendee.heatmap') }}" role="menuitem" class="{{ 'active' if active == 'heatmap' }}" id="nav-heatmap">๐ฅ Heatmap</a></li>
|
| 13 |
+
<li role="none"><a href="{{ url_for('attendee.queues') }}" role="menuitem" class="{{ 'active' if active == 'queues' }}" id="nav-queues">โฑ๏ธ Queues</a></li>
|
| 14 |
+
<li role="none"><a href="{{ url_for('attendee.navigate') }}" role="menuitem" class="{{ 'active' if active == 'navigate' }}" id="nav-navigate">๐บ๏ธ Navigate</a></li>
|
| 15 |
+
<li role="none"><a href="{{ url_for('attendee.profile') }}" role="menuitem" class="{{ 'active' if active == 'profile' }}" id="nav-profile">โ๏ธ Settings</a></li>
|
| 16 |
+
</ul>
|
| 17 |
+
|
| 18 |
+
<div class="navbar-user">
|
| 19 |
+
<div id="notification-bell" class="btn btn-ghost" style="position:relative;font-size:1.25rem;cursor:pointer;" aria-label="Notifications" role="button" tabindex="0">
|
| 20 |
+
๐
|
| 21 |
+
<span id="notif-count" class="badge badge-high" style="position:absolute;top:2px;right:2px;font-size:10px;min-width:18px;height:18px;display:none;align-items:center;justify-content:center;">0</span>
|
| 22 |
+
</div>
|
| 23 |
+
<div class="navbar-avatar" aria-hidden="true">{{ user_name[0] | upper if user_name else 'G' }}</div>
|
| 24 |
+
<a href="{{ url_for('auth.logout') }}" class="btn btn-ghost btn-sm" id="logout-btn">Logout</a>
|
| 25 |
+
</div>
|
| 26 |
+
</nav>
|
| 27 |
+
|
| 28 |
+
<!-- Mobile Bottom Nav -->
|
| 29 |
+
<nav class="mobile-nav" role="navigation" aria-label="Mobile navigation">
|
| 30 |
+
<ul class="mobile-nav-items" role="menubar">
|
| 31 |
+
<li role="none"><a href="{{ url_for('attendee.home') }}" role="menuitem" class="{{ 'active' if active == 'home' }}"><span class="nav-icon">๐ </span>Home</a></li>
|
| 32 |
+
<li role="none"><a href="{{ url_for('attendee.heatmap') }}" role="menuitem" class="{{ 'active' if active == 'heatmap' }}"><span class="nav-icon">๐ฅ</span>Heatmap</a></li>
|
| 33 |
+
<li role="none"><a href="{{ url_for('attendee.queues') }}" role="menuitem" class="{{ 'active' if active == 'queues' }}"><span class="nav-icon">โฑ๏ธ</span>Queues</a></li>
|
| 34 |
+
<li role="none"><a href="{{ url_for('attendee.navigate') }}" role="menuitem" class="{{ 'active' if active == 'navigate' }}"><span class="nav-icon">๐บ๏ธ</span>Navigate</a></li>
|
| 35 |
+
<li role="none"><a href="{{ url_for('attendee.profile') }}" role="menuitem" class="{{ 'active' if active == 'profile' }}"><span class="nav-icon">โ๏ธ</span>Settings</a></li>
|
| 36 |
+
</ul>
|
| 37 |
+
</nav>
|
| 38 |
+
{% endmacro %}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
<!-- Operator Navigation -->
|
| 42 |
+
{% macro operator_nav(active='dashboard') %}
|
| 43 |
+
<nav class="navbar" role="navigation" aria-label="Operator navigation">
|
| 44 |
+
<a href="{{ url_for('operator.dashboard') }}" class="navbar-brand">
|
| 45 |
+
<span class="logo-icon">๐๏ธ</span>
|
| 46 |
+
<span>VenueFlow <span style="font-size:var(--font-size-xs);color:var(--accent-secondary);font-weight:500;">OPS</span></span>
|
| 47 |
+
</a>
|
| 48 |
+
|
| 49 |
+
<ul class="navbar-nav" role="menubar">
|
| 50 |
+
<li role="none"><a href="{{ url_for('operator.dashboard') }}" role="menuitem" class="{{ 'active' if active == 'dashboard' }}" id="nav-dashboard">๐ Dashboard</a></li>
|
| 51 |
+
<li role="none"><a href="{{ url_for('operator.analytics') }}" role="menuitem" class="{{ 'active' if active == 'analytics' }}" id="nav-analytics">๐ Analytics</a></li>
|
| 52 |
+
<li role="none"><a href="{{ url_for('operator.alerts') }}" role="menuitem" class="{{ 'active' if active == 'alerts' }}" id="nav-alerts">๐จ Alerts</a></li>
|
| 53 |
+
<li role="none"><a href="{{ url_for('operator.simulator_view') }}" role="menuitem" class="{{ 'active' if active == 'simulator' }}" id="nav-simulator">๐ฎ Simulator</a></li>
|
| 54 |
+
<li role="none"><a href="{{ url_for('attendee.home') }}" role="menuitem" id="nav-fan-view">๐ค Fan View</a></li>
|
| 55 |
+
</ul>
|
| 56 |
+
|
| 57 |
+
<div class="navbar-user">
|
| 58 |
+
<div id="notification-bell" class="btn btn-ghost" style="position:relative;font-size:1.25rem;cursor:pointer;" aria-label="Notifications" role="button" tabindex="0">
|
| 59 |
+
๐
|
| 60 |
+
<span id="notif-count" class="badge badge-high" style="position:absolute;top:2px;right:2px;font-size:10px;min-width:18px;height:18px;display:none;align-items:center;justify-content:center;">0</span>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="navbar-avatar" aria-hidden="true" style="background: var(--gradient-warm);">{{ user_name[0] | upper if user_name else 'O' }}</div>
|
| 63 |
+
<a href="{{ url_for('auth.logout') }}" class="btn btn-ghost btn-sm" id="logout-btn">Logout</a>
|
| 64 |
+
</div>
|
| 65 |
+
</nav>
|
| 66 |
+
{% endmacro %}
|
templates/errors/404.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Page Not Found{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
<div class="auth-page">
|
| 5 |
+
<div class="auth-card text-center">
|
| 6 |
+
<div style="font-size:4rem;margin-bottom:var(--space-4);">๐๏ธ</div>
|
| 7 |
+
<h1 style="font-size:var(--font-size-4xl);">404</h1>
|
| 8 |
+
<p style="margin-top:var(--space-4);margin-bottom:var(--space-6);">Looks like you've wandered off the pitch! This page doesn't exist.</p>
|
| 9 |
+
<a href="/" class="btn btn-primary">Return to Home</a>
|
| 10 |
+
</div>
|
| 11 |
+
</div>
|
| 12 |
+
{% endblock %}
|
templates/errors/500.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% block title %}Server Error{% endblock %}
|
| 3 |
+
{% block content %}
|
| 4 |
+
<div class="auth-page">
|
| 5 |
+
<div class="auth-card text-center">
|
| 6 |
+
<div style="font-size:4rem;margin-bottom:var(--space-4);">โ ๏ธ</div>
|
| 7 |
+
<h1 style="font-size:var(--font-size-4xl);">500</h1>
|
| 8 |
+
<p style="margin-top:var(--space-4);margin-bottom:var(--space-6);">Something went wrong on our end. Our team has been notified.</p>
|
| 9 |
+
<a href="/" class="btn btn-primary">Return to Home</a>
|
| 10 |
+
</div>
|
| 11 |
+
</div>
|
| 12 |
+
{% endblock %}
|
templates/operator/alerts.html
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% from "components/nav.html" import operator_nav %}
|
| 3 |
+
{% block title %}Alerts{% endblock %}
|
| 4 |
+
{% block navbar %}{{ operator_nav('alerts') }}{% endblock %}
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="main-content">
|
| 7 |
+
<div class="card-header mb-6">
|
| 8 |
+
<div>
|
| 9 |
+
<h2>๐จ Alert Management</h2>
|
| 10 |
+
<p class="card-subtitle">Review and manage venue alerts</p>
|
| 11 |
+
</div>
|
| 12 |
+
<span class="badge badge-info">{{ alerts | length }} alerts</span>
|
| 13 |
+
</div>
|
| 14 |
+
{% if alerts %}
|
| 15 |
+
<div class="zone-list" id="alerts-list">
|
| 16 |
+
{% for alert in alerts %}
|
| 17 |
+
<div class="zone-item" style="border-left: 3px solid {{ alert.severity_color }};">
|
| 18 |
+
<span style="font-size:1.25rem;">{{ alert.severity_icon }}</span>
|
| 19 |
+
<div class="zone-info" style="flex:1;">
|
| 20 |
+
<div class="zone-name">{{ alert.title }}</div>
|
| 21 |
+
<div class="zone-type">{{ alert.message }}</div>
|
| 22 |
+
<div style="font-size:var(--font-size-xs);color:var(--text-muted);margin-top:2px;">{{ alert.timestamp }}</div>
|
| 23 |
+
</div>
|
| 24 |
+
<span class="badge badge-{{ alert.severity }}">{{ alert.severity | title }}</span>
|
| 25 |
+
</div>
|
| 26 |
+
{% endfor %}
|
| 27 |
+
</div>
|
| 28 |
+
{% else %}
|
| 29 |
+
<div class="card text-center" style="padding:var(--space-12);">
|
| 30 |
+
<p style="font-size:2rem;margin-bottom:var(--space-4);">โ
</p>
|
| 31 |
+
<p style="font-size:var(--font-size-lg);font-weight:600;">No alerts right now</p>
|
| 32 |
+
<p style="color:var(--text-tertiary);">Alerts will appear here when crowd thresholds are breached.</p>
|
| 33 |
+
</div>
|
| 34 |
+
{% endif %}
|
| 35 |
+
</div>
|
| 36 |
+
{% endblock %}
|
| 37 |
+
{% block extra_js %}
|
| 38 |
+
<script src="{{ url_for('static', filename='js/sse-client.js') }}"></script>
|
| 39 |
+
<script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
|
| 40 |
+
{% endblock %}
|