IceKhoffi's picture
Update static/js/app.js
9bd9d34 verified
const CONFIG = {
API_URL: window.location.origin,
WEBSOCKET_URL: (window.location.protocol === "https:" ? "wss://" : "ws://") + window.location.host + "/ws/video_feed",
TRIGGER_AUDIO_URL: "/trigger_audio_analysis",
GET_AUDIO_RESULT_URL: "/get_latest_audio_result",
POLLING_INTERVAL_MS: 30000,
DEFAULT_VIDEO_URLS: [
"./static/demo/3_usa.mp4",
"./static/demo/5_usa.mp4",
"./static/demo/7_usa.mp4",
"./static/demo/6_usa.mp4",
],
DEFAULT_AUDIO_URL: "./static/demo/audio.m4a",
};
// --- Global State ---
const state = {
show_detected: true,
show_density: false,
show_inactive: false,
isStreaming: false,
cameraWebSockets: [null, null, null, null],
audioPollInterval: null,
cameraUrls: [...CONFIG.DEFAULT_VIDEO_URLS],
audioUrl: CONFIG.DEFAULT_AUDIO_URL,
cameraStats: Array(4).fill().map(() => ({
detected: 0,
inactive: 0,
dense_areas: 0
})),
lastVocalization: null,
isAnalyzingAudio: false,
lastAnalyzedAudioUrl: null,
lastAnalysisTimestamp: null,
lastInactiveAlert: {},
INACTIVE_THRESHOLD: 0.15 //percentage
};
// --- DOM Elements ---
const $ = (selector) => document.querySelector(selector);
const DOMElements = { // Cache
datetime: $('#datetime'),
canvases: Array.from({ length: 4 }, (_, i) => $(`#video-canvas-${i}`)),
contexts: [],
toggleControls: $('#toggle-controls'),
settingsButton: $('#settings-button'),
detectedCount: $('#detected-count'),
densityCount: $('#density-count'),
inactiveCount: $('#inactive-count'),
vocalizationContent: $('#vocalization-content'),
systemLog: $('#system-log'),
settingsModal: $('#settings-modal'),
saveSettingsBtn: $('#save-settings-btn'),
stopAllStreamsBtn: $('#stop-all-streams-btn'),
camUrlInputs: Array.from({ length: 4 }, (_, i) => $(`#cam${i+1}-url`)),
audioUrlInput: $('#audio-url'),
exportModal: $('#export-modal'),
openExportModalBtn: $('#open-export-modal-btn'),
startDateInput: $('#start-date'),
endDateInput: $('#end-date'),
cameraSelect: $('#camera-select'),
downloadCsvBtn: $('#download-csv-btn'),
};
// Init canvas contexts
DOMElements.contexts = DOMElements.canvases.map(canvas =>
canvas ? canvas.getContext('2d') : null
);
// --- Utils Functions ---
function updateDateTime() {
if (!DOMElements.datetime) return;
DOMElements.datetime.textContent = new Date().toLocaleString('en-US', {
dateStyle: 'full',
timeStyle: 'short'
});
}
function addLog(message, type = 'info') {
if (!DOMElements.systemLog) return;
const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false });
const typeClass = {
info: 'text-slate-500',
warning: 'text-amber-600',
danger: 'text-red-600 font-semibold'
}[type] || 'text-slate-500';
const logEntry = document.createElement('p');
logEntry.className = typeClass;
logEntry.innerHTML = `<span class="font-semibold text-slate-400 mr-2">${timestamp}</span> ${message}`;
DOMElements.systemLog.prepend(logEntry);
if (DOMElements.systemLog.children.length > 30) {
DOMElements.systemLog.removeChild(DOMElements.systemLog.lastChild);
}
}
function updateToggleButtons() {
const buttons = DOMElements.toggleControls.querySelectorAll('button');
const activeColors = {
show_detected: '#22c55e', // Green
show_density: '#f97316', // Orange
show_inactive: '#ef4444' // Red
};
buttons.forEach(button => {
const control = button.dataset.control;
const isActive = state[control];
button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', isActive);
if (isActive) {
const color = activeColors[control];
button.style.backgroundColor = color;
button.style.borderColor = color;
button.style.color = '#ffffff';
} else {
button.style.backgroundColor = '';
button.style.borderColor = '';
button.style.color = '';
}
});
}
function updateDailyAnalysisUI() {
const totals = state.cameraStats.reduce((acc, stats) => ({
detected: acc.detected + (stats.detected || 0),
inactive: acc.inactive + (stats.inactive || 0),
denseAreas: acc.denseAreas + (stats.dense_areas || 0)
}), { detected: 0, inactive: 0, denseAreas: 0 });
if (DOMElements.detectedCount) DOMElements.detectedCount.textContent = totals.detected;
if (DOMElements.inactiveCount) DOMElements.inactiveCount.textContent = totals.inactive;
if (DOMElements.densityCount) DOMElements.densityCount.textContent = totals.denseAreas;
}
function displayAudioResults(data) {
if (!DOMElements.vocalizationContent) return;
if (!data) {
DOMElements.vocalizationContent.innerHTML = `<p class="text-sm text-slate-500">No response from server.</p>`;
return;
}
if (data.status === "analyzing") {
DOMElements.vocalizationContent.innerHTML = `<p class="text-sm text-amber-600">Vocalization analysis in progress...</p>`;
return;
}
if (data.status === "no_data" || data.prediction === null) {
DOMElements.vocalizationContent.innerHTML = `<p class="text-sm text-slate-500">Waiting for vocalization analysis to run...</p>`;
return;
}
if (data.prediction === "Error") {
DOMElements.vocalizationContent.innerHTML = `<p class="text-sm text-red-600">Error during audio analysis.</p>`;
return;
}
const probabilities = data.probabilities || {};
const statusMap = {
'Healthy': { text: 'Healthy', color: 'green' },
'Unhealthy': { text: 'Unhealthy', color: 'red' },
'Noise': { text: 'Noise', color: 'amber' },
};
const dominantStatus = statusMap[data.prediction] || {
text: data.prediction,
color: 'slate'
};
if (data.prediction === "Unhealthy" && state.lastVocalization !== "Unhealthy") {
addLog("Vocalization Status: <strong>Unhealthy Detected</strong>", "danger");
state.lastVocalization = "Unhealthy";
} else if (data.prediction === "Healthy") {
state.lastVocalization = "Healthy";
}
let barsHtml = '';
for (const [key, value] of Object.entries(probabilities)) {
const status = statusMap[key] || { text: key, color: 'slate' };
const percentage = (value * 100).toFixed(1);
barsHtml += `
<div>
<div class="flex justify-between text-xs mb-1">
<span class="font-medium text-${status.color}-700">${status.text}</span>
<span>${percentage}%</span>
</div>
<div class="w-full bg-slate-200 rounded-full h-2">
<div class="bg-${status.color}-500 h-2 rounded-full" style="width:${percentage}%"></div>
</div>
</div>
`;
}
DOMElements.vocalizationContent.innerHTML = `
<div class="flex items-center justify-between mb-3">
<span class="text-slate-500 text-xs">Dominant Status:</span>
<span class="font-bold text-base text-${dominantStatus.color}-600">${dominantStatus.text}</span>
</div>
<div class="space-y-2">${barsHtml}</div>
`;
}
// --- Modal function ---
function openModal(modalEl) {
modalEl.classList.add('show');
modalEl.setAttribute('aria-hidden', 'false');
document.body.classList.add('overflow-hidden');
}
function closeModal(modalEl) {
modalEl.classList.remove('show');
modalEl.setAttribute('aria-hidden', 'true');
document.body.classList.remove('overflow-hidden');
}
function populateSettingsModal() {
DOMElements.camUrlInputs.forEach((input, i) => {
if (input) input.value = state.cameraUrls[i] || '';
});
if (DOMElements.audioUrlInput) {
DOMElements.audioUrlInput.value = state.audioUrl || '';
}
}
// Make Local Storage for url when change
function loadSettingsFromStorage() {
const savedCameraUrls = localStorage.getItem('chickSenseCameraUrls');
const savedAudioUrl = localStorage.getItem('chickSenseAudioUrl');
if (savedCameraUrls) {
try {
state.cameraUrls = JSON.parse(savedCameraUrls);
} catch (e) {
console.error("Failed to parse saved camera URLs, using defaults.", e);
state.cameraUrls = [...CONFIG.DEFAULT_VIDEO_URLS];
}
}
if (savedAudioUrl) {
state.audioUrl = savedAudioUrl;
}
addLog('Settings loaded from browser storage', 'info');
}
// --- Websocket Streaming functions ---
function connectWebSocket(cameraIndex) {
const videoUrl = state.cameraUrls[cameraIndex];
if (!videoUrl) {
console.warn(`No URL for Camera ${cameraIndex + 1}, skipping connection.`);
return;
}
if (state.cameraWebSockets[cameraIndex]) {
state.cameraWebSockets[cameraIndex].close();
}
try {
const ws = new WebSocket(CONFIG.WEBSOCKET_URL);
ws.binaryType = "blob";
state.cameraWebSockets[cameraIndex] = ws;
ws.onopen = () => {
addLog(`Connecting to Camera ${cameraIndex + 1}`, 'info');
ws.send(JSON.stringify({
type: 'start_stream',
video_url: videoUrl,
audio_url: state.audioUrl,
show_detected: state.show_detected,
show_density: state.show_density,
show_inactive: state.show_inactive,
camera_id: cameraIndex + 1
}));
state.isStreaming = true;
};
ws.onmessage = (event) => {
if (event.data instanceof Blob) {
handleBlobMessage(event.data, cameraIndex);
} else if (typeof event.data === 'string') {
handleTextMessage(event.data, cameraIndex);
}
};
ws.onclose = () => {
addLog(`Camera ${cameraIndex + 1} disconnected.`, 'warning');
const ctx = DOMElements.contexts[cameraIndex];
if (ctx) ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
state.cameraWebSockets[cameraIndex] = null;
state.cameraStats[cameraIndex] = { detected: 0, inactive: 0, dense_areas: 0 };
updateDailyAnalysisUI();
state.isStreaming = state.cameraWebSockets.some(ws => ws !== null);
if (!state.isStreaming) {
addLog("All streams disconnected.", 'danger');
}
};
ws.onerror = (error) => {
console.error(`WebSocket Error for Camera ${cameraIndex + 1}:`, error);
addLog(`Connection failed for Camera ${cameraIndex + 1}.`, 'danger');
};
} catch (error) {
console.error(`Failed to create WebSocket for Camera ${cameraIndex + 1}:`, error);
addLog(`Failed to connect to Camera ${cameraIndex + 1}.`, 'danger');
}
}
function handleBlobMessage(blob, cameraIndex) {
const imageUrl = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const ctx = DOMElements.contexts[cameraIndex];
if (ctx) {
ctx.canvas.width = img.width;
ctx.canvas.height = img.height;
ctx.drawImage(img, 0, 0);
}
URL.revokeObjectURL(imageUrl);
};
img.onerror = () => {
console.error(`Failed to load image for Camera ${cameraIndex + 1}`);
URL.revokeObjectURL(imageUrl);
};
img.src = imageUrl;
}
function handleTextMessage(data, cameraIndex) {
try {
const parsedData = JSON.parse(data);
if (parsedData.type === 'stats') {
const stats = {
detected: Number(parsedData.detected || 0),
inactive: Number(parsedData.inactive || 0),
dense_areas: Number(parsedData.dense_areas || 0),
};
state.cameraStats[cameraIndex] = stats;
updateDailyAnalysisUI();
if (stats.detected > 0) {
const inactiveRatio = stats.inactive / stats.detected;
const percent = Math.round(inactiveRatio * 100);
const cameraId = cameraIndex + 1;
const isAboveThreshold = inactiveRatio > state.INACTIVE_THRESHOLD;
const hasAlerted = state.lastInactiveAlert[cameraId];
if (isAboveThreshold && !hasAlerted){
const message = `Camera ${cameraId}: <strong> Inactive chicken percentage is high (${percent}%) </strong>`;
addLog(message, "danger");
state.lastInactiveAlert[cameraId] = true;
}
else if (!isAboveThreshold && hasAlerted){
state.lastInactiveAlert[cameraId] = false;
}
}
}else if (parsedData.type === 'status') {
const msg = parsedData.message;
if (msg === "Display settings updated") {
return;
}
addLog(`[Global] ${msg}`, 'info');
}
} catch (e) {
console.error("Error parsing WebSocket JSON message:", e, data);
}
}
function startAllStreams() {
addLog("Attempting to start all configured streams...", 'info');
for (let i = 0; i < 4; i++) {
setTimeout(() => connectWebSocket(i), i * 100);
}
if (!state.audioPollInterval) {
fetchLatestAudioResult();
state.audioPollInterval = setInterval(
fetchLatestAudioResult,
CONFIG.POLLING_INTERVAL_MS
);
}
}
function stopAllStreams() {
addLog("Stopping all streams...", 'warning');
state.cameraWebSockets.forEach((ws, index) => {
if (ws) {
ws.close();
state.cameraWebSockets[index] = null;
}
});
if (state.audioPollInterval) {
clearInterval(state.audioPollInterval);
state.audioPollInterval = null;
}
displayAudioResults(null);
state.isStreaming = false;
}
async function fetchLatestAudioResult() {
try {
const response = await fetch(`${CONFIG.API_URL}${CONFIG.GET_AUDIO_RESULT_URL}`);
if (!response.ok) {
if(response.status === 404){
return;
}throw new Error(`Server status ${response.status}`)
}
const audioData = await response.json();
const resultKey = JSON.stringify(audioData);
const wasAnalyzing = state.isAnalyzingAudio;
if (state.lastAudioResultKey && state.lastAudioResultKey === resultKey){
return;
}
state.lastAudioResultKey = resultKey;
if (wasAnalyzing){
addLog("Vocalization results updated", "info");
state.isAnalyzingAudio = false;
state.lastAnalysisTimestamp = new Date();
}else{
addLog("Vocalization results available", "info");
}
displayAudioResults(audioData);
} catch (error) {
console.error("Error fetching audio result:", error);
if (state.isAnalyzingAudio){
addLog("Audio analysis failed", "danger");
state.isAnalyzingAudio = false;
}
}
}
async function downloadCSV() {
const start = DOMElements.startDateInput.value;
const end = DOMElements.endDateInput.value;
const cameraId = DOMElements.cameraSelect.value;
if (!start || !end) {
addLog("Please select a start and end date for export.", 'warning');
return;
}
try {
const url = new URL(`${CONFIG.API_URL}/metrics/export`);
url.searchParams.set('start', start);
url.searchParams.set('end', end);
if (cameraId) url.searchParams.set('camera_id', cameraId);
addLog(`Exporting CSV for ${start} to ${end}...`, 'info');
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`Export failed with status ${response.status}`);
}
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = `metrics_${start}_to_${end}${cameraId ? `_cam${cameraId}` : ''}.csv`;
document.body.appendChild(a);
a.click();
// Clean up
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
}, 100);
addLog("CSV download started.", 'info');
closeModal(DOMElements.exportModal);
} catch (error) {
console.error("CSV Download Error:", error);
addLog(error.message, 'danger');
}
}
// -- Event Handlers --
function handleToggleControlClick(e) {
const button = e.target.closest('button');
if (!button) return;
const control = button.dataset.control;
if (!control) return;
state[control] = !state[control];
updateToggleButtons();
const labelMap = {
show_detected: 'Detection',
show_density: 'Density',
show_inactive: 'Inactivity'
};
const displayName = labelMap[control] || control;
const status = state[control] ? 'enabled' : 'disabled';
addLog(`View updated: ${displayName} ${status}`, 'info');
const payload = JSON.stringify({
type: 'display_settings_update',
show_detected: state.show_detected,
show_density: state.show_density,
show_inactive: state.show_inactive
});
state.cameraWebSockets.forEach(ws => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
});
}
function handleSettingsButtonClick() {
addLog('Tip: Double-click the gear icon to open camera settings.', 'info');
}
function handleSettingsButtonDoubleClick() {
populateSettingsModal();
openModal(DOMElements.settingsModal);
}
function handleSaveSettingsClick() {
state.cameraUrls = DOMElements.camUrlInputs.map(input =>
input ? input.value.trim() : ''
);
state.audioUrl = DOMElements.audioUrlInput ?
DOMElements.audioUrlInput.value.trim() : '';
try {
localStorage.setItem('chickSenseCameraUrls', JSON.stringify(state.cameraUrls));
localStorage.setItem('chickSenseAudioUrl', state.audioUrl);
addLog('Settings saved to browser storage.', 'info');
} catch (e) {
console.error("Failed to save settings to local storage:", e);
addLog('Could not save settings.', 'danger');
}
addLog('Settings saved. Restarting streams...', 'info');
closeModal(DOMElements.settingsModal);
stopAllStreams();
setTimeout(startAllStreams, 500);
}
function handleStopStreamsClick() {
stopAllStreams();
closeModal(DOMElements.settingsModal);
}
function handleExportButtonClick() {
addLog('Tip: Double-click "Export CSV" to export metrics to a CSV file.', 'info');
}
function handleExportButtonDoubleClick() {
openModal(DOMElements.exportModal);
}
function handleModalCloseClick(e) {
const modal = e.currentTarget;
if (e.target.classList.contains('modal-backdrop') ||
e.target.closest('.modal-close-btn')) {
closeModal(modal);
}
}
function handleEscapeKey(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal.show').forEach(closeModal);
}
}
// -- Add Event Listener --
function setupEventListeners() {
if (DOMElements.toggleControls) {
DOMElements.toggleControls.addEventListener('click', handleToggleControlClick);
}
if (DOMElements.settingsButton) {
DOMElements.settingsButton.addEventListener('click', handleSettingsButtonClick);
DOMElements.settingsButton.addEventListener('dblclick', handleSettingsButtonDoubleClick);
}
if (DOMElements.saveSettingsBtn) {
DOMElements.saveSettingsBtn.addEventListener('click', handleSaveSettingsClick);
}
if (DOMElements.stopAllStreamsBtn) {
DOMElements.stopAllStreamsBtn.addEventListener('click', handleStopStreamsClick);
}
if (DOMElements.openExportModalBtn) {
DOMElements.openExportModalBtn.addEventListener('click', handleExportButtonClick);
DOMElements.openExportModalBtn.addEventListener('dblclick', handleExportButtonDoubleClick);
}
if (DOMElements.downloadCsvBtn) {
DOMElements.downloadCsvBtn.addEventListener('click', downloadCSV);
}
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', handleModalCloseClick);
});
window.addEventListener('keydown', handleEscapeKey);
}
// -- Init --
function initialize() {
updateDateTime();
updateToggleButtons();
updateDailyAnalysisUI();
displayAudioResults(null);
loadSettingsFromStorage();
setupEventListeners();
const today = new Date().toISOString().split('T')[0];
if (DOMElements.startDateInput) DOMElements.startDateInput.value = today;
if (DOMElements.endDateInput) DOMElements.endDateInput.value = today;
setInterval(updateDateTime, 30000);
addLog('System initialized. Welcome! IKGC', 'info');
startAllStreams();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}