Backend integration with the website UI

This commit is contained in:
2026-04-12 03:27:52 +00:00
parent 53c67d569a
commit 2d379bece7
16 changed files with 568 additions and 67 deletions
+5 -1
View File
@@ -31,4 +31,8 @@ build/
venv/
__pycache__/
__pycache__/
*.pt
static/
+17 -15
View File
@@ -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"]
+3 -3
View File
@@ -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
View File
@@ -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/*
+24
View File
@@ -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.
+2
View File
@@ -8,3 +8,5 @@ python-multipart
ultralytics
opencv-python-headless
scipy
pymongo
pydantic
+100
View File
@@ -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
View File
@@ -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
+105
View File
@@ -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()
+3
View File
@@ -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=="],
+1
View File
@@ -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",
-9
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;
+70 -10
View File
@@ -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>
+7 -5
View File
@@ -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
})
}
};