frontend graphs

This commit is contained in:
sameeranageshwar
2026-04-12 01:09:03 -04:00
parent 5772782dcf
commit bcc8236ddd
9 changed files with 755 additions and 281 deletions
+11
View File
@@ -6,6 +6,9 @@
"name": "bitcamp-2026",
"dependencies": {
"@auth0/auth0-spa-js": "^2.19.0",
"chart.js": "^4.4.0",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^2.30.0",
"maplibre-gl": "^5.0.1",
},
"devDependencies": {
@@ -238,6 +241,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@kurkle/color": ["@kurkle/[email protected]", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@mapbox/jsonlint-lines-primitives": ["@mapbox/[email protected]", "", {}, "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ=="],
"@mapbox/point-geometry": ["@mapbox/[email protected]", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="],
@@ -472,6 +477,10 @@
"caniuse-lite": ["[email protected]", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
"chart.js": ["[email protected]", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"chartjs-adapter-date-fns": ["[email protected]", "", { "peerDependencies": { "chart.js": ">=2.8.0", "date-fns": ">=2.0.0" } }, "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg=="],
"chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -500,6 +509,8 @@
"data-view-byte-offset": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"date-fns": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["[email protected]", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
+4 -1
View File
@@ -14,7 +14,10 @@
"type": "module",
"dependencies": {
"@auth0/auth0-spa-js": "^2.19.0",
"maplibre-gl": "^5.0.1"
"maplibre-gl": "^5.0.1",
"chart.js": "^4.4.0",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^2.30.0"
},
"devDependencies": {
"@iconify/svelte": "^5.2.1",
@@ -1,12 +1,9 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { alertsState } from '$lib/states/alerts.svelte';
import { slide, fade } from 'svelte/transition';
let isOpen = $state(false);
const mockAlerts = [
{ id: 1, loc: 'McKeldin 2nd Floor', level: 78, time: '2 mins ago' },
{ id: 2, loc: 'ESJ Atrium', level: 82, time: '15 mins ago' }
];
</script>
<div class="relative">
@@ -14,7 +11,7 @@
<button onclick={() => isOpen = !isOpen} class="relative w-12 h-12 glass-panel flex items-center justify-center rounded-xl border border-white/10 hover:bg-white/10 transition-colors group {isOpen ? 'bg-white/10 ring-1 ring-neon-alert' : ''}">
<Icon icon="mdi:bell-outline" class="text-2xl text-slate-300 group-hover:text-white" />
<!-- Badge -->
{#if mockAlerts.length > 0}
{#if alertsState.activeAlerts.length > 0}
<span class="absolute top-2 right-2 w-2.5 h-2.5 bg-neon-alert rounded-full shadow-[0_0_8px_rgba(255,7,58,0.8)] animate-pulse border border-slate-900"></span>
{/if}
</button>
@@ -24,22 +21,29 @@
<div class="absolute top-14 left-0 w-80 glass-panel rounded-2xl shadow-2xl border border-white/10 overflow-hidden z-30 animate-in fade-in slide-in-from-top-2">
<div class="px-4 py-3 border-b border-white/10 bg-black/40 flex justify-between items-center">
<h3 class="font-display font-medium text-white text-sm">Active Noise Alerts</h3>
<span class="text-xs text-slate-400 bg-white/5 py-0.5 px-2 rounded-full">{mockAlerts.length} New</span>
<span class="text-xs text-slate-400 bg-white/5 py-0.5 px-2 rounded-full">{alertsState.activeAlerts.length} New</span>
</div>
<div class="max-h-64 overflow-y-auto">
{#each mockAlerts as alert}
<button class="w-full text-left p-4 border-b border-white/5 hover:bg-white/5 transition-colors flex items-start gap-3">
{#each alertsState.activeAlerts as alert (alert.id)}
<div class="w-full text-left p-4 border-b border-white/5 hover:bg-white/5 transition-colors flex items-start gap-3 group relative">
<Icon icon="mdi:alert-rhombus-outline" class="text-neon-alert text-xl mt-0.5" />
<div class="flex-1">
<div class="flex-1 pr-6">
<p class="text-sm font-medium text-red-200">Spike Detected: {alert.level} dB</p>
<p class="text-xs text-red-400/80 mt-1">{alert.loc}</p>
<p class="text-[10px] text-slate-500 font-mono mt-2">{alert.time}</p>
</div>
<p class="text-[10px] text-slate-500 font-mono mt-1">{alert.time}</p>
<button
onclick={(e) => { e.stopPropagation(); alertsState.dismissAlert(alert.id); }}
class="absolute top-4 right-4 text-slate-500 hover:text-white transition-colors"
aria-label="Close alert"
>
<Icon icon="mdi:close" class="text-lg" />
</button>
</div>
{/each}
{#if mockAlerts.length === 0}
{#if alertsState.activeAlerts.length === 0}
<div class="p-6 text-center text-slate-500">
<Icon icon="mdi:check-circle-outline" class="text-3xl text-neon-primary mx-auto mb-2 opacity-50" />
<p class="text-sm">Campus is quiet right now.</p>
@@ -49,3 +53,26 @@
</div>
{/if}
</div>
<!-- Floating Toast Notifications -->
<!-- Displays globally without needing menu interaction to fulfill user requirement -->
<div class="fixed top-24 left-6 md:left-8 z-50 flex flex-col gap-3 pointer-events-none">
{#each alertsState.activeAlerts.slice(0, 3) as alert (alert.id)}
<div transition:slide={{ duration: 300 }} class="glass-panel w-72 rounded-xl p-4 shadow-2xl border-l-2 border-l-neon-alert pointer-events-auto flex items-start gap-3 animate-in slide-in-from-left-4" style="box-shadow: var(--shadow-glow-alert)">
<Icon icon="mdi:alert-circle-outline" class="text-neon-alert text-2xl mt-0.5 shrink-0 animate-pulse" />
<div class="flex-1">
<div class="flex justify-between items-start">
<p class="text-sm font-bold text-white tracking-wide">NOISE SPIKE: {alert.level} dB</p>
<button
onclick={() => alertsState.dismissAlert(alert.id)}
class="text-slate-400 hover:text-white -mr-1 -mt-1 p-1 transition-colors"
>
<Icon icon="mdi:close" class="text-sm" />
</button>
</div>
<p class="text-xs text-red-200 mt-1 font-medium">{alert.loc}</p>
<p class="text-[10px] text-slate-400 font-mono mt-2">{alert.time}</p>
</div>
</div>
{/each}
</div>
+308 -154
View File
@@ -1,9 +1,11 @@
<script lang="ts">
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';
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 { alertsState } from "$lib/states/alerts.svelte";
import LocationPopup from "$lib/components/LocationPopup.svelte";
import "maplibre-gl/dist/maplibre-gl.css";
let { playbackTime = null } = $props<{ playbackTime?: number | null }>();
@@ -12,10 +14,13 @@
async function fetchStudyRoomData() {
try {
const res = await fetch('http://localhost:8000/api/study-rooms/history');
const res = await fetch(
"http://localhost:8000/api/study-rooms/history",
);
if (res.ok) {
const json = await res.json();
studyRoomsData = json.data;
mapState.historyData = json.data;
updateMapData();
}
} catch (e) {
@@ -24,16 +29,18 @@
}
function updateMapData() {
if (!mapInstance || !mapInstance.getSource('study-locations')) return;
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(',');
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 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
@@ -43,135 +50,232 @@
latestByLoc.set(key, room);
} else {
const existing = latestByLoc.get(key);
const existingDate = new Date(existing.date.endsWith('Z') ? existing.date : existing.date + 'Z');
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) => {
const latestRooms = Array.from(latestByLoc.values());
if (playbackTime == null || playbackTime === undefined) {
alertsState.processLiveReadings(latestRooms);
}
const features = latestRooms.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);
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
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';
const locName = matchingLoc ? matchingLoc.name : "Unknown Location";
return {
type: 'Feature',
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
}
name: locName,
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);
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] },
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()
}
date: new Date().toISOString(),
},
});
}
});
mapInstance.getSource('study-locations').setData({
type: 'FeatureCollection',
features: features
mapInstance.getSource("study-locations").setData({
type: "FeatureCollection",
features: features,
});
if (popupInstance && hoveredFeatureId) {
const freshFeature = features.find((f: any) => f.properties.room_id === hoveredFeatureId);
if (freshFeature && popupInstance.isOpen && popupInstance.isOpen()) {
popupInstance.setHTML(getPopupHTML(freshFeature.properties));
}
}
}
function addMapLayers(map: any, isLight: boolean) {
if (!map.getSource('study-locations')) {
map.addSource('study-locations', {
type: 'geojson',
function addMapLayers(map: any, isLight: boolean, isCB: boolean) {
if (!map.getSource("study-locations")) {
map.addSource("study-locations", {
type: "geojson",
data: {
type: 'FeatureCollection',
features: []
}
type: "FeatureCollection",
features: [],
},
});
}
// Add a layer for the circles based on db level
if (!map.getLayer('study-locations-circles')) {
const colorExpr = [
"case",
["==", ["get", "db"], 0],
"#888888",
[
"step",
["get", "db"],
isCB ? "#0072b2" : isLight ? "#1e66f5" : "#89b4fa",
40,
isCB ? "#56b4e9" : isLight ? "#179299" : "#94e2d5",
55,
isCB ? "#f0e442" : isLight ? "#df8e1d" : "#f9e2af",
65,
isCB ? "#ffb000" : isLight ? "#fe640b" : "#fab387",
80,
isCB ? "#e66100" : isLight ? "#d20f39" : "#f38ba8",
],
];
if (!map.getLayer("study-locations-glow")) {
map.addLayer({
id: 'study-locations-circles',
type: 'circle',
source: 'study-locations',
id: "study-locations-glow",
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-radius": [
"case",
["==", ["get", "db"], 0],
10,
20
],
'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'
}
"circle-color": colorExpr,
"circle-opacity": 0.5,
"circle-blur": 1.5,
},
});
}
if (!map.getLayer('study-locations-labels')) {
if (!map.getLayer("study-locations-circles")) {
map.addLayer({
id: 'study-locations-labels',
type: 'symbol',
source: 'study-locations',
id: "study-locations-circles",
type: "circle",
source: "study-locations",
paint: {
"circle-radius": [
"case",
["==", ["get", "db"], 0],
5,
10
],
"circle-color": colorExpr,
"circle-opacity": 0.95,
"circle-stroke-width": 0,
},
});
}
if (!map.getLayer("study-locations-labels")) {
map.addLayer({
id: "study-locations-labels",
type: "symbol",
source: "study-locations",
layout: {
'text-field': ['get', 'name'],
'text-size': 13,
'text-variable-anchor': ['top', 'bottom', 'left', 'right'],
'text-radial-offset': 1,
'text-justify': 'center'
"text-field": ["get", "name"],
"text-size": 13,
"text-variable-anchor": ["top", "bottom", "left", "right"],
"text-radial-offset": 1,
"text-justify": "center",
"text-allow-overlap": true,
"text-ignore-placement": true,
},
paint: {
'text-color': isLight ? '#1e1e2e' : '#ffffff',
'text-halo-color': isLight ? '#ffffff' : '#000000',
'text-halo-width': 2
}
"text-color": isLight ? "#1e1e2e" : "#ffffff",
"text-halo-color": isLight ? "#ffffff" : "#000000",
"text-halo-width": 2,
},
});
}
}
let mapContainer: HTMLDivElement | undefined = $state();
let mapInstance: any = $state();
let popupInstance: any = null;
let hoveredFeatureId: string | null = null;
function getPopupHTML(props: any) {
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";
const isLight = themeState.isLight;
const isCB = themeState.isColorBlindFriendly;
if (props.db === 0) {
status = "No Data";
} else if (props.db < 40) {
status = "Ideal";
statusColor = isCB ? "#0072b2" : isLight ? "#1e66f5" : "#89b4fa";
} else if (props.db < 55) {
status = "Great";
statusColor = isCB ? "#56b4e9" : isLight ? "#179299" : "#94e2d5";
} else if (props.db < 65) {
status = "Manageable";
statusColor = isCB ? "#f0e442" : isLight ? "#df8e1d" : "#f9e2af";
} else if (props.db < 80) {
status = "Disruptive";
statusColor = isCB ? "#ffb000" : isLight ? "#fe640b" : "#fab387";
} else {
status = "Harmful";
statusColor = isCB ? "#e66100" : isLight ? "#d20f39" : "#f38ba8";
}
const rawName = props.name.split("\\n")[0].split("\n")[0];
return `
<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>
`;
}
// Watch and react to mapState updates using a Svelte 5 $effect
$effect(() => {
@@ -182,7 +286,7 @@
center: [target.lng, target.lat],
zoom: target.zoom,
essential: true,
duration: 1500
duration: 1500,
});
});
}
@@ -192,11 +296,70 @@
if (mapInstance) {
const isLight = themeState.isLight;
const style = isLight
? 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
? "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json";
untrack(() => {
mapInstance.setStyle(style);
mapInstance.once('style.load', () => addMapLayers(mapInstance, isLight));
mapInstance.once("style.load", () => {
addMapLayers(
mapInstance,
isLight,
themeState.isColorBlindFriendly,
);
});
});
}
});
$effect(() => {
if (mapInstance) {
const isLight = themeState.isLight;
const isCB = themeState.isColorBlindFriendly;
untrack(() => {
const colorExpr = [
"case",
["==", ["get", "db"], 0],
"#888888",
[
"step",
["get", "db"],
isCB
? "#0072b2"
: isLight
? "#1e66f5"
: "#89b4fa",
40,
isCB
? "#56b4e9"
: isLight
? "#179299"
: "#94e2d5",
55,
isCB
? "#f0e442"
: isLight
? "#df8e1d"
: "#f9e2af",
65,
isCB
? "#ffb000"
: isLight
? "#fe640b"
: "#fab387",
80,
isCB
? "#e66100"
: isLight
? "#d20f39"
: "#f38ba8",
],
];
if (mapInstance.getLayer("study-locations-glow")) {
mapInstance.setPaintProperty("study-locations-glow", "circle-color", colorExpr);
}
if (mapInstance.getLayer("study-locations-circles")) {
mapInstance.setPaintProperty("study-locations-circles", "circle-color", colorExpr);
}
});
}
});
@@ -210,92 +373,70 @@
let map: any;
(async () => {
const { Map, NavigationControl, Popup } = await import('maplibre-gl');
const { Map, NavigationControl, Popup } = await import(
"maplibre-gl"
);
map = new Map({
container: mapContainer!,
style: themeState.isLight
? 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
center: [-76.9425, 38.9859],
zoom: 16.5,
? "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
center: [-76.94259561477574, 38.98813763708658],
zoom: 15.5,
pitch: 0,
bearing: 0,
attributionControl: false
attributionControl: false,
});
map.addControl(new NavigationControl({ visualizePitch: true }), 'bottom-right');
map.addControl(
new NavigationControl({ visualizePitch: true }),
"bottom-right",
);
mapInstance = map;
// Create a popup, but don't add it to the map yet.
const popup = new Popup({
popupInstance = new Popup({
closeButton: false,
closeOnClick: false,
className: 'custom-map-popup'
className: "custom-map-popup",
});
map.on('mouseenter', 'study-locations-circles', (e: any) => {
map.getCanvas().style.cursor = 'pointer';
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';
}
hoveredFeatureId = props.room_id;
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);
popupInstance.setLngLat(coordinates).setHTML(getPopupHTML(props)).addTo(map);
});
map.on('mouseleave', 'study-locations-circles', () => {
map.getCanvas().style.cursor = '';
popup.remove();
map.on("mouseleave", "study-locations-circles", () => {
map.getCanvas().style.cursor = "";
hoveredFeatureId = null;
popupInstance.remove();
});
map.on('load', () => {
addMapLayers(map, themeState.isLight);
map.on("click", "study-locations-circles", (e: any) => {
const id = e.features[0].properties.room_id;
const loc = UMD_LOCATIONS.find(l => l.id === id);
if (loc) {
mapState.selectedLocation = loc;
mapState.flyTo(loc.lng, loc.lat, 17.5);
}
});
map.on("load", () => {
addMapLayers(
map,
themeState.isLight,
themeState.isColorBlindFriendly,
);
updateMapData();
});
})();
@@ -314,17 +455,24 @@
</script>
<div class="absolute inset-0 z-0 bg-crust transition-colors duration-500">
<div bind:this={mapContainer} class="absolute inset-0 z-0 {themeState.isLight ? 'opacity-100' : 'mix-blend-luminosity opacity-90'} transition-all duration-500" style="width: 100%; height: 100%;"></div>
{#if !themeState.isLight}
<div class="absolute inset-0 pointer-events-none mix-blend-color bg-crust opacity-100 z-10 transition-all duration-500"></div>
{/if}
<div
bind:this={mapContainer}
class="absolute inset-0 z-0 opacity-100 transition-all duration-500"
style="width: 100%; height: 100%;"
></div>
</div>
<LocationPopup />
<style>
:global(.maplibregl-ctrl-group) {
background: var(--color-panel-glass) !important; /* already switches per theme */
background: var(
--color-panel-glass
) !important; /* already switches per theme */
backdrop-filter: blur(12px) !important;
border: 1px solid color-mix(in srgb, var(--color-surface1) 30%, transparent) !important;
border: 1px solid
color-mix(in srgb, var(--color-surface1) 30%, transparent) !important;
border-left: 2px solid var(--color-neon-primary) !important;
box-shadow: var(--shadow-glow-primary) !important;
border-radius: 12px !important;
overflow: hidden;
@@ -332,11 +480,12 @@
:global(.maplibregl-ctrl-group button) {
width: 40px !important;
height: 40px !important;
border-bottom: 1px solid color-mix(in srgb, var(--color-surface1) 20%, transparent) !important;
border-bottom: 1px solid
color-mix(in srgb, var(--color-surface1) 20%, transparent) !important;
}
:global(.maplibregl-ctrl-icon) {
filter: opacity(0.8) !important;
filter: drop-shadow(0 0 1px #000000) !important;
filter: var(--map-icon-filter, invert(1) opacity(0.9))
drop-shadow(0 0 1px rgba(0, 0, 0, 0.5)) !important;
}
:global(.maplibregl-ctrl-group button:hover) {
background: rgba(255, 255, 255, 0.1) !important;
@@ -348,6 +497,11 @@
border-radius: 12px;
}
:global(.custom-map-popup .maplibregl-popup-tip) {
border-top-color: rgba(24, 24, 37, 0.9) !important; /* matches bg-crust */
border-top-color: rgba(
24,
24,
37,
0.9
) !important; /* matches bg-crust */
}
</style>
@@ -0,0 +1,190 @@
<script lang="ts">
import { mapState } from '$lib/states/map.svelte';
import { themeState } from '$lib/states/theme.svelte';
import Icon from '@iconify/svelte';
import { onMount, onDestroy } from 'svelte';
// Dynamic import for chart.js to avoid SSR issues
let Chart: any;
let chartCanvas: HTMLCanvasElement;
let chartInstance: any;
function closePopup() {
mapState.selectedLocation = null;
mapState.flyHome();
}
function getChartColor(db: number, isLight: boolean, isCB: boolean) {
if (db === 0) return '#888888';
if (db < 40) return isCB ? '#0072b2' : isLight ? '#1e66f5' : '#89b4fa';
if (db < 55) return isCB ? '#56b4e9' : isLight ? '#179299' : '#94e2d5';
if (db < 65) return isCB ? '#f0e442' : isLight ? '#df8e1d' : '#f9e2af';
if (db < 80) return isCB ? '#ffb000' : isLight ? '#fe640b' : '#fab387';
return isCB ? '#e66100' : isLight ? '#d20f39' : '#f38ba8';
}
function getStatusLabel(db: number) {
if (db === 0) return 'No Data';
if (db < 40) return 'Ideal';
if (db < 55) return 'Great';
if (db < 65) return 'Manageable';
if (db < 80) return 'Disruptive';
return 'Harmful';
}
// Calculate metrics
let current2hAvg = $derived.by(() => {
if (!mapState.selectedLocation) return 0;
const now = Date.now();
const twoHoursAgo = now - 2 * 60 * 60 * 1000;
const points = mapState.historyData.filter(d =>
d.room_id === mapState.selectedLocation?.id &&
new Date(d.date.endsWith('Z') ? d.date : d.date + 'Z').getTime() >= twoHoursAgo
);
if (points.length === 0) return 0;
const sum = points.reduce((acc, p) => acc + p.db, 0);
return Math.round(sum / points.length);
});
let statusColor = $derived(getChartColor(current2hAvg, themeState.isLight, themeState.isColorBlindFriendly));
let statusLabel = $derived(getStatusLabel(current2hAvg));
function drawChart() {
if (!Chart || !chartCanvas || !mapState.selectedLocation) return;
if (chartInstance) chartInstance.destroy();
// filter past 24h for this loc
const locData = mapState.historyData.filter(d => d.room_id === mapState.selectedLocation?.id)
.map(d => ({
x: new Date(d.date.endsWith('Z') ? d.date : d.date + 'Z'),
y: d.db
}))
.sort((a, b) => a.x.getTime() - b.x.getTime());
const isLight = themeState.isLight;
const isCB = themeState.isColorBlindFriendly;
const ctx = chartCanvas.getContext('2d');
let gradientLine = statusColor;
let gradientFill = statusColor + '33';
if (ctx) {
gradientLine = ctx.createLinearGradient(0, 0, 0, 200);
gradientLine.addColorStop(0, getChartColor(85, isLight, isCB));
gradientLine.addColorStop(0.35, getChartColor(65, isLight, isCB));
gradientLine.addColorStop(0.45, getChartColor(55, isLight, isCB));
gradientLine.addColorStop(0.6, getChartColor(40, isLight, isCB));
gradientLine.addColorStop(1, getChartColor(0, isLight, isCB));
gradientFill = ctx.createLinearGradient(0, 0, 0, 200);
gradientFill.addColorStop(0, getChartColor(85, isLight, isCB) + '55');
gradientFill.addColorStop(0.35, getChartColor(65, isLight, isCB) + '44');
gradientFill.addColorStop(0.45, getChartColor(55, isLight, isCB) + '33');
gradientFill.addColorStop(0.6, getChartColor(40, isLight, isCB) + '22');
gradientFill.addColorStop(1, getChartColor(0, isLight, isCB) + '00');
}
chartInstance = new Chart(chartCanvas, {
type: 'line',
data: {
datasets: [{
label: 'Loudness (dB)',
data: locData,
borderColor: gradientLine,
backgroundColor: gradientFill,
borderWidth: 3,
fill: true,
segment: {
borderColor: ctx => getChartColor(ctx.p1.parsed.y, isLight, isCB)
},
tension: 0.5,
pointRadius: 0,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', axis: 'x', intersect: false },
animation: {
y: { duration: 2000, easing: 'easeOutElastic' },
x: { duration: 1000, easing: 'easeOutQuart' }
},
scales: {
x: {
type: 'time',
time: { unit: 'hour' },
grid: { display: false, color: isLight ? '#e5e7eb' : '#313244' },
ticks: { color: isLight ? '#6b7280' : '#a6adc8' }
},
y: {
min: 0, max: 100,
grid: { color: isLight ? '#e5e7eb' : '#313244' },
ticks: { color: isLight ? '#6b7280' : '#a6adc8' }
}
},
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: isLight ? 'rgba(255,255,255,0.9)' : 'rgba(30,30,46,0.9)',
titleColor: isLight ? '#111827' : '#cdd6f4',
bodyColor: isLight ? '#111827' : '#cdd6f4',
}
}
}
});
}
$effect(() => {
if (mapState.selectedLocation && mapState.historyData) {
drawChart();
}
});
onMount(async () => {
const chartModule = await import('chart.js/auto');
const chartjsAdapter = await import('chartjs-adapter-date-fns'); // Need this for time scaling
Chart = chartModule.default;
if (mapState.selectedLocation) drawChart();
});
onDestroy(() => {
if (chartInstance) chartInstance.destroy();
});
</script>
{#if mapState.selectedLocation}
<div class="absolute right-4 md:right-8 top-24 md:top-8 z-50 w-11/12 md:w-96 glass-panel rounded-3xl overflow-hidden border border-white/10 flex flex-col pointer-events-auto transition-all duration-300" style="box-shadow: 0 0 20px {statusColor}88, 0 25px 50px -12px rgba(0,0,0,0.5); border-left: 2px solid {statusColor};">
<!-- Header -->
<div class="px-6 py-5 border-b border-white/10 relative">
<button onclick={closePopup} class="absolute right-4 top-4 w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-colors">
<Icon icon="mdi:close" class="text-xl" />
</button>
<h2 class="font-display font-semibold text-2xl text-white mb-1">{mapState.selectedLocation.name}</h2>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full shadow-[0_0_8px_currentColor]" style="color: {statusColor}; background-color: {statusColor};"></div>
<span class="text-sm font-medium" style="color: {statusColor}">{statusLabel}</span>
<span class="text-slate-400 text-sm ml-1">· {current2hAvg} dB Avg (Last 2h)</span>
</div>
</div>
<!-- Usually quiet logic -->
<div class="px-6 py-4 bg-mantle/30 flex items-start gap-3">
<Icon icon="mdi:information" class="text-neon-blue mt-0.5 text-lg" />
<div>
<h3 class="text-sm font-medium text-white">Usually quiet this time of day</h3>
<p class="text-xs text-slate-400 mt-1 leading-relaxed">Historical data shows this location typically averages less noise around this time.</p>
</div>
</div>
<!-- Chart area -->
<div class="p-6">
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4">Past 24 Hours</h3>
<div class="h-48 w-full">
<canvas bind:this={chartCanvas}></canvas>
</div>
</div>
</div>
{/if}
+66
View File
@@ -0,0 +1,66 @@
import { UMD_LOCATIONS } from "./map.svelte";
export interface Alert {
id: string;
locId: string;
loc: string;
level: number;
time: string;
timestamp: number;
}
class AlertsState {
activeAlerts = $state<Alert[]>([]);
alertHistory = new Map<string, number>();
processLiveReadings(latestReadings: any[]) {
const now = Date.now();
for (const room of latestReadings) {
// Only fire if the reading is >= 65dB (Disruptive or Harmful)
if (room.db >= 65) {
const roomDate = new Date(room.date.endsWith('Z') ? room.date : room.date + 'Z').getTime();
// Ensure the reading is recent (within 5 minutes, 300000ms), to prevent alerting on stale data on initial load
if (now - roomDate < 300000) {
const lastAlert = this.alertHistory.get(room.room_id) || 0;
// Cooldown: Don't alert for the same location within 3 minutes (180000ms)
if (now - lastAlert > 180000) {
const locData = UMD_LOCATIONS.find(l => l.id === room.room_id);
const locName = locData ? locData.name : room.room_id;
const d = new Date(roomDate);
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York' }) + ' EST';
this.activeAlerts.unshift({
id: Math.random().toString(36).substring(2, 9),
locId: room.room_id,
loc: locName,
level: Number(room.db.toFixed(1)),
time: timeStr,
timestamp: now
});
// Keep a max of 10 alerts logic
if (this.activeAlerts.length > 20) {
this.activeAlerts.pop();
}
this.alertHistory.set(room.room_id, now);
}
}
}
}
}
dismissAlert(id: string) {
this.activeAlerts = this.activeAlerts.filter(a => a.id !== id);
}
clearAll() {
this.activeAlerts = [];
}
}
export const alertsState = new AlertsState();
+22 -1
View File
@@ -19,12 +19,17 @@ export const UMD_LOCATIONS: StudyLocation[] = [
{ id: 'stamp', name: 'Stamp Student Union', hasFloors: true, floors: ['Basement', 'Floor 1', 'Floor 2'], lng: -76.94473083972326, lat: 38.988130238874874 }
];
export const DEFAULT_VIEW = { lng: -76.9425, lat: 38.9859, zoom: 16.5 };
export const DEFAULT_VIEW = { lng: -76.94259561477574, lat: 38.98813763708658, zoom: 15.5 };
class MapState {
// The target coordinates the map should fly to
targetFlyTo = $state<{ lng: number; lat: number; zoom: number; timestamp: number } | null>(null);
selectedLocation = $state<StudyLocation | null>(null);
historyData = $state<any[]>([]);
historyLoading = $state<boolean>(false);
historyPlaybackTime = $state<number>(Date.now()); // The current time scrubber for 24h
flyTo(lng: number, lat: number, zoom: number = 18) {
this.targetFlyTo = { lng, lat, zoom, timestamp: Date.now() }; // timestamp ensures reactivity even if same coords
}
@@ -32,6 +37,22 @@ class MapState {
flyHome() {
this.flyTo(DEFAULT_VIEW.lng, DEFAULT_VIEW.lat, DEFAULT_VIEW.zoom);
}
async fetchHistoryData() {
this.historyLoading = true;
try {
// Fetch from FastAPI backend
const res = await fetch('http://127.0.0.1:8000/api/study-rooms/history');
if (res.ok) {
const json = await res.json();
this.historyData = json.data;
}
} catch (e) {
console.error("Failed to fetch history data", e);
} finally {
this.historyLoading = false;
}
}
}
export const mapState = new MapState();
+2 -2
View File
@@ -66,10 +66,10 @@
<!-- Top Bar / History Mode Overlay -->
<header class="absolute top-6 left-6 md:left-8 z-10">
<div class="glass-panel flex items-center justify-between md:justify-start gap-4 px-6 py-3 rounded-2xl shadow-lg border-l-2 border-l-neon-blue drop-shadow-[0_0_10px_rgba(0,243,255,0.2)] h-12">
<div class="glass-panel flex items-center justify-between md:justify-start gap-4 px-6 py-3 rounded-2xl shadow-lg border-l-2 border-l-neon-primary h-12" style="box-shadow: var(--shadow-glow-primary)">
<div>
<h1 class="font-display font-semibold text-white tracking-wider flex items-center gap-2">
<Icon icon="mdi:history" class="text-neon-blue drop-shadow-[0_0_5px_rgba(0,243,255,0.8)] text-lg" />
<Icon icon="mdi:history" class="text-neon-primary text-lg" style="filter: drop-shadow(var(--shadow-glow-primary))" />
HISTORICAL DATA
</h1>
</div>
+2
View File
@@ -22,9 +22,11 @@
--shadow-glow-primary: 0 0 15px rgba(243, 139, 168, 0.4);
--shadow-glow-alert: 0 0 15px rgba(243, 139, 168, 0.4);
--shadow-glow-blue: 0 0 15px rgba(243, 139, 168, 0.4);
--map-icon-filter: invert(1) contrast(100) opacity(0.9);
}
.light-theme {
--map-icon-filter: invert(0) contrast(150) opacity(0.9);
--color-crust: #dce0e8;
--color-mantle: #e6e9ef;
--color-base: #eff1f5;