model-test / index.html
anoderb
feat: upgrade SKU scanner UI & add object verification
86593c6
Raw
History Blame Contribute Delete
40.2 kB
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tokiva SKU Scanner — POS Kasir Pintar</title>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg-dark: #07080e;
--bg-card: rgba(16, 18, 27, 0.7);
--border-card: rgba(255, 255, 255, 0.07);
--primary: #3b82f6;
--primary-glow: rgba(59, 130, 246, 0.35);
--success: #10b981;
--success-glow: rgba(16, 185, 129, 0.35);
--warning: #fbbf24;
--danger: #ef4444;
--text-main: #f3f4f6;
--text-muted: #9ca3af;
--font-family: 'Outfit', -apple-system, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
background-color: var(--bg-dark);
background-image:
radial-gradient(circle at top right, rgba(59, 130, 246, 0.1), transparent 450px),
radial-gradient(circle at bottom left, rgba(16, 185, 129, 0.08), transparent 450px);
background-attachment: fixed;
color: var(--text-main);
padding: 24px;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
}
/* Header Section */
header {
width: 100%;
max-width: 1200px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-card);
}
.logo-container {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
font-size: 28px;
background: linear-gradient(135deg, var(--primary), var(--success));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 800;
}
.logo-title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
}
.logo-title span {
color: var(--success);
font-weight: 400;
}
.status-badge {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-card);
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--warning);
box-shadow: 0 0 8px var(--warning);
}
.status-dot.ready {
background-color: var(--success);
box-shadow: 0 0 10px var(--success);
}
.status-dot.loading {
background-color: var(--warning);
box-shadow: 0 0 10px var(--warning);
animation: pulse 1.5s infinite alternate;
}
.status-dot.error {
background-color: var(--danger);
box-shadow: 0 0 10px var(--danger);
}
/* Main Dashboard Grid */
.dashboard-grid {
width: 100%;
max-width: 1200px;
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 24px;
}
@media (max-width: 900px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
/* Glassmorphic Cards */
.card {
background: var(--bg-card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border-card);
border-radius: 16px;
padding: 20px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
margin-bottom: 24px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding-bottom: 8px;
}
.card-title svg {
color: var(--primary);
}
/* Left Panel: Camera & Viewport */
.viewport-container {
position: relative;
width: 100%;
aspect-ratio: 1/1;
max-height: 400px;
margin: 0 auto 16px auto;
border-radius: 12px;
overflow: hidden;
background: #000;
border: 2px solid rgba(255, 255, 255, 0.05);
box-shadow: inset 0 0 30px rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
}
#video, #previewImg {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
transition: transform 0.3s ease;
}
#canvas {
display: none;
}
/* Cyberpunk brackets / scanner decoration */
.scanner-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.corner-bracket {
position: absolute;
width: 24px;
height: 24px;
border-color: rgba(255, 255, 255, 0.3);
border-style: solid;
transition: border-color 0.3s;
}
.corner-bracket.top-left { top: 16px; left: 16px; border-width: 3px 0 0 3px; border-top-left-radius: 4px; }
.corner-bracket.top-right { top: 16px; right: 16px; border-width: 3px 3px 0 0; border-top-right-radius: 4px; }
.corner-bracket.bottom-left { bottom: 16px; left: 16px; border-width: 0 0 3px 3px; border-bottom-left-radius: 4px; }
.corner-bracket.bottom-right { bottom: 16px; right: 16px; border-width: 0 3px 3px 0; border-bottom-right-radius: 4px; }
/* Pulse scan animation when active */
.scanner-laser {
position: absolute;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(to right, transparent, var(--success), transparent);
box-shadow: 0 0 10px var(--success), 0 0 20px var(--success);
opacity: 0;
pointer-events: none;
z-index: 11;
}
.scanner-active .scanner-laser {
animation: laser-sweep 2s linear infinite;
opacity: 1;
}
.scanner-active .corner-bracket {
border-color: var(--success);
}
/* Flash overlay for successful detection */
.scanner-flash {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--success);
opacity: 0;
z-index: 12;
pointer-events: none;
transition: opacity 0.1s ease-out;
}
.scanner-flash.flash-active {
opacity: 0.25;
transition: none;
}
/* Controls and sliders */
.controls-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
button, .file-btn-label {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
border-radius: 10px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.05);
}
button:hover, .file-btn-label:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
button:active, .file-btn-label:active {
transform: translateY(1px);
}
button.btn-primary {
background: linear-gradient(135deg, var(--primary), #2563eb);
box-shadow: 0 4px 14px var(--primary-glow);
}
button.btn-primary:hover {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
box-shadow: 0 6px 18px var(--primary-glow);
}
button.btn-success {
background: linear-gradient(135deg, var(--success), #059669);
box-shadow: 0 4px 14px var(--success-glow);
}
button.btn-success:hover {
background: linear-gradient(135deg, #059669, #047857);
box-shadow: 0 6px 18px var(--success-glow);
}
button.btn-danger {
background: linear-gradient(135deg, var(--danger), #dc2626);
box-shadow: 0 4px 14px rgba(239, 68, 68, 0.3);
}
button.btn-danger:hover {
background: linear-gradient(135deg, #dc2626, #b91c1c);
box-shadow: 0 6px 18px rgba(239, 68, 68, 0.35);
}
button:disabled, .file-btn-label.disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
/* File Input styling */
input[type="file"] {
display: none;
}
.upload-zone {
border: 2px dashed rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 16px;
text-align: center;
background: rgba(255, 255, 255, 0.01);
transition: all 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--primary);
background: rgba(59, 130, 246, 0.04);
}
.upload-zone svg {
color: var(--text-muted);
transition: color 0.2s;
}
.upload-zone:hover svg, .upload-zone.dragover svg {
color: var(--primary);
}
.upload-zone span {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
}
/* Threshold Control Slider */
.slider-container {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
padding: 12px;
border-radius: 10px;
margin-top: 14px;
}
.slider-label-row {
display: flex;
justify-content: space-between;
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
color: var(--text-muted);
}
.slider-value {
color: var(--primary);
font-weight: 700;
}
.range-slider {
width: 100%;
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.1);
outline: none;
}
.range-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary);
cursor: pointer;
box-shadow: 0 0 8px var(--primary-glow);
transition: transform 0.1s;
}
.range-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
/* Right Panel: Results */
.prediction-display {
background: rgba(255, 255, 255, 0.02);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.04);
padding: 16px;
margin-bottom: 16px;
position: relative;
overflow: hidden;
}
.pred-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 6px;
}
.pred-main-name {
font-size: 20px;
font-weight: 700;
color: var(--success);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pred-meta-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12.5px;
color: var(--text-muted);
}
.badge {
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
background: rgba(255, 255, 255, 0.06);
}
/* Top 5 list */
.top-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.top-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
}
.top-item-name {
width: 140px;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-muted);
font-weight: 500;
}
.top-item-bar-track {
flex: 1;
background: rgba(255, 255, 255, 0.05);
height: 12px;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.02);
}
.top-item-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 6px;
transition: width 0.3s ease-out;
}
.top-item-bar-fill.rank-success {
background: linear-gradient(90deg, var(--primary), var(--success));
}
.top-item-bar-fill.rank-warning {
background: linear-gradient(90deg, var(--primary), var(--warning));
}
.top-item-bar-fill.rank-danger {
background: linear-gradient(90deg, var(--primary), var(--danger));
}
.top-item-pct {
width: 50px;
text-align: left;
font-weight: 600;
color: var(--text-muted);
}
.warning-box {
margin-top: 14px;
background: rgba(251, 191, 36, 0.08);
border: 1px solid rgba(251, 191, 36, 0.2);
padding: 10px;
border-radius: 8px;
color: var(--warning);
font-size: 12.5px;
display: flex;
align-items: center;
gap: 8px;
}
/* POS Shopping Cart & History Section */
.pos-container {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
.cart-list {
max-height: 180px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
padding-right: 4px;
}
/* scrollbar */
.cart-list::-webkit-scrollbar, .history-list::-webkit-scrollbar {
width: 6px;
}
.cart-list::-webkit-scrollbar-thumb, .history-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.cart-item {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
}
.cart-item-info {
flex: 1;
overflow: hidden;
padding-right: 8px;
}
.cart-item-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cart-item-price {
font-size: 12px;
color: var(--text-muted);
}
.cart-item-actions {
display: flex;
align-items: center;
gap: 12px;
}
.cart-item-qty {
font-weight: 700;
color: var(--primary);
}
.cart-item-total {
font-weight: 700;
}
.btn-remove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s;
}
.btn-remove:hover {
color: var(--danger);
background: rgba(239, 68, 68, 0.15);
}
.cart-empty {
text-align: center;
padding: 24px;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}
.cart-summary {
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.summary-label {
font-weight: 600;
font-size: 14px;
}
.summary-total {
font-size: 18px;
font-weight: 800;
color: var(--success);
}
/* History list */
.history-list {
max-height: 140px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 4px;
}
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.01);
border-radius: 6px;
font-size: 12px;
border-left: 3px solid var(--primary);
}
.history-item.high-conf {
border-left-color: var(--success);
}
.history-item-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.history-item-time {
color: var(--text-muted);
font-size: 11px;
}
.history-empty {
text-align: center;
padding: 12px;
color: var(--text-muted);
font-size: 12px;
font-style: italic;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 0.7; }
100% { transform: scale(1.2); opacity: 1; }
}
@keyframes laser-sweep {
0% { top: 0%; }
50% { top: 100%; }
100% { top: 0%; }
}
</style>
</head>
<body>
<header>
<div class="logo-container">
<div class="logo-icon"></div>
<h1 class="logo-title">Tokiva <span>SKU Scanner</span></h1>
</div>
<div class="status-badge">
<div id="statusDot" class="status-dot loading"></div>
<span id="statusText">Memuat modul deteksi...</span>
</div>
</header>
<div class="dashboard-grid">
<!-- Left Panel: Scanning Feed & Controls -->
<div>
<div class="card">
<div class="card-title">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
<span>Pemindai Kamera & Input</span>
</div>
<div id="viewportContainer" class="viewport-container">
<!-- Decoration brackets for target area -->
<div class="scanner-overlay">
<div class="corner-bracket top-left"></div>
<div class="corner-bracket top-right"></div>
<div class="corner-bracket bottom-left"></div>
<div class="corner-bracket bottom-right"></div>
<div id="scannerLaser" class="scanner-laser"></div>
<div id="scannerFlash" class="scanner-flash"></div>
</div>
<video id="video" autoplay playsinline muted style="display:none;"></video>
<img id="previewImg" style="display:none;">
<canvas id="canvas" width="224" height="224"></canvas>
<div id="viewportPlaceholder" style="color: var(--text-muted); font-size: 13.5px; font-weight: 500;">
Kamera tidak aktif
</div>
</div>
<div class="controls-row">
<button id="btnCamera" class="btn-primary" onclick="startCamera()">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
Nyalakan
</button>
<button id="btnToggleCamera" onclick="switchCameraFacing()" disabled>
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 7.89"></path></svg>
Putar Kamera
</button>
<button id="btnRealtime" class="btn-success" onclick="toggleRealtime()" disabled>
Mulai Scan
</button>
</div>
<div class="upload-zone" id="dropzone" onclick="document.getElementById('upload').click()">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
<span>Klik atau seret file gambar produk di sini</span>
<input type="file" id="upload" accept="image/*" onchange="handleUpload(event)">
</div>
<div class="slider-container">
<div class="slider-label-row">
<span>Confidence Threshold (Kasir)</span>
<span class="slider-value" id="sliderValDisplay">60%</span>
</div>
<input type="range" class="range-slider" id="thresholdSlider" min="10" max="95" value="60" oninput="updateThreshold(this.value)">
</div>
</div>
</div>
<!-- Right Panel: Results & POS -->
<div class="pos-container">
<!-- Live Results Card -->
<div class="card">
<div class="card-title">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path></svg>
<span>Hasil Deteksi Model</span>
</div>
<div id="resultBox" style="display:none;">
<div class="prediction-display">
<div class="pred-label">Prediksi SKU Tertinggi</div>
<div id="mainPrediction" class="pred-main-name">Silakan scan produk...</div>
<div class="pred-meta-row">
<span id="inferenceTime" class="badge">⚡ - ms</span>
<span id="accuracyLabel">Confidence: -</span>
</div>
</div>
<div class="pred-label" style="margin-bottom: 8px;">Top-5 Kandidat Kecocokan</div>
<div class="top-list" id="top5Container">
<!-- items dynamically filled -->
</div>
<div id="lowConfidenceWarning" class="warning-box" style="display:none;">
<span>⚠️</span>
<span id="lowConfidenceWarningText">Batas confidence rendah.</span>
</div>
</div>
<div id="resultPlaceholder" style="text-align: center; padding: 24px; color: var(--text-muted); font-size: 13.5px; font-style: italic;">
Belum ada gambar yang dipindai.
</div>
</div>
<!-- POS Kasir (Simulasi) Card -->
<div class="card">
<div class="card-title">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
<span>POS Kasir Tokiva</span>
</div>
<div class="cart-list" id="cartList">
<div class="cart-empty">Belum ada item belanja. Scan produk untuk memasukkan ke kasir.</div>
</div>
<div class="cart-summary">
<div class="summary-label">Total Belanja</div>
<div class="summary-total" id="cartTotal">Rp 0</div>
</div>
<button id="btnCheckout" class="btn-success" style="width: 100%; display: flex; justify-content: center; gap: 8px;" onclick="checkoutCart()">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"></path></svg>
Bayar & Cetak Struk
</button>
</div>
<!-- Scan History Card -->
<div class="card">
<div class="card-title">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>Riwayat Scan Terakhir</span>
</div>
<div class="history-list" id="historyList">
<div class="history-empty">Belum ada riwayat pemindaian.</div>
</div>
</div>
</div>
</div>
<script>
const IMG_SIZE = 224;
const REALTIME_INTERVAL_MS = 750; // jeda scan realtime (ms)
const COOLDOWN_MS = 3000; // 3 detik cooldown per item agar tidak spamming audio & cart
// Mock database harga produk berdasarkan SKU names
const PRODUCT_CATALOG = {
"frisian-flag-fullcrm-250ml": { name: "Frisian Flag Full Cream 250ml", price: 7500 },
"frisian-flag-strwbry-250ml": { name: "Frisian Flag Strawberry 250ml", price: 7500 },
"gaga-100-grg-jalapeto": { name: "Mie Gaga 100 Goreng Jalapeno", price: 4500 },
"gaga-100-kuah-jalapeto": { name: "Mie Gaga 100 Kuah Jalapeno", price: 4500 },
"indomie-grg-cb-ijo": { name: "Indomie Goreng Cabe Ijo", price: 3500 },
"indomie-grg-cb-ijo-jumbo": { name: "Indomie Goreng Cabe Ijo Jumbo", price: 4500 },
"klik-crackers-keju": { name: "Klik Crackers Keju", price: 6000 },
"nabati-siip-keju": { name: "Nabati Siip Keju", price: 2000 },
"pepsodent-72g": { name: "Pepsodent 72g", price: 12500 },
"sarimi-aym-bwng": { name: "Sarimi Ayam Bawang", price: 3200 },
"sarimi-gls-baso-pds": { name: "Sarimi Gelas Baso Pedas", price: 2500 },
"sedaap-grng": { name: "Mie Sedaap Goreng", price: 3500 },
"sedaap-kuah-aym-bwg": { name: "Mie Sedaap Kuah Ayam Bawang", price: 3300 },
"sedaap-sg-laksa": { name: "Mie Sedaap Singapore Laksa", price: 3600 },
"siplah-mineral-220ml": { name: "Siplah Air Mineral Gelas 220ml", price: 1500 },
"soffell-bunga": { name: "Soffell Bunga Kulit", price: 15000 },
"soffell-jeruk": { name: "Soffell Jeruk Kulit", price: 15000 },
"ultramlk-fullcrm-200ml": { name: "Ultra Milk Full Cream 200ml", price: 6800 },
"vica-600ml": { name: "Vica Air Mineral 600ml", price: 4000 }
};
let model = null;
let classNames = [];
let realtimeActive = false;
let realtimeTimer = null;
let cameraStream = null;
let currentFacingMode = 'environment'; // environment = back camera, user = front camera
let CONFIDENCE_THRESHOLD = 0.60;
// State Cooldown
let lastScannedItem = '';
let lastScanTime = 0;
// POS State & History State
const cart = {};
const scanHistory = [];
// Audio Object
const beepAudio = new Audio('beep.mp3');
// Elements
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const videoEl = document.getElementById('video');
const previewImgEl = document.getElementById('previewImg');
const canvasEl = document.getElementById('canvas');
const btnCamera = document.getElementById('btnCamera');
const btnToggleCamera = document.getElementById('btnToggleCamera');
const btnRealtime = document.getElementById('btnRealtime');
const viewportPlaceholder = document.getElementById('viewportPlaceholder');
const viewportContainer = document.getElementById('viewportContainer');
const scannerFlash = document.getElementById('scannerFlash');
const sliderValDisplay = document.getElementById('sliderValDisplay');
const thresholdSlider = document.getElementById('thresholdSlider');
const resultBox = document.getElementById('resultBox');
const resultPlaceholder = document.getElementById('resultPlaceholder');
const mainPredictionEl = document.getElementById('mainPrediction');
const inferenceTimeEl = document.getElementById('inferenceTime');
const accuracyLabel = document.getElementById('accuracyLabel');
const top5ContainerEl = document.getElementById('top5Container');
const lowConfidenceWarningEl = document.getElementById('lowConfidenceWarning');
const lowConfidenceWarningText = document.getElementById('lowConfidenceWarningText');
const cartListEl = document.getElementById('cartList');
const cartTotalEl = document.getElementById('cartTotal');
const historyListEl = document.getElementById('historyList');
// ---------------------------------------------------------
// Load model + daftar kelas
// ---------------------------------------------------------
async function loadModel() {
try {
statusDot.className = 'status-dot loading';
statusText.textContent = 'Memuat model TensorFlow.js...';
model = await tf.loadGraphModel('model/model.json');
const res = await fetch('model/class_names.json');
classNames = await res.json();
statusDot.className = 'status-dot ready';
statusText.textContent = `Model Siap (${classNames.length} SKU). Nyalakan kamera atau unggah foto.`;
} catch (err) {
statusDot.className = 'status-dot error';
statusText.textContent = 'Gagal memuat model: ' + err.message;
console.error(err);
}
}
loadModel();
// ---------------------------------------------------------
// Kamera controls
// ---------------------------------------------------------
async function startCamera() {
stopCamera(); // Pastikan kamera lama dimatikan sebelum membuat koneksi baru
try {
statusText.textContent = 'Mengakses kamera...';
cameraStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: currentFacingMode,
width: { ideal: 640 },
height: { ideal: 640 }
}
});
videoEl.srcObject = cameraStream;
videoEl.style.display = 'block';
previewImgEl.style.display = 'none';
viewportPlaceholder.style.display = 'none';
btnToggleCamera.disabled = false;
btnRealtime.disabled = false;
btnCamera.textContent = 'Matikan Kamera';
btnCamera.className = 'btn-danger';
btnCamera.setAttribute('onclick', 'stopCameraUI()');
statusText.textContent = 'Kamera aktif. Klik "Mulai Scan" untuk mendeteksi otomatis.';
} catch (err) {
statusText.textContent = 'Gagal mengakses kamera: ' + err.message;
statusDot.className = 'status-dot error';
}
}
// Stop Camera stream
function stopCamera() {
if (realtimeActive) toggleRealtime();
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
videoEl.srcObject = null;
}
function stopCameraUI() {
stopCamera();
videoEl.style.display = 'none';
viewportPlaceholder.style.display = 'block';
btnToggleCamera.disabled = true;
btnRealtime.disabled = true;
btnCamera.textContent = 'Nyalakan Kamera';
btnCamera.className = 'btn-primary';
btnCamera.setAttribute('onclick', 'startCamera()');
statusText.textContent = 'Kamera dinonaktifkan.';
if (model) {
statusDot.className = 'status-dot ready';
}
}
async function switchCameraFacing() {
currentFacingMode = currentFacingMode === 'environment' ? 'user' : 'environment';
await startCamera();
}
function toggleRealtime() {
realtimeActive = !realtimeActive;
btnRealtime.textContent = realtimeActive ? 'Stop Scan' : 'Mulai Scan';
btnRealtime.className = realtimeActive ? 'btn-danger' : 'btn-success';
if (realtimeActive) {
viewportContainer.classList.add('scanner-active');
realtimeLoop();
} else {
viewportContainer.classList.remove('scanner-active');
if (realtimeTimer) clearTimeout(realtimeTimer);
}
}
async function realtimeLoop() {
if (!realtimeActive) return;
await predictFromVideoFrame();
realtimeTimer = setTimeout(realtimeLoop, REALTIME_INTERVAL_MS);
}
async function predictFromVideoFrame() {
const ctx = canvasEl.getContext('2d');
ctx.drawImage(videoEl, 0, 0, IMG_SIZE, IMG_SIZE);
await runPrediction();
}
// ---------------------------------------------------------
// Upload & Drag-Drop Gambar
// ---------------------------------------------------------
function handleUpload(event) {
const file = event.target.files[0];
processUploadedFile(file);
}
function processUploadedFile(file) {
if (!file) return;
if (realtimeActive) toggleRealtime();
const img = new Image();
img.onload = async () => {
previewImgEl.src = img.src;
previewImgEl.style.display = 'block';
videoEl.style.display = 'none';
viewportPlaceholder.style.display = 'none';
// Matikan stream kamera di belakang layar agar hemat daya
if (cameraStream) {
stopCameraUI();
}
const ctx = canvasEl.getContext('2d');
ctx.drawImage(img, 0, 0, IMG_SIZE, IMG_SIZE);
await runPrediction();
};
img.src = URL.createObjectURL(file);
}
// Drag & Drop event handlers
const dropzone = document.getElementById('dropzone');
['dragenter', 'dragover'].forEach(eventName => {
dropzone.addEventListener(eventName, e => {
e.preventDefault();
dropzone.classList.add('dragover');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropzone.addEventListener(eventName, e => {
e.preventDefault();
dropzone.classList.remove('dragover');
}, false);
});
dropzone.addEventListener('drop', e => {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 0) {
processUploadedFile(files[0]);
}
});
// ---------------------------------------------------------
// Threshold slider control
// ---------------------------------------------------------
function updateThreshold(val) {
CONFIDENCE_THRESHOLD = val / 100;
sliderValDisplay.textContent = val + '%';
}
// ---------------------------------------------------------
// Model Prediction
// ---------------------------------------------------------
async function runPrediction() {
if (!model) return;
const startTime = performance.now();
const inputTensor = tf.tidy(() => {
return tf.browser.fromPixels(canvasEl)
.resizeBilinear([IMG_SIZE, IMG_SIZE])
.toFloat()
.expandDims(0);
});
const outputTensor = model.predict(inputTensor);
const probs = await outputTensor.data();
const inferenceTimeMs = (performance.now() - startTime).toFixed(1);
inputTensor.dispose();
outputTensor.dispose();
displayResult(probs, inferenceTimeMs);
}
// Format IDR Price format
function formatIDR(price) {
return 'Rp ' + price.toLocaleString('id-ID');
}
// Get SKU Friendly Name from database Catalog
function getFriendlySKUName(sku) {
return PRODUCT_CATALOG[sku] ? PRODUCT_CATALOG[sku].name : sku;
}
function getProductPrice(sku) {
return PRODUCT_CATALOG[sku] ? PRODUCT_CATALOG[sku].price : 5000;
}
function triggerCameraFlash() {
scannerFlash.classList.add('flash-active');
setTimeout(() => {
scannerFlash.classList.remove('flash-active');
}, 150);
}
function displayResult(probs, inferenceTimeMs) {
const indexed = Array.from(probs).map((p, i) => ({ name: classNames[i], prob: p }));
indexed.sort((a, b) => b.prob - a.prob);
const top5 = indexed.slice(0, 5);
const bestMatch = top5[0];
resultPlaceholder.style.display = 'none';
resultBox.style.display = 'block';
// Display Main Prediction
const friendlyName = getFriendlySKUName(bestMatch.name);
const bestProbPct = (bestMatch.prob * 100).toFixed(2);
inferenceTimeEl.textContent = `⚡ ${inferenceTimeMs} ms`;
// Validasi Objek Tidak Terdeteksi / Tidak Dikenali
// Jika confidence di bawah 30%, kita anggap tidak terdeteksi sama sekali.
const UNKNOWN_THRESHOLD = 0.30;
if (bestMatch.prob < UNKNOWN_THRESHOLD) {
mainPredictionEl.textContent = `⚠️ Objek Tidak Dikenali / Tidak Terdeteksi`;
mainPredictionEl.style.color = 'var(--danger)';
accuracyLabel.textContent = `Confidence: ${(bestMatch.prob * 100).toFixed(1)}% (Di bawah batas minimum)`;
lowConfidenceWarningEl.style.display = 'flex';
lowConfidenceWarningText.textContent = `Produk tidak terdeteksi dengan jelas. Posisikan produk lebih dekat ke kamera atau perbaiki pencahayaan.`;
}
// Jika confidence di antara 30% dan threshold kasir (e.g. 60%)
else if (bestMatch.prob < CONFIDENCE_THRESHOLD) {
mainPredictionEl.textContent = `⚠️ ${friendlyName} (${bestProbPct}%)`;
mainPredictionEl.style.color = 'var(--warning)';
accuracyLabel.textContent = `Confidence: ${(bestMatch.prob * 100).toFixed(1)}%`;
lowConfidenceWarningEl.style.display = 'flex';
lowConfidenceWarningText.textContent = `Confidence rendah (< ${(CONFIDENCE_THRESHOLD * 100).toFixed(0)}%). Produk tidak otomatis dimasukkan ke kasir.`;
}
// Jika confidence tinggi >= threshold kasir
else {
mainPredictionEl.textContent = `${friendlyName} (${bestProbPct}%)`;
mainPredictionEl.style.color = 'var(--success)';
accuracyLabel.textContent = `Confidence: ${(bestMatch.prob * 100).toFixed(1)}%`;
lowConfidenceWarningEl.style.display = 'none';
// SUCCESS SCANNED: handle POS auto cart & beep (with cooldown)
const currentTime = Date.now();
if (bestMatch.name !== lastScannedItem || (currentTime - lastScanTime) > COOLDOWN_MS) {
lastScannedItem = bestMatch.name;
lastScanTime = currentTime;
// Play Beep sound
beepAudio.play().catch(e => console.log('Audio playback delayed until user interaction.'));
// Trigger camera border flash effect
triggerCameraFlash();
// Add to Cart State
addToCart(bestMatch.name);
}
}
// Display History logs (hanya jika confidence > UNKNOWN_THRESHOLD)
if (bestMatch.prob >= UNKNOWN_THRESHOLD) {
addToHistory(bestMatch.name, bestMatch.prob);
}
// Top-5 visualization
top5ContainerEl.innerHTML = top5.map((item, idx) => {
const pct = (item.prob * 100).toFixed(2);
let barClass = '';
if (idx === 0) {
if (item.prob >= CONFIDENCE_THRESHOLD) barClass = 'rank-success';
else if (item.prob >= UNKNOWN_THRESHOLD) barClass = 'rank-warning';
else barClass = 'rank-danger';
}
return `
<div class="top-item">
<div class="top-item-name" title="${getFriendlySKUName(item.name)}">${getFriendlySKUName(item.name)}</div>
<div class="top-item-bar-track">
<div class="top-item-bar-fill ${barClass}" style="width: ${pct}%"></div>
</div>
<div class="top-item-pct">${pct}%</div>
</div>
`;
}).join('');
}
// ---------------------------------------------------------
// POS Shopping Cart simulation
// ---------------------------------------------------------
function addToCart(sku) {
if (cart[sku]) {
cart[sku].qty += 1;
} else {
const catalogItem = PRODUCT_CATALOG[sku] || { name: sku, price: 5000 };
cart[sku] = {
name: catalogItem.name,
price: catalogItem.price,
qty: 1
};
}
renderCart();
}
function removeFromCart(sku) {
delete cart[sku];
renderCart();
}
function changeQty(sku, delta) {
if (!cart[sku]) return;
cart[sku].qty += delta;
if (cart[sku].qty <= 0) {
removeFromCart(sku);
} else {
renderCart();
}
}
function renderCart() {
const skus = Object.keys(cart);
if (skus.length === 0) {
cartListEl.innerHTML = '<div class="cart-empty">Belum ada item belanja. Scan produk untuk memasukkan ke kasir.</div>';
cartTotalEl.textContent = 'Rp 0';
return;
}
let total = 0;
cartListEl.innerHTML = skus.map(sku => {
const item = cart[sku];
const subtotal = item.price * item.qty;
total += subtotal;
return `
<div class="cart-item">
<div class="cart-item-info">
<div class="cart-item-name" title="${item.name}">${item.name}</div>
<div class="cart-item-price">${formatIDR(item.price)}</div>
</div>
<div class="cart-item-actions">
<button class="btn-remove" style="padding: 2px 8px;" onclick="changeQty('${sku}', -1)">-</button>
<span class="cart-item-qty">${item.qty}</span>
<button class="btn-remove" style="padding: 2px 8px;" onclick="changeQty('${sku}', 1)">+</button>
<span class="cart-item-total">${formatIDR(subtotal)}</span>
<button class="btn-remove" onclick="removeFromCart('${sku}')">×</button>
</div>
</div>
`;
}).join('');
cartTotalEl.textContent = formatIDR(total);
}
function checkoutCart() {
const skus = Object.keys(cart);
if (skus.length === 0) {
alert("Keranjang belanja masih kosong!");
return;
}
let summary = "--- STRUK TRANSAKSI TOKIVA ---\n\n";
let total = 0;
skus.forEach(sku => {
const item = cart[sku];
const subtotal = item.price * item.qty;
total += subtotal;
summary += `${item.name}\n ${item.qty} x ${formatIDR(item.price)} = ${formatIDR(subtotal)}\n`;
});
summary += `\n------------------------------\n`;
summary += `TOTAL BELANJA: ${formatIDR(total)}\n\nTerima kasih telah berbelanja di Tokiva!`;
alert(summary);
// Clear cart
for (const sku in cart) {
delete cart[sku];
}
renderCart();
}
// ---------------------------------------------------------
// Scan History Log
// ---------------------------------------------------------
function addToHistory(sku, confidence) {
const now = new Date();
const timeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS
const friendlyName = getFriendlySKUName(sku);
const isHighConf = confidence >= CONFIDENCE_THRESHOLD;
// Insert to the front of history
scanHistory.unshift({
name: friendlyName,
time: timeStr,
highConf: isHighConf,
pct: (confidence * 100).toFixed(0) + '%'
});
// Limit history items to last 8
if (scanHistory.length > 8) {
scanHistory.pop();
}
renderHistory();
}
function renderHistory() {
if (scanHistory.length === 0) {
historyListEl.innerHTML = '<div class="history-empty">Belum ada riwayat pemindaian.</div>';
return;
}
historyListEl.innerHTML = scanHistory.map(item => {
const confClass = item.highConf ? 'high-conf' : '';
return `
<div class="history-item ${confClass}">
<span class="history-item-name" title="${item.name}">${item.name}</span>
<div style="display: flex; gap: 8px; align-items: center;">
<span class="badge" style="background: rgba(255,255,255,0.03);">${item.pct}</span>
<span class="history-item-time">${item.time}</span>
</div>
</div>
`;
}).join('');
}
</script>
</body>
</html>