1341 lines
75 KiB
PHP
1341 lines
75 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Dashboard Monitoring</title>
|
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍈</text></svg>">
|
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@1.2.1/dist/chartjs-plugin-zoom.min.js"></script>
|
|
<style>
|
|
:root {
|
|
--green-dark: #1a5c2a; --green-mid: #2d8a45; --green-light: #4caf68;
|
|
--green-pale: #e8f5ec; --bg: #f0f4f0; --card: #ffffff;
|
|
--text: #1a2e1e; --muted: #6b7c6e; --border: #d4e4d8;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: 'DM Sans', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
nav { background: var(--green-dark); padding: 0 20px; min-height: 48px; display: flex; align-items: center; justify-content: space-between; border-bottom: 3px solid var(--green-light); flex-wrap: wrap; gap: 4px; }
|
|
nav .brand { font-family: 'Space Mono', monospace; font-size: 15px; font-weight: 700; color: #fff; letter-spacing: 1px; white-space: nowrap; }
|
|
nav .brand span { color: var(--green-light); }
|
|
nav .nav-right { display: flex; gap: 12px; font-size: 12px; color: rgba(255,255,255,0.7); font-family: 'Space Mono', monospace; align-items: center; flex-wrap: wrap; }
|
|
nav .status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #4ade80; margin-right: 5px; animation: blink 1.5s infinite; }
|
|
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
|
|
.tab-menu { background: #fff; border-bottom: 2px solid var(--border); display: flex; padding: 0 12px; overflow-x: auto; }
|
|
.tab-menu::-webkit-scrollbar { display: none; }
|
|
.tab-btn { padding: 10px 16px; font-size: 13px; font-weight: 600; color: var(--muted); border: none; background: none; cursor: pointer; border-bottom: 3px solid transparent; margin-bottom: -2px; transition: all .2s; font-family: 'DM Sans', sans-serif; white-space: nowrap; }
|
|
.tab-btn:hover { color: var(--green-mid); }
|
|
.tab-btn.active { color: var(--green-dark); border-bottom-color: var(--green-mid); }
|
|
.tab-content { display: none; }
|
|
.tab-content.active { display: block; }
|
|
.container { max-width: 1100px; margin: 18px auto; padding: 0 16px; display: grid; grid-template-columns: 1fr 300px; gap: 16px; align-items: start; }
|
|
.left-col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
|
.card { background: var(--card); border-radius: 10px; border: 1px solid var(--border); overflow: hidden; }
|
|
.card-header { padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: .5px; text-transform: uppercase; background: var(--green-pale); display: flex; align-items: center; gap: 6px; }
|
|
.main-image-wrap { position: relative; background: #111; aspect-ratio: 16/9; max-height: 340px; overflow: hidden; width: 100%; }
|
|
.main-image-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; opacity: .95; }
|
|
.offline-overlay { display: none; position: absolute; top:0; left:0; right:0; bottom:0; background: #111; align-items: center; justify-content: center; flex-direction: column; gap: 10px; z-index: 3; }
|
|
.offline-overlay.show { display: flex; }
|
|
.offline-overlay svg { opacity: .3; }
|
|
.offline-overlay span { font-family: 'Space Mono', monospace; font-size: 12px; color: rgba(255,255,255,.4); letter-spacing: 1px; }
|
|
.img-overlay { position: absolute; bottom:0; left:0; right:0; background: linear-gradient(transparent, rgba(0,0,0,.8)); padding: 16px 12px 10px; z-index: 4; }
|
|
.live-badge { color: #fff; font-size: 10px; font-family: 'Space Mono', monospace; font-weight: 700; padding: 2px 7px; border-radius: 4px; letter-spacing: 1px; margin-right: 8px; }
|
|
.img-meta { font-family: 'Space Mono', monospace; font-size: 10px; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.5); line-height: 1.8; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
.img-meta .val { color: var(--green-light); font-weight: 700; }
|
|
.stream-status { position: absolute; top: 8px; right: 10px; font-size: 10px; font-family: 'Space Mono', monospace; color: #fff; background: rgba(0,0,0,0.55); padding: 2px 8px; border-radius: 4px; z-index: 5; }
|
|
.btn-capture { display: inline-flex; align-items: center; gap: 5px; background: var(--green-mid); color: #fff; border: none; border-radius: 6px; padding: 5px 12px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: 'DM Sans', sans-serif; transition: background .2s; }
|
|
.btn-capture:hover { background: var(--green-dark); }
|
|
.btn-capture:disabled { background: #9ca3af; cursor: not-allowed; }
|
|
.timelapse-strip { display: flex; gap: 8px; padding: 12px 14px; overflow-x: auto; min-height: 90px; align-items: center; }
|
|
.timelapse-strip::-webkit-scrollbar { height: 4px; }
|
|
.timelapse-strip::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
.right-col { display: flex; flex-direction: column; gap: 16px; }
|
|
.gauges-row { display: flex; justify-content: space-around; padding: 14px 12px 12px; gap: 6px; }
|
|
.gauge-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
|
.gauge-label { font-size: 10px; color: var(--muted); font-weight: 600; text-align: center; }
|
|
.gauge-svg { width: 80px; height: 80px; transform: rotate(-90deg); }
|
|
.gauge-svg circle { fill: none; stroke-width: 8; stroke-linecap: round; }
|
|
.gauge-bg { stroke: #e9ecef; }
|
|
.gauge-val-text { font-family: 'DM Sans', sans-serif; font-size: 12px; font-weight: 700; fill: var(--text); dominant-baseline: middle; text-anchor: middle; }
|
|
.gauge-sub { font-size: 9px; fill: var(--muted); dominant-baseline: middle; text-anchor: middle; }
|
|
.chart-wrap { padding: 14px 14px 6px; height: 200px; }
|
|
.split-charts { display: flex; flex-direction: column; gap: 0; padding: 10px 14px 14px; }
|
|
.split-chart-item { display: flex; flex-direction: column; gap: 4px; padding: 12px 0; border-bottom: 1px solid var(--border); }
|
|
.split-chart-item:last-child { border-bottom: none; }
|
|
.split-chart-label { font-size: 11px; font-weight: 700; letter-spacing: .4px; font-family: 'Space Mono', monospace; }
|
|
.split-chart-wrap { width: 100%; height: 110px; position: relative; }
|
|
.chart-filter { display: flex; gap: 4px; padding: 8px 14px 0; justify-content: flex-end; }
|
|
.chart-filter-btn { padding: 3px 10px; font-size: 11px; font-weight: 600; border-radius: 20px; border: 1px solid var(--border); background: #fff; color: var(--muted); cursor: pointer; font-family: 'Space Mono', monospace; transition: all .15s; }
|
|
.chart-filter-btn:hover { border-color: var(--green-mid); color: var(--green-mid); }
|
|
.chart-filter-btn.active { background: var(--green-dark); color: #fff; border-color: var(--green-dark); }
|
|
.period-tabs { display: flex; justify-content: center; gap: 0; border-top: 1px solid var(--border); margin-top: 6px; }
|
|
.period-tab { flex: 1; padding: 7px 0; font-size: 12px; font-weight: 700; font-family: 'Space Mono', monospace; border: none; background: none; color: var(--muted); cursor: pointer; transition: color .15s; letter-spacing: .5px; }
|
|
.period-tab:hover { color: var(--green-dark); }
|
|
.period-tab.active { color: var(--green-mid); border-bottom: 2px solid var(--green-mid); }
|
|
.zoom-hint { font-size: 10px; color: var(--muted); text-align: center; padding: 4px 0 2px; font-family: 'Space Mono', monospace; }
|
|
.btn-reset-zoom { display: block; margin: 4px auto 0; padding: 2px 12px; font-size: 10px; font-weight: 600; font-family: 'Space Mono', monospace; background: var(--green-pale); border: 1px solid var(--border); border-radius: 20px; color: var(--green-dark); cursor: pointer; transition: all .15s; }
|
|
.btn-reset-zoom:hover { background: var(--green-mid); color: #fff; border-color: var(--green-mid); } .chart-legend { display: flex; gap: 14px; padding: 6px 14px 12px; flex-wrap: wrap; justify-content: center; }
|
|
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); cursor: pointer; user-select: none; transition: opacity .2s; }
|
|
.legend-item:hover { opacity: .7; }
|
|
.legend-dot { width: 28px; height: 4px; border-radius: 2px; flex-shrink: 0; }
|
|
.riwayat-chart-inner { height: 300px; padding: 14px 14px 6px; }
|
|
.sensor-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
.sensor-table td { padding: 7px 14px; border-bottom: 1px solid var(--border); }
|
|
.sensor-table tr:last-child td { border-bottom: none; }
|
|
.sensor-table .key { color: var(--muted); font-weight: 500; }
|
|
.sensor-table .val-cell { font-family: 'Space Mono', monospace; font-weight: 700; text-align: right; }
|
|
.badge-ok { background: #dcfce7; color: #16a34a; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 700; }
|
|
.badge-warn { background: #fef9c3; color: #ca8a04; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 700; }
|
|
.badge-danger { background: #fee2e2; color: #dc2626; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 700; }
|
|
.badge-info { background: #dbeafe; color: #1d4ed8; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 700; }
|
|
.badge-pump-on { background: #dcfce7; color: #14532d; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 700; border: 1px solid #86efac; }
|
|
.badge-pump-off { background: #f3f4f6; color: #4b5563; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 700; border: 1px solid #d1d5db; }
|
|
.badge-fan-on { background: #e0f2fe; color: #075985; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 700; border: 1px solid #7dd3fc; }
|
|
.badge-fan-off { background: #f3f4f6; color: #4b5563; border-radius: 4px; padding: 2px 8px; font-size: 10px; font-weight: 700; border: 1px solid #d1d5db; }
|
|
.pompa-banner { margin: 0 14px 12px; border-radius: 8px; padding: 10px 14px; display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 700; font-family: 'Space Mono', monospace; transition: all .4s; border: 2px solid transparent; }
|
|
.pompa-banner.on { background: #dcfce7; border-color: #86efac; color: #14532d; }
|
|
.pompa-banner.off { background: #f3f4f6; border-color: #d1d5db; color: #4b5563; }
|
|
.pompa-banner .pompa-icon { font-size: 22px; line-height: 1; }
|
|
.pompa-banner .pompa-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
.pompa-banner.on .pompa-dot { background: #16a34a; box-shadow: 0 0 0 0 rgba(22,163,74,.6); animation: pump-pulse 1.2s infinite; }
|
|
.pompa-banner.off .pompa-dot { background: #9ca3af; }
|
|
@keyframes pump-pulse { 0%{box-shadow:0 0 0 0 rgba(22,163,74,.6)} 70%{box-shadow:0 0 0 8px rgba(22,163,74,0)} 100%{box-shadow:0 0 0 0 rgba(22,163,74,0)} }
|
|
.pompa-banner .pompa-label { flex: 1; }
|
|
.pompa-banner .pompa-since { font-size: 10px; font-weight: 400; color: var(--muted); margin-top: 2px; }
|
|
.pump-cam-badge { position: absolute; top: 8px; left: 10px; font-size: 11px; font-family: 'Space Mono', monospace; font-weight: 700; padding: 3px 10px; border-radius: 5px; z-index: 5; transition: all .3s; display: none; }
|
|
.pump-cam-badge.on { display: block; background: rgba(22,163,74,0.85); color: #fff; animation: cam-badge-blink 1.5s infinite; }
|
|
@keyframes cam-badge-blink { 0%,100%{opacity:1} 50%{opacity:.6} }
|
|
.riwayat-wrap { max-width: 1100px; margin: 18px auto; padding: 0 16px; }
|
|
.riwayat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; }
|
|
.riwayat-header h2 { font-size: 15px; font-weight: 700; color: var(--text); }
|
|
.btn-hapus-toggle { background: #fee2e2; color: #dc2626; border: 1px solid #fca5a5; padding: 8px 16px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; }
|
|
.btn-hapus-toggle:hover { background: #fecaca; }
|
|
.btn-hapus-all { background: #dc2626; color: #fff; border: none; padding: 8px 16px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; }
|
|
.btn-hapus-all:hover { background: #b91c1c; }
|
|
.riwayat-section { background: #fff; border-radius: 10px; border: 1px solid var(--border); overflow: hidden; margin-bottom: 16px; }
|
|
.riwayat-section-header { padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: .5px; text-transform: uppercase; background: var(--green-pale); }
|
|
.foto-histori-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; padding: 12px 14px; }
|
|
.foto-histori-item { border-radius: 8px; overflow: hidden; border: 1px solid var(--border); cursor: pointer; transition: transform .2s, box-shadow .2s; position: relative; }
|
|
.foto-histori-item:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,.12); }
|
|
.foto-histori-item img { width: 100%; height: 110px; object-fit: cover; display: block; }
|
|
.foto-histori-label { padding: 4px 8px; font-size: 10px; font-family: 'Space Mono', monospace; color: var(--muted); background: #fff; }
|
|
.btn-hapus-foto-item { position: absolute; top: 5px; right: 5px; background: rgba(220,38,38,0.85); color: #fff; border: none; border-radius: 50%; width: 22px; height: 22px; font-size: 11px; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .2s; z-index: 2; line-height: 1; }
|
|
.foto-histori-item:hover .btn-hapus-foto-item { opacity: 1; }
|
|
.foto-skeleton { border-radius: 8px; background: #e8f5ec; height: 140px; border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; animation: pulse 1.5s infinite; }
|
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
|
.riwayat-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; width: 100%; }
|
|
.riwayat-table { width: 100%; border-collapse: collapse; font-size: 12px; min-width: 600px; }
|
|
.riwayat-table th { background: var(--green-pale); padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid var(--border); }
|
|
.riwayat-table td { padding: 9px 14px; border-bottom: 1px solid var(--border); font-family: 'Space Mono', monospace; font-size: 11px; }
|
|
.riwayat-table tr:last-child td { border-bottom: none; }
|
|
.riwayat-table tr:hover td { background: var(--green-pale); }
|
|
.loading-text { text-align: center; padding: 30px; color: var(--muted); font-size: 13px; }
|
|
.foto-count-badge { background: var(--green-pale); color: var(--green-dark); border-radius: 20px; padding: 3px 12px; font-size: 11px; font-weight: 600; }
|
|
.pump-on-row td { background: #f0fdf4 !important; }
|
|
.pump-off-row td { background: #fafafa !important; }
|
|
.modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 999; align-items: center; justify-content: center; }
|
|
.modal-backdrop.show { display: flex; }
|
|
.modal-box { background: #fff; border-radius: 12px; padding: 24px; width: 90%; max-width: 400px; }
|
|
.modal-box h3 { font-size: 15px; font-weight: 700; color: #1a2e1e; margin-bottom: 6px; }
|
|
.modal-box p { font-size: 12px; color: var(--muted); margin-bottom: 18px; }
|
|
.modal-box label { font-size: 11px; color: var(--muted); display: block; margin-bottom: 4px; }
|
|
.modal-box input[type=date] { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; font-family: 'DM Sans', sans-serif; margin-bottom: 12px; }
|
|
.modal-footer { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; flex-wrap: wrap; }
|
|
.btn-batal { background: none; border: 1px solid var(--border); padding: 8px 16px; border-radius: 6px; font-size: 12px; cursor: pointer; color: var(--muted); }
|
|
.btn-danger { background: #dc2626; color: #fff; border: none; padding: 8px 16px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; }
|
|
.btn-danger:hover { background: #b91c1c; }
|
|
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
|
#modal-foto-viewer .modal-inner { background: #111; border-radius: 12px; padding: 16px; width: 420px; max-width: 92vw; display: flex; flex-direction: column; align-items: center; gap: 10px; position: relative; }
|
|
#modal-foto-viewer img { width: 100%; max-width: 388px; max-height: 300px; border-radius: 8px; object-fit: contain; }
|
|
#modal-foto-viewer .close-btn { position: absolute; top: 10px; right: 12px; background: rgba(255,255,255,0.15); border: none; color: #fff; font-size: 18px; cursor: pointer; border-radius: 50%; width: 30px; height: 30px; line-height: 30px; text-align: center; }
|
|
#modal-foto-viewer .foto-waktu { color: rgba(255,255,255,0.7); font-family: 'Space Mono', monospace; font-size: 11px; margin: 0; }
|
|
#modal-foto-viewer .btn-download { background: var(--green-mid); color: #fff; padding: 6px 16px; border-radius: 6px; font-size: 12px; font-weight: 600; border: none; cursor: pointer; }
|
|
#modal-foto-viewer .btn-download:hover { background: var(--green-dark); }
|
|
#modal-foto-viewer .btn-hapus-satu { background: #dc2626; color: #fff; padding: 6px 16px; border-radius: 6px; font-size: 12px; font-weight: 600; border: none; cursor: pointer; }
|
|
#modal-foto-viewer .btn-hapus-satu:hover { background: #b91c1c; }
|
|
#toast { position: fixed; bottom: 20px; right: 20px; z-index: 9999; background: #1a2e1e; color: #fff; padding: 10px 18px; border-radius: 8px; font-size: 13px; font-family: 'DM Sans', sans-serif; box-shadow: 0 4px 12px rgba(0,0,0,0.3); opacity: 0; transition: opacity 0.3s; pointer-events: none; max-width: 320px; }
|
|
#toast.show { opacity: 1; pointer-events: auto; }
|
|
@media (max-width: 900px) {
|
|
.container { grid-template-columns: 1fr; }
|
|
.right-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
}
|
|
@media (max-width: 640px) {
|
|
nav { padding: 6px 10px; height: auto; flex-wrap: wrap; gap: 4px; }
|
|
nav .brand { font-size: 13px; }
|
|
nav .nav-right { font-size: 10px; gap: 6px; flex-wrap: wrap; }
|
|
nav .nav-right > span:first-child { order: -1; }
|
|
.cam-id { display: none; }
|
|
#clock { display: none; }
|
|
.tab-btn { padding: 8px 10px; font-size: 11px; }
|
|
.container { padding: 0 10px; margin: 12px auto; gap: 12px; }
|
|
.main-image-wrap { max-height: 220px; }
|
|
.img-meta { font-size: 9px; gap: 4px; }
|
|
.live-badge { font-size: 9px; padding: 1px 5px; }
|
|
.stream-status { font-size: 9px; padding: 2px 6px; }
|
|
.right-col { display: flex; flex-direction: column; gap: 12px; }
|
|
.gauges-row { padding: 12px 8px 10px; gap: 2px; }
|
|
.gauge-svg { width: 72px; height: 72px; }
|
|
.gauge-label { font-size: 9px; }
|
|
.sensor-table td { padding: 6px 10px; font-size: 11px; }
|
|
.timelapse-strip { padding: 10px; gap: 6px; }
|
|
.riwayat-wrap { padding: 0 10px; margin: 12px auto; }
|
|
.riwayat-header { flex-direction: column; align-items: flex-start; gap: 6px; }
|
|
.riwayat-header h2 { font-size: 13px; }
|
|
.btn-hapus-toggle, .btn-hapus-all { padding: 6px 12px; font-size: 11px; }
|
|
.chart-wrap { height: 160px; padding: 10px 10px 4px; }
|
|
.riwayat-chart-inner { height: 190px; padding: 10px 10px 4px; }
|
|
.chart-legend { gap: 10px; padding: 4px 10px 10px; justify-content: center; }
|
|
.legend-dot { width: 20px; }
|
|
.legend-item { font-size: 10px; }
|
|
.split-chart-wrap { height: 75px; }
|
|
.split-charts { padding: 8px 10px 10px; }
|
|
.riwayat-table th, .riwayat-table td { padding: 7px 8px; font-size: 10px; white-space: nowrap; }
|
|
.foto-histori-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; padding: 10px; }
|
|
.foto-histori-item img { height: 90px; }
|
|
.foto-histori-label { font-size: 9px; padding: 3px 6px; }
|
|
.modal-box { padding: 18px 16px; }
|
|
.modal-footer { flex-direction: column; }
|
|
.modal-footer button { width: 100%; }
|
|
}
|
|
@media (max-width: 400px) {
|
|
.gauges-row { justify-content: space-between; }
|
|
.gauge-svg { width: 62px; height: 62px; }
|
|
.tab-btn { padding: 7px 8px; font-size: 10px; }
|
|
nav .nav-right > span:nth-child(3) { display: none; }
|
|
}
|
|
#suhu-alert-modal .modal-box { border: 2px solid #dc2626; box-shadow: 0 0 0 4px rgba(220,38,38,0.12); }
|
|
#suhu-alert-modal h3 { display: flex; align-items: center; gap: 8px; }
|
|
.suhu-alert-icon-wrap { display: flex; justify-content: center; margin-bottom: 10px; }
|
|
.suhu-alert-icon-wrap span { font-size: 40px; animation: suhu-shake 0.6s ease-in-out infinite; display: inline-block; }
|
|
@keyframes suhu-shake { 0%,100%{transform:rotate(-6deg)} 50%{transform:rotate(6deg)} }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="hapus-modal" class="modal-backdrop">
|
|
<div class="modal-box">
|
|
<h3>🗑️ Hapus Data Sensor</h3>
|
|
<p>Pilih rentang tanggal yang ingin dihapus, atau hapus semua data sekaligus.</p>
|
|
<label>Dari Tanggal</label><input type="date" id="hapus-dari">
|
|
<label>Sampai Tanggal</label><input type="date" id="hapus-sampai">
|
|
<div class="modal-footer">
|
|
<button class="btn-batal" onclick="closeModal('hapus-modal')">Batal</button>
|
|
<button class="btn-danger" onclick="hapusData()">Hapus Rentang</button>
|
|
</div>
|
|
<hr class="divider">
|
|
<p style="margin-bottom:0;font-size:11px;color:#dc2626;">⚠️ Hapus semua data riwayat sensor secara permanen.</p>
|
|
<div class="modal-footer" style="margin-top:10px;">
|
|
<button class="btn-danger" onclick="hapusSemua()" style="background:#7f1d1d;">🗑️ Hapus Semua Data</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="hapus-foto-modal" class="modal-backdrop">
|
|
<div class="modal-box">
|
|
<h3>🗑️ Hapus Riwayat Foto</h3>
|
|
<p style="color:#dc2626;font-size:12px;margin-bottom:20px;">⚠️ Semua riwayat foto akan dihapus secara permanen. TIDAK BISA DIKEMBALIKAN.</p>
|
|
<div class="modal-footer">
|
|
<button class="btn-batal" onclick="closeModal('hapus-foto-modal')">Batal</button>
|
|
<button class="btn-danger" onclick="hapusFotoSemua()">Ya, Hapus Semua Foto</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="modal-foto-viewer" class="modal-backdrop" onclick="if(event.target===this) tutupModalFoto()">
|
|
<div class="modal-inner">
|
|
<button class="close-btn" onclick="tutupModalFoto()">✕</button>
|
|
<img id="modal-foto-img" src="" alt="foto tanaman">
|
|
<p id="modal-foto-waktu" class="foto-waktu"></p>
|
|
<div style="display:flex;gap:8px;margin-top:2px;">
|
|
<button class="btn-download" onclick="downloadFoto()">⬇️ Download</button>
|
|
<button class="btn-hapus-satu" onclick="hapusFotoSatu()">🗑️ Hapus</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="suhu-alert-modal" class="modal-backdrop">
|
|
<div class="modal-box">
|
|
<div class="suhu-alert-icon-wrap"><span>🔥</span></div>
|
|
<h3 style="color:#dc2626;justify-content:center;">⚠️ Peringatan Suhu Tinggi!</h3>
|
|
<p id="suhu-alert-text" style="color:#1a2e1e;font-size:13px;line-height:1.7;text-align:center;">
|
|
Suhu telah mencapai batas maksimal.
|
|
</p>
|
|
<div class="modal-footer" style="justify-content:center;">
|
|
<button class="btn-batal" onclick="closeModal('suhu-alert-modal')">Tutup</button>
|
|
<button class="btn-danger" style="background:#0369a1;" onclick="toggleFanRelay();closeModal('suhu-alert-modal')">🌀 Nyalakan Fan</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast"></div>
|
|
|
|
<nav>
|
|
<div class="brand"><span>HIDROGENZ</span> FARM</div>
|
|
<div class="nav-right">
|
|
<span><span class="status-dot" id="nav-dot"></span><span id="nav-live-text">CONNECTING</span></span>
|
|
<span class="cam-id">CAM_ID: MELON01</span>
|
|
<span id="clock">--:--:--</span>
|
|
<span style="border-left:1px solid rgba(255,255,255,0.3);padding-left:12px;">
|
|
Halo, {{ auth()->user()->name ?? 'Guest' }}
|
|
</span>
|
|
<form method="POST" action="{{ route('logout') }}" style="display:inline;">
|
|
@csrf
|
|
<button type="submit" style="background:none;border:none;color:rgba(255,255,255,0.7);cursor:pointer;font-family:'Space Mono',monospace;font-size:12px;text-decoration:underline;padding:0;">Logout</button>
|
|
</form>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="tab-menu">
|
|
<button class="tab-btn active" onclick="switchTab(this,'dashboard')">📊 Dashboard</button>
|
|
<button class="tab-btn" onclick="switchTab(this,'riwayat')">📋 Riwayat Data</button>
|
|
<button class="tab-btn" onclick="switchTab(this,'foto')">📸 Riwayat Foto</button>
|
|
</div>
|
|
|
|
<div id="tab-dashboard" class="tab-content active">
|
|
<div class="container">
|
|
<div class="left-col">
|
|
<div class="card">
|
|
<div class="card-header" style="justify-content:space-between;">
|
|
<span>📹 Live Camera</span>
|
|
<button class="btn-capture" id="btn-ambil-foto" onclick="ambilFotoManual()">
|
|
<i class="ti ti-camera"></i> Ambil Foto
|
|
</button>
|
|
</div>
|
|
<div class="main-image-wrap">
|
|
<img id="mainImg" alt="Live Stream" style="width:100%;height:100%;object-fit:cover;display:block;">
|
|
<div class="offline-overlay" id="offline-overlay">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1.5">
|
|
<path d="M15.6 11.6L22 7v10l-6.4-4.5v-1zM4 5h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z"/>
|
|
<line x1="2" y1="2" x2="22" y2="22" stroke="#ef4444" stroke-width="2"/>
|
|
</svg>
|
|
<span>STREAM OFFLINE</span>
|
|
</div>
|
|
<span class="stream-status" id="stream-status">● CONNECTING...</span>
|
|
<span class="pump-cam-badge" id="pump-cam-badge">💧 POMPA ON</span>
|
|
<div class="img-overlay">
|
|
<div class="img-meta">
|
|
<span class="live-badge" id="live-badge" style="background:#f59e0b;">CONNECTING</span>
|
|
<span>TEMP: <span class="val" id="overlay-suhu">{{ $latest->suhu ?? '-' }}°C</span></span>
|
|
<span>HUM: <span class="val" id="overlay-kelembapan">{{ $latest->kelembapan ?? '-' }}%</span></span>
|
|
<span>SOIL: <span class="val" id="overlay-soil">{{ $latest->soil ?? '-' }}%</span></span>
|
|
<span>DATE: <span class="val" id="overlay-date">{{ $latest->created_at ?? 'n/a' }}</span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-header">⏱ Time-lapse Progress</div>
|
|
<div class="timelapse-strip" id="timelapse-strip">
|
|
<p style="color:var(--muted);font-size:12px;padding:10px;">Memuat foto...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="right-col">
|
|
<div class="card">
|
|
<div class="card-header">🌡 Sensor Status</div>
|
|
<div class="gauges-row">
|
|
@php
|
|
$suhu=$latest->suhu??0; $kelembapan=$latest->kelembapan??0; $soil=$latest->soil??0;
|
|
$circumf=201;
|
|
$offsetSuhu=round($circumf*(1-min($suhu,100)/100));
|
|
$offsetHum=round($circumf*(1-min($kelembapan,100)/100));
|
|
$offsetSoil=round($circumf*(1-min($soil,100)/100));
|
|
@endphp
|
|
<div class="gauge-wrap">
|
|
<svg class="gauge-svg" viewBox="0 0 80 80">
|
|
<circle class="gauge-bg" cx="40" cy="40" r="32" stroke-dasharray="{{ $circumf }}" stroke-dashoffset="0"/>
|
|
<circle id="gauge-suhu-circle" cx="40" cy="40" r="32" fill="none" stroke-width="8" stroke-linecap="round" stroke="#e53e3e" stroke-dasharray="{{ $circumf }}" stroke-dashoffset="{{ $offsetSuhu }}"/>
|
|
<g transform="rotate(90,40,40)"><text id="gauge-suhu-text" class="gauge-val-text" x="40" y="37">{{ $suhu }}°</text><text class="gauge-sub" x="40" y="51">Temp</text></g>
|
|
</svg>
|
|
<div class="gauge-label">Temperature</div>
|
|
</div>
|
|
<div class="gauge-wrap">
|
|
<svg class="gauge-svg" viewBox="0 0 80 80">
|
|
<circle class="gauge-bg" cx="40" cy="40" r="32" stroke-dasharray="{{ $circumf }}" stroke-dashoffset="0"/>
|
|
<circle id="gauge-hum-circle" cx="40" cy="40" r="32" fill="none" stroke-width="8" stroke-linecap="round" stroke="#00b5a3" stroke-dasharray="{{ $circumf }}" stroke-dashoffset="{{ $offsetHum }}"/>
|
|
<g transform="rotate(90,40,40)"><text id="gauge-hum-text" class="gauge-val-text" x="40" y="37">{{ $kelembapan }}%</text><text class="gauge-sub" x="40" y="51">Humid</text></g>
|
|
</svg>
|
|
<div class="gauge-label">Humidity</div>
|
|
</div>
|
|
<div class="gauge-wrap">
|
|
<svg class="gauge-svg" viewBox="0 0 80 80">
|
|
<circle class="gauge-bg" cx="40" cy="40" r="32" stroke-dasharray="{{ $circumf }}" stroke-dashoffset="0"/>
|
|
<circle id="gauge-soil-circle" cx="40" cy="40" r="32" fill="none" stroke-width="8" stroke-linecap="round" stroke="#2563eb" stroke-dasharray="{{ $circumf }}" stroke-dashoffset="{{ $offsetSoil }}"/>
|
|
<g transform="rotate(90,40,40)"><text id="gauge-soil-text" class="gauge-val-text" x="40" y="37">{{ $soil }}%</text><text class="gauge-sub" x="40" y="51">Soil</text></g>
|
|
</svg>
|
|
<div class="gauge-label">Soil Moisture</div>
|
|
</div>
|
|
</div>
|
|
<table class="sensor-table">
|
|
<tr><td class="key">Suhu</td><td class="val-cell" id="val-suhu">{{ $suhu }} °C</td><td><span id="badge-suhu" class="badge-ok">NORMAL</span></td></tr>
|
|
<tr><td class="key">Kelembapan</td><td class="val-cell" id="val-kelembapan">{{ $kelembapan }} %</td><td><span id="badge-kelembapan" class="badge-ok">NORMAL</span></td></tr>
|
|
<tr><td class="key">Soil Moisture</td><td class="val-cell" id="val-soil">{{ $soil }} %</td><td><span id="badge-soil" class="badge-ok">IDEAL</span></td></tr>
|
|
<tr>
|
|
<td class="key">Status Pompa</td>
|
|
<td class="val-cell" id="val-pompa">-</td>
|
|
<td><span id="badge-pompa" onclick="togglePompaRelay()" style="cursor:pointer;" title="Klik untuk toggle ON/OFF manual">-</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="key">Status Fan</td>
|
|
<td class="val-cell" id="val-fan">-</td>
|
|
<td><span id="badge-fan" onclick="toggleFanRelay()" style="cursor:pointer;" title="Klik untuk toggle ON/OFF manual">-</span></td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span>📈 Time-series Chart</span>
|
|
</div>
|
|
<div class="split-charts">
|
|
<div class="split-chart-item">
|
|
<div class="split-chart-label" style="color:#e53e3e;">🌡 Suhu (°C)</div>
|
|
<div class="split-chart-wrap"><canvas id="chartSuhu"></canvas></div>
|
|
<div class="zoom-hint">🔍 Scroll/pinch zoom · drag geser</div>
|
|
<div class="period-tabs">
|
|
<button class="period-tab" onclick="filterSplitChart('hari',this)">1D</button>
|
|
<button class="period-tab" onclick="filterSplitChart('minggu',this)">1W</button>
|
|
<button class="period-tab" onclick="filterSplitChart('bulan',this)">1M</button>
|
|
<button class="period-tab active" onclick="filterSplitChart('semua',this)">ALL</button>
|
|
<button class="period-tab" onclick="resetZoomAll()">↺</button>
|
|
</div>
|
|
</div>
|
|
<div class="split-chart-item">
|
|
<div class="split-chart-label" style="color:#00b5a3;">💧 Kelembapan (%)</div>
|
|
<div class="split-chart-wrap"><canvas id="chartKelembapan"></canvas></div>
|
|
<div class="zoom-hint">🔍 Scroll/pinch zoom · drag geser</div>
|
|
<div class="period-tabs">
|
|
<button class="period-tab" onclick="filterSplitChart('hari',this)">1D</button>
|
|
<button class="period-tab" onclick="filterSplitChart('minggu',this)">1W</button>
|
|
<button class="period-tab" onclick="filterSplitChart('bulan',this)">1M</button>
|
|
<button class="period-tab active" onclick="filterSplitChart('semua',this)">ALL</button>
|
|
<button class="period-tab" onclick="resetZoomAll()">↺</button>
|
|
</div>
|
|
</div>
|
|
<div class="split-chart-item">
|
|
<div class="split-chart-label" style="color:#2563eb;">🌱 Soil Moisture (%)</div>
|
|
<div class="split-chart-wrap"><canvas id="chartSoil" height="90"></canvas></div>
|
|
<div class="zoom-hint">🔍 Scroll/pinch zoom · drag geser</div>
|
|
<div class="period-tabs">
|
|
<button class="period-tab" onclick="filterSplitChart('hari',this)">1D</button>
|
|
<button class="period-tab" onclick="filterSplitChart('minggu',this)">1W</button>
|
|
<button class="period-tab" onclick="filterSplitChart('bulan',this)">1M</button>
|
|
<button class="period-tab active" onclick="filterSplitChart('semua',this)">ALL</button>
|
|
<button class="period-tab" onclick="resetZoomAll()">↺</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tab-riwayat" class="tab-content">
|
|
<div class="riwayat-wrap">
|
|
<div class="riwayat-header">
|
|
<h2>📋 Riwayat Data Sensor</h2>
|
|
<button class="btn-hapus-toggle" onclick="openModal('hapus-modal')">🗑️ Hapus Data</button>
|
|
</div>
|
|
<div class="riwayat-section">
|
|
<div class="riwayat-section-header" style="display:flex;justify-content:space-between;align-items:center;">
|
|
<span>📈 Grafik Riwayat Sensor</span>
|
|
<div class="chart-filter" style="padding:0;">
|
|
<button class="chart-filter-btn active" onclick="filterRiwayatChart('hari',this)">Hari</button>
|
|
<button class="chart-filter-btn" onclick="filterRiwayatChart('minggu',this)">Minggu</button>
|
|
<button class="chart-filter-btn" onclick="filterRiwayatChart('bulan',this)">Bulan</button>
|
|
</div>
|
|
</div>
|
|
<div class="riwayat-chart-inner"><canvas id="riwayatChart"></canvas></div>
|
|
</div>
|
|
<div class="riwayat-section">
|
|
<div class="riwayat-section-header">📊 Tabel Data Sensor, Status Pompa & Fan</div>
|
|
<div class="riwayat-table-wrap">
|
|
<table class="riwayat-table">
|
|
<thead>
|
|
<tr>
|
|
<th>No</th><th>Waktu</th><th>Suhu (°C)</th><th>Kelembapan (%)</th>
|
|
<th>Soil (%)</th><th>St. Suhu</th><th>St. Kelembapan</th><th>St. Soil</th><th>Pompa</th><th>Fan</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="riwayat-tbody"><tr><td colspan="10" class="loading-text">Memuat data...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tab-foto" class="tab-content">
|
|
<div class="riwayat-wrap">
|
|
<div class="riwayat-header">
|
|
<h2>📸 Riwayat Foto</h2>
|
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
|
<span class="foto-count-badge" id="foto-count">0 foto</span>
|
|
<button class="btn-hapus-all" onclick="openModal('hapus-foto-modal')">🗑️ Hapus Semua Foto</button>
|
|
</div>
|
|
</div>
|
|
<div class="riwayat-section">
|
|
<div class="riwayat-section-header">Histori Foto Tanaman</div>
|
|
<div class="foto-histori-grid" id="foto-grid">
|
|
<p style="color:var(--muted);font-size:13px;padding:10px;">Memuat foto...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const ESP32_IP = '192.168.110.246';
|
|
const ESP32_STREAM = 'http://' + ESP32_IP;
|
|
const ESP32_CAPTURE = 'http://' + ESP32_IP + ':81/capture';
|
|
|
|
// ── Ambang batas soil moisture (pompa) ──
|
|
// soil <= 40 -> pompa ON
|
|
// soil >= 60 -> pompa OFF
|
|
// 40 < soil < 60 -> pertahankan status terakhir (hysteresis)
|
|
const SOIL_MULAI_SIRAM = 40;
|
|
const SOIL_STOP_SIRAM = 60;
|
|
|
|
function tick() { document.getElementById('clock').textContent = new Date().toLocaleTimeString('id-ID'); }
|
|
tick(); setInterval(tick, 1000);
|
|
function openModal(id) { document.getElementById(id).classList.add('show'); }
|
|
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
|
|
|
|
let _toastTimer = null;
|
|
function showToast(msg, durasi=3000) {
|
|
const t = document.getElementById('toast');
|
|
t.textContent = msg;
|
|
t.className = 'show';
|
|
if(_toastTimer) clearTimeout(_toastTimer);
|
|
_toastTimer = setTimeout(() => t.classList.remove('show'), durasi);
|
|
}
|
|
function switchTab(btn, tab) {
|
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
document.getElementById('tab-'+tab).classList.add('active');
|
|
btn.classList.add('active');
|
|
}
|
|
|
|
function getBadgeSuhu(v) {
|
|
v = parseFloat(v);
|
|
if (isNaN(v)) return '<span class="badge-warn">-</span>';
|
|
if (v < 25) return '<span class="badge-warn">DINGIN</span>';
|
|
if (v <= 32) return '<span class="badge-ok">NORMAL</span>';
|
|
return '<span class="badge-danger">PANAS</span>';
|
|
}
|
|
function getBadgeKelembapan(v) {
|
|
v = parseFloat(v);
|
|
if (isNaN(v)) return '<span class="badge-warn">-</span>';
|
|
if (v < 60) return '<span class="badge-warn">RENDAH</span>';
|
|
if (v <= 80) return '<span class="badge-ok">NORMAL</span>';
|
|
return '<span class="badge-warn">TINGGI</span>';
|
|
}
|
|
function getBadgeSoil(v) {
|
|
v = parseFloat(v);
|
|
if (isNaN(v)) return '<span class="badge-warn">-</span>';
|
|
if (v <= SOIL_MULAI_SIRAM) return '<span class="badge-danger">KERING</span>';
|
|
if (v < SOIL_STOP_SIRAM) return '<span class="badge-ok">IDEAL</span>';
|
|
return '<span class="badge-info">BASAH</span>';
|
|
}
|
|
function getBadgePompa(pompa) {
|
|
if (pompa === null || pompa === undefined) return '<span class="badge-pump-off">⭕ OFF</span>';
|
|
const p = pompa.toString().toUpperCase().trim();
|
|
if (p === '1' || p === 'ON' || p === 'TRUE' || p.includes('ON')) return '<span class="badge-pump-on">💧 ON</span>';
|
|
return '<span class="badge-pump-off">⭕ OFF</span>';
|
|
}
|
|
function getBadgeFan(fan) {
|
|
if (fan === null || fan === undefined) return '<span class="badge-fan-off">⭕ OFF</span>';
|
|
const f = fan.toString().toUpperCase().trim();
|
|
if (f === '1' || f === 'ON' || f === 'TRUE' || f.includes('ON')) return '<span class="badge-fan-on">🌀 ON</span>';
|
|
return '<span class="badge-fan-off">⭕ OFF</span>';
|
|
}
|
|
|
|
const mainImg=document.getElementById('mainImg'),
|
|
offlineOverlay=document.getElementById('offline-overlay'),
|
|
streamStatus=document.getElementById('stream-status'),
|
|
liveBadge=document.getElementById('live-badge'),
|
|
navDot=document.getElementById('nav-dot'),
|
|
navText=document.getElementById('nav-live-text');
|
|
let streamReconnectTimer=null, streamLoadTimeout=null;
|
|
|
|
function setStreamState(state) {
|
|
if (state==='live') {
|
|
offlineOverlay.classList.remove('show'); streamStatus.textContent='● LIVE'; streamStatus.style.color='#4ade80';
|
|
liveBadge.textContent='LIVE'; liveBadge.style.background='#ef4444'; navDot.style.background='#4ade80'; navText.textContent='LIVE';
|
|
} else if (state==='offline') {
|
|
offlineOverlay.classList.add('show'); streamStatus.textContent='● OFFLINE'; streamStatus.style.color='#ef4444';
|
|
liveBadge.textContent='OFFLINE'; liveBadge.style.background='#6b7280'; navDot.style.background='#ef4444'; navText.textContent='OFFLINE';
|
|
} else {
|
|
offlineOverlay.classList.remove('show'); streamStatus.textContent='● CONNECTING...'; streamStatus.style.color='#fff';
|
|
liveBadge.textContent='CONNECTING'; liveBadge.style.background='#f59e0b'; navDot.style.background='#f59e0b'; navText.textContent='CONNECTING';
|
|
}
|
|
}
|
|
function startStream() {
|
|
if (streamReconnectTimer) { clearTimeout(streamReconnectTimer); streamReconnectTimer=null; }
|
|
if (streamLoadTimeout) { clearTimeout(streamLoadTimeout); streamLoadTimeout=null; }
|
|
setStreamState('connecting');
|
|
mainImg.src = ESP32_STREAM+'/';
|
|
streamLoadTimeout = setTimeout(() => {
|
|
if (navText.textContent!=='LIVE') { setStreamState('offline'); streamReconnectTimer=setTimeout(startStream,5000); }
|
|
}, 10000);
|
|
}
|
|
mainImg.onload = () => { setStreamState('live'); if (streamLoadTimeout){clearTimeout(streamLoadTimeout);streamLoadTimeout=null;} };
|
|
mainImg.onerror = () => { setStreamState('offline'); if (streamLoadTimeout){clearTimeout(streamLoadTimeout);streamLoadTimeout=null;} streamReconnectTimer=setTimeout(startStream,5000); };
|
|
function setMain(src) { mainImg.src=src; offlineOverlay.classList.remove('show'); setTimeout(()=>startStream(),10000); }
|
|
startStream();
|
|
|
|
async function ambilFotoManual() {
|
|
const btn = document.getElementById('btn-ambil-foto');
|
|
if (!btn || btn.disabled) return;
|
|
const originalHtml = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="ti ti-loader-2"></i> Mengambil...';
|
|
try {
|
|
const res = await fetch(ESP32_CAPTURE, { cache: 'no-store' });
|
|
if (!res.ok) throw new Error('Kamera tidak merespon (HTTP '+res.status+')');
|
|
const blob = await res.blob();
|
|
if (!blob || blob.size < 100) throw new Error('Data gambar kosong/rusak');
|
|
|
|
const b64Raw = await new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result.split(',')[1]);
|
|
reader.onerror = () => reject(new Error('Gagal membaca gambar'));
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
|
|
const now = new Date();
|
|
const pad = x => String(x).padStart(2,'0');
|
|
const waktu = now.getFullYear()+'-'+pad(now.getMonth()+1)+'-'+pad(now.getDate())+' '+pad(now.getHours())+':'+pad(now.getMinutes())+':'+pad(now.getSeconds());
|
|
|
|
const suhuTxt = (document.getElementById('overlay-suhu')?.textContent || '').replace('°C','').trim();
|
|
const humTxt = (document.getElementById('overlay-kelembapan')?.textContent || '').replace('%','').trim();
|
|
const soilTxt = (document.getElementById('overlay-soil')?.textContent || '').replace('%','').trim();
|
|
const suhu = (suhuTxt && suhuTxt !== '-') ? suhuTxt : null;
|
|
const kelembapan = (humTxt && humTxt !== '-') ? humTxt : null;
|
|
const soil = (soilTxt && soilTxt !== '-') ? soilTxt : null;
|
|
|
|
const b64wm = await tambahWatermarkAsync(b64Raw, waktu, suhu, kelembapan, soil);
|
|
|
|
setMain('data:image/jpeg;base64,'+b64wm);
|
|
|
|
if (typeof window._simpanFotoCallback === 'function') {
|
|
await window._simpanFotoCallback({
|
|
image: b64wm,
|
|
timestamp: now.getTime(),
|
|
waktu,
|
|
suhu, kelembapan, soil
|
|
});
|
|
} else {
|
|
showToast('✅ Foto diambil (belum tersambung ke penyimpanan)');
|
|
}
|
|
} catch (e) {
|
|
showToast('❌ Gagal ambil foto: '+e.message, 4000);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
}
|
|
}
|
|
|
|
function gambarWatermark(ctx, W, H, tglStr, senStr) {
|
|
const fs=Math.max(11,Math.round(W*0.022)), lh=Math.round(fs*1.6), px=Math.round(fs*0.6), py=Math.round(fs*0.5);
|
|
ctx.font='bold '+fs+'px "Courier New",Courier,monospace';
|
|
const bw=Math.max(ctx.measureText(tglStr).width,ctx.measureText(senStr).width)+px*2, bh=lh*2+py*2;
|
|
ctx.fillStyle='rgba(0,0,0,0.72)'; ctx.fillRect(0,H-bh,bw,bh);
|
|
ctx.font='bold '+fs+'px "Courier New",Courier,monospace'; ctx.textBaseline='top';
|
|
ctx.fillStyle='#00ff41'; ctx.fillText(tglStr,px,H-bh+py);
|
|
let cx=px, y2=H-bh+py+lh;
|
|
(senStr.match(/(T:[^\s]+|H:[^\s]+|S:[^\s]+)/g)||[]).forEach((seg,i,arr)=>{
|
|
const sp=i<arr.length-1?' ':'';
|
|
if (seg.startsWith('T:')) { ctx.fillStyle='#fff'; ctx.fillText('T:',cx,y2); cx+=ctx.measureText('T:').width; ctx.fillStyle='#ff4d4d'; const v=seg.slice(2)+sp; ctx.fillText(v,cx,y2); cx+=ctx.measureText(v).width; }
|
|
else if (seg.startsWith('H:')) { ctx.fillStyle='#fff'; ctx.fillText('H:',cx,y2); cx+=ctx.measureText('H:').width; ctx.fillStyle='#00ffff'; const v=seg.slice(2)+sp; ctx.fillText(v,cx,y2); cx+=ctx.measureText(v).width; }
|
|
else if (seg.startsWith('S:')) { ctx.fillStyle='#fff'; ctx.fillText('S:',cx,y2); cx+=ctx.measureText('S:').width; ctx.fillStyle='#fff'; ctx.fillText(seg.slice(2),cx,y2); }
|
|
});
|
|
}
|
|
|
|
function tambahWatermarkAsync(b64, waktuStr, suhu, kelembapan, soil) {
|
|
return new Promise(resolve => {
|
|
try {
|
|
const img = new Image(), timer = setTimeout(() => resolve(b64), 8000);
|
|
img.onload = () => {
|
|
clearTimeout(timer);
|
|
try {
|
|
const TARGET_W=640, oriW=img.naturalWidth||320, oriH=img.naturalHeight||240;
|
|
const scale=TARGET_W/oriW, cv=document.createElement('canvas');
|
|
cv.width=TARGET_W; cv.height=Math.round(oriH*scale);
|
|
const ctx=cv.getContext('2d');
|
|
ctx.drawImage(img,0,0,cv.width,cv.height);
|
|
const tgl=(waktuStr&&waktuStr.length>=19)?waktuStr.substring(0,19):(()=>{
|
|
const n=new Date(),p=x=>String(x).padStart(2,'0');
|
|
return n.getFullYear()+'-'+p(n.getMonth()+1)+'-'+p(n.getDate())+' '+p(n.getHours())+':'+p(n.getMinutes())+':'+p(n.getSeconds());
|
|
})();
|
|
const s = (suhu != null && String(suhu) !== 'null') ? String(suhu) : '-';
|
|
const h = (kelembapan != null && String(kelembapan) !== 'null') ? String(kelembapan) : '-';
|
|
const so = (soil != null && String(soil) !== 'null') ? String(soil) : '-';
|
|
gambarWatermark(ctx,cv.width,cv.height,tgl,'T:'+s+'C H:'+h+'% S:'+so+'%');
|
|
resolve(cv.toDataURL('image/jpeg',0.88).split(',')[1]);
|
|
} catch(e) { resolve(b64); }
|
|
};
|
|
img.onerror=()=>{ clearTimeout(timer); resolve(b64); };
|
|
img.src='data:image/jpeg;base64,'+b64;
|
|
} catch(e) { resolve(b64); }
|
|
});
|
|
}
|
|
|
|
const timeseries = @json($timeseries);
|
|
|
|
window.sharedChartOptions = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: { duration: 700, easing: 'easeInOutQuart' },
|
|
interaction: { mode: 'index', intersect: false },
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
backgroundColor: 'rgba(26,44,30,0.92)', titleColor: '#a3c4a8', bodyColor: '#fff',
|
|
padding: 10, cornerRadius: 8,
|
|
titleFont: { family: "'Space Mono', monospace", size: 10 },
|
|
bodyFont: { family: "'DM Sans', sans-serif", size: 12 },
|
|
callbacks: {
|
|
title: items => '⏱ ' + items[0].label,
|
|
label: ctx => {
|
|
const u = {'Suhu':'°C','Kelembapan':'%','Soil Moisture':'%'};
|
|
const ic= {'Suhu':'🌡','Kelembapan':'💧','Soil Moisture':'🌱'};
|
|
const n = ctx.dataset.label;
|
|
return ' '+(ic[n]||'')+' '+n+': '+ctx.parsed.y+(u[n]||'');
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: { ticks:{display:false}, grid:{display:false}, border:{display:false} },
|
|
y: { grid:{color:'#e8f5ec'}, ticks:{font:{size:9},color:'#6b7c6e',padding:4}, border:{display:false} }
|
|
}
|
|
};
|
|
|
|
function filterByPeriod(arr, period) {
|
|
const now = new Date();
|
|
return arr.filter(t => {
|
|
if (!t.t) return false;
|
|
const d = new Date(t.t);
|
|
if (isNaN(d)) return false;
|
|
if (period === 'hari') return d.toDateString() === now.toDateString();
|
|
if (period === 'minggu') return (now - d) / 86400000 <= 7;
|
|
if (period === 'bulan') return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear();
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// ── Agregasi: 'hari'=per jam, 'minggu'/'bulan'=per tanggal (dd/mm), 'semua'=per tanggal lengkap (dd/mm/yy) ──
|
|
function agregasi(arr, period) {
|
|
if (!arr.length) return { labels:[], suhu:[], kelembapan:[], soil:[] };
|
|
const groups = {};
|
|
arr.forEach(t => {
|
|
const d = new Date(t.t);
|
|
let key;
|
|
if (period === 'hari') key = d.getHours().toString().padStart(2,'0') + ':00';
|
|
if (period === 'minggu') key = d.toLocaleDateString('id-ID',{weekday:'short',day:'2-digit',month:'2-digit'});
|
|
if (period === 'bulan') key = d.toLocaleDateString('id-ID',{day:'2-digit',month:'2-digit'});
|
|
if (period === 'semua') key = d.toLocaleDateString('id-ID',{day:'2-digit',month:'2-digit',year:'2-digit'});
|
|
if (!groups[key]) groups[key] = {suhu:[],kelembapan:[],soil:[]};
|
|
if (t.suhu != null) groups[key].suhu.push(parseFloat(t.suhu));
|
|
if (t.kelembapan != null) groups[key].kelembapan.push(parseFloat(t.kelembapan));
|
|
if (t.soil != null) groups[key].soil.push(parseFloat(t.soil));
|
|
});
|
|
const avg = a => a.length ? +(a.reduce((x,y)=>x+y,0)/a.length).toFixed(1) : null;
|
|
const keys = Object.keys(groups);
|
|
return { labels:keys, suhu:keys.map(k=>avg(groups[k].suhu)), kelembapan:keys.map(k=>avg(groups[k].kelembapan)), soil:keys.map(k=>avg(groups[k].soil)) };
|
|
}
|
|
|
|
function buatSplitChart(canvasId, label, initLabels, initData, borderColor, bgColor) {
|
|
return new Chart(document.getElementById(canvasId).getContext('2d'), {
|
|
type: 'line',
|
|
data: {
|
|
labels: initLabels,
|
|
datasets: [{ label, data: initData, borderColor, backgroundColor: bgColor,
|
|
tension:0.4, pointRadius:1, pointHoverRadius:4, fill:true, borderWidth:1.5 }]
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: true,
|
|
animation: { duration:700, easing:'easeInOutQuart' },
|
|
interaction: { mode:'index', intersect:false },
|
|
plugins: {
|
|
legend: { display:false },
|
|
tooltip: {
|
|
backgroundColor:'rgba(26,44,30,0.92)', titleColor:'#a3c4a8', bodyColor:'#fff',
|
|
padding:10, cornerRadius:8,
|
|
titleFont:{ family:"'Space Mono', monospace", size:10 },
|
|
bodyFont: { family:"'DM Sans', sans-serif", size:12 },
|
|
callbacks: {
|
|
title: items => '⏱ ' + items[0].label,
|
|
label: ctx => {
|
|
const u={'Suhu':'°C','Kelembapan':'%','Soil Moisture':'%'};
|
|
const ic={'Suhu':'🌡','Kelembapan':'💧','Soil Moisture':'🌱'};
|
|
const n=ctx.dataset.label;
|
|
return ' '+(ic[n]||'')+' '+n+': '+ctx.parsed.y+(u[n]||'');
|
|
}
|
|
}
|
|
},
|
|
zoom: {
|
|
pan: { enabled:true, mode:'x' },
|
|
zoom: {
|
|
wheel: { enabled:true },
|
|
pinch: { enabled:true },
|
|
mode: 'x',
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: { font:{size:9, family:"'Space Mono', monospace"}, color:'#6b7c6e', maxTicksLimit:8, maxRotation:0 },
|
|
grid: { color:'#f0f4f0' },
|
|
border:{ display:false }
|
|
},
|
|
y: {
|
|
grid: { color:'#e8f5ec' },
|
|
ticks: { font:{size:9}, color:'#6b7c6e', padding:4, maxTicksLimit:4 },
|
|
border:{ display:false }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const chartSuhu = buatSplitChart('chartSuhu', 'Suhu', [], [], '#e53e3e', 'rgba(229,62,62,.12)');
|
|
const chartKelembapan = buatSplitChart('chartKelembapan', 'Kelembapan', [], [], '#00b5a3', 'rgba(0,181,163,.12)');
|
|
const chartSoilMini = buatSplitChart('chartSoil', 'Soil Moisture', [], [], '#2563eb', 'rgba(37,99,235,.10)');
|
|
|
|
window.resetZoomAll = function() {
|
|
chartSuhu.resetZoom();
|
|
chartKelembapan.resetZoom();
|
|
chartSoilMini.resetZoom();
|
|
};
|
|
|
|
let _splitPeriod = 'semua';
|
|
|
|
window.filterSplitChart = function(period, btn) {
|
|
_splitPeriod = period;
|
|
document.querySelectorAll('.period-tab').forEach(b => {
|
|
if (b.textContent === '↺') return;
|
|
b.classList.toggle('active', b.textContent === btn.textContent);
|
|
});
|
|
updateSplitChartsFromFirebase();
|
|
};
|
|
|
|
// ── Semua periode (termasuk 'semua') selalu diagregasi biar chart halus, bukan menumpuk data mentah ──
|
|
window.updateSplitChartsFromFirebase = function() {
|
|
if (!window._historyCache || !window._historyCache.length) return;
|
|
|
|
const source = window._historyCache.map(d => ({
|
|
t: d.created_at || d.updated_at || '',
|
|
suhu: d.suhu, kelembapan: d.kelembapan, soil: d.soil
|
|
}));
|
|
|
|
const filtered = _splitPeriod === 'semua' ? source : filterByPeriod(source, _splitPeriod);
|
|
const agg = agregasi(filtered, _splitPeriod);
|
|
|
|
const noData = !agg.labels.length;
|
|
const labels = noData ? [] : agg.labels;
|
|
const dSuhu = noData ? [] : agg.suhu;
|
|
const dHum = noData ? [] : agg.kelembapan;
|
|
const dSoil = noData ? [] : agg.soil;
|
|
|
|
if (typeof chartSuhu !== 'undefined') {
|
|
chartSuhu.data.labels = labels;
|
|
chartSuhu.data.datasets[0].data = dSuhu;
|
|
chartSuhu.update('active');
|
|
chartSuhu.resetZoom();
|
|
}
|
|
if (typeof chartKelembapan !== 'undefined') {
|
|
chartKelembapan.data.labels = labels;
|
|
chartKelembapan.data.datasets[0].data = dHum;
|
|
chartKelembapan.update('active');
|
|
chartKelembapan.resetZoom();
|
|
}
|
|
if (typeof chartSoilMini !== 'undefined') {
|
|
chartSoilMini.data.labels = labels;
|
|
chartSoilMini.data.datasets[0].data = dSoil;
|
|
chartSoilMini.update('active');
|
|
chartSoilMini.resetZoom();
|
|
}
|
|
};
|
|
|
|
window.allFotoSrc=[]; window._fotoViewerIdx=-1;
|
|
window.bukaModalFoto=function(idx){
|
|
const item=window.allFotoSrc[idx]; if(!item)return;
|
|
window._fotoViewerIdx=idx;
|
|
document.getElementById('modal-foto-img').src=item.src;
|
|
document.getElementById('modal-foto-waktu').textContent=item.waktu;
|
|
document.getElementById('modal-foto-viewer').classList.add('show');
|
|
};
|
|
window.tutupModalFoto=function(){
|
|
document.getElementById('modal-foto-viewer').classList.remove('show');
|
|
setTimeout(()=>{document.getElementById('modal-foto-img').src='';},300);
|
|
window._fotoViewerIdx=-1;
|
|
};
|
|
window.downloadFoto=function(){
|
|
const img=document.getElementById('modal-foto-img');
|
|
const w=document.getElementById('modal-foto-waktu').textContent;
|
|
const a=document.createElement('a'); a.href=img.src; a.download='foto-melon-'+w.replace(/[/:, ]/g,'-')+'.jpg'; a.click();
|
|
};
|
|
</script>
|
|
|
|
<script type="module">
|
|
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.0.0/firebase-app.js";
|
|
import { getDatabase, ref, onValue, remove, push, set } from "https://www.gstatic.com/firebasejs/10.0.0/firebase-database.js";
|
|
|
|
const app=initializeApp({
|
|
apiKey:"AIzaSyCmIIEjajnv7m95T4gMoUUMCwFcf2qwClw",
|
|
databaseURL:"https://monitoring-tanaman-d2cd2-default-rtdb.firebaseio.com",
|
|
projectId:"monitoring-tanaman-d2cd2",
|
|
});
|
|
const db=getDatabase(app), circumf=201;
|
|
|
|
let relayValue = -1;
|
|
let _historyCache = null;
|
|
let _fotoSnapshot = null;
|
|
let _historyReady = false;
|
|
let _fotoReady = false;
|
|
let riwayatChart = null;
|
|
|
|
let _suhuAlertOn = false;
|
|
const SUHU_BATAS_MAKS = 30;
|
|
|
|
// ── Ambang batas suhu (fan) ──
|
|
// suhu >= 30 -> fan ON
|
|
// suhu <= 28 -> fan OFF
|
|
// 28 < suhu < 30 -> pertahankan status terakhir (hysteresis)
|
|
const SUHU_FAN_ON = 30;
|
|
const SUHU_FAN_OFF = 28;
|
|
|
|
// ── Konfigurasi Telegram ──
|
|
const TELEGRAM_BOT_TOKEN = '8782805447:AAHGVQ8a6gsc-Wfs_LW58e0zHjsSJVTeA80';
|
|
const TELEGRAM_CHAT_ID = '8474879260';
|
|
|
|
window.kirimTelegramAlert = async function(suhu) {
|
|
const pesan = '🔥 *PERINGATAN SUHU TINGGI!*\n\n' +
|
|
'🌡 Suhu sensor saat ini: *' + suhu + '°C*\n' +
|
|
'⚠️ Suhu sudah terlalu tinggi!\n\n' +
|
|
'📅 ' + new Date().toLocaleString('id-ID');
|
|
try {
|
|
await fetch('https://api.telegram.org/bot' + TELEGRAM_BOT_TOKEN + '/sendMessage', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
chat_id: TELEGRAM_CHAT_ID,
|
|
text: pesan,
|
|
parse_mode: 'Markdown'
|
|
})
|
|
});
|
|
} catch (e) {
|
|
console.error('Gagal kirim notifikasi Telegram:', e);
|
|
}
|
|
}
|
|
|
|
function cariSensorTerdekat(waktuStr, field) {
|
|
const cache = window._historyCache || _historyCache;
|
|
if (!cache || !cache.length || !waktuStr) return null;
|
|
const target = new Date(waktuStr).getTime();
|
|
if (isNaN(target)) return null;
|
|
let best = null, bestDiff = Infinity;
|
|
for (const d of cache) {
|
|
const diff = Math.abs(new Date(d.created_at).getTime() - target);
|
|
if (diff < bestDiff) { bestDiff = diff; best = d; }
|
|
}
|
|
return best ? (best[field] ?? null) : null;
|
|
}
|
|
|
|
window._simpanFotoCallback = function(p) {
|
|
return push(ref(db,'/foto'),{
|
|
image:p.image, timestamp:p.timestamp, waktu:p.waktu,
|
|
suhu:p.suhu??null, kelembapan:p.kelembapan??null, soil:p.soil??null
|
|
}).then(()=>showToast('✅ Foto berhasil disimpan!',3000));
|
|
};
|
|
|
|
window.hapusFotoSatu=async function(){
|
|
const item=window.allFotoSrc[window._fotoViewerIdx];
|
|
if(!item||!item.firebaseKey){alert('Key tidak ditemukan!');return;}
|
|
if(!confirm('Yakin hapus foto ini? TIDAK BISA DIKEMBALIKAN.'))return;
|
|
try{ await remove(ref(db,'/foto/'+item.firebaseKey)); showToast('✅ Foto dihapus!'); tutupModalFoto(); }
|
|
catch(e){ alert('❌ Gagal: '+e.message); }
|
|
};
|
|
|
|
window.hapusFotoGrid=async function(event,firebaseKey){
|
|
event.stopPropagation();
|
|
if(!confirm('Yakin hapus foto ini? TIDAK BISA DIKEMBALIKAN.'))return;
|
|
try{ await remove(ref(db,'/foto/'+firebaseKey)); showToast('✅ Foto dihapus!'); }
|
|
catch(e){ alert('❌ Gagal: '+e.message); }
|
|
};
|
|
|
|
window.hapusFotoSemua=async function(){
|
|
if(!confirm('⚠️ YAKIN hapus SEMUA foto? Ini TIDAK BISA DIKEMBALIKAN!'))return;
|
|
if(!confirm('Konfirmasi sekali lagi — HAPUS SEMUA FOTO PERMANEN?'))return;
|
|
try{ await remove(ref(db,'/foto')); showToast('✅ Semua foto dihapus!'); closeModal('hapus-foto-modal'); }
|
|
catch(e){ alert('❌ Gagal: '+e.message); }
|
|
};
|
|
|
|
function parseEntries(data) {
|
|
const allKeys=Object.keys(data);
|
|
const hasPushKeys=allKeys.some(k=>k.startsWith('-'));
|
|
if(!hasPushKeys&&allKeys.includes('image')&&typeof data.image==='string'&&data.image.length>100){
|
|
return [{key:'legacy-flat',image:data.image,timestamp:data.timestamp||Date.now(),waktu:data.waktu||'',suhu:data.suhu??null,kelembapan:data.kelembapan??null,soil:data.soil??null}];
|
|
}
|
|
return Object.entries(data)
|
|
.map(([key,foto])=>{
|
|
if(typeof foto!=='object'||foto===null)return null;
|
|
if(!foto.image||typeof foto.image!=='string'||foto.image.length<100)return null;
|
|
return {key,...foto};
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a,b)=>{
|
|
const wa=a.waktu||'',wb=b.waktu||'';
|
|
if(wa&&wb)return wb.localeCompare(wa);
|
|
const ta=a.timestamp<1e10?a.timestamp*1000:(a.timestamp||0);
|
|
const tb=b.timestamp<1e10?b.timestamp*1000:(b.timestamp||0);
|
|
return tb-ta;
|
|
});
|
|
}
|
|
|
|
function renderFotoItem(i,src,waktu,firebaseKey){
|
|
window.allFotoSrc[i]={src,waktu,firebaseKey};
|
|
const sk=document.getElementById('foto-skeleton-'+i); if(!sk)return;
|
|
const div=document.createElement('div');
|
|
div.className='foto-histori-item'; div.onclick=()=>bukaModalFoto(i);
|
|
div.innerHTML='<img src="'+src+'" title="'+waktu+'" alt="foto" loading="lazy"><div class="foto-histori-label">'+waktu+'</div><button class="btn-hapus-foto-item" onclick="hapusFotoGrid(event,\''+firebaseKey+'\')" title="Hapus foto ini">✕</button>';
|
|
sk.replaceWith(div);
|
|
}
|
|
|
|
async function renderSemuaFoto(data) {
|
|
const grid=document.getElementById('foto-grid');
|
|
const countBadge=document.getElementById('foto-count');
|
|
const strip=document.getElementById('timelapse-strip');
|
|
if(!data){
|
|
grid.innerHTML='<p style="color:var(--muted);font-size:13px;padding:10px;">Belum ada foto tersimpan</p>';
|
|
strip.innerHTML='<p style="color:var(--muted);font-size:12px;padding:10px;">Belum ada foto</p>';
|
|
countBadge.textContent='0 foto'; window.allFotoSrc=[]; return;
|
|
}
|
|
const entries=parseEntries(data);
|
|
countBadge.textContent=entries.length+' foto';
|
|
window.allFotoSrc=new Array(entries.length);
|
|
grid.innerHTML=entries.map((_,i)=>'<div id="foto-skeleton-'+i+'" class="foto-skeleton"><span style="font-size:20px">⏳</span></div>').join('');
|
|
strip.innerHTML=entries.map((foto,i)=>{
|
|
const ts=foto.timestamp<1e10?foto.timestamp*1000:(foto.timestamp||Date.now());
|
|
const src='data:image/jpeg;base64,'+foto.image;
|
|
const wkt=foto.waktu?foto.waktu.substring(5,16):new Date(ts).toLocaleString('id-ID',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
|
|
return '<div style="flex-shrink:0;position:relative;" id="strip-'+i+'"><img src="'+src+'" onclick="setMain(\''+src+'\')" style="width:80px;height:58px;object-fit:cover;border-radius:6px;border:2px solid '+(i===0?'var(--green-mid)':'transparent')+';cursor:pointer;" loading="lazy"><span style="position:absolute;bottom:3px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.55);color:#fff;font-size:9px;font-family:\'Space Mono\',monospace;padding:1px 5px;border-radius:3px;white-space:nowrap;">'+wkt+'</span></div>';
|
|
}).join('')||'<p style="color:var(--muted);font-size:12px;padding:10px;">Belum ada foto</p>';
|
|
|
|
for(let i=0;i<entries.length;i++){
|
|
const foto=entries[i];
|
|
const ts=foto.timestamp<1e10?foto.timestamp*1000:(foto.timestamp||Date.now());
|
|
const waktu=foto.waktu||new Date(ts).toLocaleString('id-ID');
|
|
|
|
const suhuFoto = cariSensorTerdekat(foto.waktu, 'suhu');
|
|
const humFoto = cariSensorTerdekat(foto.waktu, 'kelembapan');
|
|
const soilFoto = cariSensorTerdekat(foto.waktu, 'soil');
|
|
|
|
let b64wm=foto.image;
|
|
try{ b64wm=await tambahWatermarkAsync(foto.image, foto.waktu||'', suhuFoto, humFoto, soilFoto); }
|
|
catch(e){ console.warn('[WM] foto ke-'+i+' gagal watermark'); }
|
|
const src='data:image/jpeg;base64,'+b64wm;
|
|
renderFotoItem(i,src,waktu,foto.key);
|
|
const si=document.getElementById('strip-'+i);
|
|
if(si){ const img=si.querySelector('img'); if(img) img.src=src; }
|
|
}
|
|
}
|
|
|
|
onValue(ref(db,'/sensor/history'),(snap)=>{
|
|
const raw=snap.val();
|
|
if(!raw){ window._historyCache=[]; }
|
|
else {
|
|
window._historyCache = Object.values(raw)
|
|
.filter(d => d.created_at || d.updated_at)
|
|
.map(d => ({...d, created_at: d.created_at || d.updated_at}))
|
|
.sort((a,b) => new Date(a.created_at) - new Date(b.created_at));
|
|
}
|
|
_historyReady = true;
|
|
|
|
if (typeof window.updateSplitChartsFromFirebase === 'function') {
|
|
window.updateSplitChartsFromFirebase();
|
|
}
|
|
|
|
if(!raw){
|
|
document.getElementById('riwayat-tbody').innerHTML='<tr><td colspan="10" class="loading-text">Belum ada data</td></tr>';
|
|
} else {
|
|
const data=Object.values(raw).sort((a,b)=>new Date(b.created_at||b.updated_at||0)-new Date(a.created_at||a.updated_at||0));
|
|
document.getElementById('riwayat-tbody').innerHTML=data.map((d,i)=>{
|
|
const pompaRaw=d.pompa;
|
|
const pompaStr=(pompaRaw!==null&&pompaRaw!==undefined)?pompaRaw.toString().toUpperCase().trim():'';
|
|
const isOn=(pompaStr==='1'||pompaStr==='ON'||pompaStr.includes('ON'));
|
|
const rc=isOn?'pump-on-row':'pump-off-row';
|
|
return '<tr class="'+rc+'"><td>'+(i+1)+'</td><td>'+(d.created_at||d.updated_at||'-')+'</td><td>'+(d.suhu??'-')+'</td><td>'+(d.kelembapan??'-')+'</td><td>'+(d.soil??'-')+'</td><td>'+getBadgeSuhu(d.suhu)+'</td><td>'+getBadgeKelembapan(d.kelembapan)+'</td><td>'+getBadgeSoil(d.soil)+'</td><td>'+getBadgePompa(d.pompa)+'</td><td>'+getBadgeFan(d.fan)+'</td></tr>';
|
|
}).join('');
|
|
const asc=[...data].reverse().slice(-50);
|
|
const rl=asc.map(d=>(d.created_at||d.updated_at||'').slice(0,16)||'-');
|
|
|
|
const mkDatasets = (src) => [
|
|
{label:'Suhu (°C)', data:src.map(d=>d.suhu), borderColor:'#e53e3e', backgroundColor:'rgba(229,62,62,.13)', tension:0.4, pointRadius:3, pointHoverRadius:6, fill:true, borderWidth:2.5},
|
|
{label:'Kelembapan (%)', data:src.map(d=>d.kelembapan), borderColor:'#00b5a3', backgroundColor:'rgba(0,181,163,.13)', tension:0.4, pointRadius:3, pointHoverRadius:6, fill:true, borderWidth:2.5},
|
|
{label:'Soil Moisture (%)',data:src.map(d=>d.soil), borderColor:'#2563eb', backgroundColor:'rgba(37,99,235,.10)', tension:0.4, pointRadius:3, pointHoverRadius:6, fill:true, borderWidth:2.5},
|
|
];
|
|
|
|
const riwayatOpts = {
|
|
responsive:true, maintainAspectRatio:false,
|
|
animation:{ duration:700, easing:'easeInOutQuart' },
|
|
interaction:{ mode:'index', intersect:false },
|
|
plugins:{
|
|
legend:{
|
|
display:true,
|
|
position:'bottom',
|
|
labels:{ color:'#6b7c6e', font:{size:11, family:"'DM Sans', sans-serif"}, usePointStyle:true, pointStyleWidth:20, padding:20 }
|
|
},
|
|
tooltip:{
|
|
backgroundColor:'rgba(26,44,30,0.92)', titleColor:'#a3c4a8', bodyColor:'#fff',
|
|
padding:10, cornerRadius:8,
|
|
titleFont:{ family:"'Space Mono', monospace", size:10 },
|
|
bodyFont:{ family:"'DM Sans', sans-serif", size:12 },
|
|
callbacks:{
|
|
title: items => '⏱ '+items[0].label,
|
|
label: ctx => {
|
|
const ic={'Suhu (°C)':'🌡','Kelembapan (%)':'💧','Soil Moisture (%)':'🌱'};
|
|
return ' '+(ic[ctx.dataset.label]||'')+' '+ctx.dataset.label+': '+ctx.parsed.y;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales:{
|
|
x:{
|
|
ticks:{ font:{size:9, family:"'Space Mono', monospace"}, color:'#6b7c6e', maxTicksLimit:10, maxRotation:0 },
|
|
grid:{ color:'#f0f4f0' }, border:{ display:false }
|
|
},
|
|
y:{
|
|
grid:{ color:'#e8f5ec' },
|
|
ticks:{ font:{size:9}, color:'#6b7c6e', padding:4 },
|
|
border:{ display:false }
|
|
}
|
|
}
|
|
};
|
|
|
|
if(riwayatChart){
|
|
riwayatChart.data.labels=rl;
|
|
riwayatChart.data.datasets[0].data=asc.map(d=>d.suhu);
|
|
riwayatChart.data.datasets[1].data=asc.map(d=>d.kelembapan);
|
|
riwayatChart.data.datasets[2].data=asc.map(d=>d.soil);
|
|
riwayatChart.update('active');
|
|
} else {
|
|
riwayatChart=new Chart(document.getElementById('riwayatChart').getContext('2d'),{
|
|
type:'line', data:{ labels:rl, datasets:mkDatasets(asc) }, options:riwayatOpts
|
|
});
|
|
}
|
|
|
|
window.filterRiwayatChart = function(period, btn) {
|
|
btn.closest('.chart-filter').querySelectorAll('.chart-filter-btn').forEach(b=>b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
|
|
const now = new Date();
|
|
const allData = Object.values(raw).sort((a,b)=>new Date(a.created_at||a.updated_at||0)-new Date(b.created_at||b.updated_at||0));
|
|
|
|
const filtered = allData.filter(d => {
|
|
const t = new Date(d.created_at||d.updated_at||0);
|
|
if (period==='hari') return t.toDateString()===now.toDateString();
|
|
if (period==='minggu') return (now-t)/86400000<=7;
|
|
if (period==='bulan') return t.getMonth()===now.getMonth()&&t.getFullYear()===now.getFullYear();
|
|
return true;
|
|
});
|
|
|
|
if (period==='hari') {
|
|
const grp={};
|
|
filtered.forEach(d=>{
|
|
const h=new Date(d.created_at||d.updated_at).getHours().toString().padStart(2,'0')+':00';
|
|
if(!grp[h])grp[h]={s:[],k:[],so:[]};
|
|
if(d.suhu!=null) grp[h].s.push(+d.suhu);
|
|
if(d.kelembapan!=null) grp[h].k.push(+d.kelembapan);
|
|
if(d.soil!=null) grp[h].so.push(+d.soil);
|
|
});
|
|
const avg=a=>a.length?+(a.reduce((x,y)=>x+y,0)/a.length).toFixed(1):null;
|
|
const keys=Object.keys(grp);
|
|
riwayatChart.data.labels=keys;
|
|
riwayatChart.data.datasets[0].data=keys.map(k=>avg(grp[k].s));
|
|
riwayatChart.data.datasets[1].data=keys.map(k=>avg(grp[k].k));
|
|
riwayatChart.data.datasets[2].data=keys.map(k=>avg(grp[k].so));
|
|
} else if (period==='minggu'||period==='bulan') {
|
|
const grp={};
|
|
filtered.forEach(d=>{
|
|
const key=new Date(d.created_at||d.updated_at).toLocaleDateString('id-ID',{day:'2-digit',month:'2-digit'});
|
|
if(!grp[key])grp[key]={s:[],k:[],so:[]};
|
|
if(d.suhu!=null) grp[key].s.push(+d.suhu);
|
|
if(d.kelembapan!=null) grp[key].k.push(+d.kelembapan);
|
|
if(d.soil!=null) grp[key].so.push(+d.soil);
|
|
});
|
|
const avg=a=>a.length?+(a.reduce((x,y)=>x+y,0)/a.length).toFixed(1):null;
|
|
const keys=Object.keys(grp);
|
|
riwayatChart.data.labels=keys;
|
|
riwayatChart.data.datasets[0].data=keys.map(k=>avg(grp[k].s));
|
|
riwayatChart.data.datasets[1].data=keys.map(k=>avg(grp[k].k));
|
|
riwayatChart.data.datasets[2].data=keys.map(k=>avg(grp[k].so));
|
|
} else {
|
|
riwayatChart.data.labels=filtered.map(d=>(d.created_at||d.updated_at||'').slice(0,16));
|
|
riwayatChart.data.datasets[0].data=filtered.map(d=>d.suhu);
|
|
riwayatChart.data.datasets[1].data=filtered.map(d=>d.kelembapan);
|
|
riwayatChart.data.datasets[2].data=filtered.map(d=>d.soil);
|
|
}
|
|
riwayatChart.update('active');
|
|
};
|
|
|
|
window.toggleRiwayatDataset = function(idx) {
|
|
if(!riwayatChart) return;
|
|
const meta = riwayatChart.getDatasetMeta(idx);
|
|
meta.hidden = !meta.hidden;
|
|
riwayatChart.update();
|
|
const items = document.querySelectorAll('#tab-riwayat .legend-item');
|
|
if(items[idx]) items[idx].style.opacity = meta.hidden ? '0.35' : '1';
|
|
};
|
|
}
|
|
|
|
if(_fotoReady && _fotoSnapshot !== null){
|
|
renderSemuaFoto(_fotoSnapshot);
|
|
}
|
|
});
|
|
|
|
onValue(ref(db,'/foto'), async (snapshot)=>{
|
|
_fotoSnapshot = snapshot.val();
|
|
_fotoReady = true;
|
|
if(!_historyReady) return;
|
|
renderSemuaFoto(_fotoSnapshot);
|
|
});
|
|
|
|
onValue(ref(db,'/relay'),(snap)=>{
|
|
const val=snap.val();
|
|
relayValue=(val===null||val===undefined)?0:Number(val);
|
|
const isOn=(relayValue===1);
|
|
const valPompa=document.getElementById('val-pompa');
|
|
const badgePompa=document.getElementById('badge-pompa');
|
|
const camBadge=document.getElementById('pump-cam-badge');
|
|
if(isOn){
|
|
valPompa.textContent='ON';
|
|
badgePompa.innerHTML='<span class="badge-pump-on">💧 ON</span>';
|
|
camBadge.className='pump-cam-badge on';
|
|
} else {
|
|
valPompa.textContent='OFF';
|
|
badgePompa.innerHTML='<span class="badge-pump-off">⭕ OFF</span>';
|
|
camBadge.className='pump-cam-badge';
|
|
}
|
|
});
|
|
|
|
window.togglePompaRelay=async function(){
|
|
const next=(relayValue===1)?0:1;
|
|
try{
|
|
await set(ref(db,'/relay'),next);
|
|
showToast(next===1?'✅ Pompa diset ON (manual)':'✅ Pompa diset OFF (manual)');
|
|
}catch(e){
|
|
showToast('❌ Gagal: '+e.message,4000);
|
|
}
|
|
};
|
|
|
|
let fanValue = 0;
|
|
|
|
onValue(ref(db,'/fan'),(snap)=>{
|
|
const val=snap.val();
|
|
fanValue=(val===null||val===undefined)?0:Number(val);
|
|
const isOn=(fanValue===1);
|
|
const valFan=document.getElementById('val-fan');
|
|
const badgeFan=document.getElementById('badge-fan');
|
|
if(isOn){
|
|
valFan.textContent='ON';
|
|
badgeFan.innerHTML='<span class="badge-fan-on">🌀 ON</span>';
|
|
} else {
|
|
valFan.textContent='OFF';
|
|
badgeFan.innerHTML='<span class="badge-fan-off">⭕ OFF</span>';
|
|
}
|
|
});
|
|
|
|
window.toggleFanRelay=async function(){
|
|
const next=(fanValue===1)?0:1;
|
|
try{
|
|
await set(ref(db,'/fan'),next);
|
|
showToast(next===1?'✅ Fan diset ON (manual)':'✅ Fan diset OFF (manual)');
|
|
}catch(e){
|
|
showToast('❌ Gagal: '+e.message,4000);
|
|
}
|
|
};
|
|
|
|
onValue(ref(db,'/sensor/latest'),(snapshot)=>{
|
|
const data=snapshot.val(); if(!data)return;
|
|
const suhu=parseFloat(data.suhu)||0, kelembapan=parseFloat(data.kelembapan)||0, soil=parseFloat(data.soil)||0;
|
|
const updatedAt=data.updated_at??data.created_at??'n/a';
|
|
document.getElementById('overlay-suhu').textContent=suhu+'°C';
|
|
document.getElementById('overlay-kelembapan').textContent=kelembapan+'%';
|
|
document.getElementById('overlay-soil').textContent=soil+'%';
|
|
document.getElementById('overlay-date').textContent=updatedAt;
|
|
document.getElementById('val-suhu').textContent=suhu+' °C';
|
|
document.getElementById('val-kelembapan').textContent=kelembapan+' %';
|
|
document.getElementById('val-soil').textContent=soil+' %';
|
|
document.getElementById('gauge-suhu-circle').setAttribute('stroke-dashoffset',Math.round(circumf*(1-Math.min(suhu,100)/100)));
|
|
document.getElementById('gauge-suhu-text').textContent=suhu+'°';
|
|
document.getElementById('gauge-hum-circle').setAttribute('stroke-dashoffset',Math.round(circumf*(1-Math.min(kelembapan,100)/100)));
|
|
document.getElementById('gauge-hum-text').textContent=kelembapan+'%';
|
|
document.getElementById('gauge-soil-circle').setAttribute('stroke-dashoffset',Math.round(circumf*(1-Math.min(soil,100)/100)));
|
|
document.getElementById('gauge-soil-text').textContent=soil+'%';
|
|
const bs=document.getElementById('badge-suhu');
|
|
if(suhu<25){bs.className='badge-warn';bs.textContent='DINGIN';}
|
|
else if(suhu<=32){bs.className='badge-ok';bs.textContent='NORMAL';}
|
|
else{bs.className='badge-danger';bs.textContent='PANAS';}
|
|
const bh=document.getElementById('badge-kelembapan');
|
|
if(kelembapan<60){bh.className='badge-warn';bh.textContent='RENDAH';}
|
|
else if(kelembapan<=80){bh.className='badge-ok';bh.textContent='NORMAL';}
|
|
else{bh.className='badge-warn';bh.textContent='TINGGI';}
|
|
const bso=document.getElementById('badge-soil');
|
|
if(soil<=SOIL_MULAI_SIRAM){bso.className='badge-danger';bso.textContent='KERING';}
|
|
else if(soil<SOIL_STOP_SIRAM){bso.className='badge-ok';bso.textContent='IDEAL';}
|
|
else{bso.className='badge-info';bso.textContent='BASAH';}
|
|
|
|
// ── Auto kontrol POMPA berdasarkan soil moisture (dengan hysteresis) ──
|
|
if (soil <= SOIL_MULAI_SIRAM && relayValue !== 1) {
|
|
set(ref(db,'/relay'), 1).catch(e=>console.error('Gagal auto ON pompa:', e));
|
|
} else if (soil >= SOIL_STOP_SIRAM && relayValue !== 0) {
|
|
set(ref(db,'/relay'), 0).catch(e=>console.error('Gagal auto OFF pompa:', e));
|
|
}
|
|
|
|
// ── Auto kontrol FAN berdasarkan suhu (dengan hysteresis) ──
|
|
if (suhu >= SUHU_FAN_ON && fanValue !== 1) {
|
|
set(ref(db,'/fan'), 1).catch(e=>console.error('Gagal auto ON fan:', e));
|
|
} else if (suhu <= SUHU_FAN_OFF && fanValue !== 0) {
|
|
set(ref(db,'/fan'), 0).catch(e=>console.error('Gagal auto OFF fan:', e));
|
|
}
|
|
|
|
if (suhu >= SUHU_BATAS_MAKS) {
|
|
if (!_suhuAlertOn) {
|
|
_suhuAlertOn = true;
|
|
const teksAlert = document.getElementById('suhu-alert-text');
|
|
if (teksAlert) {
|
|
teksAlert.innerHTML =
|
|
'⚠️ <b>Suhu telah mencapai ' + suhu + '°C</b>, melebihi batas aman (' + SUHU_BATAS_MAKS + '°C)';
|
|
}
|
|
openModal('suhu-alert-modal');
|
|
showToast('🔥 Suhu tinggi terdeteksi: ' + suhu + '°C!', 5000);
|
|
kirimTelegramAlert(suhu);
|
|
}
|
|
} else {
|
|
_suhuAlertOn = false;
|
|
}
|
|
});
|
|
|
|
window.hapusData=async function(){
|
|
const dari=document.getElementById('hapus-dari').value, sampai=document.getElementById('hapus-sampai').value;
|
|
if(!dari||!sampai){alert('Pilih tanggal dulu!');return;}
|
|
if(!confirm('Hapus data '+dari+' s/d '+sampai+'?'))return;
|
|
const snap=await new Promise(res=>onValue(ref(db,'/sensor/history'),res,{onlyOnce:true}));
|
|
const raw=snap.val(); if(!raw){alert('Tidak ada data!');return;}
|
|
const keys=Object.entries(raw).filter(([,v])=>{const t=(v.created_at||v.updated_at||'').slice(0,10);return t&&t>=dari&&t<=sampai;}).map(([k])=>k);
|
|
if(!keys.length){alert('Tidak ada data di rentang itu.');return;}
|
|
await Promise.all(keys.map(k=>remove(ref(db,'/sensor/history/'+k))));
|
|
alert('✅ '+keys.length+' data dihapus!'); closeModal('hapus-modal');
|
|
};
|
|
|
|
window.hapusSemua=async function(){
|
|
if(!confirm('Hapus SEMUA data sensor?'))return;
|
|
await remove(ref(db,'/sensor/history'));
|
|
alert('✅ Semua data sensor dihapus!'); closeModal('hapus-modal');
|
|
};
|
|
</script>
|
|
</body>
|
|
</html> |