diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..7fd9e1096 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Node +node_modules/ +.vite/ + +# Python +__pycache__/ +*.pyc +*.pyo + + +# Python virtual environments +venv/ +venv*/ +*/venv*/ +*/venv310/ + +# Env +.env + +# Generated data/outputs +backend/data/ +backend/analytics/ +backend/uploads/ +backend/heatmaps/ + +# OS junk +*.log +*.DS_Store +Thumbs.db + +# Generated analytics +tracking_code/analytics/ diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/README.md b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/README.md index e69de29bb..33ac6ccd5 100644 --- a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/README.md +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/README.md @@ -0,0 +1,107 @@ +πŸ“„ README.md (Backend Service) +# ⚑ Backend Service (FastAPI + PostgreSQL) + +This backend service manages: +- Video uploads +- Player tracking inference (calls Player Tracking microservice) +- Crowd monitoring inference (calls external API provided by Son Tung Bui) +- Authentication (JWT) +- Analytics storage in PostgreSQL + +--- + +## βš™οΈ Requirements + +- **Python 3.10** (recommended) +- **PostgreSQL 15** (via Docker or local installation) + +--- + +## πŸ“¦ Setup Instructions + +### 1. Clone the repository +```bash +git clone https://github.com/.git +cd afl-redback-orion/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend + +2. Create a virtual environment +py -3.10 -m venv venv +.\venv\Scripts\activate # (Windows) +# or +source venv/bin/activate # (Linux/Mac) + +3. Upgrade pip +python -m pip install --upgrade pip + +4. Install dependencies +pip install -r requirements.txt + +πŸ—„οΈ Run PostgreSQL in Docker + +Run PostgreSQL locally in Docker: + +docker run --name afl-postgres ^ + -e POSTGRES_USER=postgres ^ + -e POSTGRES_PASSWORD=postgres ^ + -e POSTGRES_DB=aflvision ^ + -p 5432:5432 ^ + -d postgres:15 + +βš™οΈ Environment Variables + +Create a .env file inside the backend/ folder: + +# Database connection +DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:5432/aflvision + +# JWT secret key +JWT_SECRET=your-secret-key + +🌐 Configure Crowd Monitoring API + +Open: + +backend/routes/crowd.py + + +Update this line with the API URL provided by Son Tung Bui: + +CROWD_API_URL = "https:///analyze_frame/" + +πŸš€ Run the Backend + +Start the FastAPI server: + +uvicorn main:app --reload --port 8000 + + +Backend will run at: + +http://127.0.0.1:8000 + + +Swagger docs available at: + +http://127.0.0.1:8000/docs + +πŸ§ͺ Example Workflow + +Upload a video via backend β†’ stored in uploaded_videos/. + +Backend calls: + +Player Tracking Service (http://127.0.0.1:8001) + +Crowd Monitoring API (Son Tung Bui’s endpoint). + +PostgreSQL stores analytics + user data. + +Results available via API endpoints. + +πŸ› οΈ Notes + +Ensure PostgreSQL is running before starting backend. + +Update CROWD_API_URL whenever Son Tung Bui provides a new endpoint (e.g., ngrok link). + +Works best when combined with Player Tracking Service. \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/TRACKING_README.md b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/TRACKING_README.md new file mode 100644 index 000000000..ac3f03ecb --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/TRACKING_README.md @@ -0,0 +1,187 @@ +# Player Tracking Analysis System + +This system implements the player tracking logic from the Week 5-6 notebook, providing a comprehensive backend API for analyzing player movement data, generating heatmaps, and computing player statistics. + +## Features + +### 🎯 **Core Functionality** +- **CSV Upload & Processing**: Handle tracking data in the same format as the notebook +- **Player Statistics**: Calculate distance, speed, participation, and intensity metrics +- **Heatmap Generation**: Create movement heatmaps for individual players +- **Movement Path Analysis**: Extract detailed movement trajectories +- **Real-time Processing**: Fast analysis with configurable parameters + +### πŸ“Š **Player Metrics Computed** +- **Frame Count**: Total frames each player appears in +- **Total Distance**: Cumulative movement in pixels +- **Average Speed**: Mean movement speed (pixels/second) +- **Max Speed**: Peak movement speed +- **Participation Score**: Time presence ratio +- **Intensity Score**: Average confidence level +- **Confidence Statistics**: Min, max, and average confidence + +### πŸ—ΊοΈ **Heatmap Generation** +- **Field Scaling**: Automatically scales to AFL oval dimensions (165m x 135m) +- **Configurable Grid**: Adjustable resolution (default: 200x150 cells) +- **Gaussian Smoothing**: Configurable sigma for visual appeal +- **Confidence Weighting**: Heatmap intensity based on detection confidence + +## API Endpoints + +### πŸ“€ **Upload & Processing** +``` +POST /api/v1/tracking/upload-csv +``` +Upload a tracking CSV file and get basic dataset information. + +### πŸ“ˆ **Player Statistics** +``` +POST /api/v1/tracking/player-stats +``` +Upload CSV file to get comprehensive statistics for all players in the dataset. + +### πŸ—ΊοΈ **Heatmap Generation** +``` +POST /api/v1/tracking/generate-heatmap +``` +Generate a heatmap for a specific player with configurable parameters. + +### πŸ–ΌοΈ **Heatmap Retrieval** +``` +GET /api/v1/tracking/heatmap/{filename} +``` +Retrieve a generated heatmap image. + +### πŸƒ **Movement Analysis** +``` +POST /api/v1/tracking/player-movement/{player_id} +``` +Upload CSV file to get detailed movement path data for a specific player. + +### πŸ“‹ **Player Information** +``` +POST /api/v1/tracking/available-players +``` +Upload CSV file to get list of all available player IDs in the dataset. + +### 🧹 **Cleanup** +``` +DELETE /api/v1/tracking/cleanup-heatmaps +``` +Remove all generated heatmap files. + +## CSV Format Requirements + +The system expects CSV files with the following columns: + +| Column | Type | Description | +|--------|------|-------------| +| `frame_id` | int | Frame number from video | +| `player_id` | int | Unique player identifier | +| `timestamp_s` | float | Timestamp in seconds | +| `x1, y1` | int | Top-left bounding box coordinates | +| `x2, y2` | int | Bottom-right bounding box coordinates | +| `cx, cy` | int | Center coordinates (auto-calculated if missing) | +| `w, h` | int | Bounding box width and height | +| `confidence` | float | Detection confidence (0.0-1.0) | + +## Usage Examples + +### 1. **Upload and Process CSV** +```bash +curl -X POST "http://localhost:8000/api/v1/tracking/upload-csv" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@tracking.csv" +``` + +### 2. **Get Player Statistics** +```bash +curl -X POST "http://localhost:8000/api/v1/tracking/player-stats" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@tracking.csv" +``` + +### 3. **Generate Player Heatmap** +```bash +curl -X POST "http://localhost:8000/api/v1/tracking/generate-heatmap" \ + -H "Content-Type: multipart/form-data" \ + -F "player_id=1" \ + -F "file=@tracking.csv" \ + -F "field_length=165" \ + -F "field_width=135" \ + -F "nx=200" \ + -F "ny=150" \ + -F "sigma=2.0" +``` + +### 4. **Get Movement Path** +```bash +curl -X POST "http://localhost:8000/api/v1/tracking/player-movement/1" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@tracking.csv" +``` + +## Configuration Options + +### **Heatmap Parameters** +- `field_length`: Field length in meters (default: 165) +- `field_width`: Field width in meters (default: 135) +- `nx`: Grid resolution in X direction (default: 200) +- `ny`: Grid resolution in Y direction (default: 150) +- `sigma`: Gaussian smoothing parameter (default: 2.0) + +### **Performance Tuning** +- **Grid Resolution**: Higher values (400x300) for detailed analysis, lower (100x75) for faster processing +- **Smoothing**: Higher sigma (3.0-5.0) for smoother visuals, lower (1.0-2.0) for sharper details + +## Integration with Frontend + +The system is designed to work seamlessly with React frontends: + +1. **Upload Interface**: Drag & drop CSV files +2. **Player Selection**: Dropdown with available player IDs +3. **Real-time Analysis**: Instant statistics and heatmap generation +4. **Visual Dashboard**: Display heatmaps, movement paths, and metrics + +## Error Handling + +The system includes comprehensive error handling for: +- **Invalid CSV Format**: Missing columns or wrong data types +- **File Processing Errors**: Corrupted or unreadable files +- **Player Not Found**: Invalid player ID requests +- **Memory Issues**: Large dataset handling + +## Performance Considerations + +- **Large Datasets**: Optimized for datasets with 1000+ frames +- **Memory Usage**: Efficient pandas operations with minimal memory overhead +- **Heatmap Generation**: Fast processing with matplotlib optimization +- **File Storage**: Automatic cleanup of generated heatmaps + +## Dependencies + +- **pandas**: Data processing and analysis +- **numpy**: Numerical computations +- **matplotlib**: Heatmap visualization +- **scipy**: Gaussian filtering +- **PIL**: Image processing +- **fastapi**: Web framework + +## Testing + +Use the included `sample_tracking.csv` file to test the system: + +```bash +# Test with sample data +curl -X POST "http://localhost:8000/api/v1/tracking/upload-csv" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@sample_tracking.csv" +``` + +## Future Enhancements + +- **Batch Processing**: Handle multiple CSV files simultaneously +- **Advanced Analytics**: Acceleration, direction changes, player interactions +- **Real-time Streaming**: Process live video feeds +- **Machine Learning**: Predictive movement patterns +- **Export Formats**: JSON, CSV, and image exports diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/__init__.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/analytics/crowd_8703ba69-e28a-4c3f-9e45-81ba616dec15.json b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/analytics/crowd_8703ba69-e28a-4c3f-9e45-81ba616dec15.json new file mode 100644 index 000000000..78d94afc0 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/analytics/crowd_8703ba69-e28a-4c3f-9e45-81ba616dec15.json @@ -0,0 +1,57 @@ +[ + { + "frame": 0, + "count": 0, + "heatmap": "crowd_heatmaps\\8703ba69-e28a-4c3f-9e45-81ba616dec15_heatmap_0.png" + }, + { + "frame": 30, + "count": 0, + "heatmap": "crowd_heatmaps\\8703ba69-e28a-4c3f-9e45-81ba616dec15_heatmap_30.png" + }, + { + "frame": 60, + "count": 0, + "heatmap": "crowd_heatmaps\\8703ba69-e28a-4c3f-9e45-81ba616dec15_heatmap_60.png" + }, + { + "frame": 90, + "count": 0, + "heatmap": "crowd_heatmaps\\8703ba69-e28a-4c3f-9e45-81ba616dec15_heatmap_90.png" + }, + { + "frame": 120, + "count": 0, + "heatmap": "crowd_heatmaps\\8703ba69-e28a-4c3f-9e45-81ba616dec15_heatmap_120.png" + }, + { + "frame": 150, + "count": 0, + "heatmap": "crowd_heatmaps\\8703ba69-e28a-4c3f-9e45-81ba616dec15_heatmap_150.png" + }, + { + "frame": 180, + "count": 0, + "heatmap": "crowd_heatmaps\\8703ba69-e28a-4c3f-9e45-81ba616dec15_heatmap_180.png" + }, + { + "frame": 210, + "count": 0, + "heatmap": "crowd_heatmaps\\8703ba69-e28a-4c3f-9e45-81ba616dec15_heatmap_210.png" + }, + { + "frame": 240, + "count": 0, + "heatmap": "crowd_heatmaps\\8703ba69-e28a-4c3f-9e45-81ba616dec15_heatmap_240.png" + }, + { + "frame": 270, + "count": 0, + "heatmap": "crowd_heatmaps\\8703ba69-e28a-4c3f-9e45-81ba616dec15_heatmap_270.png" + }, + { + "frame": 300, + "count": 0, + "heatmap": "crowd_heatmaps\\8703ba69-e28a-4c3f-9e45-81ba616dec15_heatmap_300.png" + } +] \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/app/__pycache__/main.cpython-313.pyc b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/app/__pycache__/main.cpython-313.pyc deleted file mode 100644 index 8e410b6b9..000000000 Binary files a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/app/__pycache__/main.cpython-313.pyc and /dev/null differ diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/app/main.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/app/main.py deleted file mode 100644 index 75caf875b..000000000 --- a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/app/main.py +++ /dev/null @@ -1,7 +0,0 @@ -from fastapi import FastAPI - -app = FastAPI() - -@app.get("/") -def read_root(): - return {"message": "Backend is running!"} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/config/cors.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/config/cors.py new file mode 100644 index 000000000..99946263d --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/config/cors.py @@ -0,0 +1,24 @@ +# backend/config/cors.py +from fastapi.middleware.cors import CORSMiddleware + +def add_cors(app, extra_origins=None): + """ + Attach CORS middleware to the FastAPI app. + Allows React dev server + optional extra origins. + """ + default_origins = [ + "http://localhost:8080", # Vite dev server + "http://127.0.0.1:8080", # sometimes Vite uses this + ] + + # Add extra origins + if extra_origins: + default_origins.extend(extra_origins) + + app.add_middleware( + CORSMiddleware, + allow_origins=default_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/data/app.db b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/data/app.db new file mode 100644 index 000000000..b1f7c2c77 Binary files /dev/null and b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/data/app.db differ diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/main.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/main.py new file mode 100644 index 000000000..53244f68c --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/main.py @@ -0,0 +1,73 @@ +# backend/main.py +from dotenv import load_dotenv +load_dotenv() + +from fastapi import FastAPI, Request +from routes import upload, inference, metrics, tracking_analysis_simple, analysis, auth, crowd,inference +from config.cors import add_cors +from storage import init_db # βœ… switched to PostgreSQL storage +from fastapi.staticfiles import StaticFiles +from pathlib import Path + +# ----------------------------- +# App Initialization +# ----------------------------- +app = FastAPI( + title="AFL Vision Backend", + version="1.0.0", + description="Backend API for AFL Vision Insight project " + "(Upload, Inference, Metrics)." +) + +# ----------------------------- +# Startup Event (DB init, etc.) +# ----------------------------- +@app.on_event("startup") +async def startup_event(): + init_db() # βœ… now creates tables in Postgres instead of memory + +# ----------------------------- +# CORS Middleware +# ----------------------------- +add_cors(app) + +# ----------------------------- +# Global Middleware +# ----------------------------- +@app.middleware("http") +async def api_version_header(request: Request, call_next): + """Attach API version header to every response.""" + response = await call_next(request) + response.headers["X-API-Version"] = "1" + return response + +# ----------------------------- +# Healthcheck Root +# ----------------------------- +@app.get("/", tags=["Health"]) +def read_root(): + return { + "status": "success", + "message": "AFL Vision Backend Running", + "version": "1.0.0" + } + +# ----------------------------- +# Routers +# ----------------------------- +app.include_router(upload.router, prefix="/api/v1/uploads", tags=["Uploads"]) +app.include_router(inference.router, prefix="/api/v1/inference", tags=["Inference"]) +app.include_router(analysis.router, prefix="/api/v1/analysis", tags=["Analysis"]) +app.include_router(metrics.router, prefix="/api/v1/metrics", tags=["Metrics"]) +app.include_router(tracking_analysis_simple.router, prefix="/api/v1/tracking", tags=["Player Tracking"]) +app.include_router(auth.router) +app.include_router(crowd.router, prefix="/api/v1") +app.include_router(inference.router, prefix="/api/v1/inference") + + +# ----------------------------- +# Static files +# ----------------------------- + +STATIC_DIR = Path(__file__).resolve().parent / "static" +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/package-lock.json b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/package-lock.json new file mode 100644 index 000000000..b3cfac427 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "httpx": "^3.0.1" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/httpx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/httpx/-/httpx-3.0.1.tgz", + "integrity": "sha512-ILTmufYaGAH2Def/wQ++6mz41xDghoRFd3b49uSkVMIRUKm2nHTvPPaVGtNN5hoTu9E3bJ8Jg+8TRkx+PiN6sg==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + } + } +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/package.json b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/package.json new file mode 100644 index 000000000..617ad1551 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "httpx": "^3.0.1" + } +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/requirements.txt b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/requirements.txt index c75698ab3..bd0cd9d1d 100644 --- a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/requirements.txt +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/requirements.txt @@ -1,3 +1,35 @@ -fastapi -uvicorn -opencv-python +# ================================ +# Backend Service +# FastAPI + PostgreSQL + Analytics +# Python 3.10 recommended +# ================================ + +# Web framework +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +requests==2.32.5 +httpx==0.25.2 +python-multipart==0.0.6 + +# Data validation +pydantic==2.11.7 + +# Database +SQLAlchemy==2.0.43 +psycopg2-binary==2.9.10 +alembic==1.13.2 + +# Env + Auth +python-dotenv==1.1.1 +passlib[bcrypt]==1.7.4 +python-jose==3.5.0 +rsa==4.9.1 +ecdsa==0.19.1 +cryptography==45.0.7 + +# Analytics / Data Science +numpy==1.26.4 +pandas==2.2.2 +matplotlib==3.9.0 +scipy==1.15.1 +opencv-python==4.10.0.84 diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/__init__.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/analysis.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/analysis.py new file mode 100644 index 000000000..3a34e6e7d --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/analysis.py @@ -0,0 +1,204 @@ +from fastapi import APIRouter, HTTPException, Depends +from storage import ( + get_player_analysis, + get_crowd_analysis, + _db, + PlayerAnalysis, + get_upload +) +from routes.auth import get_current_user +import uuid + +router = APIRouter(tags=["Analysis"]) + +# ------------------------------- +# Player Analysis - all players +# ------------------------------- +@router.get("/players/{upload_id}", summary="Get all player analysis for a video") +def fetch_all_players(upload_id: str, user_id: int = Depends(get_current_user)): + rec = get_upload(upload_id) + if not rec or rec["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Not authorized or upload not found") + + with _db() as db: + rows = db.query(PlayerAnalysis).filter( + PlayerAnalysis.upload_id == uuid.UUID(upload_id) + ).all() + + if not rows: + raise HTTPException(status_code=404, detail="No players found for this upload") + + return [ + { + "player_id": row.player_id, + "heatmap_url": row.heatmap_path, + "distance_m": row.stats.get("distance_m", 0.0), + "avg_speed_kmh": row.stats.get("average_speed_kmh", 0.0), + "max_speed_kmh": row.stats.get("max_speed_kmh", 0.0), + "created_at": row.created_at.isoformat() + } + for row in rows + ] + + +# ------------------------------- +# Player Analysis - one player +# ------------------------------- +@router.get("/player/{upload_id}/{player_id}", summary="Get detailed analysis for one player") +def fetch_player(upload_id: str, player_id: int, user_id: int = Depends(get_current_user)): + rec_upload = get_upload(upload_id) + if not rec_upload or rec_upload["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Not authorized or upload not found") + + rec = get_player_analysis(upload_id, player_id) + if not rec: + raise HTTPException(status_code=404, detail="No analysis found for this player") + + return { + "player_id": rec["player_id"], + "heatmap_url": rec["heatmap_path"], + "team_heatmap_url": rec.get("team_heatmap_path"), + "zone_heatmaps": { + "back_50": rec.get("zone_back_50_path"), + "midfield": rec.get("zone_midfield_path"), + "forward_50": rec.get("zone_forward_50_path"), + }, + "stats": rec.get("stats", {}), + "created_at": rec["created_at"] + } + + +# ------------------------------- +# Team Heatmaps +# ------------------------------- +@router.get("/team/{upload_id}/heatmap", summary="Get team + zone heatmaps for a video") +def fetch_team_heatmap(upload_id: str, user_id: int = Depends(get_current_user)): + rec_upload = get_upload(upload_id) + if not rec_upload or rec_upload["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Not authorized or upload not found") + + with _db() as db: + row = db.query(PlayerAnalysis).filter( + PlayerAnalysis.upload_id == uuid.UUID(upload_id) + ).first() + + if not row or not row.team_heatmap_path: + raise HTTPException(status_code=404, detail="No team heatmap found for this upload") + + return { + "upload_id": str(row.upload_id), + "team_heatmap_url": row.team_heatmap_path, + "zones": { + "back_50": row.zone_back_50_path, + "midfield": row.zone_midfield_path, + "forward_50": row.zone_forward_50_path + }, + "created_at": row.created_at.isoformat() + } + + +# ------------------------------- +# βœ… New: Combined Player Dashboard +# ------------------------------- +@router.get("/player-dashboard/{upload_id}", summary="Get team heatmaps + player stats for dashboard") +def fetch_player_dashboard(upload_id: str, user_id: int = Depends(get_current_user)): + rec_upload = get_upload(upload_id) + if not rec_upload or rec_upload["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Not authorized or upload not found") + + with _db() as db: + rows = db.query(PlayerAnalysis).filter( + PlayerAnalysis.upload_id == uuid.UUID(upload_id) + ).all() + + if not rows: + raise HTTPException(status_code=404, detail="No player analysis found for this upload") + + # Collect team-level heatmaps (take from first record that has them) + team_data = None + for row in rows: + if row.team_heatmap_path: + team_data = { + "team_heatmap_url": row.team_heatmap_path, + "zones": { + "back_50": row.zone_back_50_path, + "midfield": row.zone_midfield_path, + "forward_50": row.zone_forward_50_path, + }, + } + break + + # Collect per-player stats + players = [ + { + "player_id": row.player_id, + "heatmap_url": row.heatmap_path, + "distance_m": row.stats.get("distance_m", 0.0), + "avg_speed_kmh": row.stats.get("average_speed_kmh", 0.0), + "max_speed_kmh": row.stats.get("max_speed_kmh", 0.0), + } + for row in rows + ] + + return { + "upload_id": upload_id, + "team": team_data, + "players": players + } + + +# ------------------------------- +# Crowd Analysis +# ------------------------------- +@router.get("/crowd/{upload_id}", summary="Get crowd analysis results") +def fetch_crowd(upload_id: str, user_id: int = Depends(get_current_user)): + rec_upload = get_upload(upload_id) + if not rec_upload or rec_upload["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Not authorized or upload not found") + + rows = get_crowd_analysis(upload_id) # returns list[dict] with heatmap_image_path + + if not rows: + return { + "status": "no-heatmaps", + "message": "No people detected in this video. No crowd analysis results available.", + "upload_id": upload_id, + "results": [], + "time_series": [] + } + + # Compute summary stats + counts = [row["people_count"] for row in rows if row["people_count"] is not None] + avg_count = sum(counts) / len(counts) if counts else 0 + max_count = max(counts) if counts else 0 + min_count = min(counts) if counts else 0 + + # Build time-series for charts + time_series = [ + { + "frame_number": row["frame_number"], + "people_count": row["people_count"] + } + for row in rows + ] + + # Build results with heatmap URLs + results = [ + { + "frame_number": row["frame_number"], + "people_count": row["people_count"], + "heatmap_url": row["heatmap_image_path"] # βœ… add URL here + } + for row in rows + ] + + return { + "status": "success", + "upload_id": upload_id, + "frames_detected": len(rows), + "avg_count": avg_count, + "peak_count": max_count, + "min_count": min_count, + "results": results, # now includes heatmap_url + "time_series": time_series # chart data + } diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/auth.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/auth.py new file mode 100644 index 000000000..d0aecbb34 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/auth.py @@ -0,0 +1,92 @@ +from fastapi import APIRouter, HTTPException, Depends, status +from pydantic import BaseModel +from passlib.context import CryptContext +from jose import JWTError, jwt +from datetime import datetime, timedelta +from storage import _db, User +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm + +# ----------------------------- +# Config +# ----------------------------- +SECRET_KEY = "super-secret-key" # ⚠️ later move to .env +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +router = APIRouter(prefix="/api/v1/auth", tags=["Auth"]) + +# ----------------------------- +# Schemas +# ----------------------------- +class UserCreate(BaseModel): + email: str + password: str + +class Token(BaseModel): + access_token: str + token_type: str + +# ----------------------------- +# Utils +# ----------------------------- +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def hash_password(password: str): + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: timedelta | None = None): + to_encode = data.copy() + expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + +def get_current_user(token: str = Depends(oauth2_scheme)) -> int: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id = payload.get("sub") + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid token") + return int(user_id) # βœ… cast to int + except JWTError: + raise HTTPException(status_code=401, detail="Invalid token") + except ValueError: + raise HTTPException(status_code=401, detail="Invalid token format") + + +# ----------------------------- +# Routes +# ----------------------------- +@router.post("/register", response_model=Token) +def register(user: UserCreate): + with _db() as db: + existing = db.query(User).filter(User.email == user.email).first() + if existing: + raise HTTPException(status_code=400, detail="Email already registered") + + new_user = User(email=user.email, hashed_password=hash_password(user.password)) + db.add(new_user) + db.commit() + db.refresh(new_user) + + token = create_access_token({"sub": str(new_user.id)}) + return {"access_token": token, "token_type": "bearer"} + + +@router.post("/login", response_model=Token) +def login(form_data: OAuth2PasswordRequestForm = Depends()): + """ + Swagger popup will send `username` and `password` as form-data. + We treat `username` as the email field in DB. + """ + with _db() as db: + db_user = db.query(User).filter(User.email == form_data.username).first() + if not db_user or not verify_password(form_data.password, db_user.hashed_password): + raise HTTPException(status_code=401, detail="Invalid credentials") + + token = create_access_token({"sub": str(db_user.id)}) + return {"access_token": token, "token_type": "bearer"} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/crowd.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/crowd.py new file mode 100644 index 000000000..ccca3c46d --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/crowd.py @@ -0,0 +1,162 @@ +from fastapi import APIRouter, HTTPException, Depends +from pathlib import Path +import cv2, requests, shutil + +from routes.auth import get_current_user +from storage import get_upload, save_crowd_analysis, get_crowd_analysis,save_inference,get_inferences + +router = APIRouter(tags=["Crowd Inference"]) + +CROWD_API_URL = "https://5c18a59a4255.ngrok-free.app" + +# ------------------------------- +# Helpers & directories +# ------------------------------- +def _resolve_upload_abs_path(rec: dict) -> Path: + """Resolve upload record relative path into absolute backend path.""" + backend_dir = Path(__file__).resolve().parents[1] # backend/ + return (backend_dir / str(rec["path"]).lstrip("/\\")).resolve() + +STATIC_DIR = Path("static") +CROWD_ROOT = STATIC_DIR / "crowd" +CROWD_ROOT.mkdir(parents=True, exist_ok=True) + +def _make_static_url(path: Path) -> str: + """Convert a saved file path into a static URL.""" + return f"http://127.0.0.1:8000/static/{path.relative_to(STATIC_DIR).as_posix()}" + + +# ------------------------------- +# Crowd Inference +# ------------------------------- +@router.post("/inference/crowd/{upload_id}", summary="Run crowd analysis on a video") +def run_crowd_analysis( + upload_id: str, + user_id: int = Depends(get_current_user), +): + # πŸ”Ή Validate upload + upload = get_upload(upload_id) + if not upload or upload["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Not authorized or upload not found") + + abs_path = _resolve_upload_abs_path(upload) + if not abs_path.exists(): + raise HTTPException(status_code=404, detail="Video file not found") + + # πŸ”Ή Mark job as "Analyzing..." + inference = save_inference( + upload_id=upload_id, + user_id=user_id, + task="crowd", + status="Analyzing...", + payload={} + ) + + # πŸ”Ή Prepare directories + upload_dir = CROWD_ROOT / upload_id + frames_dir = upload_dir / "frames" + heatmaps_dir = upload_dir / "heatmaps" + frames_dir.mkdir(parents=True, exist_ok=True) + heatmaps_dir.mkdir(parents=True, exist_ok=True) + + cap = cv2.VideoCapture(str(abs_path)) + frame_num = 0 + frames_detected = 0 + frames_analyzed = 0 + + try: + while True: + ret, frame = cap.read() + if not ret: + break + + if frame_num % 30 == 0: # sample every 30th frame + frames_analyzed += 1 + frame_path = frames_dir / f"frame_{frame_num}.jpg" + cv2.imwrite(str(frame_path), frame) + + with open(frame_path, "rb") as f: + files = {"file": (frame_path.name, f, "image/jpeg")} + resp = requests.post(CROWD_API_URL, files=files) + + if resp.status_code == 200: + heatmap_path = heatmaps_dir / f"heatmap_{frame_num}.png" + with open(heatmap_path, "wb") as out: + out.write(resp.content) + + count = int(resp.headers.get("People-Count", 0)) + + if count > 0: + # πŸ”Ή Save per-frame detection + save_crowd_analysis( + upload_id=upload_id, + frame_number=frame_num, + people_count=count, + frame_image_path=_make_static_url(frame_path), + heatmap_image_path=_make_static_url(heatmap_path), + ) + frames_detected += 1 + else: + # cleanup unused files if no people detected + frame_path.unlink(missing_ok=True) + heatmap_path.unlink(missing_ok=True) + else: + print(f"❌ Crowd API failed for frame {frame_num}") + + frame_num += 1 + + cap.release() + + results = get_crowd_analysis(upload_id) + + if frames_detected == 0: + shutil.rmtree(upload_dir, ignore_errors=True) + inference = save_inference( + upload_id=upload_id, + user_id=user_id, + task="crowd", + status="Failed", + payload={ + "frames_analyzed": frames_analyzed, + "frames_detected": 0, + "message": "No people detected" + } + ) + return { + "status": "no-heatmaps", + "message": "No people detected in this video. No heatmaps generated.", + "upload_id": upload_id, + "inference": inference, + "results": [] + } + + # βœ… Update inference β†’ Completed + inference = save_inference( + upload_id=upload_id, + user_id=user_id, + task="crowd", + status="Completed", + payload={ + "frames_analyzed": frames_analyzed, + "frames_detected": frames_detected + } + ) + + return { + "status": "success", + "upload_id": upload_id, + "inference": inference, + "frames_analyzed": frames_analyzed, + "frames_detected": frames_detected, + "results": results + } + + except Exception as e: + inference = save_inference( + upload_id=upload_id, + user_id=user_id, + task="crowd", + status="Failed", + payload={"error": str(e)} + ) + raise HTTPException(status_code=500, detail=f"crowd analysis failed: {str(e)}") diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/inference.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/inference.py new file mode 100644 index 000000000..e502c4534 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/inference.py @@ -0,0 +1,197 @@ +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel, Field +from typing import Dict, Any, Optional +from pathlib import Path +import os +import httpx +import json + +from storage import ( + get_upload, + save_player_analysis, + get_crowd_analysis, + save_crowd_analysis, + save_inference,get_inferences +) + + +from routes.auth import get_current_user # βœ… JWT auth + +router = APIRouter(tags=["Inference"]) + +# ------------------------------- +# Service configuration +# ------------------------------- +PLAYER_SVC_URL = os.getenv("PLAYER_SVC_URL", "http://127.0.0.1:8001") + +# ------------------------------- +# Request models +# ------------------------------- +class PlayerTrackRequest(BaseModel): + id: str = Field(..., description="Upload ID returned by /upload") + location: str = Field("unknown", description="Optional context like stadium/venue") + sampling_fps: Optional[int] = Field(5, ge=1, le=60) + conf_threshold: Optional[float] = Field(0.5, ge=0, le=1) + + + +# ------------------------------- +# Helpers +# ------------------------------- +def _resolve_upload_abs_path(rec: Dict[str, Any]) -> Path: + """Resolve upload record relative path into absolute backend path.""" + backend_dir = Path(__file__).resolve().parents[1] # backend/ + return (backend_dir / str(rec["path"]).lstrip("/\\")).resolve() + + +STATIC_DIR = Path("static") +HEATMAP_ROOT = STATIC_DIR / "heatmaps" +ANALYTICS_ROOT = STATIC_DIR / "analytics" +HEATMAP_ROOT.mkdir(parents=True, exist_ok=True) +ANALYTICS_ROOT.mkdir(parents=True, exist_ok=True) + + +async def _download_file(orig_url: str, out_path: Path) -> str: + """Download file from player service and save locally. Return static URL.""" + try: + async with httpx.AsyncClient(timeout=None) as client: + resp = await client.get(orig_url) + resp.raise_for_status() + except Exception as e: + raise HTTPException(status_code=500, detail=f"failed to fetch file: {e}") + + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(out_path, "wb") as f: + f.write(resp.content) + + return f"http://127.0.0.1:8000/static/{out_path.relative_to(STATIC_DIR).as_posix()}" + + +# ------------------------------- +# Player Tracking +# ------------------------------- +@router.post("/player/track", summary="Run player tracking and return stats") +async def run_player_track( + req: PlayerTrackRequest, + user_id: int = Depends(get_current_user) +): + rec = get_upload(req.id) + if not rec: + raise HTTPException(status_code=404, detail="upload id not found") + if rec["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Not authorized to access this upload") + + abs_path = _resolve_upload_abs_path(rec) + if not abs_path.exists(): + raise HTTPException(status_code=410, detail="file missing on disk") + + # βœ… Save job as "Analyzing..." + save_inference(req.id, user_id, "player", "Analyzing...", {}) + + try: + # πŸ”Ή Call player tracking microservice + async with httpx.AsyncClient(timeout=None) as client: + with abs_path.open("rb") as f: + files = {"file": (abs_path.name, f, "video/mp4")} + resp = await client.post(f"{PLAYER_SVC_URL}/track", files=files) + + if resp.status_code != 200: + save_inference(req.id, user_id, "player", "Failed", {"error": resp.text}) + raise HTTPException(status_code=502, detail=f"player service error: {resp.text}") + + tracking_result = resp.json() + + # ------------------------------- + # Save analytics + heatmaps locally + # ------------------------------- + analytics_dir = ANALYTICS_ROOT / req.id + heatmap_dir = HEATMAP_ROOT / req.id + analytics_dir.mkdir(parents=True, exist_ok=True) + (heatmap_dir / "players").mkdir(parents=True, exist_ok=True) + (heatmap_dir / "zones").mkdir(parents=True, exist_ok=True) + + # Save analytics.json + team_json_path = analytics_dir / "analytics.json" + with open(team_json_path, "w") as jf: + json.dump(tracking_result["analytics"], jf, indent=2) + backend_team_json_url = f"http://127.0.0.1:8000/static/analytics/{req.id}/analytics.json" + + # Save team heatmap + team_heatmap_url = None + if tracking_result["analytics"].get("team_heatmap"): + team_heatmap_url = await _download_file( + tracking_result["analytics"]["team_heatmap"], + heatmap_dir / "team.png" + ) + + # Save zone heatmaps + zone_urls = {} + for zone, url in tracking_result["analytics"].get("zones", {}).items(): + zone_urls[zone] = await _download_file( + url, + heatmap_dir / "zones" / f"{zone}.png" + ) + + # ------------------------------- + # Save per-player analysis to DB + # ------------------------------- + for player in tracking_result["analytics"]["players"]: + pid = int(player["id"]) + player_heatmap_url = await _download_file( + player["heatmap"], + heatmap_dir / "players" / f"id_{pid}.png" + ) + + save_player_analysis( + upload_id=req.id, + player_id=pid, + json_path=backend_team_json_url, + heatmap_path=player_heatmap_url, + team_heatmap_path=team_heatmap_url, + stats={ + "distance_m": player.get("distance_m"), + "average_speed_m_s": player.get("average_speed_m_s"), + "average_speed_kmh": player.get("average_speed_kmh"), + "max_speed_m_s": player.get("max_speed_m_s"), + "max_speed_kmh": player.get("max_speed_kmh"), + }, + zone_back_50_path=zone_urls.get("back_50"), + zone_midfield_path=zone_urls.get("midfield"), + zone_forward_50_path=zone_urls.get("forward_50"), + ) + + # βœ… Update inference as Completed + save_inference(req.id, user_id, "player", "Completed", tracking_result) + + return { + "id": rec["id"], + "task": "player-track", + "status": "ok", + "team_heatmap": team_heatmap_url, + "zones": zone_urls, + "team_analytics_json": backend_team_json_url, + "data": tracking_result, + } + + except Exception as e: + save_inference(req.id, user_id, "player", "Failed", {"error": str(e)}) + raise HTTPException(status_code=500, detail=f"player tracking failed: {str(e)}") + + +# ------------------------------- +# Get Inference Records +# ------------------------------- +@router.get("/inferences", summary="List inference jobs") +def list_inferences( + upload_id: str, + user_id: int = Depends(get_current_user) +): + results = get_inferences(upload_id) + + # πŸ”Ή Ensure user can only see their own inferences + filtered = [r for r in results if r["user_id"] == user_id] + + if not filtered: + raise HTTPException(status_code=404, detail="No inferences found for this upload") + + return filtered \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/metrics.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/metrics.py new file mode 100644 index 000000000..ff71ceb14 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/metrics.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from routes import metrics_store + +router = APIRouter() + +@router.get("/") +async def get_metrics(): + metrics_data = { + "player_tracking": { + "calls": metrics_store.player_tracking_calls, + "last_output": metrics_store.last_player_tracking_output + }, + "crowd_monitoring": { + "calls": metrics_store.crowd_monitoring_calls, + "last_output": metrics_store.last_crowd_monitoring_output + } + } + return JSONResponse(content=metrics_data) \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/metrics_store.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/metrics_store.py new file mode 100644 index 000000000..d2da397db --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/metrics_store.py @@ -0,0 +1,32 @@ +# backend/routes/metrics_store.py +import time +from threading import RLock + +class MetricsStore: + def __init__(self): + self._lock = RLock() + self._data = { + "player": {"calls": 0, "total_ms": 0.0, "last_output": None}, + "crowd": {"calls": 0, "total_ms": 0.0, "last_output": None}, + } + + def record(self, model: str, ms: float, last_output): + with self._lock: + m = self._data[model] + m["calls"] += 1 + m["total_ms"] += ms + m["last_output"] = last_output + + def snapshot(self): + with self._lock: + out = {} + for k, v in self._data.items(): + avg = (v["total_ms"] / v["calls"]) if v["calls"] else 0.0 + out[k] = { + "calls": v["calls"], + "avg_latency_ms": round(avg, 2), + "last_output": v["last_output"] + } + return out + +metrics = MetricsStore() diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/tracking_analysis_simple.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/tracking_analysis_simple.py new file mode 100644 index 000000000..fd6cc671e --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/tracking_analysis_simple.py @@ -0,0 +1,199 @@ +from fastapi import APIRouter, UploadFile, File, HTTPException +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.patches as patches +from scipy.ndimage import gaussian_filter +import io +import os +import math +import uuid +from typing import Dict + +router = APIRouter() + +# Directory to save heatmaps +HEATMAP_DIR = "heatmap_outputs" +os.makedirs(HEATMAP_DIR, exist_ok=True) + + +class PlayerTrackingData: + def __init__(self, csv_data: str): + """Initialize with CSV data string""" + self.df = pd.read_csv(io.StringIO(csv_data)) + self._process_data() + + def _process_data(self): + """Validate and preprocess tracking data""" + required_columns = [ + "frame_id", "player_id", "timestamp_s", + "x1", "y1", "x2", "y2", "cx", "cy", "w", "h", "confidence" + ] + missing = [col for col in required_columns if col not in self.df.columns] + if missing: + raise ValueError(f"Missing required columns: {missing}") + + self.df = self.df.sort_values(["frame_id", "timestamp_s", "player_id"]) + + # Compute center if missing + if "cx" not in self.df.columns or "cy" not in self.df.columns: + self.df["cx"] = (self.df["x1"] + self.df["x2"]) / 2 + self.df["cy"] = (self.df["y1"] + self.df["y2"]) / 2 + + def get_player_stats(self) -> Dict: + """Calculate statistics for each player""" + stats = {} + for pid in self.df["player_id"].unique(): + player = self.df[self.df["player_id"] == pid].copy().sort_values(["frame_id", "timestamp_s"]) + frame_count = len(player) + total_time = float(player["timestamp_s"].max() - player["timestamp_s"].min()) + + # Distance & speed calculations + distances, speeds = [], [] + for i in range(1, len(player)): + prev = (player.iloc[i - 1]["cx"], player.iloc[i - 1]["cy"]) + curr = (player.iloc[i]["cx"], player.iloc[i]["cy"]) + dist = float(math.sqrt((curr[0] - prev[0])**2 + (curr[1] - prev[1])**2)) + distances.append(dist) + t_diff = float(player.iloc[i]["timestamp_s"] - player.iloc[i - 1]["timestamp_s"]) + if t_diff > 0: + speeds.append(dist / t_diff) + + stats[int(pid)] = { + "frame_count": frame_count, + "total_time": total_time, + "total_distance_pixels": float(sum(distances)) if distances else 0.0, + "average_speed_pixels_per_sec": float(np.mean(speeds)) if speeds else 0.0, + "max_speed_pixels_per_sec": float(max(speeds)) if speeds else 0.0, + "participation_score": float(frame_count / self.df["frame_id"].max()) if self.df["frame_id"].max() > 0 else 0.0, + "confidence_avg": float(player["confidence"].mean()), + "confidence_min": float(player["confidence"].min()), + "confidence_max": float(player["confidence"].max()), + } + return stats + + def get_player_movement(self, player_id: int): + """Return movement path for a given player""" + player = self.df[self.df["player_id"] == player_id].copy().sort_values(["frame_id", "timestamp_s"]) + if len(player) == 0: + raise ValueError(f"No data for player {player_id}") + + return { + "player_id": int(player_id), + "total_frames": len(player), + "path_data": [ + { + "frame_id": int(r["frame_id"]), + "timestamp": float(r["timestamp_s"]), + "x": int(r["cx"]), + "y": int(r["cy"]), + "confidence": float(r["confidence"]), + } + for _, r in player.iterrows() + ] + } + + def generate_heatmap(self, player_id: int, + field_length: float = 165, field_width: float = 135, + nx: int = 200, ny: int = 150, sigma: float = 2.0) -> str: + """Generate and save heatmap image for one player""" + player = self.df[self.df["player_id"] == player_id] + if len(player) == 0: + raise ValueError(f"No data for player {player_id}") + + # Create heatmap grid + heatmap = np.zeros((ny, nx)) + x_bins = np.linspace(0, field_length, nx) + y_bins = np.linspace(0, field_width, ny) + + for _, row in player.iterrows(): + x_scaled = (row["cx"] - self.df["cx"].min()) / (self.df["cx"].max() - self.df["cx"].min()) * field_length + y_scaled = (row["cy"] - self.df["cy"].min()) / (self.df["cy"].max() - self.df["cy"].min()) * field_width + x_idx, y_idx = int(np.digitize(x_scaled, x_bins)) - 1, int(np.digitize(y_scaled, y_bins)) - 1 + if 0 <= x_idx < nx and 0 <= y_idx < ny: + heatmap[y_idx, x_idx] += float(row["confidence"]) + + heatmap = gaussian_filter(heatmap, sigma=sigma) + if heatmap.max() > 0: + heatmap = heatmap / heatmap.max() + + # Plot and save + fig, ax = plt.subplots(figsize=(10, 8)) + ax.imshow(heatmap, extent=[0, field_length, 0, field_width], + origin="lower", cmap="hot", alpha=0.8) + ax.add_patch(patches.Rectangle((0, 0), field_length, field_width, + linewidth=2, edgecolor="white", facecolor="none")) + ax.set_title(f"Player {player_id} Heatmap") + ax.set_xlabel("Field Length (m)") + ax.set_ylabel("Field Width (m)") + + filename = f"heatmap_{player_id}_{uuid.uuid4().hex[:8]}.png" + filepath = os.path.join(HEATMAP_DIR, filename) + plt.savefig(filepath, dpi=150, bbox_inches="tight", facecolor="black") + plt.close() + return filepath + + +# -------------------- ROUTES -------------------- + +@router.post("/analyze-csv") +async def analyze_tracking_csv(file: UploadFile = File(...)): + """ + Upload CSV and get: + - Player list + - Stats for all players + (summary only) + """ + if not file.filename.endswith(".csv"): + raise HTTPException(status_code=400, detail="File must be a CSV") + + try: + content = await file.read() + tracking = PlayerTrackingData(content.decode("utf-8")) + + players = sorted([int(pid) for pid in tracking.df["player_id"].unique()]) + stats = tracking.get_player_stats() + + return { + "message": "CSV analyzed successfully", + "filename": file.filename, + "total_players": len(players), + "player_ids": players, + "statistics": stats + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error analyzing CSV: {str(e)}") + + +@router.post("/analyze-player") +async def analyze_player(file: UploadFile = File(...), player_id: int = None): + """ + Upload CSV + player_id and get: + - Movement path + - Heatmap for that player + """ + if not file.filename.endswith(".csv"): + raise HTTPException(status_code=400, detail="File must be a CSV") + if player_id is None: + raise HTTPException(status_code=400, detail="player_id is required") + + try: + content = await file.read() + tracking = PlayerTrackingData(content.decode("utf-8")) + + movement = tracking.get_player_movement(player_id) + heatmap_path = tracking.generate_heatmap(player_id) + + return { + "message": f"Analysis for player {player_id} completed", + "player_id": player_id, + "movement": movement, + "heatmap": { + "path": heatmap_path, + "filename": os.path.basename(heatmap_path) + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error analyzing player: {str(e)}") \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/upload.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/upload.py new file mode 100644 index 000000000..ff55d1585 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/routes/upload.py @@ -0,0 +1,143 @@ +from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends +from fastapi.responses import JSONResponse +from pathlib import Path +import os, uuid + +from routes.auth import get_current_user # βœ… import auth dependency +from storage import save_upload, _db, Upload, PlayerAnalysis, CrowdAnalysis + +router = APIRouter(tags=["Uploads"]) + +MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "200")) +CHUNK_SIZE = 1024 * 1024 # 1 MB + +BASE_DIR = Path(__file__).resolve().parents[1] # backend/ +VIDEO_DIR = BASE_DIR / "uploaded_videos" +VIDEO_DIR.mkdir(parents=True, exist_ok=True) + +VIDEO_MIME = {"video/mp4", "video/quicktime"} # mp4 / mov +VIDEO_EXTS = {".mp4", ".mov"} + + +def _safe_ext(name: str) -> str: + return Path(name).suffix.lower() + + +# ------------------------------- +# Upload Endpoint +# ------------------------------- +@router.post("/", summary="Upload video (mp4/mov only)") +async def upload_video( + file: UploadFile = File(..., description="mp4/mov only"), + user_id: int = Depends(get_current_user) # βœ… require JWT auth +): + """Upload a video file. Only accessible for authenticated users.""" + if not file or not file.filename: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="file is required", + ) + + ext = _safe_ext(file.filename) + content_type = (file.content_type or "").lower() + + mime_ok = content_type in VIDEO_MIME if content_type else False + ext_ok = ext in VIDEO_EXTS + if not (mime_ok or ext_ok): + raise HTTPException(status_code=400, detail="Only video files (.mp4, .mov) are accepted") + + # Generate ID + destination path + file_id = uuid.uuid4().hex + suffix = ext if ext_ok else ".mp4" + dest_path = VIDEO_DIR / f"{file_id}{suffix}" + + # Stream to disk with size guard + max_bytes = MAX_UPLOAD_MB * 1024 * 1024 + written = 0 + try: + with dest_path.open("wb") as out: + while True: + chunk = await file.read(CHUNK_SIZE) + if not chunk: + break + written += len(chunk) + if written > max_bytes: + out.close() + dest_path.unlink(missing_ok=True) + raise HTTPException(status_code=413, detail=f"file too large (> {MAX_UPLOAD_MB}MB)") + out.write(chunk) + except HTTPException: + raise + except Exception as e: + dest_path.unlink(missing_ok=True) + raise HTTPException(status_code=500, detail=str(e)) + finally: + await file.close() + + # Relative path stored in DB (relative to backend/ root) + rel_path = f"uploaded_videos/{dest_path.name}" + + # βœ… Save to Postgres (now includes original_filename) + rec = save_upload( + path=rel_path, + media_type="video", + size_bytes=written, + user_id=user_id, + original_filename=file.filename + ) + + return JSONResponse(rec) + + +# ------------------------------- +# List Uploads +# ------------------------------- +@router.get("/", summary="List all uploads for current user") +def list_uploads(user_id: int = Depends(get_current_user)): + with _db() as db: + rows = db.query(Upload).filter(Upload.user_id == user_id).all() + return [ + { + "id": str(r.id), + "user_id": r.user_id, + "path": r.path, + "media_type": r.media_type, + "size_bytes": r.size_bytes, + "created_at": r.created_at.isoformat(), + "original_filename": r.original_filename, # βœ… include name + } + for r in rows + ] + + +# ------------------------------- +# Delete Upload +# ------------------------------- +@router.delete("/{upload_id}", summary="Delete an upload and its analyses") +def delete_upload(upload_id: str, user_id: int = Depends(get_current_user)): + import uuid as uuid_pkg + with _db() as db: + row = db.query(Upload).filter(Upload.id == uuid_pkg.UUID(upload_id)).first() + + if not row: + raise HTTPException(status_code=404, detail="Upload not found") + if row.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized to delete this upload") + + # Delete related analyses + db.query(PlayerAnalysis).filter(PlayerAnalysis.upload_id == row.id).delete() + db.query(CrowdAnalysis).filter(CrowdAnalysis.upload_id == row.id).delete() + + # Delete the upload record + db.delete(row) + db.commit() + + # Delete the actual file from disk if it exists + file_path = BASE_DIR / row.path + try: + if file_path.exists(): + file_path.unlink() + except Exception: + pass # don’t block if file missing + + return {"status": "deleted", "upload_id": upload_id} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/sample_tracking.csv b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/sample_tracking.csv new file mode 100644 index 000000000..4756aca0f --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/sample_tracking.csv @@ -0,0 +1,21 @@ +frame_id,player_id,timestamp_s,x1,y1,x2,y2,cx,cy,w,h,confidence +1,1,0.00,624,478,841,964,732,721,217,485,0.77 +1,2,0.00,534,419,772,872,653,645,238,452,0.74 +2,1,0.04,613,482,807,964,710,723,193,481,0.86 +2,2,0.04,486,418,744,855,615,636,257,436,0.84 +3,1,0.08,621,483,788,968,704,725,166,484,0.80 +3,2,0.08,512,420,738,850,625,635,226,430,0.82 +4,1,0.12,628,485,785,970,706,727,157,485,0.88 +4,2,0.12,498,422,732,848,615,635,234,426,0.85 +5,1,0.16,635,487,782,972,708,729,147,485,0.90 +5,2,0.16,484,424,726,846,605,635,242,422,0.87 +6,1,0.20,642,489,779,974,710,731,137,485,0.92 +6,2,0.20,470,426,720,844,595,635,250,418,0.89 +7,1,0.24,649,491,776,976,712,733,127,485,0.94 +7,2,0.24,456,428,714,842,585,635,258,414,0.91 +8,1,0.28,656,493,773,978,714,735,117,485,0.96 +8,2,0.28,442,430,708,840,575,635,266,410,0.93 +9,1,0.32,663,495,770,980,716,737,107,485,0.98 +9,2,0.32,428,432,702,838,565,635,274,406,0.95 +10,1,0.36,670,497,767,982,718,739,97,485,1.00 +10,2,0.36,414,434,696,836,555,635,282,402,0.97 \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/storage.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/storage.py new file mode 100644 index 000000000..38c8a651e --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/storage.py @@ -0,0 +1,352 @@ +from __future__ import annotations +import os, uuid +from datetime import datetime +from typing import Optional, Dict + +from sqlalchemy import create_engine, func, String, Integer, DateTime, ForeignKey, Boolean +from sqlalchemy.orm import sessionmaker, DeclarativeBase, Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import relationship + +# --- DB setup --- +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + raise RuntimeError("DATABASE_URL not set") + +engine = create_engine(DATABASE_URL, future=True, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + +class Base(DeclarativeBase): + pass + +# ------------------- +# ORM models +# ------------------- +class Upload(Base): + __tablename__ = "uploads" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True) + path: Mapped[str] = mapped_column(String(512), nullable=False) + media_type: Mapped[str] = mapped_column(String(32), nullable=False, default="video") + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + original_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + + # πŸ”Ή Cascade delete relationship to inferences + inferences = relationship( + "Inference", + back_populates="upload", + cascade="all, delete-orphan", + passive_deletes=True + ) + +class Inference(Base): + __tablename__ = "inferences" + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True) + upload_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("uploads.id", ondelete="CASCADE"), # πŸ”Ή important + nullable=False, + index=True + ) + task: Mapped[str] = mapped_column(String(16), nullable=False) # "player" | "crowd" + status: Mapped[str] = mapped_column(String(16), nullable=False, default="ok") + payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + upload = relationship("Upload", back_populates="inferences") + + +class PlayerAnalysis(Base): + __tablename__ = "player_analysis" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + upload_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("uploads.id"), nullable=False, index=True) + player_id: Mapped[int] = mapped_column(Integer, nullable=False) + json_path: Mapped[str] = mapped_column(String(512), nullable=False) + heatmap_path: Mapped[str] = mapped_column(String(512), nullable=False) + team_heatmap_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) + + # βœ… zone fields + zone_back_50_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) + zone_midfield_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) + zone_forward_50_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) + + stats: Mapped[dict] = mapped_column(JSONB, nullable=True, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class CrowdAnalysis(Base): + __tablename__ = "crowd_analysis" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + upload_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("uploads.id"), nullable=False, index=True) + frame_number: Mapped[int] = mapped_column(Integer, nullable=False) + people_count: Mapped[int] = mapped_column(Integer, nullable=True) + frame_image_path: Mapped[str] = mapped_column(String(512), nullable=False) + heatmap_image_path: Mapped[str] = mapped_column(String(512), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +# ------------------- +# Table creation +# ------------------- +Base.metadata.create_all(bind=engine) + +def _db(): + return SessionLocal() + +# ------------------- +# Upload helpers +# ------------------- +def save_upload(path: str, media_type: str, size_bytes: int, user_id: int, original_filename: Optional[str] = None) -> dict: + with _db() as db: + row = Upload( + path=path, + media_type=media_type, + size_bytes=size_bytes, + user_id=user_id, + original_filename=original_filename, # βœ… save filename + ) + db.add(row) + db.commit() + db.refresh(row) + return { + "id": str(row.id), + "user_id": row.user_id, + "path": row.path, + "media_type": row.media_type, + "size_bytes": row.size_bytes, + "created_at": row.created_at.isoformat(), + "original_filename": row.original_filename, # βœ… include filename + } + +def get_upload(upload_id: str) -> Optional[dict]: + from sqlalchemy.exc import NoResultFound + with _db() as db: + try: + row = db.query(Upload).filter(Upload.id == uuid.UUID(upload_id)).one() + return { + "id": str(row.id), + "user_id": row.user_id, + "path": row.path, + "media_type": row.media_type, + "size_bytes": row.size_bytes, + "created_at": row.created_at.isoformat(), + "original_filename": row.original_filename, # βœ… include filename + } + except NoResultFound: + return None + + +# ------------------- +# Player Analysis helpers +# ------------------- +def get_player_analysis(upload_id: str, player_id: int) -> Optional[dict]: + with _db() as db: + row = db.query(PlayerAnalysis).filter( + PlayerAnalysis.upload_id == uuid.UUID(upload_id), + PlayerAnalysis.player_id == player_id + ).first() + if row: + return { + "id": row.id, + "upload_id": str(row.upload_id), + "player_id": row.player_id, + "json_path": row.json_path, + "heatmap_path": row.heatmap_path, + "team_heatmap_path": row.team_heatmap_path, + "stats": row.stats, + "created_at": row.created_at.isoformat() + } + return None + +def save_player_analysis( + upload_id: str, + player_id: int, + json_path: str, + heatmap_path: str, + team_heatmap_path: Optional[str] = None, + zone_back_50_path: Optional[str] = None, + zone_midfield_path: Optional[str] = None, + zone_forward_50_path: Optional[str] = None, + stats: Optional[dict] = None +) -> dict: + with _db() as db: + row = db.query(PlayerAnalysis).filter( + PlayerAnalysis.upload_id == uuid.UUID(upload_id), + PlayerAnalysis.player_id == player_id + ).first() + + if row: + row.json_path = json_path or row.json_path + row.heatmap_path = heatmap_path or row.heatmap_path + row.team_heatmap_path = team_heatmap_path or row.team_heatmap_path + row.zone_back_50_path = zone_back_50_path or row.zone_back_50_path + row.zone_midfield_path = zone_midfield_path or row.zone_midfield_path + row.zone_forward_50_path = zone_forward_50_path or row.zone_forward_50_path + row.stats = stats or row.stats + else: + row = PlayerAnalysis( + upload_id=uuid.UUID(upload_id), + player_id=player_id, + json_path=json_path, + heatmap_path=heatmap_path, + team_heatmap_path=team_heatmap_path, + zone_back_50_path=zone_back_50_path, + zone_midfield_path=zone_midfield_path, + zone_forward_50_path=zone_forward_50_path, + stats=stats or {} + ) + db.add(row) + + db.commit() + db.refresh(row) + + return { + "id": row.id, + "upload_id": str(row.upload_id), + "player_id": row.player_id, + "json_path": row.json_path, + "heatmap_path": row.heatmap_path, + "team_heatmap_path": row.team_heatmap_path, + "zone_back_50_path": row.zone_back_50_path, + "zone_midfield_path": row.zone_midfield_path, + "zone_forward_50_path": row.zone_forward_50_path, + "stats": row.stats, + "created_at": row.created_at.isoformat() + } + +# ------------------- +# Crowd Analysis helpers +# ------------------- +def get_crowd_analysis(upload_id: str) -> list[dict]: + """Fetch all crowd analysis entries for a video""" + with _db() as db: + rows = db.query(CrowdAnalysis).filter( + CrowdAnalysis.upload_id == uuid.UUID(upload_id) + ).order_by(CrowdAnalysis.frame_number).all() + + return [ + { + "id": row.id, + "upload_id": str(row.upload_id), + "frame_number": row.frame_number, + "people_count": row.people_count, + "frame_image_path": row.frame_image_path, + "heatmap_image_path": row.heatmap_image_path, + "created_at": row.created_at.isoformat() + } + for row in rows + ] + + +def save_crowd_analysis( + upload_id: str, + frame_number: int, + people_count: int, + frame_image_path: str, + heatmap_image_path: str +) -> dict: + """Save one crowd analysis record (per frame)""" + with _db() as db: + row = CrowdAnalysis( + upload_id=uuid.UUID(upload_id), + frame_number=frame_number, + people_count=people_count, + frame_image_path=frame_image_path, + heatmap_image_path=heatmap_image_path, + ) + db.add(row) + db.commit() + db.refresh(row) + + return { + "id": row.id, + "upload_id": str(row.upload_id), + "frame_number": row.frame_number, + "people_count": row.people_count, + "frame_image_path": row.frame_image_path, + "heatmap_image_path": row.heatmap_image_path, + "created_at": row.created_at.isoformat() + } + + + +# ------------------- +# Init check +# ------------------- +def init_db() -> None: + Base.metadata.create_all(bind=engine) + with engine.connect() as conn: + conn.exec_driver_sql("SELECT 1") + + +# ------------------- +# Inference helpers +# ------------------- +def save_inference(upload_id: str, user_id: int, task: str, status: str, payload: dict = None) -> dict: + """Create a new inference if not exists, otherwise update the existing one""" + with _db() as db: + row = db.query(Inference).filter( + Inference.upload_id == uuid.UUID(upload_id), + Inference.task == task + ).first() + + if row: + # πŸ”Ή Update existing record + row.status = status + row.payload = payload or row.payload + else: + # πŸ”Ή Create new record + row = Inference( + upload_id=uuid.UUID(upload_id), + user_id=user_id, + task=task, + status=status, + payload=payload or {} + ) + db.add(row) + + db.commit() + db.refresh(row) + + return { + "id": str(row.id), + "upload_id": str(row.upload_id), + "user_id": row.user_id, + "task": row.task, + "status": row.status, + "payload": row.payload, + "created_at": row.created_at.isoformat(), + } + + +def get_inferences(upload_id: str) -> list[dict]: + """Fetch all inferences for an upload (both player and crowd)""" + with _db() as db: + rows = db.query(Inference).filter( + Inference.upload_id == uuid.UUID(upload_id) + ).order_by(Inference.created_at).all() + + return [ + { + "id": str(row.id), + "upload_id": str(row.upload_id), + "user_id": row.user_id, + "task": row.task, + "status": row.status, + "payload": row.payload, + "created_at": row.created_at.isoformat() + } + for row in rows + ] + diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/storage_simple.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/storage_simple.py new file mode 100644 index 000000000..5cb82d86f --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/storage_simple.py @@ -0,0 +1,105 @@ +# backend/storage_simple.py +""" +Simplified storage module without database dependencies +Uses in-memory storage for development/testing +""" +from __future__ import annotations +import os, uuid +from datetime import datetime +from typing import Optional, List, Dict, Any + +# In-memory storage (replace with database later) +_uploads = {} +_inferences = {} + +def init_db(): + """Initialize storage (no-op for in-memory storage)""" + pass + +def _db(): + """Mock database session (no-op)""" + class MockDB: + def __enter__(self): + return self + def __exit__(self, exc_type, exc_val, exc_tb): + pass + def add(self, item): + pass + def commit(self): + pass + def refresh(self, item): + pass + def query(self, model): + return MockQuery(model) + + class MockQuery: + def __init__(self, model): + self.model = model + def filter(self, condition): + return self + def one(self): + return None + def all(self): + return [] + def order_by(self, field): + return self + def limit(self, limit): + return [] + + return MockDB() + +def save_upload(path: str, media_type: str, size_bytes: int) -> dict: + """Save upload info to in-memory storage""" + upload_id = str(uuid.uuid4()) + upload_data = { + "id": upload_id, + "path": path, + "media_type": media_type, + "size_bytes": size_bytes, + "created_at": datetime.now().isoformat() + } + _uploads[upload_id] = upload_data + return upload_data + +def get_upload(upload_id: str) -> Optional[dict]: + """Get upload info from in-memory storage""" + return _uploads.get(upload_id) + +def save_inference(upload_id: str, task: str, status: str, payload: Dict[str, Any]) -> dict: + """Save inference result to in-memory storage""" + inference_id = str(uuid.uuid4()) + inference_data = { + "id": inference_id, + "upload_id": upload_id, + "task": task, + "status": status, + "payload": payload or {}, + "created_at": datetime.now().isoformat() + } + _inferences[inference_id] = inference_data + return inference_data + +def list_inferences(limit: int = 50) -> List[dict]: + """List recent inferences from in-memory storage""" + all_inferences = list(_inferences.values()) + all_inferences.sort(key=lambda x: x['created_at'], reverse=True) + return all_inferences[:limit] + +def inferences_summary() -> dict: + """Get summary of inferences from in-memory storage""" + total = len(_inferences) + by_task = {} + by_status = {} + + for inference in _inferences.values(): + task = inference['task'] + status = inference['status'] + + by_task[task] = by_task.get(task, 0) + 1 + by_status[status] = by_status.get(status, 0) + 1 + + return { + "total": total, + "by_task": by_task, + "by_status": by_status + } diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/test_tracking.py b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/test_tracking.py new file mode 100644 index 000000000..03532cd4b --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/backend/test_tracking.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Test script for the Player Tracking Analysis System +Run this to verify all endpoints are working correctly +""" + +import requests +import json +import os + +# Configuration +BASE_URL = "http://localhost:8000" +SAMPLE_CSV = "sample_tracking.csv" + +def test_upload_csv(): + """Test CSV upload endpoint""" + print("πŸ§ͺ Testing CSV Upload...") + + if not os.path.exists(SAMPLE_CSV): + print(f"❌ Sample CSV file not found: {SAMPLE_CSV}") + return False + + try: + with open(SAMPLE_CSV, 'rb') as f: + files = {'file': (SAMPLE_CSV, f, 'text/csv')} + response = requests.post(f"{BASE_URL}/api/v1/tracking/upload-csv", files=files) + + if response.status_code == 200: + data = response.json() + print(f"βœ… Upload successful: {data['message']}") + print(f" Players: {data['total_players']}, Frames: {data['total_frames']}") + return True + else: + print(f"❌ Upload failed: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"❌ Upload error: {str(e)}") + return False + +def test_player_stats(): + """Test player statistics endpoint""" + print("\nπŸ§ͺ Testing Player Statistics...") + + try: + with open(SAMPLE_CSV, 'rb') as f: + files = {'file': (SAMPLE_CSV, f, 'text/csv')} + response = requests.post(f"{BASE_URL}/api/v1/tracking/player-stats", files=files) + + if response.status_code == 200: + data = response.json() + print(f"βœ… Stats generated: {data['message']}") + print(f" Total players: {data['total_players']}") + + # Show first player stats + if data['statistics']: + first_player = list(data['statistics'].keys())[0] + stats = data['statistics'][first_player] + print(f" Player {first_player} sample stats:") + print(f" Frames: {stats['frame_count']}") + print(f" Distance: {stats['total_distance_pixels']:.2f} pixels") + print(f" Avg Speed: {stats['average_speed_pixels_per_sec']:.2f} px/s") + return True + else: + print(f"❌ Stats failed: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"❌ Stats error: {str(e)}") + return False + +def test_available_players(): + """Test available players endpoint""" + print("\nπŸ§ͺ Testing Available Players...") + + try: + with open(SAMPLE_CSV, 'rb') as f: + files = {'file': (SAMPLE_CSV, f, 'text/csv')} + response = requests.post(f"{BASE_URL}/api/v1/tracking/available-players", files=files) + + if response.status_code == 200: + data = response.json() + print(f"βœ… Players retrieved: {data['message']}") + print(f" Available player IDs: {data['player_ids']}") + return data['player_ids'] + else: + print(f"❌ Players failed: {response.status_code} - {response.text}") + return [] + + except Exception as e: + print(f"❌ Players error: {str(e)}") + return [] + +def test_heatmap_generation(player_ids): + """Test heatmap generation endpoint""" + if not player_ids: + print("\n❌ Skipping heatmap test - no player IDs available") + return False + + print(f"\nπŸ§ͺ Testing Heatmap Generation for Player {player_ids[0]}...") + + try: + with open(SAMPLE_CSV, 'rb') as f: + files = { + 'file': (SAMPLE_CSV, f, 'text/csv'), + 'player_id': (None, str(player_ids[0])), + 'field_length': (None, '165'), + 'field_width': (None, '135'), + 'nx': (None, '200'), + 'ny': (None, '150'), + 'sigma': (None, '2.0') + } + response = requests.post(f"{BASE_URL}/api/v1/tracking/generate-heatmap", files=files) + + if response.status_code == 200: + data = response.json() + print(f"βœ… Heatmap generated: {data['message']}") + print(f" Filename: {data['filename']}") + + # Test retrieving the heatmap + heatmap_response = requests.get(f"{BASE_URL}/api/v1/tracking/heatmap/{data['filename']}") + if heatmap_response.status_code == 200: + print(f"βœ… Heatmap retrieved successfully") + return True + else: + print(f"❌ Heatmap retrieval failed: {heatmap_response.status_code}") + return False + else: + print(f"❌ Heatmap generation failed: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"❌ Heatmap error: {str(e)}") + return False + +def test_movement_path(player_ids): + """Test movement path endpoint""" + if not player_ids: + print("\n❌ Skipping movement test - no player IDs available") + return False + + print(f"\nπŸ§ͺ Testing Movement Path for Player {player_ids[0]}...") + + try: + with open(SAMPLE_CSV, 'rb') as f: + files = {'file': (SAMPLE_CSV, f, 'text/csv')} + response = requests.post(f"{BASE_URL}/api/v1/tracking/player-movement/{player_ids[0]}", files=files) + + if response.status_code == 200: + data = response.json() + print(f"βœ… Movement data retrieved: {data['message']}") + print(f" Total frames: {data['data']['total_frames']}") + print(f" Path data points: {len(data['data']['path_data'])}") + return True + else: + print(f"❌ Movement failed: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"❌ Movement error: {str(e)}") + return False + +def test_cleanup(): + """Test cleanup endpoint""" + print("\nπŸ§ͺ Testing Cleanup...") + + try: + response = requests.delete(f"{BASE_URL}/api/v1/tracking/cleanup-heatmaps") + + if response.status_code == 200: + data = response.json() + print(f"βœ… Cleanup completed: {data['message']}") + print(f" Files removed: {data['files_removed']}") + return True + else: + print(f"❌ Cleanup failed: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"❌ Cleanup error: {str(e)}") + return False + +def main(): + """Run all tests""" + print("πŸš€ Starting Player Tracking Analysis System Tests") + print("=" * 60) + + # Check if server is running + try: + response = requests.get(f"{BASE_URL}/") + if response.status_code != 200: + print(f"❌ Server not responding: {response.status_code}") + print(" Make sure to start the backend server first:") + print(" cd afl-vision-insight/backend") + print(" uvicorn main:app --reload") + return + print("βœ… Server is running") + except Exception as e: + print(f"❌ Cannot connect to server: {str(e)}") + print(" Make sure to start the backend server first:") + print(" cd afl-vision-insight/backend") + print(" uvicorn main:app --reload") + return + + # Run tests + tests = [ + ("CSV Upload", test_upload_csv), + ("Player Statistics", test_player_stats), + ("Available Players", lambda: test_available_players()), + ("Heatmap Generation", lambda: test_heatmap_generation(test_available_players())), + ("Movement Path", lambda: test_movement_path(test_available_players())), + ("Cleanup", test_cleanup) + ] + + results = [] + for test_name, test_func in tests: + try: + result = test_func() + results.append((test_name, result)) + except Exception as e: + print(f"❌ {test_name} test crashed: {str(e)}") + results.append((test_name, False)) + + # Summary + print("\n" + "=" * 60) + print("πŸ“Š Test Results Summary") + print("=" * 60) + + passed = sum(1 for _, result in results if result) + total = len(results) + + for test_name, result in results: + status = "βœ… PASS" if result else "❌ FAIL" + print(f"{status} {test_name}") + + print(f"\nOverall: {passed}/{total} tests passed") + + if passed == total: + print("πŸŽ‰ All tests passed! The tracking system is working correctly.") + else: + print("⚠️ Some tests failed. Check the output above for details.") + +if __name__ == "__main__": + main() diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.dockerignore b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.dockerignore new file mode 100644 index 000000000..2f30b0a76 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.dockerignore @@ -0,0 +1,62 @@ +node_modules +dist +.git +.gitignore +README.md +.env +.env.local +.env.development +.env.test +.env.production +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +.vscode +.idea +*.swp +*.swo +*~ +.cursor +coverage +.nyc_output +.eslintcache +*.log +*.lock +*.tmp +*.tmp.* +log.txt + +.DS_Store +node_modules +**/node_modules/** +build +data +.env +load-ids.txt + +server +tmp +types +.git +.gitignore +dist +service +tests +fixtures-pages +fixtures-apps + +# Netlify +.netlify +packages/ml-air/lib +packages/ml-air/bin +packages/ml-air/project +packages/ml-air/share +packages/ml-air/random_forest_classification/ +packages/ml-air/__pycache__/ +packages/ml-air/app/__pycache__/ +packages/vcp-common/native-bridge/build +packages/vcp-common/_tests_/dataset-ranking.csv +node_modules/ +Dockerfile +.gitignore \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.env b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.env new file mode 100644 index 000000000..6658458c6 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.env @@ -0,0 +1,6 @@ +# .env is better suited for public variables, ie, variables that should not commited +# For secret variables is better to use DevServerControl tool with set_env_variable: ["KEY", "SECRET"] + +# https://www.builder.io/c/docs/using-your-api-key +VITE_PUBLIC_BUILDER_KEY=__BUILDER_PUBLIC_KEY__ +PING_MESSAGE="ping pong" diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.gitignore b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.gitignore index a547bf36d..b5ae3a9a7 100644 Binary files a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.gitignore and b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.gitignore differ diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.npmrc b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.npmrc new file mode 100644 index 000000000..045129fcb --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.prettierrc b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.prettierrc new file mode 100644 index 000000000..d3be6d22e --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.prettierrc @@ -0,0 +1,5 @@ +{ + "tabWidth": 2, + "useTabs": false, + "trailingComma": "all" +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/AGENTS.md b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/AGENTS.md new file mode 100644 index 000000000..22b98d61c --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/AGENTS.md @@ -0,0 +1,164 @@ +# Fusion Starter + +A production-ready full-stack React application template with integrated Express server, featuring React Router 6 SPA mode, TypeScript, Vitest, Zod and modern tooling. + +While the starter comes with a express server, only create endpoint when strictly neccesary, for example to encapsulate logic that must leave in the server, such as private keys handling, or certain DB operations, db... + +## Tech Stack + +- **PNPM**: Prefer pnpm +- **Frontend**: React 18 + React Router 6 (spa) + TypeScript + Vite + TailwindCSS 3 +- **Backend**: Express server integrated with Vite dev server +- **Testing**: Vitest +- **UI**: Radix UI + TailwindCSS 3 + Lucide React icons + +## Project Structure + +``` +client/ # React SPA frontend +β”œβ”€β”€ pages/ # Route components (Index.tsx = home) +β”œβ”€β”€ components/ui/ # Pre-built UI component library +β”œβ”€β”€ App.tsx # App entry point and with SPA routing setup +└── global.css # TailwindCSS 3 theming and global styles + +server/ # Express API backend +β”œβ”€β”€ index.ts # Main server setup (express config + routes) +└── routes/ # API handlers + +shared/ # Types used by both client & server +└── api.ts # Example of how to share api interfaces +``` + +## Key Features + +## SPA Routing System + +The routing system is powered by React Router 6: + +- `client/pages/Index.tsx` represents the home page. +- Routes are defined in `client/App.tsx` using the `react-router-dom` import +- Route files are located in the `client/pages/` directory + +For example, routes can be defined with: + +```typescript +import { BrowserRouter, Routes, Route } from "react-router-dom"; + + + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> +; +``` + +### Styling System + +- **Primary**: TailwindCSS 3 utility classes +- **Theme and design tokens**: Configure in `client/global.css` +- **UI components**: Pre-built library in `client/components/ui/` +- **Utility**: `cn()` function combines `clsx` + `tailwind-merge` for conditional classes + +```typescript +// cn utility usage +className={cn( + "base-classes", + { "conditional-class": condition }, + props.className // User overrides +)} +``` + +### Express Server Integration + +- **Development**: Single port (8080) for both frontend/backend +- **Hot reload**: Both client and server code +- **API endpoints**: Prefixed with `/api/` + +#### Example API Routes +- `GET /api/ping` - Simple ping api +- `GET /api/demo` - Demo endpoint + +### Shared Types +Import consistent types in both client and server: +```typescript +import { DemoResponse } from '@shared/api'; +``` + +Path aliases: +- `@shared/*` - Shared folder +- `@/*` - Client folder + +## Development Commands + +```bash +pnpm dev # Start dev server (client + server) +pnpm build # Production build +pnpm start # Start production server +pnpm typecheck # TypeScript validation +pnpm test # Run Vitest tests +``` + +## Adding Features + +### Add new colors to the theme + +Open `client/global.css` and `tailwind.config.ts` and add new tailwind colors. + +### New API Route +1. **Optional**: Create a shared interface in `shared/api.ts`: +```typescript +export interface MyRouteResponse { + message: string; + // Add other response properties here +} +``` + +2. Create a new route handler in `server/routes/my-route.ts`: +```typescript +import { RequestHandler } from "express"; +import { MyRouteResponse } from "@shared/api"; // Optional: for type safety + +export const handleMyRoute: RequestHandler = (req, res) => { + const response: MyRouteResponse = { + message: 'Hello from my endpoint!' + }; + res.json(response); +}; +``` + +3. Register the route in `server/index.ts`: +```typescript +import { handleMyRoute } from "./routes/my-route"; + +// Add to the createServer function: +app.get("/api/my-endpoint", handleMyRoute); +``` + +4. Use in React components with type safety: +```typescript +import { MyRouteResponse } from '@shared/api'; // Optional: for type safety + +const response = await fetch('/api/my-endpoint'); +const data: MyRouteResponse = await response.json(); +``` + +### New Page Route +1. Create component in `client/pages/MyPage.tsx` +2. Add route in `client/App.tsx`: +```typescript +} /> +``` + +## Production Deployment + +- **Standard**: `pnpm build` +- **Binary**: Self-contained executables (Linux, macOS, Windows) +- **Cloud Deployment**: Use either Netlify or Vercel via their MCP integrations for easy deployment. Both providers work well with this starter template. + +## Architecture Notes + +- Single-port development with Vite + Express integration +- TypeScript throughout (client, server, shared) +- Full hot reload for rapid development +- Production-ready with multiple deployment options +- Comprehensive UI component library included +- Type-safe API communication via shared interfaces diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/Frontend Setup.md b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/Frontend Setup.md new file mode 100644 index 000000000..f6827c0d4 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/Frontend Setup.md @@ -0,0 +1,198 @@ +# Frontend Setup and Onboarding Guide + +This guide explains how to set up, run, understand, and extend the AFL Player Tracking & Crowd Monitoring frontend. It is designed for future maintainers. + +- Tech: React 18, TypeScript, Vite 7, TailwindCSS 3, Radix UI, Express (integrated for APIs in dev and prod), TanStack Query, Vitest +- App location: `Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend` +- Dev port: 8080 + +## 1) Prerequisites +- Node.js 18+ (22+ recommended) +- npm (bundled with Node.js) +- Git access to the repository + +## 2) Install and Run (Local) with npm +From the frontend folder: + +```bash +cd Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend +npm install +npm run dev +``` + +- Local dev server: http://localhost:8080/ +- Vite serves the SPA; Express is attached as middleware so API routes are available at `/api/*` in development. + +Note: This repository also contains a `pnpm-lock.yaml`. Using npm will ignore that file. If your environment warns about mixed lockfiles, you can safely proceed with npm, or remove the pnpm lockfile in your local clone. + +### Production build and run +```bash +npm run build # builds client to dist/spa and server to dist/server +npm start # serves dist via Express (node dist/server/node-build.mjs) +``` +- Production server reads `process.env.PORT` (defaults 3000). + +### Useful scripts +- `npm test` – run Vitest tests +- `npm run typecheck` – TypeScript type checking +- `npm run format.fix` – format code with Prettier + +## 3) Environment Variables +Environment variables are consumed in the Express server. + +Common variables: +- `JWT_SECRET` – Required for production auth token signing +- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` – For Google OAuth +- `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY` – For Apple Sign In +- `CLIENT_URL` – Client origin (default `http://localhost:8080`) +- `PING_MESSAGE` – Overrides `/api/ping` message +- `PORT` – Production server port + +Local development (.env): +```bash +# file: Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/.env +JWT_SECRET=your-local-dev-secret +CLIENT_URL=http://localhost:8080 +PING_MESSAGE=ping +# OAuth (optional for local testing) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= +``` +Do not commit real secrets. Use environment variables locally via a `.env` file and configure secrets in your deployment environment. + +OAuth details are documented in `OAUTH_SETUP.md`. + +## 4) Project Structure (Frontend) +``` +frontend/ +β”œβ”€ client/ # React SPA +β”‚ β”œβ”€ pages/ # Route components (Index.tsx = sample route) +β”‚ β”œβ”€ components/ # UI and feature components +β”‚ β”‚ β”œβ”€ auth/ # Auth screens & helpers +β”‚ β”‚ └─ ui/ # Shadcn/Radix UI wrappers +β”‚ β”œβ”€ api/ # Axios instance and API helpers +β”‚ β”œβ”€ hooks/ # Custom hooks +β”‚ β”œβ”€ lib/ # Utilities, formatting, mocks, reports +β”‚ β”œβ”€ types/ # Shared TS types for client +β”‚ β”œβ”€ App.tsx # Router setup +β”‚ └─ global.css # Tailwind styles +β”œβ”€ server/ # Express server (used in dev & prod) +β”‚ β”œβ”€ routes/ # API handlers (oauth.ts, demo.ts) +β”‚ β”œβ”€ index.ts # createServer() wiring +β”‚ └─ node-build.ts # Serves built SPA in prod +β”œβ”€ shared/ # Shared types (client & server) +β”œβ”€ public/ # Static assets +β”œβ”€ vite.config.ts # Vite + Express dev-integration +β”œβ”€ vite.config.server.ts # Server build config +└─ package.json +``` + +Key files to know: +- `client/App.tsx` – routes (react-router-dom) +- `client/api/axiosInstance.ts` – preconfigured Axios with token handling +- `server/index.ts` – Express app, mounts `/api/*` +- `server/routes/oauth.ts` – Google/Apple auth flows and JWT + +## 5) Routing and Pages +Routes live in `client/pages/` and are wired in `client/App.tsx`: +- `/` or `/login` – Login page (`client/pages/Login.tsx`) +- `/home`, `/afl-dashboard` – Main dashboard +- `/player-performance`, `/team-match-performance` – Analytics pages +- `/crowd-monitor`, `/analytics`, `/reports`, `/api-diagnostics`, `/error-demo`, `/stitch` +- Fallback route `*` -> NotFound + +Add a new page: +1) Create `client/pages/MyPage.tsx` +2) Add route in `client/App.tsx`: +```tsx +} /> +``` + +## 6) UI and Styling +- TailwindCSS 3 utilities with theme tokens in `client/global.css` +- Radix UI components wrapped in `client/components/ui/*` +- Keep existing design tokens/variables intact +- Prefer class names over inline styles; create small, descriptive class names + +## 7) State, Data Fetching, and APIs +- TanStack Query (`QueryClientProvider` in `App.tsx`) handles caching/retries +- Axios instance: `client/api/axiosInstance.ts` + - Base URL defaults to `http://127.0.0.1:8000/api/v1` + - If your backend differs, update `baseURL` accordingly + - Automatically attaches `Authorization: Bearer ` from `localStorage` + - 401 responses clear token and redirect to `/login` + +Calling APIs: +```ts +import API from "@/api/axiosInstance"; +const { data } = await API.get("/players"); +``` + +## 8) Authentication +- OAuth endpoints are exposed via Express server (`server/routes/oauth.ts`): + - `GET /api/auth/google`, `GET /api/auth/google/callback` + - `POST /api/auth/apple/callback` + - `POST /api/auth/verify-token` +- Requires `JWT_SECRET` in production; defaults to a dev-only secret and logs a warning otherwise +- See `OAUTH_SETUP.md` for complete setup + +## 9) Testing and Quality +- Unit tests: `npm test` (Vitest) +- Types: `npm run typecheck` +- Formatting: `npm run format.fix` + +Suggested conventions: +- Keep components small and cohesive +- Co-locate tests next to components when practical +- Prefer descriptive names (e.g., `PlayerStatsGrid`) over generic ones +- Avoid TODO/placeholder comments; implement real logic + +## 10) Build and Deploy +- `npm run build` outputs: + - SPA: `dist/spa` + - Server: `dist/server/node-build.mjs` +- `npm start` serves both via Express + +Deployment options: +- Host on any Node-capable environment or platform. Configure environment variables in that environment and run `npm start`. + +## 11) Troubleshooting (Common Issues) +- Install fails at repo root: run commands inside `frontend/` (this app’s package.json lives here) +- Mixed lockfile warnings: npm ignores `pnpm-lock.yaml`; proceed with npm or remove that file locally +- Warning: `Using default JWT_SECRET` – set `JWT_SECRET` before production +- 401s from API: token expired or invalid; the app will redirect to `/login` +- Wrong API URL: update `client/api/axiosInstance.ts` `baseURL` to your backend +- Port conflicts: change Vite dev port in `vite.config.ts` (server.port) + +## 12) How to Add a Feature (Example Flow) +1) Plan UI and data needs +2) Create a page/component under `client/pages` or `client/components` +3) Add the route in `client/App.tsx` +4) Use `API` for data fetching; model response types in `client/types` +5) Style with Tailwind and UI components under `client/components/ui` +6) Add basic tests (Vitest) if applicable +7) Run `npm run typecheck`, `npm test`, `npm run format.fix` +8) Verify locally (`npm run dev`), then build (`npm run build`) if needed + +## 13) Useful Endpoints (Dev) +- `GET /api/ping` – returns `{ message: PING_MESSAGE || "ping" }` +- `GET /api/demo` – example endpoint + +## 14) Security Best Practices +- Never commit secrets; use environment variables +- Rotate `JWT_SECRET` regularly and use strong values +- Validate inputs on server endpoints +- Keep dependencies updated (check for warnings) + +## 15) References +- `AGENTS.md` – fusion starter overview +- `OAUTH_SETUP.md` – detailed OAuth configuration +- `client/App.tsx` – route definitions +- `server/index.ts` – Express API wiring +- `client/api/axiosInstance.ts` – Axios configuration + +With this, a new team member should be able to install, run, navigate the code, and implement new features safely and consistently. diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/OAUTH_SETUP.md b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/OAUTH_SETUP.md new file mode 100644 index 000000000..6ce099078 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/OAUTH_SETUP.md @@ -0,0 +1,153 @@ +# OAuth Authentication Setup + +This guide will help you set up Google and Apple OAuth authentication for your AFL Analytics application. + +## Prerequisites + +You need to create OAuth applications with Google and Apple to get the required credentials. + +## Google OAuth Setup + +### 1. Create a Google Cloud Project + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Enable the Google+ API and Google OAuth2 API + +### 2. Configure OAuth Consent Screen + +1. In the Google Cloud Console, go to "APIs & Services" > "OAuth consent screen" +2. Choose "External" user type +3. Fill in the required information: + - App name: AFL Analytics + - User support email: your email + - Developer contact information: your email +4. Add scopes: `userinfo.email` and `userinfo.profile` +5. Add test users if needed + +### 3. Create OAuth2 Credentials + +1. Go to "APIs & Services" > "Credentials" +2. Click "Create Credentials" > "OAuth 2.0 Client IDs" +3. Choose "Web application" +4. Set authorized redirect URIs: + - Development: `http://localhost:8080/api/auth/google/callback` + - Production: `https://yourdomain.com/api/auth/google/callback` +5. Save and copy the Client ID and Client Secret + +### 4. Update Environment Variables + +Set these environment variables in your application: + +```bash +GOOGLE_CLIENT_ID=your-actual-google-client-id +GOOGLE_CLIENT_SECRET=your-actual-google-client-secret +``` + +## Apple OAuth Setup + +### 1. Apple Developer Account + +You need an active Apple Developer Account to set up Sign In with Apple. + +### 2. Create an App ID + +1. Go to [Apple Developer Portal](https://developer.apple.com/account/) +2. Navigate to "Certificates, Identifiers & Profiles" > "Identifiers" +3. Create a new App ID with Sign In with Apple capability enabled + +### 3. Create a Services ID + +1. Create a new Services ID in the Apple Developer Portal +2. Enable "Sign In with Apple" +3. Configure the service: + - Primary App ID: Select the App ID you created + - Web Domain: Your domain (e.g., `localhost` for development) + - Return URLs: + - Development: `http://localhost:8080/api/auth/apple/callback` + - Production: `https://yourdomain.com/api/auth/apple/callback` + +### 4. Create a Private Key + +1. Go to "Keys" section in Apple Developer Portal +2. Create a new key with "Sign In with Apple" enabled +3. Download the private key file (.p8) +4. Note the Key ID + +### 5. Update Environment Variables + +Set these environment variables in your application: + +```bash +APPLE_CLIENT_ID=your-services-id +APPLE_TEAM_ID=your-team-id +APPLE_KEY_ID=your-key-id +APPLE_PRIVATE_KEY=your-private-key-content +``` + +## Setting Environment Variables in Builder.io + +To set these environment variables in your Builder.io project: + +1. **Important**: Use the DevServerControl tool to set environment variables (they won't be committed to git) +2. Or manually set them in your deployment environment + +Example for setting via DevServerControl: + +``` +GOOGLE_CLIENT_ID=your-actual-google-client-id +GOOGLE_CLIENT_SECRET=your-actual-google-client-secret +APPLE_CLIENT_ID=your-services-id +JWT_SECRET=your-super-secure-jwt-secret +``` + +## Testing OAuth + +### Google OAuth Test Flow + +1. Click "Continue with Google" button +2. You'll be redirected to Google's OAuth consent screen +3. Choose your Google account and grant permissions +4. You'll be redirected back and automatically logged in + +### Apple OAuth Test Flow + +1. Click "Continue with Apple" button +2. You'll be redirected to Apple's Sign In page +3. Enter your Apple ID credentials +4. Choose to share or hide your email +5. You'll be redirected back and automatically logged in + +## Security Notes + +- Never commit OAuth secrets to your repository +- Use different OAuth applications for development and production +- Regularly rotate your JWT secret +- Implement proper token expiration and refresh logic for production + +## Troubleshooting + +### Common Google OAuth Issues + +- **Invalid redirect_uri**: Ensure the redirect URI in Google Console matches exactly +- **Access blocked**: Add your domain to authorized domains in OAuth consent screen +- **API not enabled**: Enable Google+ API and OAuth2 API in Google Cloud Console + +### Common Apple OAuth Issues + +- **Invalid client**: Verify your Services ID is correctly configured +- **Invalid redirect**: Ensure redirect URIs match in Apple Developer Portal +- **Private key issues**: Ensure the private key is properly formatted + +### Debug Mode + +Set `NODE_ENV=development` to see detailed OAuth error logs in the server console. + +## Production Considerations + +1. Set up proper error handling for OAuth failures +2. Implement token refresh mechanisms +3. Add rate limiting to OAuth endpoints +4. Use HTTPS in production +5. Set secure cookie flags +6. Implement proper session management diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/README.md b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/README.md deleted file mode 100644 index 7059a962a..000000000 --- a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# React + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/Standards and Code Guide .md b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/Standards and Code Guide .md new file mode 100644 index 000000000..b7e929bae --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/Standards and Code Guide .md @@ -0,0 +1,180 @@ +# Frontend Coding Guide (Standards & Workflow) + +This guide explains how juniors should code, review, and ship features in this frontend. Follow these standards to keep the codebase consistent, accessible, and easy to maintain. + +## Core Principles +- Keep components small, focused, and typed. +- Prefer composition over inheritance; lift shared logic into hooks or utilities. +- No inline styles. Use Tailwind classes or CSS modules when needed. +- Preserve existing design tokens and variables (e.g., `var(--token)`, `@token`, `$token`). Do not rename or replace them. +- Use CSS shorthands (e.g., `p-4`, `px-4`, `m-2`) and keep media queries and breakpoints unchanged. +- Handle errors and loading states explicitly; never swallow errors. + +## Folder & Naming Conventions +- Pages: `client/pages/*` (route-level components) +- Components: `client/components/*` (reusable UI and feature blocks) +- UI primitives: `client/components/ui/*` (Radix/Shadcn wrappers) +- Hooks: `client/hooks/*` (prefix with `use`) +- Utilities: `client/lib/*` +- Types: `client/types/*` (cohesive domain-focused typings) +- File names: PascalCase for components/pages, camelCase for hooks/utils, kebab-case for non-TS assets + +## React Component Standards +- Use function components with explicit props interfaces. +- Co-locate minimal logic; extract heavy logic to hooks in `client/hooks`. +- Example: +```tsx +import { cn } from "@/lib/utils"; + +interface PlayerBadgeProps { + name: string; + highlight?: boolean; +} + +export function PlayerBadge({ name, highlight = false }: PlayerBadgeProps) { + return ( + + {name} + + ); +} +``` + +## Styling & Accessibility +- Tailwind first. Keep existing variables and media queries intact. +- Create descriptive class names when adding CSS classes; avoid generic names like `div-1`. +- Use semantic HTML. Add `aria-*` attributes and keyboard handlers for interactive elements. +- Do not duplicate styles; prefer shared utilities and tokens. + +## Data Fetching & State +- Use TanStack Query from `App.tsx` provider. +- Server data: `useQuery`/`useMutation`. Local UI state: `useState`/`useReducer`. +- Example API pattern: +```ts +// client/api/players.ts +import API from "@/api/axiosInstance"; +import type { Player } from "@/types/dashboard"; + +export async function fetchPlayers(): Promise { + const { data } = await API.get("/players"); + return data; +} +``` +```tsx +// inside a component +import { useQuery } from "@tanstack/react-query"; +import { fetchPlayers } from "@/api/players"; + +const { data, isLoading, isError } = useQuery({ queryKey: ["players"], queryFn: fetchPlayers }); +``` +- 401s trigger a redirect to `/login` via the Axios interceptor. + +## Forms & Validation +- Use `react-hook-form` with Zod. +```tsx +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +const schema = z.object({ email: z.string().email(), password: z.string().min(8) }); + +type FormValues = z.infer; + +export function LoginForm() { + const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({ + resolver: zodResolver(schema), + mode: "onSubmit", + }); + + return ( +
{})} noValidate> + + + +
+ ); +} +``` + +## Routing +- Add a page under `client/pages/` and wire it in `client/App.tsx`. +```tsx +// client/pages/MyPage.tsx +export default function MyPage() { return
Hello
; } +``` +```tsx +// client/App.tsx +} /> +``` + +## Server Endpoints (Dev & Prod) +- Express routes live in `server/routes/*` and are mounted in `server/index.ts`. +- When adding a new endpoint, define types in `shared/` if shared with client. +- Keep business logic on the server; the client should just call APIs. + +## Error Handling +- UI: show explicit error states, never crash silently. +- Network: map server errors to friendly messages. Log details to console only in dev. +- JWT warnings: set `JWT_SECRET` for production to remove the startup warning. + +## Testing & Quality +- Vitest for unit tests; co-locate tests as `*.test.ts(x)`. +```ts +import { describe, expect, it } from "vitest"; +import { formatNumber } from "@/lib/format"; + +describe("formatNumber", () => { + it("formats thousands", () => { + expect(formatNumber(1200)).toBe("1,200"); + }); +}); +``` +- Run `pnpm typecheck`, `pnpm test`, `pnpm format.fix` before pushing. + +## Performance +- Lazy-load large routes/components using `React.lazy` where appropriate. +- Memoize expensive subtrees with `React.memo` and use `useMemo`/`useCallback` intentionally. +- Avoid unnecessary re-renders; keep state as close to where it’s used as possible. + +## Security +- Never commit secrets; use environment variables. +- Validate inputs server-side; sanitize any user-facing content. +- Keep dependencies reasonably up to date. + +## Git & PR Workflow +- Small, focused commits with descriptive messages. +- Push via the platform [Push Code] button, then [Create PR]. Keep PRs scoped and linked to an issue. +- Code review checklist: + - Types correct, no `any` where avoidable + - No inline styles; tokens preserved; media queries unchanged + - Loading/error/empty states covered + - Accessibility: labels, roles, keyboard, focus order + - Tests and typecheck pass + +## Working With Integrations (MCP) +- Connect integrations from the MCP popover: + - Builder CMS, Linear, Notion, Sentry, Neon, Prisma Postgres, Supabase, Netlify, Zapier, Context7, Figma plugin +- Examples: + - Error monitoring: [Connect to Sentry](#open-mcp-popover) + - Deploy hosting: [Connect to Netlify](#open-mcp-popover) + - DB-backed features: [Connect to Neon](#open-mcp-popover) or [Connect to Supabase](#open-mcp-popover) + - Docs lookup during dev: [Connect to Context7](#open-mcp-popover) + - UI from designs: use the Builder.io Figma plugin + +## Feature Template (End-to-End) +1. Create types in `client/types` (and `shared/` if used by server). +2. Create API helpers in `client/api/*`. +3. Create components in `client/components/*` and page in `client/pages/*`. +4. Add route in `client/App.tsx`. +5. Style with Tailwind; do not alter existing tokens or breakpoints. +6. Add tests; run `pnpm typecheck`, `pnpm test`, `pnpm format.fix`. +7. Manually verify error/loading/empty states. +8. Push and open PR for review. + +Following this guide ensures future contributions remain consistent, maintainable, and production-ready. diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/App.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/App.tsx new file mode 100644 index 000000000..90c22edcf --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/App.tsx @@ -0,0 +1,98 @@ +import { Toaster } from "@/components/ui/toaster"; +import { Toaster as Sonner } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import ErrorBoundary from "@/components/ErrorBoundary"; +import Index from "./pages/Index"; +import Login from "./pages/Login"; +import AFLDashboard from "./pages/AFLDashboard"; +import PlayerPerformance from "./pages/PlayerPerformance"; +import CrowdMonitor from "./pages/CrowdMonitor"; +import Analytics from "./pages/Analytics"; +import Reports from "./pages/Reports"; +import ApiDiagnostics from "./pages/ApiDiagnostics"; +import ErrorDemo from "./pages/ErrorDemo"; +import NotFound from "./pages/NotFound"; +import TeamMatchPerformance from "./pages/TeamMatchPerformance"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: (failureCount, error) => { + try { + // Don't retry on 4xx errors + if (error && typeof error === "object" && "status" in error) { + const status = (error as any).status; + if (status >= 400 && status < 500) { + return false; + } + } + return failureCount < 3; + } catch (retryError) { + console.error("Error in retry logic:", retryError); + return false; + } + }, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + refetchOnWindowFocus: false, // Prevent unnecessary refetches that could cause errors + }, + mutations: { + retry: (failureCount, error) => { + try { + // Don't retry mutations on client errors + if (error && typeof error === "object" && "status" in error) { + const status = (error as any).status; + if (status >= 400 && status < 500) { + return false; + } + } + return failureCount < 2; // Fewer retries for mutations + } catch (retryError) { + console.error("Error in mutation retry logic:", retryError); + return false; + } + }, + }, + }, +}); + +export default function App() { + return ( + { + // In a real app, send this to your logging service + console.error("Global error caught:", error, errorInfo); + }} + > + + + + + + + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> + + + + + + ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/api/axiosInstance.ts b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/api/axiosInstance.ts new file mode 100644 index 000000000..c0b298d9c --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/api/axiosInstance.ts @@ -0,0 +1,36 @@ +import axios, { InternalAxiosRequestConfig } from "axios"; + +const API = axios.create({ + baseURL: "http://127.0.0.1:8000/api/v1", // change if backend is running elsewhere +}); + +// Request interceptor: attach token if it exists +API.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = localStorage.getItem("token"); + if (token) { + config.headers = { + ...config.headers, + Authorization: `Bearer ${token}`, + }; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Response interceptor: handle global errors +API.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem("token"); + window.location.href = "/login"; + } + return Promise.reject(error); + } +); + +export default API; diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/assets/Card_Back.png b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/assets/Card_Back.png new file mode 100644 index 000000000..42002ca3d Binary files /dev/null and b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/assets/Card_Back.png differ diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/assets/Card_Front.png b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/assets/Card_Front.png new file mode 100644 index 000000000..4b11b7f74 Binary files /dev/null and b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/assets/Card_Front.png differ diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ErrorBoundary.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ErrorBoundary.tsx new file mode 100644 index 000000000..85a88a70b --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ErrorBoundary.tsx @@ -0,0 +1,323 @@ +import React, { Component, ErrorInfo, ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Activity, + Home, + RefreshCw, + Bug, + AlertTriangle, + Copy, + ExternalLink, +} from "lucide-react"; +import { Link } from "react-router-dom"; + +interface Props { + children?: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; + showDetails: boolean; +} + +class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + errorInfo: null, + showDetails: false, + }; + + public static getDerivedStateFromError(error: Error): State { + return { + hasError: true, + error, + errorInfo: null, + showDetails: false, + }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("ErrorBoundary caught an error:", error, errorInfo); + + this.setState({ + error, + errorInfo, + }); + + // Call the onError callback if provided - wrap in try/catch to prevent cascading errors + try { + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + } catch (callbackError) { + console.error("Error in ErrorBoundary onError callback:", callbackError); + } + + // In a real app, you might want to send this to a logging service + // logErrorToService(error, errorInfo); + } + + private handleRetry = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + showDetails: false, + }); + }; + + private handleReload = () => { + window.location.reload(); + }; + + private copyErrorDetails = () => { + try { + const errorDetails = this.getErrorDetails(); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard + .writeText(errorDetails) + .then(() => { + console.log("Error details copied to clipboard"); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + // Fallback: try to select text or show alert + this.fallbackCopy(errorDetails); + }); + } else { + this.fallbackCopy(errorDetails); + } + } catch (error) { + console.error("Copy operation failed:", error); + } + }; + + private fallbackCopy = (text: string) => { + try { + // Try to use the old execCommand method as fallback + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + document.execCommand("copy"); + console.log("Error details copied using fallback method"); + } finally { + if (textArea.parentNode) { + textArea.parentNode.removeChild(textArea); + } + } + } catch (fallbackError) { + console.error("Fallback copy failed:", fallbackError); + // Last resort: show alert with the text + alert("Copy failed. Here are the error details:\n\n" + text); + } + }; + + private getErrorDetails = () => { + const { error, errorInfo } = this.state; + return ` +AFL Analytics - Error Report +============================ +Time: ${new Date().toISOString()} +URL: ${window.location.href} +User Agent: ${navigator.userAgent} + +Error: ${error?.message || "Unknown error"} +Stack: ${error?.stack || "No stack trace"} + +Component Stack: ${errorInfo?.componentStack || "No component stack"} + `.trim(); + }; + + public render() { + if (this.state.hasError) { + // If a custom fallback is provided, use it + if (this.props.fallback) { + return this.props.fallback; + } + + // Default error UI + return ( +
+ {/* Header */} +
+
+ +
+ +
+ + AFL Analytics + +
+ Application Error +
+
+ + {/* Main Content */} +
+
+ + +
+ +
+ + Something Went Wrong + + + An unexpected error occurred in the application. Our team + has been notified. + +
+ + + + + Error:{" "} + {this.state.error?.message || "An unknown error occurred"} + + + +
+ + + +
+ + {/* Error Details Section */} +
+ + + {this.state.showDetails && ( +
+
+

+ Error Details +

+ +
+
+                          {this.getErrorDetails()}
+                        
+
+ )} +
+ + {/* Help Section */} +
+

+ Need Help? +

+
    +
  • + β€’ Try refreshing the page or going back to the dashboard +
  • +
  • β€’ Clear your browser cache and cookies
  • +
  • β€’ Contact support if the error persists
  • +
  • + β€’ Include the error details above when reporting the + issue +
  • +
+ +
+
+
+
+
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; + +// Higher-order component for wrapping components with error boundary +export function withErrorBoundary( + Component: React.ComponentType, + onError?: (error: Error, errorInfo: ErrorInfo) => void, +) { + return function WrappedComponent(props: T) { + return ( + + + + ); + }; +} + +// Hook for manual error reporting +export function useErrorHandler() { + return (error: Error, context?: string) => { + console.error("Manual error report:", { error, context }); + // In a real app, send to logging service + }; +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/LiveClock.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/LiveClock.tsx new file mode 100644 index 000000000..bc224ccd9 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/LiveClock.tsx @@ -0,0 +1,136 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Play, Pause, Clock } from "lucide-react"; + +interface LiveClockProps { + isLive: boolean; + onToggleLive: (isLive: boolean) => void; + matchTime?: { + quarter: number; + timeRemaining: string; + }; +} + +export default function LiveClock({ + isLive, + onToggleLive, + matchTime, +}: LiveClockProps) { + const [currentTime, setCurrentTime] = useState(new Date()); + const [gameTime, setGameTime] = useState({ + quarter: matchTime?.quarter || 2, + minutes: 15, + seconds: 23, + }); + + // Update current time every second + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + + return () => clearInterval(timer); + }, []); + + // Update game time when live + useEffect(() => { + if (!isLive) return; + + const gameTimer = setInterval(() => { + setGameTime((prev) => { + let newSeconds = prev.seconds - 1; + let newMinutes = prev.minutes; + let newQuarter = prev.quarter; + + if (newSeconds < 0) { + newSeconds = 59; + newMinutes -= 1; + } + + if (newMinutes < 0) { + newMinutes = 19; + newSeconds = 59; + newQuarter += 1; + if (newQuarter > 4) { + newQuarter = 4; + newMinutes = 0; + newSeconds = 0; + } + } + + return { + quarter: newQuarter, + minutes: newMinutes, + seconds: newSeconds, + }; + }); + }, 1000); + + return () => clearInterval(gameTimer); + }, [isLive]); + + const formatTime = (time: Date) => { + return time.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + }; + + const formatGameTime = () => { + const mins = gameTime.minutes.toString().padStart(2, "0"); + const secs = gameTime.seconds.toString().padStart(2, "0"); + return `${mins}:${secs}`; + }; + + return ( +
+ {/* Live Status */} +
+ +
+ {isLive ? "LIVE" : "OFFLINE"} + + + +
+ + {/* Time Display */} +
+
+ + {formatTime(currentTime)} +
+ + {isLive && ( + <> +
+
+ Q{gameTime.quarter} + {formatGameTime()} +
+ + )} +
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/LoadingState.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/LoadingState.tsx new file mode 100644 index 000000000..19f1dd73c --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/LoadingState.tsx @@ -0,0 +1,297 @@ +import { ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Loader2, + RefreshCw, + AlertCircle, + Wifi, + Server, + Clock, + CheckCircle, +} from "lucide-react"; + +interface LoadingStateProps { + isLoading?: boolean; + error?: Error | string | null; + isEmpty?: boolean; + children?: ReactNode; + loadingText?: string; + emptyText?: string; + emptyDescription?: string; + onRetry?: () => void; + retryText?: string; + showRetry?: boolean; + variant?: "default" | "card" | "inline" | "skeleton"; + skeletonRows?: number; +} + +export default function LoadingState({ + isLoading = false, + error = null, + isEmpty = false, + children, + loadingText = "Loading...", + emptyText = "No data available", + emptyDescription = "There's nothing to show here yet.", + onRetry, + retryText = "Try Again", + showRetry = true, + variant = "default", + skeletonRows = 3, +}: LoadingStateProps) { + // Loading state + if (isLoading) { + if (variant === "skeleton") { + return ( +
+ {Array.from({ length: skeletonRows }).map((_, i) => ( +
+ + +
+ ))} +
+ ); + } + + if (variant === "inline") { + return ( +
+
+ + {loadingText} +
+
+ ); + } + + if (variant === "card") { + return ( + + + +

{loadingText}

+
+
+ ); + } + + // Default loading + return ( +
+ +

{loadingText}

+
+ ); + } + + // Error state + if (error) { + const errorMessage = typeof error === "string" ? error : error.message; + const isNetworkError = + errorMessage.toLowerCase().includes("network") || + errorMessage.toLowerCase().includes("fetch"); + const isServerError = + errorMessage.toLowerCase().includes("server") || + errorMessage.includes("5"); + + const ErrorIcon = isNetworkError + ? Wifi + : isServerError + ? Server + : AlertCircle; + const errorColor = isNetworkError + ? "text-blue-600" + : isServerError + ? "text-red-600" + : "text-orange-600"; + const bgColor = isNetworkError + ? "bg-blue-50 border-blue-200" + : isServerError + ? "bg-red-50 border-red-200" + : "bg-orange-50 border-orange-200"; + + if (variant === "inline") { + return ( + + + + {errorMessage} + {showRetry && onRetry && ( + + )} + + + ); + } + + if (variant === "card") { + return ( + + + +

+ {isNetworkError + ? "Connection Error" + : isServerError + ? "Server Error" + : "Error"} +

+

{errorMessage}

+ {showRetry && onRetry && ( + + )} +
+
+ ); + } + + // Default error + return ( +
+
+ +
+

+ {isNetworkError + ? "Connection Problem" + : isServerError + ? "Server Error" + : "Something went wrong"} +

+

{errorMessage}

+ {showRetry && onRetry && ( + + )} +
+
+
+ ); + } + + // Empty state + if (isEmpty) { + if (variant === "inline") { + return ( +
+

{emptyText}

+
+ ); + } + + if (variant === "card") { + return ( + + +
+ +
+

+ {emptyText} +

+

{emptyDescription}

+
+
+ ); + } + + // Default empty + return ( +
+
+ +
+

{emptyText}

+

{emptyDescription}

+
+ ); + } + + // Success state - render children + return <>{children}; +} + +// Specialized loading components +export const SkeletonLoader = ({ rows = 3 }: { rows?: number }) => ( + +); + +export const InlineLoader = ({ text = "Loading..." }: { text?: string }) => ( + +); + +export const CardLoader = ({ text = "Loading..." }: { text?: string }) => ( + +); + +// Data fetching wrapper component +interface DataWrapperProps { + data: any; + isLoading: boolean; + error: Error | string | null; + children: ReactNode; + emptyMessage?: string; + onRetry?: () => void; +} + +export const DataWrapper = ({ + data, + isLoading, + error, + children, + emptyMessage = "No data available", + onRetry, +}: DataWrapperProps) => { + const isEmpty = !data || (Array.isArray(data) && data.length === 0); + + return ( + + {children} + + ); +}; + +// Success state component +export const SuccessState = ({ + message = "Success!", + description, + action, +}: { + message?: string; + description?: string; + action?: ReactNode; +}) => ( +
+
+ +
+

{message}

+ {description &&

{description}

} + {action} +
+); diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/MobileNavigation.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/MobileNavigation.tsx new file mode 100644 index 000000000..6630c048b --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/MobileNavigation.tsx @@ -0,0 +1,218 @@ +import { useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { + Activity, + BarChart3, + Users, + Download, + Video, + Menu, + Home, + Zap, + Terminal, +} from "lucide-react"; + +const navigationItems = [ + { + name: "Home", + href: "/afl-dashboard", + icon: Home, + description: "Main dashboard", + }, + { + name: "Player Performance", + href: "/player-performance", + icon: BarChart3, + description: "Player stats & analysis", + }, + { + name: "Crowd Monitor", + href: "/crowd-monitor", + icon: Users, + description: "Stadium crowd analytics", + }, + { + name: "Analytics", + href: "/analytics", + icon: Video, + description: "Video analysis & reports", + }, + { + name: "Reports", + href: "/reports", + icon: Download, + description: "Download & manage reports", + }, + { + name: "API Diagnostics", + href: "/api-diagnostics", + icon: Terminal, + description: "System monitoring", + }, +]; + +export default function MobileNavigation() { + const [isOpen, setIsOpen] = useState(false); + const location = useLocation(); + + const isActive = (href: string) => { + if (href === "/") { + return location.pathname === "/"; + } + return location.pathname.startsWith(href); + }; + + return ( + <> + {/* Mobile Header */} +
+
+ +
+ +
+ + AFL Analytics + + + + + + + + +
+
+
+ +
+ AFL Analytics +
+ + +
+
+
+
+
+ + {/* Desktop Navigation */} + + + {/* Bottom Navigation for Mobile */} + + + ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/PlayerCardBack.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/PlayerCardBack.tsx new file mode 100644 index 000000000..f93c76d47 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/PlayerCardBack.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import cardBack from "@/assets/Card_Back.png"; + +export default function PlayerCardBack() { + return ( +
+ Player Card Back +
+ ); +} + + diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/PlayerCardFront.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/PlayerCardFront.tsx new file mode 100644 index 000000000..8e2195792 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/PlayerCardFront.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import cardFront from "@/assets/Card_Front.png"; + +export default function PlayerCardFront() { + return ( +
+ Player Card Front +
+ ); +} + + diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/PlayerCharts.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/PlayerCharts.tsx new file mode 100644 index 000000000..dbdd5142f --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/PlayerCharts.tsx @@ -0,0 +1,84 @@ +import React, { useState } from "react"; +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + BarChart, + Bar, + Legend, +} from "recharts"; + +const labels = ["Match 1", "Match 2", "Match 3", "Match 4", "Match 5"]; + +const metricData = { + "Fatigue Score": [67, 72, 63, 70, 69], + "Time On Ground": [82, 79, 85, 80, 84], + "Distance Covered": [10.8, 11.2, 9.5, 10.1, 10.9], +}; + +const possessionData = [55, 34, 40, 37, 68]; +const barColors: Record = { + "Fatigue Score": "#dc2626", + "Time On Ground": "#2563eb", + "Distance Covered": "#16a34a", +}; + +const possessionSeries = labels.map((label, i) => ({ label, value: possessionData[i] })); + +export function PossessionChart() { + return ( +
+ + + + + + + + + + +
+ ); +} + +export function MetricsChart() { + const [selectedMetric, setSelectedMetric] = useState("Fatigue Score"); + const data = labels.map((label, i) => ({ label, value: metricData[selectedMetric][i] })); + + return ( +
+
+ {Object.keys(metricData).map((metric) => ( + + ))} +
+
+ + + + + + + + + + +
+
+ ); +} + + diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/AuthHero.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/AuthHero.tsx new file mode 100644 index 000000000..b5e805bdd --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/AuthHero.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import FeatureCards from "@/components/auth/FeatureCards"; +import DemoAccessCard from "@/components/auth/DemoAccessCard"; +import { Activity, Users, BarChart3, Video, Shield } from "lucide-react"; + +export default function AuthHero({ onLoadDemo }: { onLoadDemo: () => void }) { + const features = [ + { + icon: Activity, + title: "Player Performance", + description: "Real-time player statistics and performance metrics", + }, + { + icon: Users, + title: "Crowd Monitoring", + description: "Stadium crowd density and safety analytics", + }, + { + icon: BarChart3, + title: "Advanced Analytics", + description: "Comprehensive reporting and data insights", + }, + { + icon: Video, + title: "Video Analysis", + description: "AI-powered match video analysis and highlights", + }, + ]; + + return ( +
+
+ + + Trusted by AFL Teams + +

+ Professional AFL + + Analytics Platform + +

+

+ Comprehensive player performance tracking, crowd monitoring, and match analytics designed specifically for Australian Football League professionals. +

+
+ + + + +
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/AuthProviderButtons.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/AuthProviderButtons.tsx new file mode 100644 index 000000000..946bcc769 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/AuthProviderButtons.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; + +export function AuthProviderButtons({ + mode, + onGoogle, + onApple, +}: { + mode: "login" | "signup"; + onGoogle: () => void; + onApple: () => void; +}) { + const googleText = mode === "signup" ? "Sign up with Google" : "Continue with Google"; + const appleText = mode === "signup" ? "Sign up with Apple" : "Continue with Apple"; + + return ( +
+ + + +
+
+ +
+
+ + {mode === "signup" ? "Or sign up with email" : "Or continue with email"} + +
+
+
+ ); +} + +export default AuthProviderButtons; diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/DemoAccessCard.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/DemoAccessCard.tsx new file mode 100644 index 000000000..5483aeba0 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/DemoAccessCard.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { User } from "lucide-react"; + +export default function DemoAccessCard({ onLoadDemo }: { onLoadDemo: () => void }) { + return ( +
+
+ + Demo Access +
+

+ Try the platform with demo credentials to explore all features +

+ +
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/FeatureCards.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/FeatureCards.tsx new file mode 100644 index 000000000..09fb7246a --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/FeatureCards.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +export type Feature = { + icon: React.ComponentType<{ className?: string }>; + title: string; + description: string; +}; + +export default function FeatureCards({ features }: { features: Feature[] }) { + return ( +
+ {features.map((feature, index) => { + const Icon = feature.icon; + return ( +
+
+
+ +
+
+

{feature.title}

+

{feature.description}

+
+
+
+ ); + })} +
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/ForgotPasswordDialog.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/ForgotPasswordDialog.tsx new file mode 100644 index 000000000..23888038e --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/ForgotPasswordDialog.tsx @@ -0,0 +1,233 @@ +import React, { useState } from "react"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Mail, Lock, ArrowLeft, CheckCircle } from "lucide-react"; +import { sendResetEmail, verifyResetCode, resetPassword} from "@/lib/auth"; + +export default function ForgotPasswordDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [step, setStep] = useState<1 | 2 | 3 | 4>(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + const [form, setForm] = useState({ email: "", code: "", newPassword: "", confirmNewPassword: "" }); + + const close = () => { + onOpenChange(false); + setStep(1); + setForm({ email: "", code: "", newPassword: "", confirmNewPassword: "" }); + setError(""); + setMessage(""); + }; + + const onSendEmail: React.FormEventHandler = async (e) => { + e.preventDefault(); + setIsLoading(true); + setError(""); + const res = await sendResetEmail(form.email); + if (res.success) { + setMessage(`Reset link sent to ${form.email}`); + setStep(2); + } else { + if ("message" in res) setError(res.message); + } + setIsLoading(false); + }; + + const onVerify: React.FormEventHandler = async (e) => { + e.preventDefault(); + setIsLoading(true); + setError(""); + const res = await verifyResetCode(form.code); + if (res.success) { + setStep(3); + } else { + if ("message" in res) setError(res.message); + } + setIsLoading(false); + }; + + const onReset: React.FormEventHandler = async (e) => { + e.preventDefault(); + setIsLoading(true); + setError(""); + const res = await resetPassword(form.newPassword, form.confirmNewPassword); + if (res.success) { + setStep(4); + } else { + if ("message" in res) setError(res.message); + } + setIsLoading(false); + }; + + return ( + + + + + {step > 1 && step < 4 && ( + + )} + {step === 1 && "Reset Password"} + {step === 2 && "Enter Verification Code"} + {step === 3 && "Create New Password"} + {step === 4 && "Password Reset Complete"} + + + {step === 1 && "Enter your email to receive a reset link"} + {step === 2 && "Check your email for a verification code"} + {step === 3 && "Enter your new password"} + {step === 4 && "Your password has been successfully reset"} + + + +
+ {error && ( + + {error} + + )} + + {message && step === 2 && ( + + + {message} + + )} + + {step === 1 && ( +
+
+ +
+ + setForm({ ...form, email: e.target.value })} + className="pl-10" + required + /> +
+
+ +
+ )} + + {step === 2 && ( +
+
+ + setForm({ ...form, code: e.target.value })} + maxLength={6} + required + /> +
+ +
+ )} + + {step === 3 && ( +
+
+ +
+ + setForm({ ...form, newPassword: e.target.value })} + className="pl-10" + required + /> +
+
+
+ +
+ + setForm({ ...form, confirmNewPassword: e.target.value })} + className="pl-10" + required + /> +
+
+ +
+ )} + + {step === 4 && ( +
+
+ +
+
+

Success!

+

Your password has been reset successfully.

+
+ +
+ )} +
+
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/HeaderBrand.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/HeaderBrand.tsx new file mode 100644 index 000000000..018249147 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/HeaderBrand.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import { Activity, Monitor, Smartphone } from "lucide-react"; + +export default function HeaderBrand() { + return ( +
+
+
+
+
+ +
+
+

+ AFL Analytics +

+

Professional Sports Analytics Platform

+
+
+
+ + + Web App + + + + Mobile Optimized + +
+
+
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/LoginForm.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/LoginForm.tsx new file mode 100644 index 000000000..5cd0bfb07 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/LoginForm.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Mail, Lock, Eye, EyeOff } from "lucide-react"; + +export type LoginValues = { + email: string; + password: string; + rememberMe: boolean; +}; + +export default function LoginForm({ + values, + showPassword, + onToggleShowPassword, + onChange, + onSubmit, + isLoading, + onForgotPassword, +}: { + values: LoginValues; + showPassword: boolean; + onToggleShowPassword: () => void; + onChange: (update: Partial) => void; + onSubmit: (e: React.FormEvent) => void; + isLoading: boolean; + onForgotPassword: () => void; +}) { + return ( +
+
+ +
+ + onChange({ email: e.target.value })} + className="pl-10" + required + /> +
+
+ +
+ +
+ + onChange({ password: e.target.value })} + className="pl-10 pr-10" + required + /> + +
+
+ +
+
+ onChange({ rememberMe: checked as boolean })} + /> + +
+ +
+ + +
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/SignupForm.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/SignupForm.tsx new file mode 100644 index 000000000..5459d18a1 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/auth/SignupForm.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Mail, Lock, Building } from "lucide-react"; + +export type SignupValues = { + firstName: string; + lastName: string; + email: string; + password: string; + confirmPassword: string; + organization: string; + role: string; + agreeTerms: boolean; +}; + +export default function SignupForm({ + values, + onChange, + onSubmit, + isLoading, +}: { + values: SignupValues; + onChange: (update: Partial) => void; + onSubmit: (e: React.FormEvent) => void; + isLoading: boolean; +}) { + return ( +
+
+
+ + onChange({ firstName: e.target.value })} + required + /> +
+
+ + onChange({ lastName: e.target.value })} + required + /> +
+
+ +
+ +
+ + onChange({ email: e.target.value })} + className="pl-10" + required + /> +
+
+ +
+ +
+ + onChange({ organization: e.target.value })} + className="pl-10" + required + /> +
+
+ +
+ +
+ + onChange({ password: e.target.value })} + className="pl-10" + required + /> +
+
+ +
+ +
+ + onChange({ confirmPassword: e.target.value })} + className="pl-10" + required + /> +
+
+ +
+ onChange({ agreeTerms: checked as boolean })} + required + /> + +
+ + +
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/AnalysisResultsPanel.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/AnalysisResultsPanel.tsx new file mode 100644 index 000000000..46c2f7f2f --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/AnalysisResultsPanel.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { Eye, Download, FileText, Video } from "lucide-react"; + +export default function AnalysisResultsPanel({ + videoAnalysisComplete, + selectedAnalysisType, + selectedVideoFileName, + selectedFocusAreas, + onDownloadVideoClips, + onDownloadReport, +}: { + videoAnalysisComplete: boolean; + selectedAnalysisType: string; + selectedVideoFileName?: string; + selectedFocusAreas: string[]; + onDownloadVideoClips: () => void; + onDownloadReport: (format: "pdf" | "json" | "txt") => void; +}) { + return ( + + + + + Analysis Results + + AI-generated insights from uploaded videos + + + {!videoAnalysisComplete ? ( +
+
+ ) : ( +
+
+
+ + Analysis Type:{" "} + {selectedAnalysisType === "highlights" + ? "Match Highlights" + : selectedAnalysisType === "player" + ? "Player Tracking" + : selectedAnalysisType === "tactics" + ? "Tactical Analysis" + : selectedAnalysisType === "performance" + ? "Performance Metrics" + : "Crowd Reactions"} + + Complete +
+
Video: {selectedVideoFileName}
+
+ + {selectedFocusAreas.length > 0 && ( +
+
+ Focus Areas Analyzed + {selectedFocusAreas.length} areas +
+
{selectedFocusAreas.join(", ")}
+
+ )} + +
+
+ AI Insights Generated + Ready +
+
+ {selectedAnalysisType === "highlights" && "Key moments and highlights identified"} + {selectedAnalysisType === "player" && "Player movements and performance tracked"} + {selectedAnalysisType === "tactics" && "Tactical patterns and strategies analyzed"} + {selectedAnalysisType === "performance" && "Performance metrics calculated"} + {selectedAnalysisType === "crowd" && "Crowd reactions and engagement measured"} +
+
+
+ )} + + + +
+

Export Analysis

+

Download analysis data from backend in different formats

+
+ +
+ + + +
+
+
+ PDF: Formatted report for printing/sharing +
+
+ JSON: Raw backend data for developers +
+
+ TXT: Plain text summary for analysis +
+
+
+
+
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/DashboardHeader.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/DashboardHeader.tsx new file mode 100644 index 000000000..ccb45bf25 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/DashboardHeader.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Activity, Settings, LogOut } from "lucide-react"; + +export default function DashboardHeader({ + isLive, + userEmail, + onLogout, +}: { + isLive: boolean; + userEmail?: string; + onLogout: () => void; +}) { + return ( +
+
+
+
+
+ +
+
+

+ AFL Analytics +

+

Real-time match insights & player analytics

+
+
+
+ +
+ {isLive ? "LIVE" : "OFFLINE"} + + {userEmail && ( + Welcome, {userEmail} + )} + + +
+
+
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/MatchesList.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/MatchesList.tsx new file mode 100644 index 000000000..9b15a459a --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/MatchesList.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Flag } from "lucide-react"; +import TeamCompareBar from "@/components/dashboard/TeamCompareBar"; + +export default function MatchesList() { + // Mock matches data - this should come from the backend + const matches = [ + { + id: 1, + round: "Round 12", + venue: "MCG", + date: "2025-07-02", + teams: { home: "Western Bulldogs", away: "Richmond" }, + stats: { + home: { goals: 12, behinds: 8, disposals: 368, marks: 86, tackles: 57, clearances: 34, inside50: 55, efficiency: 76 }, + away: { goals: 10, behinds: 11, disposals: 341, marks: 73, tackles: 62, clearances: 31, inside50: 49, efficiency: 72 }, + }, + }, + { + id: 2, + round: "Round 12", + venue: "Marvel Stadium", + date: "2025-07-03", + teams: { home: "Geelong", away: "Collingwood" }, + stats: { + home: { goals: 14, behinds: 7, disposals: 402, marks: 90, tackles: 51, clearances: 39, inside50: 61, efficiency: 79 }, + away: { goals: 9, behinds: 12, disposals: 359, marks: 77, tackles: 66, clearances: 30, inside50: 47, efficiency: 71 }, + }, + }, + ]; + + if (!matches || matches.length === 0) { + return ( +
+

No matches available

+
+ ); + } + + return ( +
+ {matches.map((m) => { + const homePoints = m.stats.home.goals * 6 + m.stats.home.behinds; + const awayPoints = m.stats.away.goals * 6 + m.stats.away.behinds; + const winPct = Math.min(100, Math.max(0, Math.round((homePoints / (homePoints + awayPoints || 1)) * 100))); + return ( + + +
+
+ + + {m.teams.home} vs {m.teams.away} + +
+ {m.round} +
+ + {m.venue} β€’ {new Date(m.date).toLocaleDateString()} + +
+ +
+
+
Score
+
+ {homePoints} - {awayPoints} +
+
+ {m.stats.home.goals}.{m.stats.home.behinds} vs {m.stats.away.goals}.{m.stats.away.behinds} +
+
+
+
Win Probability ({m.teams.home})
+ +
+
+ +
+ + + + + + +
+
+
+ ); + })} +
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/PlayerComparison.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/PlayerComparison.tsx new file mode 100644 index 000000000..88bcffb9e --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/PlayerComparison.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Target } from "lucide-react"; +import { ResponsiveContainer as RC, LineChart as RL, CartesianGrid as RG, XAxis as RX, YAxis as RY, Tooltip as RT, Legend as RLg, Line as RLine } from "recharts"; + +export default function PlayerComparison({ + selectedPlayer, + comparisonPlayer, + setComparisonPlayer, + mockPlayers, + playerComparisonData, +}: { + selectedPlayer: any; + comparisonPlayer: any; + setComparisonPlayer: (p: any) => void; + mockPlayers: any[]; + playerComparisonData: any[]; +}) { + return ( + + + + + Player Comparison + + Compare {selectedPlayer.name} with another player + + +
+ +
+ +
+
+

Statistical Comparison

+ {["kicks", "handballs", "marks", "tackles", "goals"].map((stat) => ( +
+
+ {stat} + + {selectedPlayer[stat]} vs {comparisonPlayer[stat]} + +
+
+
+ +
{selectedPlayer.name}
+
+
+ +
{comparisonPlayer.name}
+
+
+
+ ))} +
+ +
+

Performance Trend

+
+ + + + + + + + + + + +
+
Performance metrics comparison between selected players
+
+
+
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/PlayerSearchFilters.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/PlayerSearchFilters.tsx new file mode 100644 index 000000000..f0f31b702 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/PlayerSearchFilters.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Search, Users, Target } from "lucide-react"; + +export default function PlayerSearchFilters({ + searchTerm, + setSearchTerm, + selectedTeam, + setSelectedTeam, + filteredPlayers, + selectedPlayer, + onSelectPlayer, + availableTeams, +}: { + searchTerm: string; + setSearchTerm: (v: string) => void; + selectedTeam: string; + setSelectedTeam: (v: string) => void; + filteredPlayers: any[]; + selectedPlayer: any; + onSelectPlayer: (player: any) => void; + availableTeams: string[]; +}) { + return ( + + + + + Player Search + + + + {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="pl-10 h-10" + /> +
+ + {/* Team Filter */} + + + {/* Player List */} +
+ {filteredPlayers && filteredPlayers.length > 0 ? ( + filteredPlayers.map((player) => ( +
onSelectPlayer(player)} + > +
+
+
{player.name}
+
{player.team}
+
+
+ + {player.position} + +
+ + {player.efficiency}% +
+
+
+
+ )) + ) : ( +
+ +

No players found

+

Try adjusting your search or filters

+
+ )} +
+ + {/* Results Count */} + {filteredPlayers && filteredPlayers.length > 0 && ( +
+ {filteredPlayers.length} player{filteredPlayers.length !== 1 ? 's' : ''} found +
+ )} +
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/PlayerStatsGrid.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/PlayerStatsGrid.tsx new file mode 100644 index 000000000..4d096b8ad --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/PlayerStatsGrid.tsx @@ -0,0 +1,130 @@ +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"; +import { TrendingUp } from "lucide-react"; + +export default function PlayerStatsGrid({ selectedPlayer, performanceTrendData }: { selectedPlayer: any; performanceTrendData: any[] }) { + if (!selectedPlayer) { + return ( + + + Player Statistics + No player selected + + +

Please select a player to view statistics.

+
+
+ ); + } + + return ( + + + + Player Statistics - {selectedPlayer.name} + {selectedPlayer.team} + + {selectedPlayer.position} + + +
+
+
{selectedPlayer.kicks}
+
Kicks
+
+
+
{selectedPlayer.handballs}
+
Handballs
+
+
+
{selectedPlayer.marks}
+
Marks
+
+
+
{selectedPlayer.tackles}
+
Tackles
+
+
+
{selectedPlayer.goals}
+
Goals
+
+
+
{selectedPlayer.efficiency}%
+
Efficiency
+
+
+ + {/* Performance Trend Graph */} + {performanceTrendData && performanceTrendData.length > 0 && ( +
+
+ +

Performance Trend (Last 5 Weeks)

+
+
+ + + + + + + + + + + + + + + +
+
+ )} +
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/ProcessingQueueList.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/ProcessingQueueList.tsx new file mode 100644 index 000000000..0fa77e448 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/ProcessingQueueList.tsx @@ -0,0 +1,202 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Clock, Download, Eye, ChevronDown, FileText, Zap } from "lucide-react"; +import QueueStatusIcon from "@/components/dashboard/QueueStatusIcon"; +import type { QueueItem } from "@/types/dashboard"; + +export function ProcessingQueueList({ + items, + onRetry, + onRemove, + onView, + onDownload, + formatTimeAgo, + formatETA, +}: { + items: QueueItem[]; + onRetry: (id: string) => void; + onRemove: (id: string) => void; + onView: (item: QueueItem) => void; + onDownload: (item: QueueItem, format: "pdf" | "json" | "txt") => void; + formatTimeAgo: (ts: string) => string; + formatETA: (ts: string | null) => string; +}) { + return ( +
+ {items.map((item) => ( +
+
+
+ +
+
{item.name}
+
+ {item.analysisType} + β€’ + {item.duration} + β€’ + {item.size} + {item.priority === "high" && ( + <> + β€’ + HIGH PRIORITY + + )} +
+
+
+
+ + {item.status} + + {item.retryCount > 0 && ( + Retry #{item.retryCount} + )} +
+
+ + {item.progress > 0 && item.progress < 100 && ( +
+
+ + {item.status === "uploading" + ? "Uploading file..." + : item.status === "processing" + ? "Pre-processing video..." + : item.status === "analyzing" + ? "Analyzing video content..." + : "Processing..."} + + {Math.round(item.progress)}% +
+ +
+ Stage: {item.processingStage.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())} +
+
+ )} + + {item.status === "failed" && ( +
+
+
+ + Processing failed after {item.errorCount} attempt{item.errorCount > 1 ? "s" : ""} + +
+
+ Common causes: Unsupported format, corrupted file, or insufficient server resources +
+
+ )} + +
+
+ Uploaded: {formatTimeAgo(item.uploadTime)} + {item.status === "completed" && item.completedTime && ( + Completed: {formatTimeAgo(item.completedTime)} + )} + {item.estimatedCompletion && item.status !== "completed" && ( + ETA: {formatETA(item.estimatedCompletion)} + )} +
+
+ {item.status === "completed" && ( + <> + + + + + + + onDownload(item, "pdf")}> + PDF Report + + onDownload(item, "json")}> + JSON Data + + onDownload(item, "txt")}> + Text Summary + + + + + )} + + {item.status === "failed" && ( + <> + + + + )} + + {(item.status === "queued" || item.status === "uploading") && ( + + )} +
+
+
+ ))} + + {items.length === 0 && ( +
+ +

No items in processing queue

+

Upload a video to start analysis

+
+ )} +
+ ); +} + +export default ProcessingQueueList; diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/QueueStatusIcon.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/QueueStatusIcon.tsx new file mode 100644 index 000000000..952435281 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/QueueStatusIcon.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +export type QueueStatus = + | "uploading" + | "queued" + | "processing" + | "analyzing" + | "completed" + | "failed"; + +export function QueueStatusIcon({ status }: { status: QueueStatus }) { + switch (status) { + case "completed": + return
; + case "analyzing": + case "processing": + return
; + case "uploading": + return
; + case "queued": + return
; + case "failed": + return
; + default: + return
; + } +} + +export default QueueStatusIcon; diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/ReportsPanel.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/ReportsPanel.tsx new file mode 100644 index 000000000..fcf2c928c --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/ReportsPanel.tsx @@ -0,0 +1,212 @@ +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { FileText, Users, Download } from "lucide-react"; +import { downloadText } from "@/lib/download"; + +export default function ReportsPanel() { + const recentReports = [ + { name: "Weekly Player Performance - Round 15", date: "2024-01-15", size: "2.4 MB", format: "PDF" }, + { name: "Crowd Density Analysis - MCG", date: "2024-01-14", size: "1.8 MB", format: "Excel" }, + { name: "Season Summary Report", date: "2024-01-12", size: "5.2 MB", format: "PDF" }, + { name: "Player Comparison - Top 50", date: "2024-01-10", size: "3.1 MB", format: "Excel" }, + ]; + + const handleDownloadRecent = (report: (typeof recentReports)[number]) => { + const content = `AFL Analytics Report: ${report.name}\n\nGenerated: ${report.date}\nFormat: ${report.format}\nSize: ${report.size}\n\nThis is a sample report from AFL Analytics Platform.\nReport details and analysis data would be included here in a real implementation.\n\nGenerated on: ${new Date().toLocaleString()}\n`; + downloadText(content, `${report.name.replace(/[^a-z0-9]/gi, "_")}_${Date.now()}`); + }; + + return ( +
+
+ + + + + Player Performance Reports + + Generate detailed analytics reports for players and teams + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+

Include Sections

+
+ {["Performance Statistics", "Match Highlights", "Trend Analysis", "Comparison Charts", "Heat Maps"].map( + (section) => ( + + ), + )} +
+
+ + +
+
+ + + + + + Crowd Analytics Reports + + Generate crowd movement and density reports + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+

Analytics Features

+
+ {["Heat Map Visualization", "Peak Hour Analysis", "Entry/Exit Patterns", "Safety Compliance", "Revenue Optimization"].map( + (feature) => ( + + ), + )} +
+
+ + +
+
+
+ + + + Recent Reports + Download previously generated reports + + +
+ {recentReports.map((report, i) => ( +
+
+
{report.name}
+
+ {report.date} β€’ {report.size} β€’ {report.format} +
+
+ +
+ ))} +
+
+
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamCompareBar.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamCompareBar.tsx new file mode 100644 index 000000000..7cbc0df5c --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamCompareBar.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +export function TeamCompareBar({ + label, + aLabel, + aValue, + bLabel, + bValue, +}: { + label: string; + aLabel: string; + aValue: number; + bLabel: string; + bValue: number; +}) { + const max = Math.max(aValue, bValue) || 1; + const aPct = Math.round((aValue / max) * 100); + const bPct = Math.round((bValue / max) * 100); + + return ( +
+
+ {label} + + {aValue} vs {bValue} + +
+
+
+ {aLabel} +
+
+
+
+
+ {bLabel} +
+
+
+
+
+
+ ); +} + +export default TeamCompareBar; diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamMatchCompare.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamMatchCompare.tsx new file mode 100644 index 000000000..a09a1ee4d --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamMatchCompare.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Target } from "lucide-react"; +import TeamCompareBar from "@/components/dashboard/TeamCompareBar"; + +export default function TeamMatchCompare({ + teamA, + setTeamA, + teamB, + setTeamB, + teamTeams, + teamCompare, +}: { + teamA: string; + setTeamA: (v: string) => void; + teamB: string; + setTeamB: (v: string) => void; + teamTeams: string[]; + teamCompare: { a: any; b: any; aEff: number; bEff: number }; +}) { + const ready = teamA !== "all" && teamB !== "all" && teamA !== teamB; + return ( + + + + + Compare Teams + + Select two teams to compare totals across listed matches + + +
+
+ +
+
+ +
+
+ + {ready ? "Ready" : "Select two different teams"} + +
+
+ + {ready && ( +
+ + + + + + +
+ )} +
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamMatchFilters.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamMatchFilters.tsx new file mode 100644 index 000000000..cde5cb4d7 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamMatchFilters.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +export default function TeamMatchFilters({ + teamSearch, + setTeamSearch, + teamFilter, + setTeamFilter, + teamRound, + setTeamRound, + teamRounds, + teamTeams, +}: { + teamSearch: string; + setTeamSearch: (v: string) => void; + teamFilter: string; + setTeamFilter: (v: string) => void; + teamRound: string; + setTeamRound: (v: string) => void; + teamRounds: string[]; + teamTeams: string[]; +}) { + return ( +
+
+
+ setTeamSearch(e.target.value)} /> +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamPerformanceTab.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamPerformanceTab.tsx new file mode 100644 index 000000000..b428973fe --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamPerformanceTab.tsx @@ -0,0 +1,162 @@ +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Progress } from "@/components/ui/progress"; +import { Separator } from "@/components/ui/separator"; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"; +import { TrendingUp, TrendingDown, Users, Target, Calendar, MapPin } from "lucide-react"; +import TeamMatchFilters from "./TeamMatchFilters"; +import TeamMatchCompare from "./TeamMatchCompare"; +import TeamSummaryCards from "./TeamSummaryCards"; +import MatchesList from "./MatchesList"; + +interface TeamPerformanceTabProps { + teamA: string; + setTeamA: (team: string) => void; + teamB: string; + setTeamB: (team: string) => void; + teamCompare: any; + teamTeams: string[]; +} + +export default function TeamPerformanceTab({ + teamA, + setTeamA, + teamB, + setTeamB, + teamCompare, + teamTeams, +}: TeamPerformanceTabProps) { + // Mock data for team performance charts + const teamPerformanceData = [ + { metric: "Goals", teamA: teamCompare.a.goals, teamB: teamCompare.b.goals }, + { metric: "Disposals", teamA: teamCompare.a.disposals, teamB: teamCompare.b.disposals }, + { metric: "Marks", teamA: teamCompare.a.marks, teamB: teamCompare.b.marks }, + { metric: "Tackles", teamA: teamCompare.a.tackles, teamB: teamCompare.b.tackles }, + { metric: "Clearances", teamA: teamCompare.a.clearances, teamB: teamCompare.b.clearances }, + { metric: "Inside 50s", teamA: teamCompare.a.inside50, teamB: teamCompare.b.inside50 }, + ]; + + return ( +
+ {/* Team Comparison Header */} +
+
+

Team Performance

+

Compare team statistics and match performance

+
+ + + Live Data + +
+ + {/* Team Selection */} + + + + + Team Comparison + + + Select teams to compare their performance metrics + + + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Team Summary Cards */} + + + {/* Performance Comparison Chart */} + + + + + Performance Comparison + + + Side-by-side comparison of key performance metrics + + + +
+ + + + + + + + + + + +
+
+
+ + {/* Team Match Comparison */} + + + {/* Recent Matches */} + + + + + Recent Matches + + + Latest match results and statistics + + + + + + +
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamSummaryCards.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamSummaryCards.tsx new file mode 100644 index 000000000..360a5ddc5 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/TeamSummaryCards.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { Card, CardContent } from "@/components/ui/card"; + +export default function TeamSummaryCards({ teamCompare, teamA, teamB }: { teamCompare: any; teamA: string; teamB: string }) { + // Create summary data from teamCompare + const teamASummary = { + games: 3, // Mock data - should come from backend + goals: teamCompare?.a?.goals || 0, + disposals: teamCompare?.a?.disposals || 0, + inside50: teamCompare?.a?.inside50 || 0, + }; + + const teamBSummary = { + games: 3, // Mock data - should come from backend + goals: teamCompare?.b?.goals || 0, + disposals: teamCompare?.b?.disposals || 0, + inside50: teamCompare?.b?.inside50 || 0, + }; + + return ( +
+ {/* Team A Summary */} +
+

{teamA}

+
+ + +
Matches
+
{teamASummary.games}
+
+
+ + +
Total Goals
+
{teamASummary.goals}
+
+
+ + +
Disposals
+
{teamASummary.disposals}
+
+
+ + +
Inside 50s
+
{teamASummary.inside50}
+
+
+
+
+ + {/* Team B Summary */} +
+

{teamB}

+
+ + +
Matches
+
{teamBSummary.games}
+
+
+ + +
Total Goals
+
{teamBSummary.goals}
+
+
+ + +
Disposals
+
{teamBSummary.disposals}
+
+
+ + +
Inside 50s
+
{teamBSummary.inside50}
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/VideoUploadPanel.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/VideoUploadPanel.tsx new file mode 100644 index 000000000..4d06969c8 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/VideoUploadPanel.tsx @@ -0,0 +1,139 @@ +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Progress } from "@/components/ui/progress"; +import { Video, Upload, Zap } from "lucide-react"; + +export default function VideoUploadPanel({ + selectedVideoFile, + videoAnalysisError, + isVideoUploading, + videoUploadProgress, + isVideoAnalyzing, + videoAnalysisProgress, + selectedAnalysisType, + setSelectedAnalysisType, + selectedFocusAreas, + onFocusAreaChange, + onFileSelect, + onStart, + disabledStart, +}: { + selectedVideoFile: File | null; + videoAnalysisError: string | null; + isVideoUploading: boolean; + videoUploadProgress: number; + isVideoAnalyzing: boolean; + videoAnalysisProgress: number; + selectedAnalysisType: string; + setSelectedAnalysisType: (v: string) => void; + selectedFocusAreas: string[]; + onFocusAreaChange: (area: string, checked: boolean) => void; + onFileSelect: (e: React.ChangeEvent) => void; + onStart: () => void; + disabledStart: boolean; +}) { + return ( + + + + + Video Upload & Analysis + + Upload match videos for AI-powered analysis + + +
+ + +
+ + {selectedVideoFile && ( +
+
+
+
Size: {(selectedVideoFile.size / 1024 / 1024).toFixed(1)} MB
+
+ )} + + {videoAnalysisError && ( +
+
{videoAnalysisError}
+
+ )} + + {isVideoUploading && ( +
+
+ Uploading video... + {videoUploadProgress}% +
+ +
+ )} + + {isVideoAnalyzing && ( +
+
+ Analyzing video... + {videoAnalysisProgress}% +
+ +
+ )} + +
+
+ + +
+ +
+ +
+ {["Goals & Scoring", "Defensive Actions", "Player Movement", "Ball Possession", "Set Pieces", "Injuries"].map( + (area) => ( + + ), + )} +
+
+
+ + +
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/CrowdMonitorTab.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/CrowdMonitorTab.tsx new file mode 100644 index 000000000..537cf5ab6 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/CrowdMonitorTab.tsx @@ -0,0 +1,241 @@ +import React, { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Users, TrendingUp, Image as ImageIcon } from "lucide-react"; +import { getCrowdAnalysis } from "@/lib/video"; +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from "recharts"; + +interface UploadMeta { + id: string; + original_filename: string; + created_at: string; + status: string; +} + +interface CrowdMonitorTabProps { + upload: UploadMeta | null; +} + +export default function CrowdMonitorTab({ upload }: CrowdMonitorTabProps) { + const [crowdData, setCrowdData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!upload?.id) return; + + setLoading(true); + setError(null); + + getCrowdAnalysis(upload.id) + .then((res) => { + if (res?.status === "not_available" || res?.status === "no-heatmaps") { + setError("⚠ Crowd analysis was not run for this video."); + } else { + setCrowdData(res); + } + }) + .catch((err) => { + console.error("❌ Failed to fetch crowd analysis:", err); + setError("⚠ Crowd analysis was not run for this video."); + }) + .finally(() => setLoading(false)); + }, [upload?.id]); + + if (!upload) { + return ( +

+ ⚠️ No video selected for analysis. +

+ ); + } + + if (loading) { + return ( +

+ ⏳ Loading crowd analysis... +

+ ); + } + + if (error) { + return

{error}

; + } + + if (!crowdData) { + return ( +

+ No crowd analysis results yet. +

+ ); + } + + const { + avg_count = 0, + peak_count = 0, + min_count = 0, + time_series = [], + results = [], + } = crowdData; + + return ( +
+ {/* Header */} +
+

+ Crowd Monitor – {upload.original_filename} +

+

+ Uploaded: {new Date(upload.created_at).toLocaleString()} +

+
+ + {/* Title + Status */} +
+
+

+ + Crowd Analysis +

+

+ Crowd analytics from analyzed video +

+
+ + LIVE + +
+ + {/* βœ… Summary Stats */} + + + Summary Statistics + + Aggregate metrics from video analysis + + + +
+
{avg_count}
+
Avg Count
+
+
+
{peak_count}
+
Peak Count
+
+
+
{min_count}
+
Min Count
+
+
+
+ + {/* βœ… Line Chart */} + + + + + Crowd Density Over Time + + People count per frame + + + {time_series.length === 0 ? ( +

+ No time series data available. +

+ ) : ( + + + + + + + + + + + + + + + )} +
+
+ + {/* βœ… Frame-wise Heatmaps */} + + + + + Frame-wise Heatmaps + + + Detected people count with heatmap per frame + + + + {results.length === 0 ? ( +

+ No per-frame results available. +

+ ) : ( +
+ {results.map((row: any, idx: number) => ( +
+ {row.heatmap_url ? ( + {`Frame + ) : ( +

+ No heatmap available +

+ )} +

+ Frame:{" "} + {row.frame_number} +

+

+ People Count:{" "} + + {row.people_count} + +

+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/PlayerPerformanceTab.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/PlayerPerformanceTab.tsx new file mode 100644 index 000000000..21ca3b092 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/PlayerPerformanceTab.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useState } from "react"; +import { getPlayerDashboard } from "@/lib/video"; + +interface PlayerRow { + player_id: number; + distance_m: number; + avg_speed_kmh: number; + max_speed_kmh: number; + heatmap_url: string; + zone_heatmaps?: { + back_50?: string; + midfield?: string; + forward_50?: string; + }; +} + +interface TeamHeatmap { + team_heatmap_url: string; + zones: { + back_50?: string; + midfield?: string; + forward_50?: string; + }; +} + +interface PlayerDashboardResponse { + upload_id: string; + team?: TeamHeatmap; + players: PlayerRow[]; + status?: string; // βœ… to handle not_available +} + +interface UploadMeta { + id: string; + original_filename: string; + created_at: string; + status: string; +} + +interface PlayerPerformanceTabProps { + upload: UploadMeta | null; +} + +export default function PlayerPerformanceTab({ upload }: PlayerPerformanceTabProps) { + const [dashboard, setDashboard] = useState(null); + const [selectedPlayer, setSelectedPlayer] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!upload?.id) return; + + const loadData = async () => { + try { + setLoading(true); + setError(null); + const data = await getPlayerDashboard(upload.id); + + if (data?.status === "not_available") { + setError("⚠ Player tracking was not run for this video."); + } else { + setDashboard(data); + } + } catch (err) { + console.error("❌ Failed to load player performance dashboard:", err); + setError("⚠ Player tracking was not run for this video."); + } finally { + setLoading(false); + } + }; + + loadData(); + }, [upload?.id]); + + const handlePlayerClick = (playerId: number) => { + const player = dashboard?.players.find((p) => p.player_id === playerId); + if (player) { + setSelectedPlayer(player); + } + }; + + if (!upload) { + return

Upload a video to view player performance.

; + } + + if (loading) { + return

Loading player performance...

; + } + + if (error) { + return

{error}

; + } + + if (!dashboard) { + return

No analysis data available.

; + } + + return ( +
+
+

+ Player Performance – {upload.original_filename} +

+

+ Uploaded: {new Date(upload.created_at).toLocaleString()} +

+
+ + {dashboard.team && ( +
+

Team Heatmaps

+
+ Team Heatmap + {dashboard.team.zones.back_50 && Back 50} + {dashboard.team.zones.midfield && Midfield} + {dashboard.team.zones.forward_50 && Forward 50} +
+
+ )} + +
+

Player Stats

+ + + + + + + + + + + + {dashboard.players.map((p) => ( + handlePlayerClick(p.player_id)} + className="cursor-pointer hover:bg-purple-50/50 even:bg-gray-50/40 transition-colors" + > + + + + + + + ))} + +
Player IDDistance (m)Avg Speed (km/h)Max Speed (km/h)Heatmap
{p.player_id}{p.distance_m?.toFixed(2)}{p.avg_speed_kmh?.toFixed(2)}{p.max_speed_kmh?.toFixed(2)} + {p.heatmap_url && ( + {`Player + )} +
+
+ + {selectedPlayer && ( +
+
+

+ Player {selectedPlayer.player_id} Details +

+

Distance: {selectedPlayer.distance_m?.toFixed(2)} m

+

Avg Speed: {selectedPlayer.avg_speed_kmh?.toFixed(2)} km/h

+

Max Speed: {selectedPlayer.max_speed_kmh?.toFixed(2)} km/h

+ +
+ {selectedPlayer.heatmap_url && Player Heatmap} + {selectedPlayer.zone_heatmaps && ( + <> + {selectedPlayer.zone_heatmaps.back_50 && Back 50 Zone} + {selectedPlayer.zone_heatmaps.midfield && Midfield Zone} + {selectedPlayer.zone_heatmaps.forward_50 && Forward 50 Zone} + + )} +
+ + +
+
+ )} +
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/ReportsTab.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/ReportsTab.tsx new file mode 100644 index 000000000..a924ad341 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/ReportsTab.tsx @@ -0,0 +1,184 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { getPlayerDashboard, getCrowdAnalysis, listUploads } from "@/lib/video"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Download, FileText } from "lucide-react"; +import { buildAnalysisPdf } from "@/lib/pdf"; +import { downloadFile } from "@/lib/download"; + +interface UploadMeta { + id: string; + original_filename: string; + created_at: string; + status?: string; +} + +function slugify(s: string) { + return s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); +} + +async function generatePDFWithFetcher(title: string, upload: UploadMeta, section: string, fetcher: () => Promise) { + try { + const data = await fetcher(); + const blob = await buildAnalysisPdf({ title, upload, section, data }); + const name = `${slugify(title)}_${slugify(upload.original_filename)}_${Date.now()}.pdf`; + downloadFile(blob, name, "application/pdf"); + } catch (e) { + const blob = await buildAnalysisPdf({ title, upload, section, data: { error: "Failed to load analysis" } }); + const name = `${slugify(title)}_${slugify(upload.original_filename)}_${Date.now()}.pdf`; + downloadFile(blob, name, "application/pdf"); + } +} + +export default function ReportsTab({ upload }: { upload: UploadMeta | null }) { + const [uploads, setUploads] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let mounted = true; + (async () => { + try { + setLoading(true); + const res = await listUploads(); + if (!mounted) return; + const normalized: UploadMeta[] = (res || []).map((u: any) => ({ + id: u.id, + original_filename: u.original_filename, + created_at: u.created_at, + status: u.status || "Completed", + })); + setUploads(normalized); + } catch (e) { + console.error("Failed to load uploads", e); + } finally { + setLoading(false); + } + })(); + return () => { + mounted = false; + }; + }, []); + + const sortedUploads = useMemo(() => { + return [...uploads].sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at)); + }, [uploads]); + + const handleCurrentPlayerPDF = async () => { + if (!upload?.id) return; + await generatePDFWithFetcher( + "Player Analysis Report", + upload, + "Player Analysis", + () => getPlayerDashboard(upload.id).catch(() => null), + ); + }; + + const handleCurrentCrowdPDF = async () => { + if (!upload?.id) return; + await generatePDFWithFetcher( + "Crowd Analysis Report", + upload, + "Crowd Analysis", + () => getCrowdAnalysis(upload.id).catch(() => null), + ); + }; + + return ( +
+
+
+

+ + Reports +

+

Download the analysis that has currently been run

+
+
+ + + + + + Export Current Analysis (PDF) + + + {upload?.id ? `Selected: ${upload.original_filename}` : "Select a processed upload in Video Analysis first"} + + + +
+ + +
+
+
+ + + + + + Previous Analyses + + + {loading ? "Loading..." : "Download any past analysis as separate Player or Crowd reports"} + + + +
+ {sortedUploads.map((u) => ( +
+
+
{u.original_filename}
+
{new Date(u.created_at).toLocaleString()}
+
+
+ + +
+
+ ))} + {!loading && sortedUploads.length === 0 && ( +
No analyses found.
+ )} +
+
+
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/TeamMatchTab.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/TeamMatchTab.tsx new file mode 100644 index 000000000..66c5b60bf --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/TeamMatchTab.tsx @@ -0,0 +1,257 @@ +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Progress } from "@/components/ui/progress"; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"; +import { Target, TrendingUp, Users, Calendar, MapPin, BarChart3 } from "lucide-react"; +import TeamMatchFilters from "../TeamMatchFilters"; +import TeamMatchCompare from "../TeamMatchCompare"; +import TeamSummaryCards from "../TeamSummaryCards"; +import MatchesList from "../MatchesList"; +import type { TeamComparison } from "@/hooks/useDashboardState"; + +interface TeamMatchTabProps { + teamA: string; + setTeamA: (team: string) => void; + teamB: string; + setTeamB: (team: string) => void; + teamCompare: TeamComparison; + teamTeams: string[]; +} + +export default function TeamMatchTab({ + teamA, + setTeamA, + teamB, + setTeamB, + teamCompare, + teamTeams, +}: TeamMatchTabProps) { + // Mock data for team performance charts + const teamPerformanceData = [ + { metric: "Goals", teamA: teamCompare.a.goals, teamB: teamCompare.b.goals }, + { metric: "Disposals", teamA: teamCompare.a.disposals, teamB: teamCompare.b.disposals }, + { metric: "Marks", teamA: teamCompare.a.marks, teamB: teamCompare.b.marks }, + { metric: "Tackles", teamA: teamCompare.a.tackles, teamB: teamCompare.b.tackles }, + { metric: "Clearances", teamA: teamCompare.a.clearances, teamB: teamCompare.b.clearances }, + { metric: "Inside 50s", teamA: teamCompare.a.inside50, teamB: teamCompare.b.inside50 }, + ]; + + return ( +
+ {/* Header */} +
+
+

+ + Team Match Performance +

+

Compare team statistics and match performance

+
+ + + Live Data + +
+ + {/* Team Selection */} + + + + + Team Comparison + + + Select teams to compare their performance metrics + + + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Team Summary Cards */} + + + {/* Performance Comparison Chart */} + + + + + Performance Comparison + + + Side-by-side comparison of key performance metrics + + + +
+ + + + + + + + + + + +
+
+
+ + {/* Team Match Comparison */} + + + {/* Recent Matches */} + + + + + Recent Matches + + + Latest match results and statistics + + + + + + + + {/* Match Statistics */} +
+ + + + + Venue Performance + + + How teams perform at different venues + + + +
+
+
+

MCG

+

Melbourne Cricket Ground

+
+
+

W 8

+

L 2

+
+
+
+
+

Marvel Stadium

+

Docklands Stadium

+
+
+

W 6

+

L 4

+
+
+
+
+

Adelaide Oval

+

Adelaide, SA

+
+
+

W 5

+

L 3

+
+
+
+
+
+ + + + + + Season Trends + + + Performance trends over the season + + + +
+
+
+ Win Rate + 75% +
+ +
+
+
+ Average Score + 85.2 +
+ +
+
+
+ Disposal Efficiency + 78% +
+ +
+
+
+ Goal Accuracy + 82% +
+ +
+
+
+
+
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/VideoAnalysisTab.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/VideoAnalysisTab.tsx new file mode 100644 index 000000000..5ecde4cd3 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/dashboard/tabs/VideoAnalysisTab.tsx @@ -0,0 +1,193 @@ +import React, { useState } from "react"; +import { deleteUpload } from "@/lib/video"; // βœ… API call + +interface CompletedAnalysis { + id: string; + original_filename: string; + created_at: string; + status: string; // "Analyzing..." | "Completed" | "Failed" +} + +interface VideoAnalysisTabProps { + selectedVideoFile: File | null; + videoAnalysisError: string | null; + isVideoUploading: boolean; + videoUploadProgress: number; + isVideoAnalyzing: boolean; + onFileSelect: (e: React.ChangeEvent) => void; + onAnalyze: (file: File, runPlayer: boolean, runCrowd: boolean) => void; + completedAnalyses: CompletedAnalysis[]; + setCompletedAnalyses: React.Dispatch>; + setActiveTab: React.Dispatch>; + setSelectedUploadId: React.Dispatch>; +} + +export default function VideoAnalysisTab({ + selectedVideoFile, + videoAnalysisError, + isVideoUploading, + videoUploadProgress, + isVideoAnalyzing, + onFileSelect, + onAnalyze, + completedAnalyses, + setCompletedAnalyses, + setActiveTab, + setSelectedUploadId, +}: VideoAnalysisTabProps) { + const [runPlayer, setRunPlayer] = useState(true); + const [runCrowd, setRunCrowd] = useState(false); + + const isDisabled = !selectedVideoFile || isVideoUploading || isVideoAnalyzing; + + const getStatusColor = (status: string) => { + if (status.includes("Analyzing")) return "text-amber-600"; + if (status.includes("Failed")) return "text-red-600"; + return "text-green-600"; + }; + + const handleDelete = async (uploadId: string) => { + if (typeof window !== "undefined" && !window.confirm("Are you sure you want to delete this video?")) return; + try { + await deleteUpload(uploadId); + setCompletedAnalyses((prev) => prev.filter((item) => item.id !== uploadId)); + } catch (err) { + console.error("❌ Failed to delete upload:", err); + alert("Failed to delete video."); + } + }; + + return ( +
+ {/* File input */} +
+ + + + {/* βœ… Checklist for services */} +
+ + +
+ + + + {isVideoUploading && ( +

+ Uploading… {videoUploadProgress}% +

+ )} + {videoAnalysisError && ( +

{videoAnalysisError}

+ )} +
+ + {/* βœ… Analysis Completed Section (disabled while analyzing) */} +
+

Analysis Completed

+ + {/* πŸ”Ή Banner shown only while analyzing */} + {isVideoAnalyzing && ( +
+ ⏳ Analysis is in progress… Please wait until it finishes before accessing results. +
+ )} + + {completedAnalyses.length === 0 ? ( +

No analyses yet.

+ ) : ( +
+ {completedAnalyses.map((video) => ( +
+
+ + {video.original_filename} + +

+ Uploaded: {new Date(video.created_at).toLocaleString()} +

+

+ Status: {video.status} +

+
+
+ + + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/accordion.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/accordion.tsx new file mode 100644 index 000000000..83ff01790 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/alert-dialog.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..235001462 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/alert.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/alert.tsx new file mode 100644 index 000000000..13219e774 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/aspect-ratio.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/aspect-ratio.tsx new file mode 100644 index 000000000..c9e6f4bf9 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/avatar.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/avatar.tsx new file mode 100644 index 000000000..444b1dbaa --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/badge.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/badge.tsx new file mode 100644 index 000000000..d3d5d6040 --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/breadcrumb.tsx b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/breadcrumb.tsx new file mode 100644 index 000000000..6934f83bd --- /dev/null +++ b/Player_Tracking/afl_player_tracking_and_crowd_monitoring/frontend/client/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>