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
+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
})
}
};