Backend integration with the website UI
This commit is contained in:
@@ -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