frontend graphs
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user