691 lines
32 KiB
HTML
691 lines
32 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Chicken Counter — Live</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:system-ui,monospace;background:#0f0f14;color:#e0e0e0;overflow:hidden;height:100vh;display:flex;flex-direction:column}
|
|
#top-bar{display:flex;justify-content:space-between;align-items:center;padding:8px 16px;background:#16161e;font-size:12px;flex-shrink:0}
|
|
#top-bar h1{font-size:16px;color:#80dc5a}
|
|
#top-bar .dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin:0 6px}
|
|
#top-bar .dot.online{background:#80dc5a;box-shadow:0 0 4px #80dc5a}
|
|
#top-bar .dot.offline{background:#555}
|
|
#main{flex:1;display:flex;overflow:hidden}
|
|
#frame-area{flex:1;background:#000;display:flex;align-items:center;justify-content:center;position:relative}
|
|
#frame-area img{max-width:100%;max-height:100%;object-fit:contain}
|
|
#frame-area .overlay{position:absolute;top:12px;left:12px;pointer-events:none}
|
|
#frame-area .overlay .tag{display:inline-block;padding:4px 12px;border-radius:6px;font-size:12px;font-weight:700;margin-bottom:4px}
|
|
#frame-area .overlay .tag.live{background:#1a3a2a;color:#80dc5a}
|
|
#frame-area .overlay .tag.off{background:#333;color:#888}
|
|
#frame-area .overlay .count{font-size:36px;font-weight:900;color:#fff;text-shadow:0 0 16px rgba(0,0,0,.8)}
|
|
#sidebar{width:290px;background:#12121a;padding:16px;overflow-y:auto;flex-shrink:0;display:flex;flex-direction:column;gap:12px}
|
|
#sidebar .stat{padding:12px;background:#16161e;border-radius:8px}
|
|
#sidebar .stat label{display:block;font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:2px}
|
|
#sidebar .stat .val{font-size:22px;font-weight:700}
|
|
#sidebar .stat .val.good{color:#80dc5a}
|
|
#sidebar .stat .val.warn{color:#ff9f43}
|
|
#sidebar .cam-list{}
|
|
#sidebar .cam-list .cam-row{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;margin:2px 0;border-radius:6px;font-size:12px;cursor:pointer;transition:background .15s}
|
|
#sidebar .cam-list .cam-row:hover{background:#1a1a24}
|
|
#sidebar .cam-list .cam-row.act{background:#1a3a2a;color:#80dc5a;font-weight:700}
|
|
#sidebar .cam-list .cam-row .cam-total{font-size:11px;color:#666}
|
|
#sidebar .cam-list .cam-row.act .cam-total{color:#5a9a4a}
|
|
#db-panel, #mort-panel{padding:12px;background:#16161e;border-radius:8px;font-size:11px}
|
|
#db-panel h3, #mort-panel h3{font-size:10px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center}
|
|
#db-panel .db-row{display:flex;justify-content:space-between;padding:2px 0;color:#888}
|
|
#db-panel .db-row .db-total{color:#aaa;font-weight:600}
|
|
|
|
/* Mortality UI */
|
|
.mort-badge{background:#2a1518;color:#ff6b81;border:1px solid #ff4757;font-weight:700;padding:2px 6px;border-radius:4px;font-size:10px}
|
|
.mort-filter-btn{background:#0e0e14;border:1px solid #333;color:#888;border-radius:4px;padding:2px 6px;font-size:9px;cursor:pointer;font-weight:600;transition:all .15s}
|
|
.mort-filter-btn:hover{color:#fff;border-color:#555}
|
|
.mort-filter-btn.active{background:#ff4757;color:#fff;border-color:#ff4757}
|
|
.mort-coop-tag{display:inline-block;background:#33141a;color:#ff6b81;padding:1px 4px;border-radius:3px;font-size:9px;font-weight:700;margin-right:3px}
|
|
.mort-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px}
|
|
.mort-card{background:#0e0e14;border-radius:6px;overflow:hidden;cursor:pointer;border:1px solid #222;transition:all .15s;text-align:left}
|
|
.mort-card:hover{border-color:#ff4757;transform:translateY(-1px)}
|
|
.mort-card img{width:100%;height:65px;object-fit:cover;display:block;background:#000}
|
|
.mort-card .info{padding:4px 6px;display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#888}
|
|
.mort-card .info .cnt{color:#ff9f43;font-weight:700}
|
|
|
|
.mort-upload-btn{background:#2a1518;color:#ff6b81;border:1px solid #ff4757;font-weight:700;padding:2px 8px;border-radius:4px;font-size:10px;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:3px}
|
|
.mort-upload-btn:hover{background:#ff4757;color:#fff}
|
|
|
|
/* Upload & Capture Modal */
|
|
#mort-upload-modal{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,.85);z-index:1000;align-items:center;justify-content:center;backdrop-filter:blur(4px)}
|
|
#mort-upload-modal.active{display:flex}
|
|
.mort-upload-box{background:#16161e;border:1px solid #333;border-radius:10px;width:90%;max-width:480px;padding:18px;box-shadow:0 8px 32px rgba(0,0,0,.8);position:relative;color:#ddd}
|
|
.mort-upload-box h2{font-size:15px;margin:0 0 10px 0;color:#fff;display:flex;align-items:center;gap:6px}
|
|
.mort-tab-row{display:flex;gap:6px;margin-bottom:12px;border-bottom:1px solid #282834;padding-bottom:8px}
|
|
.mort-tab-btn{background:none;border:none;color:#888;font-size:11px;font-weight:600;cursor:pointer;padding:4px 10px;border-radius:4px;transition:all .15s}
|
|
.mort-tab-btn:hover{color:#fff}
|
|
.mort-tab-btn.active{background:#2a1518;color:#ff6b81;border:1px solid #ff4757}
|
|
.mort-cam-preview-box{background:#0e0e14;border:1px solid #333;border-radius:6px;overflow:hidden;position:relative;height:180px;display:flex;align-items:center;justify-content:center;margin-bottom:10px}
|
|
.mort-cam-preview-box img{width:100%;height:100%;object-fit:contain;background:#000}
|
|
.mort-cam-preview-overlay{position:absolute;bottom:6px;right:8px;background:rgba(0,0,0,.75);padding:2px 8px;border-radius:4px;font-size:10px;color:#2ed573;border:1px solid #333}
|
|
.mort-capture-btn{width:100%;padding:10px;background:#ff4757;color:#fff;border:none;border-radius:6px;font-weight:700;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;transition:background .15s}
|
|
.mort-capture-btn:hover{background:#ff6b81}
|
|
.mort-capture-btn:disabled{background:#333;color:#777;cursor:not-allowed}
|
|
.mort-upload-row{display:flex;gap:10px;margin-bottom:12px}
|
|
.mort-upload-field{flex:1}
|
|
.mort-upload-field label{display:block;font-size:10px;color:#888;margin-bottom:4px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
|
|
.mort-upload-field select, .mort-upload-field input[type="date"]{width:100%;padding:6px 8px;background:#0e0e14;border:1px solid #333;border-radius:6px;color:#fff;font-size:11px;outline:none;box-sizing:border-box}
|
|
.mort-dropzone{border:2px dashed #444;border-radius:8px;padding:18px 12px;text-align:center;background:#0e0e14;cursor:pointer;transition:border-color .15s, background .15s}
|
|
.mort-dropzone:hover, .mort-dropzone.dragover{border-color:#ff4757;background:#1b1419}
|
|
.mort-dropzone-icon{font-size:26px;margin-bottom:4px;display:block}
|
|
.mort-dropzone-txt{font-size:12px;color:#aaa}
|
|
.mort-dropzone-sub{font-size:10px;color:#666;margin-top:2px}
|
|
.mort-preview-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(70px, 1fr));gap:6px;margin-top:10px;max-height:140px;overflow-y:auto}
|
|
.mort-preview-item{position:relative;border-radius:4px;overflow:hidden;border:1px solid #333;height:65px}
|
|
.mort-preview-item img{width:100%;height:100%;object-fit:cover}
|
|
.mort-preview-item .rm-btn{position:absolute;top:2px;right:2px;background:rgba(0,0,0,.7);color:#ff4757;border:none;border-radius:50%;width:16px;height:16px;font-size:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1}
|
|
.mort-submit-btn{width:100%;padding:9px;background:#ff4757;color:#fff;border:none;border-radius:6px;font-weight:700;font-size:12px;cursor:pointer;margin-top:12px;transition:background .15s}
|
|
.mort-submit-btn:hover{background:#ff6b81}
|
|
.mort-submit-btn:disabled{background:#333;color:#777;cursor:not-allowed}
|
|
.mort-upload-status{margin-top:10px;font-size:11px;text-align:center;display:none}
|
|
|
|
/* Fullscreen Image Modal */
|
|
#mort-modal{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,.88);z-index:999;align-items:center;justify-content:center;flex-direction:column;backdrop-filter:blur(4px)}
|
|
#mort-modal.active{display:flex}
|
|
#mort-modal img{max-width:90vw;max-height:85vh;border-radius:8px;border:1px solid #333;box-shadow:0 0 32px rgba(0,0,0,.9);object-fit:contain}
|
|
#mort-modal .close-btn{position:absolute;top:20px;right:28px;font-size:32px;color:#aaa;cursor:pointer;background:none;border:none;line-height:1;transition:color .15s}
|
|
#mort-modal .close-btn:hover{color:#fff}
|
|
#mort-modal .caption{margin-top:12px;font-size:13px;color:#eee;background:#16161e;padding:6px 14px;border-radius:6px;border:1px solid #333}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="top-bar">
|
|
<h1>🐔 Chicken Counter</h1>
|
|
<span style="color:#888"><span id="stat-date">{{ date }}</span> | <span id="clock"></span></span>
|
|
</div>
|
|
<div id="main">
|
|
<div id="frame-area">
|
|
<canvas id="frame-canvas" style="display:none"></canvas>
|
|
<img id="frame-img" src="" alt="live stream" style="display:none">
|
|
<div id="waiting-msg" style="color:#555;font-size:16px;text-align:center">waiting for pipeline...</div>
|
|
<div class="overlay" id="frame-overlay" style="display:none">
|
|
<div class="tag live" id="cam-tag"></div>
|
|
<div class="count" id="cam-count"></div>
|
|
</div>
|
|
</div>
|
|
<div id="sidebar">
|
|
<div class="stat"><label>Total Entered</label><div class="val good" id="s-total">--</div></div>
|
|
<div class="stat"><label>Inside Box</label><div class="val" id="s-inside">--</div></div>
|
|
<div class="stat"><label>Tracks</label><div class="val" id="s-tracks">--</div></div>
|
|
<div class="stat"><label>Frame</label><div class="val" id="s-frame">--</div></div>
|
|
<div class="stat"><label>Motion</label><div class="val" id="s-speed">--</div></div>
|
|
<div class="cam-list" id="cam-list"></div>
|
|
|
|
<!-- Mortality / Carcass Detection Panel -->
|
|
<div class="mort-panel" id="mort-panel">
|
|
<h3>
|
|
<span>☠️ Mortality Scans</span>
|
|
<span style="display:flex;gap:5px;align-items:center">
|
|
<button class="mort-upload-btn" onclick="openMortUploadModal()">📷 Upload / Capture</button>
|
|
<span class="mort-badge" id="mort-header-badge">0</span>
|
|
</span>
|
|
</h3>
|
|
<div id="mort-coop-filters" style="display:flex;gap:4px;flex-wrap:wrap;margin:4px 0 6px 0"></div>
|
|
<div id="mort-summary-text" style="color:#777;font-size:10px;margin-bottom:4px"></div>
|
|
<div class="mort-grid" id="mort-grid"></div>
|
|
</div>
|
|
|
|
<div class="db-panel" id="db-panel">
|
|
<h3>📊 History</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal for Capturing Mortality Photos from Stationary Camera or Uploading -->
|
|
<div id="mort-upload-modal" onclick="closeMortUploadModal(event)">
|
|
<div class="mort-upload-box" onclick="event.stopPropagation()">
|
|
<button class="close-btn" onclick="closeMortUploadModal(event)" style="top:10px;right:14px;font-size:24px">×</button>
|
|
<h2>📸 Mortality Inspection Camera</h2>
|
|
|
|
<div class="mort-tab-row">
|
|
<button class="mort-tab-btn active" id="tab-btn-cam" onclick="switchMortModalTab('cam')">📷 Stationary Camera</button>
|
|
<button class="mort-tab-btn" id="tab-btn-file" onclick="switchMortModalTab('file')">📁 File Upload</button>
|
|
</div>
|
|
|
|
<div class="mort-upload-row">
|
|
<div class="mort-upload-field">
|
|
<label for="mort-upload-coop">Target Coop / Cage</label>
|
|
<select id="mort-upload-coop" onchange="onMortCoopChanged()">
|
|
<option value="K1">K1 (L1, L2, L3)</option>
|
|
<option value="K2">K2 (L1, L2, L3)</option>
|
|
<option value="K3">K3 (L1, L2, L3)</option>
|
|
<option value="K4">K4 (L1, L2)</option>
|
|
<option value="K5">K5 (L1, L2)</option>
|
|
<option value="kandang-atas">kandang-atas</option>
|
|
</select>
|
|
</div>
|
|
<div class="mort-upload-field">
|
|
<label for="mort-upload-date">Date</label>
|
|
<input type="date" id="mort-upload-date">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 1: Stationary Camera Capture (Default) -->
|
|
<div id="mort-cam-tab-content">
|
|
<div class="mort-cam-preview-box" id="mort-cam-preview-box">
|
|
<img id="mort-cam-live-img" src="" alt="Stationary Camera View" onerror="this.style.display='none';document.getElementById('mort-cam-offline-msg').style.display='block'">
|
|
<div id="mort-cam-offline-msg" style="display:none;color:#777;font-size:11px;text-align:center;padding:12px">
|
|
📷 Stationary Camera Preview<br><span style="color:#555;font-size:9px">Click Capture to snap high-res photo</span>
|
|
</div>
|
|
<div class="mort-cam-preview-overlay" id="mort-cam-status-overlay">🔴 Camera Ready</div>
|
|
</div>
|
|
|
|
<button class="mort-capture-btn" id="mort-capture-btn" onclick="captureFromStationaryCamera()">
|
|
📸 Snap & Count Batch from Camera
|
|
</button>
|
|
<div style="font-size:10px;color:#666;text-align:center;margin-top:6px">
|
|
Place batch of chickens on table → click Snap → clear table → repeat if needed
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 2: Manual File Upload -->
|
|
<div id="mort-file-tab-content" style="display:none">
|
|
<input type="file" id="mort-file-input" accept="image/*" multiple style="display:none" onchange="handleMortFilesSelected(event)">
|
|
<div class="mort-dropzone" id="mort-dropzone" onclick="document.getElementById('mort-file-input').click()">
|
|
<span class="mort-dropzone-icon">📸</span>
|
|
<div class="mort-dropzone-txt">Browse or Drop Photos</div>
|
|
<div class="mort-dropzone-sub">Select multiple photos for this coop</div>
|
|
</div>
|
|
<div class="mort-preview-grid" id="mort-preview-grid"></div>
|
|
<button class="mort-submit-btn" id="mort-submit-btn" onclick="submitMortalityUpload()" disabled>
|
|
Count Mortalities (0 photos selected)
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mort-upload-status" id="mort-upload-status"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal for high-res mortality preview -->
|
|
<div id="mort-modal" onclick="closeMortModal(event)">
|
|
<button class="close-btn" onclick="closeMortModal(event)">×</button>
|
|
<img id="mort-modal-img" src="" alt="Mortality detection preview" onclick="event.stopPropagation()">
|
|
<div class="caption" id="mort-modal-caption" onclick="event.stopPropagation()"></div>
|
|
</div>
|
|
|
|
<script>
|
|
var POLL_MS = {{ poll_ms }};
|
|
var SHM = "{{ shm_dir }}";
|
|
var DB_PATH = "{{ db_path }}";
|
|
var RUN_DATE = "{{ date }}";
|
|
var cameras = [], activeCam = null, lastUpdate = 0;
|
|
var perCam = {}, lastFramePerCam = {};
|
|
|
|
function boot() {
|
|
loadCameras();
|
|
setInterval(loadCameras, 3000);
|
|
setInterval(poll, POLL_MS);
|
|
setInterval(updateClock, 1000);
|
|
loadHistory();
|
|
setInterval(loadHistory, 60000);
|
|
loadMortality();
|
|
setInterval(loadMortality, 30000);
|
|
updateClock();
|
|
}
|
|
|
|
function loadCameras() {
|
|
fetch("/api/cameras").then(r => r.json()).then(data => {
|
|
cameras = data.cameras || [];
|
|
renderCamList();
|
|
if (cameras.length && !activeCam) selectCam(cameras[0]);
|
|
detectActive();
|
|
if (!cameras.length) showWaiting("no cameras detected");
|
|
}).catch(function() {});
|
|
updateSidebar();
|
|
}
|
|
|
|
function detectActive() {
|
|
var pending = cameras.length;
|
|
cameras.forEach(function(cam) {
|
|
fetch("/shm/" + cam + "/stats.json?t=" + Date.now()).then(r => r.ok ? r.json() : null).then(s => {
|
|
pending--;
|
|
if (!s) return;
|
|
var prev = lastFramePerCam[cam] || 0;
|
|
perCam[cam] = { total: s.total_entered_count, inside: s.inside_box_count, frame: s.frame_index, tracks: s.track_count, speed: s.smoothed_speed };
|
|
if (s.frame_index > prev) {
|
|
lastFramePerCam[cam] = s.frame_index;
|
|
if (activeCam !== cam) selectCam(cam);
|
|
}
|
|
}).catch(function() {}).finally(function() { if (pending === 0) renderCamList(); });
|
|
});
|
|
}
|
|
|
|
function selectCam(id) {
|
|
activeCam = id;
|
|
document.getElementById("waiting-msg").style.display = "none";
|
|
document.getElementById("frame-overlay").style.display = "block";
|
|
document.getElementById("frame-img").style.display = "block";
|
|
document.getElementById("frame-img").src = "/stream/" + id;
|
|
renderCamList();
|
|
refreshNow();
|
|
}
|
|
|
|
function poll() {
|
|
if (!activeCam) return;
|
|
var t = Date.now();
|
|
fetch("/shm/" + activeCam + "/stats.json?t=" + t).then(r => r.ok ? r.json() : null).then(s => {
|
|
if (!s) { setOffline(); return; }
|
|
lastUpdate = Date.now();
|
|
lastFramePerCam[activeCam] = s.frame_index;
|
|
perCam[activeCam] = { total: s.total_entered_count, inside: s.inside_box_count, frame: s.frame_index, tracks: s.track_count, speed: s.smoothed_speed };
|
|
document.getElementById("s-total").textContent = s.total_entered_count;
|
|
document.getElementById("s-inside").textContent = s.inside_box_count;
|
|
document.getElementById("s-tracks").textContent = s.track_count;
|
|
document.getElementById("s-frame").textContent = s.frame_index;
|
|
document.getElementById("s-speed").textContent = Number(s.smoothed_speed).toFixed(1);
|
|
var tag = document.getElementById("cam-tag");
|
|
var status = s.backward_active ? "BACKWARD STOP" : "RUNNING";
|
|
tag.textContent = activeCam + " \u2022 " + status;
|
|
tag.className = "tag " + (s.backward_active ? "off" : "live");
|
|
document.getElementById("cam-count").textContent = s.total_entered_count;
|
|
}).catch(function() { setOffline(); });
|
|
}
|
|
|
|
function refreshNow() {
|
|
poll();
|
|
}
|
|
|
|
function showWaiting(msg) {
|
|
document.getElementById("waiting-msg").textContent = msg;
|
|
document.getElementById("waiting-msg").style.display = "block";
|
|
document.getElementById("frame-overlay").style.display = "none";
|
|
document.getElementById("frame-img").style.display = "none";
|
|
}
|
|
|
|
function setOffline() {
|
|
var tag = document.getElementById("cam-tag");
|
|
if (tag && activeCam) {
|
|
tag.textContent = activeCam + " \u2022 OFFLINE";
|
|
tag.className = "tag off";
|
|
}
|
|
}
|
|
|
|
function updateSidebar() {}
|
|
|
|
function renderCamList() {
|
|
var ul = document.getElementById("cam-list");
|
|
ul.innerHTML = cameras.map(function(c) {
|
|
var info = perCam[c] || {};
|
|
var cls = c === activeCam ? " act" : "";
|
|
return '<div class="cam-row' + cls + '" onclick="selectCam(\'' + c + '\')">' +
|
|
'<span>' + c + '</span>' +
|
|
'<span class="cam-total">' + (info.total || 0) + '</span></div>';
|
|
}).join("");
|
|
}
|
|
|
|
function updateClock() {
|
|
document.getElementById("clock").textContent = new Date().toLocaleTimeString();
|
|
}
|
|
|
|
function loadHistory() {
|
|
if (!DB_PATH) return;
|
|
var panel = document.getElementById("db-panel");
|
|
panel.style.display = "block";
|
|
|
|
fetch("/api/db/history").then(r => r.json()).then(rows => {
|
|
var html = "<h3>📊 History</h3>";
|
|
if (rows && rows.length) {
|
|
html += rows.map(function(r) {
|
|
return '<div class="db-row"><span>' + r.date + ' ' + (r.location || '') + '</span>' +
|
|
'<span class="db-total">' + Number(r.total || 0).toLocaleString() + '</span></div>';
|
|
}).join("");
|
|
}
|
|
panel.innerHTML = html;
|
|
}).catch(function() {});
|
|
|
|
if (RUN_DATE && RUN_DATE !== "today") {
|
|
fetch("/api/db/date/" + RUN_DATE).then(r => r.json()).then(data => {
|
|
if (!data.cameras || !data.cameras.length) return;
|
|
var html = panel.innerHTML;
|
|
html += '<h3 style="margin-top:12px">📹 ' + RUN_DATE + ' (' + (data.total.total || 0).toLocaleString() + ')</h3>';
|
|
data.cameras.forEach(function(c) {
|
|
html += '<div class="db-row"><span>' + c.camera_id + '</span>' +
|
|
'<span class="db-total">' + Number(c.total_entered || 0).toLocaleString() + '</span></div>';
|
|
});
|
|
panel.innerHTML = html;
|
|
}).catch(function() {});
|
|
}
|
|
}
|
|
|
|
var allMortalityData = [];
|
|
var selectedMortCoop = "ALL";
|
|
|
|
function loadMortality() {
|
|
fetch("/api/mortality/history").then(r => r.ok ? r.json() : []).then(reports => {
|
|
var panel = document.getElementById("mort-panel");
|
|
if (!reports || !reports.length) {
|
|
fetch("/api/mortality/latest").then(r => r.ok ? r.json() : null).then(latest => {
|
|
if (!latest || latest.error || !latest.results || !latest.results.length) {
|
|
panel.style.display = "none";
|
|
return;
|
|
}
|
|
allMortalityData = [latest];
|
|
renderMortalityUI();
|
|
}).catch(function() { panel.style.display = "none"; });
|
|
return;
|
|
}
|
|
allMortalityData = reports;
|
|
renderMortalityUI();
|
|
}).catch(function() {});
|
|
}
|
|
|
|
function renderMortalityUI() {
|
|
var panel = document.getElementById("mort-panel");
|
|
if (!allMortalityData || !allMortalityData.length) {
|
|
panel.style.display = "none";
|
|
return;
|
|
}
|
|
panel.style.display = "block";
|
|
|
|
var coopsSet = {};
|
|
allMortalityData.forEach(function(rep) {
|
|
var c = rep.coop || rep.location || "kandang-atas";
|
|
coopsSet[c] = (coopsSet[c] || 0) + (rep.total_mortality_count || 0);
|
|
});
|
|
var coopsList = Object.keys(coopsSet).sort();
|
|
|
|
var filterDiv = document.getElementById("mort-coop-filters");
|
|
if (coopsList.length > 1) {
|
|
var fHtml = '<button class="mort-filter-btn' + (selectedMortCoop === 'ALL' ? ' active' : '') + '" onclick="setMortCoopFilter(\'ALL\')">ALL</button>';
|
|
coopsList.forEach(function(c) {
|
|
fHtml += '<button class="mort-filter-btn' + (selectedMortCoop === c ? ' active' : '') + '" onclick="setMortCoopFilter(\'' + c + '\')">' + c + '</button>';
|
|
});
|
|
filterDiv.innerHTML = fHtml;
|
|
filterDiv.style.display = "flex";
|
|
} else {
|
|
filterDiv.style.display = "none";
|
|
}
|
|
|
|
var filteredReports = selectedMortCoop === "ALL"
|
|
? allMortalityData
|
|
: allMortalityData.filter(function(r) { return (r.coop || r.location || "").toUpperCase() === selectedMortCoop.toUpperCase(); });
|
|
|
|
var totalCarcasses = 0;
|
|
var allItems = [];
|
|
filteredReports.forEach(function(rep) {
|
|
totalCarcasses += (rep.total_mortality_count || 0);
|
|
var cName = rep.coop || rep.location || "kandang-atas";
|
|
var cFloors = rep.covered_floors ? rep.covered_floors.join(", ") : "";
|
|
(rep.results || []).forEach(function(res) {
|
|
allItems.push({
|
|
item: res,
|
|
coop: cName,
|
|
floors: cFloors,
|
|
date: rep.date || ""
|
|
});
|
|
});
|
|
});
|
|
|
|
document.getElementById("mort-header-badge").textContent = totalCarcasses + " detected";
|
|
var summaryTxt = (selectedMortCoop === "ALL" ? "All Coops" : selectedMortCoop) + " \u2022 " + allItems.length + " scan(s)";
|
|
document.getElementById("mort-summary-text").textContent = summaryTxt;
|
|
|
|
var grid = document.getElementById("mort-grid");
|
|
grid.innerHTML = allItems.map(function(obj) {
|
|
var item = obj.item;
|
|
var imgSrc = "/api/mortality/image/" + encodeURIComponent(item.output_image || item.input_image);
|
|
var title = (item.output_image || item.input_image || "scan").replace(/^output_/, "");
|
|
var count = item.count !== undefined ? item.count : (item.detections ? item.detections.length : 0);
|
|
var captionText = '[' + obj.coop + (obj.floors ? ' (Floors: ' + obj.floors + ')' : '') + '] ' + title + ' (' + count + ' carcasses)';
|
|
return '<div class="mort-card" onclick="openMortModal(\'' + imgSrc + '\', \'' + captionText.replace(/'/g, "\\'") + '\')">' +
|
|
'<img src="' + imgSrc + '" alt="' + title + '" loading="lazy" onerror="this.style.opacity=0.3">' +
|
|
'<div class="info">' +
|
|
'<span style="max-width:85px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + title + '">' +
|
|
'<span class="mort-coop-tag">' + obj.coop + '</span>' + title + '</span>' +
|
|
'<span class="cnt">' + count + ' 💀</span>' +
|
|
'</div></div>';
|
|
}).join("");
|
|
}
|
|
|
|
function setMortCoopFilter(coop) {
|
|
selectedMortCoop = coop;
|
|
renderMortalityUI();
|
|
}
|
|
|
|
function openMortModal(src, caption) {
|
|
var modal = document.getElementById("mort-modal");
|
|
document.getElementById("mort-modal-img").src = src;
|
|
document.getElementById("mort-modal-caption").textContent = caption || "";
|
|
modal.classList.add("active");
|
|
}
|
|
|
|
function closeMortModal(event) {
|
|
document.getElementById("mort-modal").classList.remove("active");
|
|
}
|
|
|
|
/* Mortality Inspection Camera & Upload Logic */
|
|
var selectedUploadFiles = [];
|
|
var mortModalTab = "cam";
|
|
|
|
function switchMortModalTab(tab) {
|
|
mortModalTab = tab;
|
|
var btnCam = document.getElementById("tab-btn-cam");
|
|
var btnFile = document.getElementById("tab-btn-file");
|
|
var contentCam = document.getElementById("mort-cam-tab-content");
|
|
var contentFile = document.getElementById("mort-file-tab-content");
|
|
|
|
if (tab === "cam") {
|
|
if (btnCam) btnCam.classList.add("active");
|
|
if (btnFile) btnFile.classList.remove("active");
|
|
if (contentCam) contentCam.style.display = "block";
|
|
if (contentFile) contentFile.style.display = "none";
|
|
refreshMortCamPreview();
|
|
} else {
|
|
if (btnFile) btnFile.classList.add("active");
|
|
if (btnCam) btnCam.classList.remove("active");
|
|
if (contentFile) contentFile.style.display = "block";
|
|
if (contentCam) contentCam.style.display = "none";
|
|
}
|
|
}
|
|
|
|
function onMortCoopChanged() {
|
|
if (mortModalTab === "cam") {
|
|
refreshMortCamPreview();
|
|
}
|
|
}
|
|
|
|
function refreshMortCamPreview() {
|
|
var coop = document.getElementById("mort-upload-coop").value || "K1";
|
|
var img = document.getElementById("mort-cam-live-img");
|
|
var offlineMsg = document.getElementById("mort-cam-offline-msg");
|
|
var overlay = document.getElementById("mort-cam-status-overlay");
|
|
if (!img) return;
|
|
|
|
overlay.textContent = "Connecting to " + coop + " camera...";
|
|
overlay.style.color = "#ff9f43";
|
|
|
|
img.onload = function() {
|
|
img.style.display = "block";
|
|
offlineMsg.style.display = "none";
|
|
overlay.textContent = "🟢 " + coop + " Camera Live";
|
|
overlay.style.color = "#2ed573";
|
|
};
|
|
img.onerror = function() {
|
|
img.style.display = "none";
|
|
offlineMsg.style.display = "block";
|
|
overlay.textContent = "⚪ Stationary Camera Ready";
|
|
overlay.style.color = "#aaa";
|
|
};
|
|
img.src = "/api/mortality/camera/preview?coop=" + encodeURIComponent(coop) + "&_t=" + Date.now();
|
|
}
|
|
|
|
function captureFromStationaryCamera() {
|
|
var coop = document.getElementById("mort-upload-coop").value || "K1";
|
|
var date = document.getElementById("mort-upload-date").value || new Date().toISOString().split("T")[0];
|
|
var btn = document.getElementById("mort-capture-btn");
|
|
var statusDiv = document.getElementById("mort-upload-status");
|
|
|
|
btn.disabled = true;
|
|
btn.innerHTML = "⏳ Snapping & Counting with YOLO...";
|
|
statusDiv.style.display = "block";
|
|
statusDiv.style.color = "#ff9f43";
|
|
statusDiv.textContent = "Capturing frame from " + coop + " stationary camera and counting carcasses...";
|
|
|
|
fetch("/api/mortality/capture", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ coop: coop, date: date })
|
|
}).then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.status === "success") {
|
|
statusDiv.style.color = "#2ed573";
|
|
statusDiv.innerHTML = "✅ <b>Batch #" + data.total_images + ": " + data.batch_count + " carcass(es)</b> detected!<br>" +
|
|
"<span style='color:#ff9f43'>Total for " + coop + " Today: <b>" + data.total_mortality_count + " carcasses</b> across " + data.total_images + " batch(es)</span>";
|
|
btn.disabled = false;
|
|
btn.innerHTML = "📸 Snap & Count Next Batch";
|
|
|
|
loadMortality();
|
|
setTimeout(refreshMortCamPreview, 600);
|
|
} else {
|
|
statusDiv.style.color = "#ff4757";
|
|
statusDiv.textContent = "❌ Capture error: " + (data.message || "Failed to capture frame");
|
|
btn.disabled = false;
|
|
btn.innerHTML = "📸 Retry Capture";
|
|
}
|
|
}).catch(function(err) {
|
|
statusDiv.style.color = "#ff4757";
|
|
statusDiv.textContent = "❌ Network error: " + err.message;
|
|
btn.disabled = false;
|
|
btn.innerHTML = "📸 Retry Capture";
|
|
});
|
|
}
|
|
|
|
function openMortUploadModal() {
|
|
var modal = document.getElementById("mort-upload-modal");
|
|
var today = new Date().toISOString().split("T")[0];
|
|
document.getElementById("mort-upload-date").value = today;
|
|
|
|
var coopSelect = document.getElementById("mort-upload-coop");
|
|
if (allMortalityData && allMortalityData.length) {
|
|
var coopsSet = {};
|
|
allMortalityData.forEach(function(r) {
|
|
var c = r.coop || r.location;
|
|
if (c) coopsSet[c] = true;
|
|
});
|
|
["K1", "K2", "K3", "K4", "K5", "kandang-atas"].forEach(function(c) { coopsSet[c] = true; });
|
|
var list = Object.keys(coopsSet).sort();
|
|
if (list.length > 0) {
|
|
coopSelect.innerHTML = list.map(function(c) {
|
|
return '<option value="' + c + '">' + c + '</option>';
|
|
}).join("");
|
|
}
|
|
}
|
|
|
|
selectedUploadFiles = [];
|
|
renderUploadPreviews();
|
|
document.getElementById("mort-upload-status").style.display = "none";
|
|
modal.classList.add("active");
|
|
switchMortModalTab("cam");
|
|
}
|
|
|
|
function closeMortUploadModal(event) {
|
|
var modal = document.getElementById("mort-upload-modal");
|
|
modal.classList.remove("active");
|
|
}
|
|
|
|
function handleMortFilesSelected(event) {
|
|
var files = event.target.files;
|
|
if (!files || !files.length) return;
|
|
for (var i = 0; i < files.length; i++) {
|
|
selectedUploadFiles.push(files[i]);
|
|
}
|
|
renderUploadPreviews();
|
|
}
|
|
|
|
function removeUploadFile(idx) {
|
|
selectedUploadFiles.splice(idx, 1);
|
|
renderUploadPreviews();
|
|
}
|
|
|
|
function renderUploadPreviews() {
|
|
var grid = document.getElementById("mort-preview-grid");
|
|
var btn = document.getElementById("mort-submit-btn");
|
|
|
|
if (!selectedUploadFiles.length) {
|
|
grid.innerHTML = "";
|
|
btn.disabled = true;
|
|
btn.textContent = "Count Mortalities (0 photos selected)";
|
|
return;
|
|
}
|
|
|
|
btn.disabled = false;
|
|
btn.textContent = "Scan & Count Chickens (" + selectedUploadFiles.length + " photo" + (selectedUploadFiles.length > 1 ? "s" : "") + ")";
|
|
|
|
grid.innerHTML = selectedUploadFiles.map(function(file, idx) {
|
|
var url = URL.createObjectURL(file);
|
|
return '<div class="mort-preview-item">' +
|
|
'<img src="' + url + '" alt="preview">' +
|
|
'<button class="rm-btn" onclick="removeUploadFile(' + idx + ')">×</button>' +
|
|
'</div>';
|
|
}).join("");
|
|
}
|
|
|
|
function submitMortalityUpload() {
|
|
if (!selectedUploadFiles.length) return;
|
|
var coop = document.getElementById("mort-upload-coop").value || "K1";
|
|
var date = document.getElementById("mort-upload-date").value || new Date().toISOString().split("T")[0];
|
|
var btn = document.getElementById("mort-submit-btn");
|
|
var statusDiv = document.getElementById("mort-upload-status");
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = "⏳ Running YOLO Detection...";
|
|
statusDiv.style.display = "block";
|
|
statusDiv.style.color = "#ff9f43";
|
|
statusDiv.textContent = "Uploading " + selectedUploadFiles.length + " photo(s) and counting carcasses...";
|
|
|
|
var formData = new FormData();
|
|
formData.append("coop", coop);
|
|
formData.append("date", date);
|
|
for (var i = 0; i < selectedUploadFiles.length; i++) {
|
|
formData.append("images", selectedUploadFiles[i]);
|
|
}
|
|
|
|
fetch("/api/mortality/upload", {
|
|
method: "POST",
|
|
body: formData
|
|
}).then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.status === "success") {
|
|
statusDiv.style.color = "#2ed573";
|
|
statusDiv.innerHTML = "✅ <b>" + data.total_mortality_count + " carcasses</b> detected across " + data.total_images + " photo(s) for <b>" + coop + "</b> (" + date + ")!";
|
|
btn.textContent = "✅ Scan Completed";
|
|
|
|
loadMortality();
|
|
|
|
setTimeout(function() {
|
|
closeMortUploadModal();
|
|
}, 2500);
|
|
} else {
|
|
statusDiv.style.color = "#ff4757";
|
|
statusDiv.textContent = "❌ Error: " + (data.message || "Failed to process photos");
|
|
btn.disabled = false;
|
|
btn.textContent = "Retry Scan";
|
|
}
|
|
}).catch(function(err) {
|
|
statusDiv.style.color = "#ff4757";
|
|
statusDiv.textContent = "❌ Network error: " + err.message;
|
|
btn.disabled = false;
|
|
btn.textContent = "Retry Scan";
|
|
});
|
|
}
|
|
|
|
document.addEventListener("keydown", function(e) {
|
|
if (e.key === "Escape") {
|
|
closeMortModal();
|
|
closeMortUploadModal();
|
|
}
|
|
});
|
|
|
|
setTimeout(function() { if (activeCam) refreshNow(); }, 500);
|
|
boot();
|
|
</script>
|
|
</body>
|
|
</html>
|