Code comments Removed Update
This commit is contained in:
Vendored
+6
-6
@@ -1,12 +1,12 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
type CallState = 'idle' | 'listening' | 'processing' | 'speaking';
|
||||
let callState = $state<CallState>('idle');
|
||||
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let stream: MediaStream | null = null;
|
||||
let audioContext: AudioContext | null = null;
|
||||
@@ -16,10 +16,10 @@
|
||||
let silenceTime = 0;
|
||||
let errorMessage = $state<string>('');
|
||||
let hardwareSampleRate = 16000;
|
||||
|
||||
// Visualizer data
|
||||
|
||||
|
||||
let currentRms = $state<number>(0);
|
||||
|
||||
|
||||
function cleanupMic() {
|
||||
try {
|
||||
if (processor) {
|
||||
@@ -44,15 +44,15 @@
|
||||
function playTTS(url: string) {
|
||||
callState = 'speaking';
|
||||
currentAudio = new Audio(url + "?t=" + Date.now());
|
||||
currentRms = 0.06; // Set a much smaller safe static visualizer size
|
||||
currentRms = 0.06;
|
||||
currentAudio.onended = () => {
|
||||
currentRms = 0;
|
||||
if (callState === 'speaking') {
|
||||
// AI is done talking, start listening automatically!
|
||||
|
||||
startListeningPhase();
|
||||
}
|
||||
};
|
||||
// Some browsers require explicit play tracking
|
||||
|
||||
const playPromise = currentAudio.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(e => {
|
||||
@@ -72,7 +72,7 @@
|
||||
currentRms = 0;
|
||||
|
||||
try {
|
||||
// Synchronous audio context resume
|
||||
|
||||
if (audioContext && audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
@@ -80,27 +80,27 @@
|
||||
if (!stream) {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (!audioContext) return;
|
||||
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
processor = audioContext.createScriptProcessor(2048, 1, 1);
|
||||
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN || callState !== 'listening') return;
|
||||
|
||||
|
||||
const float32 = e.inputBuffer.getChannelData(0);
|
||||
const int16 = new Int16Array(float32.length);
|
||||
let sumSq = 0;
|
||||
|
||||
|
||||
for (let i = 0; i < float32.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, float32[i]));
|
||||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||||
sumSq += s * s;
|
||||
}
|
||||
|
||||
|
||||
ws.send(int16.buffer);
|
||||
|
||||
const rms = Math.sqrt(sumSq / float32.length);
|
||||
currentRms = rms; // Drive the visualizer UI
|
||||
currentRms = rms;
|
||||
|
||||
if (rms > 0.035) {
|
||||
hasSpoken = true;
|
||||
@@ -132,15 +132,15 @@
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ event: 'stop_listening', sample_rate: hardwareSampleRate }));
|
||||
}
|
||||
// Do NOT cleanup hardware mic here as the session is perfectly continuous!
|
||||
|
||||
}
|
||||
|
||||
function startCall() {
|
||||
if (callState !== 'idle') return;
|
||||
callState = 'listening'; // transition state instantly
|
||||
|
||||
callState = 'listening';
|
||||
|
||||
try {
|
||||
// MUST CREATE AUDIO CONTEXT SYNCHRONOUSLY IN CLICK HANDLER
|
||||
|
||||
const AC = window.AudioContext || (window as any).webkitAudioContext;
|
||||
audioContext = new AC();
|
||||
hardwareSampleRate = audioContext.sampleRate;
|
||||
@@ -152,7 +152,6 @@
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.hostname;
|
||||
ws = new WebSocket(`${protocol}//${host}:8000/ws/voice`);
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.event === 'tts_ready') {
|
||||
@@ -189,7 +188,7 @@
|
||||
if (callState === 'idle') {
|
||||
startCall();
|
||||
} else if (callState === 'listening') {
|
||||
// Force manual send
|
||||
|
||||
hasSpoken = true;
|
||||
finishUtterance();
|
||||
} else {
|
||||
@@ -204,7 +203,7 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Auto-start the call when modal opens
|
||||
|
||||
startCall();
|
||||
});
|
||||
|
||||
@@ -215,7 +214,7 @@
|
||||
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
||||
<div class="glass-panel bg-crust/95 border border-white/10 rounded-3xl p-8 max-w-sm w-full shadow-2xl flex flex-col items-center">
|
||||
|
||||
|
||||
<!-- Header -->
|
||||
<h2 class="text-white text-xl font-display font-medium mb-1">Live AI Assistant</h2>
|
||||
<p class="text-slate-400 text-sm mb-8 font-medium">
|
||||
@@ -234,20 +233,20 @@
|
||||
<div class="relative w-32 h-32 flex items-center justify-center mb-10">
|
||||
<!-- Animated rings based on RMS volume -->
|
||||
{#if callState === 'listening' || callState === 'speaking'}
|
||||
<div
|
||||
<div
|
||||
class="absolute inset-0 rounded-full transition-all duration-75 {callState === 'speaking' ? 'animate-pulse' : ''}"
|
||||
class:bg-neon-primary={callState === 'listening'}
|
||||
class:bg-blue-500={callState === 'speaking'}
|
||||
style={`opacity: ${0.15 + (currentRms * 6)}; transform: scale(${1 + (currentRms * 8)});`}
|
||||
></div>
|
||||
<div
|
||||
<div
|
||||
class="absolute inset-2 rounded-full transition-all duration-150 {callState === 'speaking' ? 'animate-pulse' : ''}"
|
||||
class:bg-neon-primary={callState === 'listening'}
|
||||
class:bg-blue-400={callState === 'speaking'}
|
||||
style={`opacity: ${0.25 + (currentRms * 8)}; transform: scale(${1 + (currentRms * 6)});`}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
|
||||
<div class="z-10 w-20 h-20 rounded-full bg-surface0 border-[3px] shadow-inner flex items-center justify-center
|
||||
{callState === 'listening' ? 'border-neon-primary' : callState === 'processing' ? 'border-blue-500 border-dashed animate-spin-slow' : callState === 'speaking' ? 'border-blue-400' : 'border-surface1'}">
|
||||
{#if callState === 'processing'}
|
||||
@@ -271,21 +270,21 @@
|
||||
<!-- Controls -->
|
||||
<div class="flex gap-4 w-full justify-center">
|
||||
{#if callState === 'idle'}
|
||||
<button
|
||||
<button
|
||||
onclick={handleAction}
|
||||
class="bg-blue-600 hover:bg-blue-500 text-white py-3 px-6 rounded-xl font-display font-medium shadow-lg transition-colors flex-1 border border-white/5">
|
||||
Start Call
|
||||
</button>
|
||||
{:else if callState === 'listening'}
|
||||
<button
|
||||
<button
|
||||
onclick={handleAction}
|
||||
title="Force process audio"
|
||||
class="bg-surface0 hover:bg-surface1 border border-white/10 text-neon-primary rounded-xl p-4 font-display font-medium shadow-lg transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M2.01 21L23 12L2.01 3L2 10l15 2l-15 2z"/></svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
|
||||
<button
|
||||
onclick={handleHangUp}
|
||||
class="bg-red-600 hover:bg-red-500 text-white py-3 px-6 rounded-xl font-display font-medium shadow-lg transition-colors flex-1 flex items-center justify-center gap-2 border border-red-500/50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9c-.98.49-1.87 1.12-2.66 1.85c-.18.18-.43.28-.7.28c-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 0 1 0-1.4C3.36 8.42 7.46 6.5 12 6.5s8.64 1.92 11.71 5.18c.39.39.39 1.02 0 1.41l-2.48 2.48c-.18.18-.43.29-.71.29c-.27 0-.52-.11-.7-.28c-.79-.74-1.69-1.36-2.67-1.85c-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z"/></svg>
|
||||
@@ -299,7 +298,7 @@
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
@@ -308,7 +307,7 @@
|
||||
.animate-spin-slow {
|
||||
animation: spin 3s linear infinite;
|
||||
}
|
||||
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<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">{alertsState.activeAlerts.length} New</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#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">
|
||||
@@ -33,8 +33,8 @@
|
||||
<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>
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); alertsState.dismissAlert(alert.id); }}
|
||||
<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"
|
||||
>
|
||||
@@ -42,7 +42,7 @@
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
|
||||
{#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" />
|
||||
@@ -63,8 +63,8 @@
|
||||
<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)}
|
||||
<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" />
|
||||
|
||||
@@ -31,19 +31,19 @@
|
||||
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)) {
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -98,7 +98,7 @@
|
||||
};
|
||||
});
|
||||
|
||||
// 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,
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
properties: {
|
||||
room_id: loc.id,
|
||||
db: 0, // 0 db for no data
|
||||
db: 0,
|
||||
name: loc.name,
|
||||
date: new Date().toISOString(),
|
||||
},
|
||||
@@ -277,7 +277,7 @@
|
||||
`;
|
||||
}
|
||||
|
||||
// Watch and react to mapState updates using a Svelte 5 $effect
|
||||
|
||||
$effect(() => {
|
||||
const target = mapState.targetFlyTo;
|
||||
if (mapInstance && target) {
|
||||
@@ -368,7 +368,7 @@
|
||||
if (!browser || !mapContainer) return;
|
||||
|
||||
fetchStudyRoomData();
|
||||
refreshInterval = setInterval(fetchStudyRoomData, 10000); // refresh every 10s
|
||||
refreshInterval = setInterval(fetchStudyRoomData, 10000);
|
||||
|
||||
let map: any;
|
||||
|
||||
@@ -468,7 +468,7 @@
|
||||
:global(.maplibregl-ctrl-group) {
|
||||
background: var(
|
||||
--color-panel-glass
|
||||
) !important; /* already switches per theme */
|
||||
) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--color-surface1) 30%, transparent) !important;
|
||||
@@ -502,6 +502,6 @@
|
||||
24,
|
||||
37,
|
||||
0.9
|
||||
) !important; /* matches bg-crust */
|
||||
) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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;
|
||||
@@ -32,13 +32,13 @@
|
||||
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 &&
|
||||
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;
|
||||
@@ -53,7 +53,7 @@
|
||||
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'),
|
||||
@@ -63,11 +63,11 @@
|
||||
|
||||
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));
|
||||
@@ -145,7 +145,7 @@
|
||||
|
||||
onMount(async () => {
|
||||
const chartModule = await import('chart.js/auto');
|
||||
const chartjsAdapter = await import('chartjs-adapter-date-fns'); // Need this for time scaling
|
||||
const chartjsAdapter = await import('chartjs-adapter-date-fns');
|
||||
Chart = chartModule.default;
|
||||
if (mapState.selectedLocation) drawChart();
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
|
||||
|
||||
@@ -15,21 +15,21 @@ class AlertsState {
|
||||
|
||||
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';
|
||||
|
||||
@@ -41,8 +41,8 @@ class AlertsState {
|
||||
time: timeStr,
|
||||
timestamp: now
|
||||
});
|
||||
|
||||
// Keep a max of 10 alerts logic
|
||||
|
||||
|
||||
if (this.activeAlerts.length > 20) {
|
||||
this.activeAlerts.pop();
|
||||
}
|
||||
@@ -57,7 +57,7 @@ class AlertsState {
|
||||
dismissAlert(id: string) {
|
||||
this.activeAlerts = this.activeAlerts.filter(a => a.id !== id);
|
||||
}
|
||||
|
||||
|
||||
clearAll() {
|
||||
this.activeAlerts = [];
|
||||
}
|
||||
|
||||
@@ -22,16 +22,16 @@ export const UMD_LOCATIONS: StudyLocation[] = [
|
||||
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
|
||||
historyPlaybackTime = $state<number>(Date.now());
|
||||
|
||||
flyTo(lng: number, lat: number, zoom: number = 18) {
|
||||
this.targetFlyTo = { lng, lat, zoom, timestamp: Date.now() }; // timestamp ensures reactivity even if same coords
|
||||
this.targetFlyTo = { lng, lat, zoom, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
flyHome() {
|
||||
@@ -41,7 +41,7 @@ class MapState {
|
||||
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();
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
|
||||
<div class="h-screen w-full overflow-hidden bg-base text-slate-200 relative">
|
||||
|
||||
|
||||
<!-- Floating Sidebar (Desktop) / Bottom Bar (Mobile) -->
|
||||
<nav class="absolute bottom-4 md:bottom-auto md:top-1/2 left-1/2 md:left-6 -translate-x-1/2 md:translate-x-0 md:-translate-y-1/2 z-50 rounded-3xl glass-panel md:w-16 w-11/12 md:h-auto py-3 md:py-6 px-4 md:px-0 flex md:flex-col items-center justify-around md:justify-center gap-6 overflow-hidden" style="box-shadow: var(--shadow-glow-primary); border-left: 2px solid var(--color-neon-primary);">
|
||||
<!-- Shell Pattern Background -->
|
||||
<div class="absolute inset-0 pointer-events-none opacity-[0.08] z-0" style="background-image: url('data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2228%22 height=%2249%22 viewBox=%220 0 28 49%22%3E%3Cg fill-rule=%22evenodd%22%3E%3Cg id=%22hexagons%22 fill=%22%23ffffff%22 fill-opacity=%221%22 fill-rule=%22nonzero%22%3E%3Cpath d=%22M13.99 9.25l13 7.5v15l-13 7.5L1 31.75v-15l12.99-7.5zM3 17.9v12.7l10.99 6.34 11-6.35V17.9l-11-6.34L3 17.9zM0 15l12.98-7.5V0h-2v6.35L0 12.69v2.3zm0 18.5L12.98 41v8h-2v-6.85L0 35.81v-2.3zM15 0v7.5L27.99 15H28v-2.31h-.01L17 6.35V0h-2zm0 49v-8l12.99-7.5H28v2.31h-.01L17 42.15V49h-2z%22/%3E%3C/g%3E%3C/g%3E%3C/svg%3E'); background-repeat: repeat;"></div>
|
||||
|
||||
|
||||
<!-- Nav Item: Live Map -->
|
||||
<a href="/" class="relative flex items-center justify-center p-3 rounded-2xl transition-all duration-300 hover:bg-white/10 group {$page.url.pathname === '/' ? 'text-neon-blue drop-shadow-[0_0_10px_rgba(0,243,255,0.6)] bg-white/5' : 'text-slate-400 hover:text-white'}">
|
||||
<Icon icon="mdi:map" class="text-2xl" />
|
||||
|
||||
@@ -4,30 +4,30 @@
|
||||
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
|
||||
}, 200);
|
||||
} else {
|
||||
clearInterval(playInterval);
|
||||
}
|
||||
@@ -37,9 +37,9 @@
|
||||
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', {
|
||||
@@ -79,37 +79,37 @@
|
||||
<!-- Time Control Bottom Bar -->
|
||||
<div class="absolute bottom-24 md:bottom-8 left-4 md:left-8 right-4 md:right-8 z-10 flex justify-center">
|
||||
<div class="glass-panel rounded-2xl p-6 border-l-2 border-l-neon-primary w-full max-w-4xl" style="box-shadow: var(--shadow-glow-primary)">
|
||||
|
||||
|
||||
<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)]">{formattedTime}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center gap-6 mt-6">
|
||||
<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 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>
|
||||
|
||||
|
||||
<!-- Native Range Input (Hidden visual, overlay over the track) -->
|
||||
<input
|
||||
type="range"
|
||||
min={NOW - TWENTY_FOUR_HOURS}
|
||||
max={NOW}
|
||||
<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">
|
||||
<p>24H Window</p>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
--font-sans: 'Comic Relief', 'Comic Neue', 'Comic Sans MS', cursive, system-ui, sans-serif;
|
||||
--font-display: 'Comic Relief', 'Comic Neue', 'Comic Sans MS', cursive, system-ui, sans-serif;
|
||||
|
||||
/* Catppuccin Theme */
|
||||
|
||||
--color-crust: #11111b;
|
||||
--color-mantle: #181825;
|
||||
--color-base: #1e1e2e;
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
<div class="relative w-full h-full bg-crust border-l border-white/5 p-6 md:p-12 overflow-y-auto duration-500 transition-colors">
|
||||
<!-- Background Mesh -->
|
||||
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-surface0/30 via-crust to-crust z-0 pointer-events-none transition-colors duration-500"></div>
|
||||
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-surface0/30 via-crust to-crust z-0 pointer-events-none transition-colors duration-500"></div>
|
||||
|
||||
<div class="relative z-10 max-w-4xl mx-auto">
|
||||
<header class="mb-10">
|
||||
@@ -62,7 +62,7 @@
|
||||
<Icon icon="mdi:palette-outline" class="text-neon-blue" />
|
||||
Appearance and Accesibility
|
||||
</h2>
|
||||
|
||||
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between p-4 md:p-6 bg-mantle/40 rounded-xl border border-white/5 hover:border-white/10 transition-colors gap-4">
|
||||
<div class="pr-4">
|
||||
<h3 class="font-display font-medium text-white text-lg flex items-center gap-2">
|
||||
@@ -70,7 +70,7 @@
|
||||
Light / Dark Mode
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<button onclick={handleLightModeClick} class="shrink-0 px-5 py-2.5 bg-surface0 rounded-xl border border-white/10 hover:border-yellow-400/50 hover:text-yellow-400 hover:bg-surface1 transition-all flex items-center justify-center gap-2 font-medium">
|
||||
<Icon icon={themeState.isLight ? "mdi:weather-night" : "mdi:weather-sunny"} class="text-lg" />
|
||||
{themeState.isLight ? "Enable Dark Mode" : "Enable Light Mode"}
|
||||
@@ -84,7 +84,7 @@
|
||||
High Contrast Mode
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<button onclick={() => themeState.toggleHighContrast()} class="shrink-0 px-5 py-2.5 bg-surface0 rounded-xl border border-white/10 hover:border-white/20 transition-all flex items-center justify-center gap-2 font-medium">
|
||||
<Icon icon={themeState.isHighContrast ? "mdi:toggle-switch" : "mdi:toggle-switch-off-outline"} class="text-2xl {themeState.isHighContrast ? 'text-neon-primary' : 'text-slate-400'}" />
|
||||
{themeState.isHighContrast ? "Enabled" : "Disabled"}
|
||||
@@ -98,7 +98,7 @@
|
||||
Color Blind Friendly
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<button onclick={() => themeState.toggleColorBlind()} class="shrink-0 px-5 py-2.5 bg-surface0 rounded-xl border border-white/10 hover:border-white/20 transition-all flex items-center justify-center gap-2 font-medium">
|
||||
<Icon icon={themeState.isColorBlindFriendly ? "mdi:toggle-switch" : "mdi:toggle-switch-off-outline"} class="text-2xl {themeState.isColorBlindFriendly ? 'text-neon-primary' : 'text-slate-400'}" />
|
||||
{themeState.isColorBlindFriendly ? "Enabled" : "Disabled"}
|
||||
@@ -109,11 +109,11 @@
|
||||
<div class="pr-4">
|
||||
<h3 class="font-display font-medium text-white text-lg flex items-center gap-2"><Icon icon="mdi:translate" class="text-neon-primary" /> Global Translation</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="shrink-0 p-2 min-h-[44px] flex items-center justify-center">
|
||||
<!-- Custom Styled Dropdown -->
|
||||
<div class="glass-panel rounded-xl border border-white/10 overflow-hidden flex flex-col w-48 transition-all hover:border-neon-primary/40 bg-surface0 relative">
|
||||
<select
|
||||
<select
|
||||
bind:value={currentLang}
|
||||
class="bg-transparent font-display text-sm md:text-base p-3 border-none outline-none focus:ring-0 cursor-pointer w-full font-medium"
|
||||
style="color: {themeState.isLight ? '#4c4f69' : '#ffffff'} !important; appearance: none; -webkit-appearance: none;"
|
||||
@@ -124,7 +124,7 @@
|
||||
<option value={lang.code} class="bg-crust" style="color: {themeState.isLight ? '#4c4f69' : '#ffffff'} !important;">{lang.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
|
||||
<!-- Dropdown Arrow -->
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<Icon icon="mdi:chevron-down" style="color: {themeState.isLight ? '#4c4f69' : '#ffffff'} !important;" />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||
|
||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||
},
|
||||
kit: {
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
name: 'HushMap',
|
||||
short_name: 'HushMap',
|
||||
description: 'Campus noise mapping and intervention.',
|
||||
theme_color: '#0f172a', /* slate-900 */
|
||||
theme_color: '#0f172a',
|
||||
background_color: '#0f172a',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
|
||||
Reference in New Issue
Block a user