Backend integration with the website UI
This commit is contained in:
+5
-1
@@ -31,4 +31,8 @@ build/
|
||||
|
||||
venv/
|
||||
|
||||
__pycache__/
|
||||
__pycache__/
|
||||
|
||||
*.pt
|
||||
|
||||
static/
|
||||
+17
-15
@@ -1,28 +1,30 @@
|
||||
# Build frontend
|
||||
FROM oven/bun:latest AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
WORKDIR /project/website
|
||||
COPY website/package.json website/bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Build the SvelteKit application
|
||||
COPY website/ .
|
||||
RUN bun run build
|
||||
RUN mkdir -p ../backend && bun run build
|
||||
|
||||
# Setup the production environment
|
||||
FROM oven/bun:latest
|
||||
# Backend and final image
|
||||
FROM python:3.9-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ffmpeg libgl1 libglib2.0-0 && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy production dependencies configuration and install
|
||||
COPY --from=builder /app/package.json /app/bun.lock ./
|
||||
RUN bun install --production --frozen-lockfile
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the build output and the custom Bun server
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/server ./server
|
||||
COPY backend/ .
|
||||
# Copy static build from builder
|
||||
COPY --from=builder /project/backend/static /app/static
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["bun", "run", "server/app.ts"]
|
||||
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -37,14 +37,13 @@ MicroPython scripts for the M5Stack M5GO device.
|
||||
|
||||
## Docker Setup
|
||||
|
||||
The entire stack can be run via Docker Compose, which builds both the Svelte website and the Python AI Backend.
|
||||
The entire stack can be run via Docker Compose, which builds both the Svelte website and the Python AI Backend into a single seamless container.
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
- **Web Frontend**: Runs on port `3000`
|
||||
- **AI Backend**: Runs on port `8000`
|
||||
- **App (Frontend + Backend)**: Runs on port `8000`
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -56,6 +55,7 @@ ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
|
||||
ELEVENLABS_VOICE_ID=JBFqnCBsd6RMkjVDRZzb
|
||||
TERP_AI_BEARER_TOKEN=your_jwt_token_here
|
||||
TERP_AI_CONVERSATION_ID=5e752e56-06c6-ec73-1f13-456029ce1299
|
||||
MONGODB_URI=mongodb_url_here
|
||||
```
|
||||
|
||||
Update the `/m5go/main.py` file to include your Wi-Fi credentials and the correct local IP for the WebSocket (`WS_URL`).
|
||||
+1
-1
@@ -2,7 +2,7 @@ FROM python:3.9-slim
|
||||
|
||||
# Install ffmpeg and other necessary packages
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ffmpeg libgl1-mesa-glx libglib2.0-0 && \
|
||||
apt-get install -y ffmpeg libgl1 libglib2.0-0 && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -100,6 +100,30 @@ Returns a JSON object detailing the room status, counts, and pairings.
|
||||
}
|
||||
```
|
||||
|
||||
### `/api/study-rooms` (GET)
|
||||
|
||||
Returns a list of all recorded study room data.
|
||||
|
||||
### `/api/study-rooms/history` (GET)
|
||||
|
||||
Returns a list of all recorded study room data from the last 24 hours, sorted by most recent first.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"location": {
|
||||
"type": "Point",
|
||||
"coordinates": [-77.3079, 38.8315]
|
||||
},
|
||||
"db": 65.2,
|
||||
"date": "2026-04-12T14:30:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Client Integration Notes
|
||||
|
||||
For the ESP32/M5GO client (`m5go/main.py`), ensure you update the `WS_URL` variable to point to the correct local IP address of the machine running this backend server.
|
||||
|
||||
@@ -8,3 +8,5 @@ python-multipart
|
||||
ultralytics
|
||||
opencv-python-headless
|
||||
scipy
|
||||
pymongo
|
||||
pydantic
|
||||
|
||||
@@ -8,13 +8,46 @@ import json
|
||||
import base64
|
||||
import urllib.request
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, File, UploadFile
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from pymongo import MongoClient
|
||||
from dotenv import load_dotenv
|
||||
from vision import analyze_room_image
|
||||
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# MongoDB Setup
|
||||
MONGO_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017/")
|
||||
mongo_client = MongoClient(MONGO_URI)
|
||||
db = mongo_client.study_buddy_db
|
||||
study_rooms_collection = db.study_rooms
|
||||
|
||||
# Pydantic models for Study Room Data
|
||||
class GeoJSONPoint(BaseModel):
|
||||
type: str = "Point"
|
||||
coordinates: List[float]
|
||||
|
||||
class StudyRoomData(BaseModel):
|
||||
room_id: Optional[str] = None
|
||||
location: GeoJSONPoint
|
||||
db: float
|
||||
date: Optional[datetime] = None
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
BITS_PER_SAMPLE = 16
|
||||
NUM_CHANNELS = 1
|
||||
@@ -253,3 +286,70 @@ async def check_room_status(file: UploadFile = File(...)):
|
||||
contents = await file.read()
|
||||
result = analyze_room_image(contents)
|
||||
return result
|
||||
|
||||
UMD_LOCATIONS = [
|
||||
{ "id": 'esj', "name": 'Edward St. John (ESJ)', "lng": -76.94209511596014, "lat": 38.987133359608755 },
|
||||
{ "id": 'mckeldin', "name": 'McKeldin Library', "lng": -76.94494907523277, "lat": 38.986021017749366 },
|
||||
{ "id": 'hornbake', "name": 'Hornbake Library', "lng": -76.94161787005467, "lat": 38.988233373664826 },
|
||||
{ "id": 'stem', "name": 'STEM Library', "lng": -76.93942003731279, "lat": 38.988991437126195 },
|
||||
{ "id": 'clarice', "name": 'Clarice Library', "lng": -76.9500912552473, "lat": 38.990547823732285 },
|
||||
{ "id": 'yahentamitsi', "name": 'Yahentamitsi', "lng": -76.9448027183373, "lat": 38.99108961575231 },
|
||||
{ "id": 'iribe', "name": 'Iribe', "lng": -76.93643838603555, "lat": 38.98933701397555 },
|
||||
{ "id": 'reckord', "name": 'Reckord Armory', "lng": -76.93897470250619, "lat": 38.98609556181066 },
|
||||
{ "id": 'stamp', "name": 'Stamp Student Union', "lng": -76.94473083972326, "lat": 38.988130238874874 }
|
||||
]
|
||||
|
||||
@app.post("/api/study-rooms")
|
||||
async def create_study_room_data(data: StudyRoomData):
|
||||
# Check if the coordinates match one of the known locations (with small tolerance)
|
||||
is_valid_location = False
|
||||
req_lng, req_lat = data.location.coordinates[0], data.location.coordinates[1]
|
||||
|
||||
for loc in UMD_LOCATIONS:
|
||||
if abs(loc["lng"] - req_lng) < 0.0001 and abs(loc["lat"] - req_lat) < 0.0001:
|
||||
is_valid_location = True
|
||||
# Override coordinates to exactly match known location for consistency
|
||||
data.location.coordinates = [loc["lng"], loc["lat"]]
|
||||
data.room_id = loc["id"]
|
||||
break
|
||||
|
||||
if not is_valid_location:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=400, detail="Invalid location. Coordinates must correspond to a known UMD location.")
|
||||
|
||||
if not data.date:
|
||||
data.date = datetime.utcnow()
|
||||
doc = data.dict()
|
||||
result = study_rooms_collection.insert_one(doc)
|
||||
return {"id": str(result.inserted_id), "room_id": data.room_id, "status": "success"}
|
||||
|
||||
@app.get("/api/study-rooms")
|
||||
async def get_study_room_data():
|
||||
rooms = list(study_rooms_collection.find({}, {"_id": 0}))
|
||||
return {"data": rooms}
|
||||
|
||||
@app.get("/api/study-rooms/history")
|
||||
async def get_study_room_history():
|
||||
"""Get all study room data from the last 24 hours."""
|
||||
twenty_four_hours_ago = datetime.utcnow() - timedelta(hours=24)
|
||||
rooms = list(study_rooms_collection.find(
|
||||
{"date": {"$gte": twenty_four_hours_ago}},
|
||||
{"_id": 0}
|
||||
).sort("date", -1))
|
||||
return {"data": rooms}
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
static_dir = "static"
|
||||
if not os.path.exists(static_dir):
|
||||
return {"error": "Static directory not found. Please build the frontend."}
|
||||
|
||||
static_path = os.path.join(static_dir, full_path)
|
||||
if os.path.isfile(static_path):
|
||||
return FileResponse(static_path)
|
||||
|
||||
index_path = os.path.join(static_dir, "index.html")
|
||||
if os.path.exists(index_path):
|
||||
return FileResponse(index_path)
|
||||
|
||||
return {"error": "index.html not found in static directory"}
|
||||
|
||||
+2
-15
@@ -1,22 +1,9 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
||||
ai_backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
- ./backend/.env
|
||||
@@ -0,0 +1,105 @@
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from pymongo import MongoClient
|
||||
|
||||
# MongoDB Setup
|
||||
MONGO_URI = "mongodb+srv://SarayuJ:[email protected]/testing"
|
||||
client = MongoClient(MONGO_URI)
|
||||
db = client.study_buddy_db
|
||||
collection = db.study_rooms
|
||||
|
||||
UMD_LOCATIONS = [
|
||||
{ "id": 'esj', "name": 'Edward St. John (ESJ)', "lng": -76.94209511596014, "lat": 38.987133359608755 },
|
||||
{ "id": 'mckeldin', "name": 'McKeldin Library', "lng": -76.94494907523277, "lat": 38.986021017749366 },
|
||||
{ "id": 'hornbake', "name": 'Hornbake Library', "lng": -76.94161787005467, "lat": 38.988233373664826 },
|
||||
{ "id": 'stem', "name": 'STEM Library', "lng": -76.93942003731279, "lat": 38.988991437126195 },
|
||||
{ "id": 'clarice', "name": 'Clarice Library', "lng": -76.9500912552473, "lat": 38.990547823732285 },
|
||||
{ "id": 'yahentamitsi', "name": 'Yahentamitsi', "lng": -76.9448027183373, "lat": 38.99108961575231 },
|
||||
{ "id": 'iribe', "name": 'Iribe', "lng": -76.93643838603555, "lat": 38.98933701397555 },
|
||||
{ "id": 'reckord', "name": 'Reckord Armory', "lng": -76.93897470250619, "lat": 38.98609556181066 },
|
||||
{ "id": 'stamp', "name": 'Stamp Student Union', "lng": -76.94473083972326, "lat": 38.988130238874874 }
|
||||
]
|
||||
|
||||
def get_db_for_time_and_location(hour, loc_id):
|
||||
"""
|
||||
Generate a dB level based on the hour of the day and the location.
|
||||
This creates a recognizable pattern for AI analysis.
|
||||
"""
|
||||
base_db = 40.0 # Ambient noise
|
||||
|
||||
if loc_id in ['mckeldin', 'esj']:
|
||||
# Busy during the day (10am - 4pm)
|
||||
if 10 <= hour <= 16:
|
||||
base_db = 75.0
|
||||
elif 17 <= hour <= 22:
|
||||
base_db = 60.0
|
||||
else:
|
||||
base_db = 45.0
|
||||
|
||||
elif loc_id in ['stem', 'iribe']:
|
||||
# Busy in the afternoon/evening (2pm - 8pm)
|
||||
if 14 <= hour <= 20:
|
||||
base_db = 70.0
|
||||
elif 9 <= hour <= 13:
|
||||
base_db = 55.0
|
||||
else:
|
||||
base_db = 42.0
|
||||
|
||||
elif loc_id == 'stamp':
|
||||
# Busy during lunch (12pm - 2pm) and dinner (5pm - 7pm)
|
||||
if 12 <= hour <= 14 or 17 <= hour <= 19:
|
||||
base_db = 85.0
|
||||
elif 10 <= hour <= 21:
|
||||
base_db = 65.0
|
||||
else:
|
||||
base_db = 50.0
|
||||
|
||||
else:
|
||||
# General locations (Clarice, Yahentamitsi, Reckord, Hornbake)
|
||||
# Moderate noise during the day
|
||||
if 9 <= hour <= 18:
|
||||
base_db = 60.0
|
||||
else:
|
||||
base_db = 45.0
|
||||
|
||||
# Add random noise to make it look realistic (+/- 5 dB)
|
||||
noise = random.uniform(-5.0, 5.0)
|
||||
return max(30.0, min(100.0, base_db + noise))
|
||||
|
||||
def generate_fake_data():
|
||||
print("Clearing existing study room data...")
|
||||
collection.delete_many({})
|
||||
|
||||
now = datetime.utcnow()
|
||||
start_time = now - timedelta(hours=24)
|
||||
|
||||
docs_to_insert = []
|
||||
|
||||
print("Generating 24 hours of fake data with patterns...")
|
||||
# Generate data points every 15 minutes for the last 24 hours
|
||||
current_time = start_time
|
||||
while current_time <= now:
|
||||
hour = current_time.hour
|
||||
|
||||
for loc in UMD_LOCATIONS:
|
||||
db_level = get_db_for_time_and_location(hour, loc["id"])
|
||||
|
||||
doc = {
|
||||
"room_id": loc["id"],
|
||||
"location": {
|
||||
"type": "Point",
|
||||
"coordinates": [loc["lng"], loc["lat"]]
|
||||
},
|
||||
"db": round(db_level, 2),
|
||||
"date": current_time
|
||||
}
|
||||
docs_to_insert.append(doc)
|
||||
|
||||
current_time += timedelta(minutes=15)
|
||||
|
||||
print(f"Inserting {len(docs_to_insert)} records into MongoDB...")
|
||||
collection.insert_many(docs_to_insert)
|
||||
print("Done!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_fake_data()
|
||||
@@ -11,6 +11,7 @@
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@sveltejs/adapter-node": "^5.2.10",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.57.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
@@ -367,6 +368,8 @@
|
||||
|
||||
"@sveltejs/adapter-node": ["@sveltejs/[email protected]", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ=="],
|
||||
|
||||
"@sveltejs/adapter-static": ["@sveltejs/[email protected]", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/[email protected]", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/[email protected]", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="],
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@sveltejs/adapter-node": "^5.2.10",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.57.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { handler } from '../build/handler.js';
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
const server = createServer(handler as any);
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`Bun Server is listening on http://localhost:${port}`);
|
||||
});
|
||||
@@ -1,21 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { onMount, untrack, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { mapState, UMD_LOCATIONS } from '$lib/states/map.svelte';
|
||||
import { themeState } from '$lib/states/theme.svelte';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
let { playbackTime = null } = $props<{ playbackTime?: number | null }>();
|
||||
|
||||
let studyRoomsData: any[] = [];
|
||||
let refreshInterval: any;
|
||||
|
||||
async function fetchStudyRoomData() {
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/api/study-rooms/history');
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
studyRoomsData = json.data;
|
||||
updateMapData();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch study room data", e);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMapData() {
|
||||
if (!mapInstance || !mapInstance.getSource('study-locations')) return;
|
||||
|
||||
// Group history by location using room_id
|
||||
const latestByLoc = new Map();
|
||||
for (const room of studyRoomsData) {
|
||||
// Use room_id as the key, fallback to coordinates if room_id is missing for some reason
|
||||
const key = room.room_id || room.location.coordinates.join(',');
|
||||
|
||||
// Ensure date is treated as UTC
|
||||
const roomDateString = room.date.endsWith('Z') ? room.date : room.date + 'Z';
|
||||
const roomDate = new Date(roomDateString);
|
||||
|
||||
// Filter out points strictly in the future of our playback time
|
||||
if (playbackTime && roomDate.getTime() > playbackTime) continue;
|
||||
|
||||
if (!latestByLoc.has(key)) {
|
||||
latestByLoc.set(key, room);
|
||||
} else {
|
||||
const existing = latestByLoc.get(key);
|
||||
const existingDate = new Date(existing.date.endsWith('Z') ? existing.date : existing.date + 'Z');
|
||||
if (roomDate > existingDate) {
|
||||
latestByLoc.set(key, room);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const features = Array.from(latestByLoc.values()).map((room: any) => {
|
||||
// Find the corresponding UMD_LOCATION to get the name
|
||||
let matchingLoc = null;
|
||||
if (room.room_id) {
|
||||
matchingLoc = UMD_LOCATIONS.find(loc => loc.id === room.room_id);
|
||||
} else {
|
||||
matchingLoc = UMD_LOCATIONS.find(loc =>
|
||||
Math.abs(loc.lng - room.location.coordinates[0]) < 0.0001 &&
|
||||
Math.abs(loc.lat - room.location.coordinates[1]) < 0.0001
|
||||
);
|
||||
}
|
||||
|
||||
const locName = matchingLoc ? matchingLoc.name : 'Unknown Location';
|
||||
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: room.location,
|
||||
properties: {
|
||||
room_id: room.room_id,
|
||||
db: room.db,
|
||||
name: `${locName}\n${room.db.toFixed(1)} dB`,
|
||||
date: room.date
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Add features for UMD_LOCATIONS that don't have sensor data yet
|
||||
UMD_LOCATIONS.forEach(loc => {
|
||||
const hasData = features.some(f => f.properties.room_id === loc.id);
|
||||
|
||||
if (!hasData) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [loc.lng, loc.lat] },
|
||||
properties: {
|
||||
room_id: loc.id,
|
||||
db: 0, // 0 db for no data
|
||||
name: loc.name,
|
||||
date: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
mapInstance.getSource('study-locations').setData({
|
||||
type: 'FeatureCollection',
|
||||
features: features
|
||||
});
|
||||
}
|
||||
|
||||
function addMapLayers(map: any, isLight: boolean) {
|
||||
if (!map.getSource('study-locations')) {
|
||||
map.addSource('study-locations', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: UMD_LOCATIONS.map(loc => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [loc.lng, loc.lat] },
|
||||
properties: { name: loc.name }
|
||||
}))
|
||||
features: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add a layer for the circles based on db level
|
||||
if (!map.getLayer('study-locations-circles')) {
|
||||
map.addLayer({
|
||||
id: 'study-locations-circles',
|
||||
type: 'circle',
|
||||
source: 'study-locations',
|
||||
paint: {
|
||||
'circle-radius': [
|
||||
'case',
|
||||
['==', ['get', 'db'], 0], 5, // Small radius for 0 dB (no data)
|
||||
[
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'db'],
|
||||
40, 10,
|
||||
60, 20,
|
||||
80, 40
|
||||
]
|
||||
],
|
||||
'circle-color': [
|
||||
'case',
|
||||
['==', ['get', 'db'], 0], '#888888', // Gray for no data
|
||||
[
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'db'],
|
||||
40, '#00ff00',
|
||||
60, '#ffff00',
|
||||
80, '#ff0000'
|
||||
]
|
||||
],
|
||||
'circle-opacity': 0.6,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -75,10 +204,13 @@
|
||||
onMount(() => {
|
||||
if (!browser || !mapContainer) return;
|
||||
|
||||
fetchStudyRoomData();
|
||||
refreshInterval = setInterval(fetchStudyRoomData, 10000); // refresh every 10s
|
||||
|
||||
let map: any;
|
||||
|
||||
(async () => {
|
||||
const { Map, NavigationControl } = await import('maplibre-gl');
|
||||
const { Map, NavigationControl, Popup } = await import('maplibre-gl');
|
||||
|
||||
map = new Map({
|
||||
container: mapContainer!,
|
||||
@@ -95,13 +227,90 @@
|
||||
map.addControl(new NavigationControl({ visualizePitch: true }), 'bottom-right');
|
||||
mapInstance = map;
|
||||
|
||||
map.on('load', () => addMapLayers(map, themeState.isLight));
|
||||
// Create a popup, but don't add it to the map yet.
|
||||
const popup = new Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
className: 'custom-map-popup'
|
||||
});
|
||||
|
||||
map.on('mouseenter', 'study-locations-circles', (e: any) => {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
|
||||
const coordinates = e.features[0].geometry.coordinates.slice();
|
||||
const props = e.features[0].properties;
|
||||
|
||||
// Format the date
|
||||
let timeStr = 'No data';
|
||||
if (props.db > 0 && props.date) {
|
||||
const dateStr = props.date.endsWith('Z') ? props.date : props.date + 'Z';
|
||||
const date = new Date(dateStr);
|
||||
timeStr = date.toLocaleTimeString('en-US', { timeZone: 'America/New_York', hour: '2-digit', minute: '2-digit' }) + ' EST';
|
||||
}
|
||||
|
||||
let status = 'Unknown';
|
||||
let statusColor = '#888888';
|
||||
if (props.db === 0) {
|
||||
status = 'No Data';
|
||||
} else if (props.db < 50) {
|
||||
status = 'Quiet';
|
||||
statusColor = '#00ff00';
|
||||
} else if (props.db < 70) {
|
||||
status = 'Moderate';
|
||||
statusColor = '#ffff00';
|
||||
} else {
|
||||
status = 'Loud / Busy';
|
||||
statusColor = '#ff0000';
|
||||
}
|
||||
|
||||
const rawName = props.name.split('\\n')[0].split('\n')[0]; // Handle both literal and escaped newlines
|
||||
|
||||
const html = `
|
||||
<div class="px-3 py-2 bg-crust/90 backdrop-blur-md border border-white/10 rounded-xl shadow-[0_0_15px_rgba(0,0,0,0.5)] min-w-[150px] text-text">
|
||||
<h3 class="font-display font-bold text-sm mb-1 text-white border-b border-white/10 pb-1">${rawName}</h3>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<div class="w-2 h-2 rounded-full shadow-[0_0_5px_${statusColor}]" style="background-color: ${statusColor}"></div>
|
||||
<span class="text-xs font-semibold" style="color: ${statusColor}">${status}</span>
|
||||
</div>
|
||||
<p class="text-xs text-subtext0 mt-1">Noise: <span class="font-mono text-white">${props.db > 0 ? props.db.toFixed(1) + ' dB' : 'N/A'}</span></p>
|
||||
<p class="text-[10px] text-surface2 mt-2 font-mono">Last updated: ${timeStr}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Ensure that if the map is zoomed out such that multiple
|
||||
// copies of the feature are visible, the popup appears
|
||||
// over the copy being pointed to.
|
||||
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
|
||||
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
|
||||
}
|
||||
|
||||
popup.setLngLat(coordinates)
|
||||
.setHTML(html)
|
||||
.addTo(map);
|
||||
});
|
||||
|
||||
map.on('mouseleave', 'study-locations-circles', () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
popup.remove();
|
||||
});
|
||||
|
||||
map.on('load', () => {
|
||||
addMapLayers(map, themeState.isLight);
|
||||
updateMapData();
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
map?.remove();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (playbackTime !== undefined) {
|
||||
updateMapData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="absolute inset-0 z-0 bg-crust transition-colors duration-500">
|
||||
@@ -132,4 +341,13 @@
|
||||
:global(.maplibregl-ctrl-group button:hover) {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
:global(.custom-map-popup .maplibregl-popup-content) {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 12px;
|
||||
}
|
||||
:global(.custom-map-popup .maplibregl-popup-tip) {
|
||||
border-top-color: rgba(24, 24, 37, 0.9) !important; /* matches bg-crust */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
@@ -2,6 +2,56 @@
|
||||
import Icon from '@iconify/svelte';
|
||||
import MapControls from '$lib/components/MapControls.svelte';
|
||||
import InteractiveMap from '$lib/components/InteractiveMap.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
// Time state
|
||||
const NOW = Date.now();
|
||||
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
|
||||
let playbackTime = $state(NOW);
|
||||
|
||||
let isPlaying = $state(false);
|
||||
let playInterval: any;
|
||||
|
||||
function togglePlay() {
|
||||
isPlaying = !isPlaying;
|
||||
if (isPlaying) {
|
||||
// Auto replay from beginning if at the end
|
||||
if (playbackTime >= NOW) {
|
||||
playbackTime = NOW - TWENTY_FOUR_HOURS;
|
||||
}
|
||||
playInterval = setInterval(() => {
|
||||
// Advance 15 minutes per tick
|
||||
playbackTime += 15 * 60 * 1000;
|
||||
if (playbackTime >= NOW) {
|
||||
playbackTime = NOW;
|
||||
isPlaying = false;
|
||||
clearInterval(playInterval);
|
||||
}
|
||||
}, 200); // Ticks every 200ms
|
||||
} else {
|
||||
clearInterval(playInterval);
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (playInterval) clearInterval(playInterval);
|
||||
});
|
||||
|
||||
// Derived values for the UI
|
||||
let progressPercent = $derived(((playbackTime - (NOW - TWENTY_FOUR_HOURS)) / TWENTY_FOUR_HOURS) * 100);
|
||||
|
||||
let formattedTime = $derived.by(() => {
|
||||
const d = new Date(playbackTime);
|
||||
return d.toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/New_York'
|
||||
}) + ' EST';
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -10,7 +60,7 @@
|
||||
|
||||
<div class="relative w-full h-full bg-crust border-l border-white/5">
|
||||
<!-- Map Engine Engine -->
|
||||
<InteractiveMap />
|
||||
<InteractiveMap {playbackTime} />
|
||||
|
||||
<MapControls showDropdown={true} />
|
||||
|
||||
@@ -32,21 +82,32 @@
|
||||
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="font-display font-medium text-lg text-white">Playback Controls</h2>
|
||||
<span class="text-neon-blue font-mono text-sm tracking-wider drop-shadow-[0_0_5px_rgba(0,243,255,0.5)]">Tue, Oct 14 - 14:00</span>
|
||||
<span class="text-neon-blue font-mono text-sm tracking-wider drop-shadow-[0_0_5px_rgba(0,243,255,0.5)]">{formattedTime}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 mt-6">
|
||||
<button class="w-12 h-12 rounded-full bg-neon-blue/10 hover:bg-neon-blue/20 flex items-center justify-center text-neon-blue transition-colors border border-neon-blue/30 shrink-0">
|
||||
<Icon icon="mdi:play" class="text-2xl" />
|
||||
<button onclick={togglePlay} class="w-12 h-12 rounded-full bg-neon-blue/10 hover:bg-neon-blue/20 flex items-center justify-center text-neon-blue transition-colors border border-neon-blue/30 shrink-0 focus:outline-none">
|
||||
<Icon icon={isPlaying ? "mdi:pause" : "mdi:play"} class="text-2xl" />
|
||||
</button>
|
||||
|
||||
<!-- Slider Track -->
|
||||
<div class="flex-1 relative group cursor-pointer h-8 flex items-center">
|
||||
<div class="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-neon-blue w-[60%] shadow-[0_0_10px_rgba(0,243,255,0.8)]"></div>
|
||||
<div class="flex-1 relative h-8 flex items-center group">
|
||||
<div class="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden absolute pointer-events-none">
|
||||
<div class="h-full bg-neon-blue shadow-[0_0_10px_rgba(0,243,255,0.8)]" style="width: {progressPercent}%"></div>
|
||||
</div>
|
||||
<!-- Slider Thumb -->
|
||||
<div class="absolute top-1/2 left-[60%] -translate-x-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full shadow-[0_0_10px_rgba(255,255,255,0.8)] border-2 border-neon-blue group-hover:scale-125 transition-transform"></div>
|
||||
|
||||
<!-- Native Range Input (Hidden visual, overlay over the track) -->
|
||||
<input
|
||||
type="range"
|
||||
min={NOW - TWENTY_FOUR_HOURS}
|
||||
max={NOW}
|
||||
bind:value={playbackTime}
|
||||
oninput={() => { if (isPlaying) togglePlay(); }}
|
||||
class="w-full absolute opacity-0 cursor-pointer h-full z-20"
|
||||
/>
|
||||
|
||||
<!-- Custom Thumb (visually synced to the input value) -->
|
||||
<div class="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full shadow-[0_0_10px_rgba(255,255,255,0.8)] border-2 border-neon-blue group-hover:scale-125 transition-transform pointer-events-none z-10" style="left: calc({progressPercent}% - 8px)"></div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-slate-400 font-mono shrink-0">
|
||||
@@ -55,5 +116,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
@@ -7,10 +7,12 @@ const config = {
|
||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||
},
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
adapter: adapter({
|
||||
pages: '../backend/static',
|
||||
assets: '../backend/static',
|
||||
fallback: 'index.html',
|
||||
strict: false
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user