MicroHS / templates /index.html
github-actions[bot]
Sync from GitHub 38cd8d69dc858672e22cd1448f7768fef87468b1
79f9b3a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HS Code Classifier — Harbour</title>
<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=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<style>
/* ============================================
Harbour Design System — Dark-first tokens
============================================ */
:root {
/* Background hierarchy */
--h-bg: #0a0a0a;
--h-bg-raised: rgba(255,255,255,0.02);
--h-bg-surface: rgba(255,255,255,0.04);
--h-bg-hover: rgba(255,255,255,0.06);
--h-bg-active: rgba(255,255,255,0.08);
/* Borders */
--h-border: rgba(255,255,255,0.05);
--h-border-mid: rgba(255,255,255,0.08);
--h-border-strong: rgba(255,255,255,0.12);
/* Text hierarchy */
--h-text: #ffffff;
--h-text-2: rgba(255,255,255,0.70);
--h-text-3: rgba(255,255,255,0.50);
--h-text-4: rgba(255,255,255,0.30);
--h-text-5: rgba(255,255,255,0.20);
/* Accent — Harbour blue-cyan */
--h-accent: #3b82f6;
--h-accent-soft: rgba(59,130,246,0.15);
--h-accent-glow: rgba(59,130,246,0.25);
/* Semantic */
--h-emerald: #10b981;
--h-emerald-soft:rgba(16,185,129,0.15);
--h-amber: #f59e0b;
--h-amber-soft: rgba(245,158,11,0.15);
--h-red: #ef4444;
--h-red-soft: rgba(239,68,68,0.15);
--h-purple: #8b5cf6;
--h-purple-soft: rgba(139,92,246,0.15);
--h-pink: #ec4899;
--h-cyan: #06b6d4;
/* Chart palette (oklch-inspired, mapped to hex) */
--h-chart-1: #3b82f6;
--h-chart-2: #10b981;
--h-chart-3: #f59e0b;
--h-chart-4: #8b5cf6;
--h-chart-5: #ec4899;
/* Layout */
--h-radius: 0.625rem;
--h-radius-lg: 0.875rem;
--h-radius-xl: 1.125rem;
--h-radius-full: 9999px;
--h-shadow: 0 1px 2px rgba(0,0,0,0.4);
/* Font */
--h-font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--h-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
/* Overlay / Modal */
--h-overlay-bg: rgba(10,10,10,0.92);
--h-modal-bg: #111111;
--h-modal-shadow: 0 25px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255,255,255,0.04);
--h-backdrop: rgba(0, 0, 0, 0.7);
}
/* ============================================
Light theme overrides
============================================ */
:root.light {
--h-bg: #ffffff;
--h-bg-raised: rgba(0,0,0,0.02);
--h-bg-surface: rgba(0,0,0,0.03);
--h-bg-hover: rgba(0,0,0,0.05);
--h-bg-active: rgba(0,0,0,0.08);
--h-border: rgba(0,0,0,0.08);
--h-border-mid: rgba(0,0,0,0.12);
--h-border-strong: rgba(0,0,0,0.18);
--h-text: #111111;
--h-text-2: rgba(0,0,0,0.70);
--h-text-3: rgba(0,0,0,0.50);
--h-text-4: rgba(0,0,0,0.35);
--h-text-5: rgba(0,0,0,0.20);
--h-accent: #2563eb;
--h-accent-soft: rgba(37,99,235,0.10);
--h-accent-glow: rgba(37,99,235,0.20);
--h-emerald: #059669;
--h-emerald-soft:rgba(5,150,105,0.10);
--h-amber: #d97706;
--h-amber-soft: rgba(217,119,6,0.10);
--h-red: #dc2626;
--h-red-soft: rgba(220,38,38,0.10);
--h-purple: #7c3aed;
--h-purple-soft: rgba(124,58,237,0.10);
--h-pink: #db2777;
--h-cyan: #0891b2;
--h-shadow: 0 1px 3px rgba(0,0,0,0.1);
--h-overlay-bg: rgba(255,255,255,0.95);
--h-modal-bg: #ffffff;
--h-modal-shadow: 0 25px 60px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.06);
--h-backdrop: rgba(0, 0, 0, 0.3);
}
/* ============================================
Reset & Base
============================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--h-font);
background: var(--h-bg);
color: var(--h-text);
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "rlig" 1, "calt" 1;
overflow-x: hidden;
}
/* ============================================
Header
============================================ */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--h-border);
background: var(--h-bg);
position: sticky;
top: 0;
z-index: 50;
backdrop-filter: blur(12px);
}
.header-logo {
display: flex;
align-items: center;
gap: 0.625rem;
}
.header-logo h1 {
font-size: 1.125rem;
font-weight: 600;
color: var(--h-text);
letter-spacing: -0.01em;
}
.header-badge {
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.2rem 0.5rem;
border-radius: var(--h-radius-full);
background: var(--h-accent-soft);
color: var(--h-accent);
}
.header-stats {
margin-left: auto;
font-size: 0.75rem;
color: var(--h-text-4);
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-stats .stat-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.5rem;
background: var(--h-bg-surface);
border-radius: var(--h-radius-full);
font-family: var(--h-mono);
font-size: 0.6875rem;
}
.header-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: 1rem;
}
/* ============================================
Layout
============================================ */
.app-layout {
display: grid;
grid-template-columns: 420px 1fr;
min-height: calc(100vh - 53px);
}
.sidebar-panel {
border-right: 1px solid var(--h-border);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.main-panel {
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ============================================
Cards
============================================ */
.card {
background: var(--h-bg-raised);
border: 1px solid var(--h-border);
border-radius: var(--h-radius-lg);
overflow: hidden;
}
.card-header {
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--h-border);
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-header h2 {
font-size: 0.8125rem;
font-weight: 600;
color: var(--h-text);
}
.card-header .subtitle {
font-size: 0.6875rem;
color: var(--h-text-4);
margin-left: auto;
}
.card-body {
padding: 1.25rem;
}
/* ============================================
Buttons
============================================ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: var(--h-font);
font-size: 0.8125rem;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: var(--h-radius);
border: none;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
outline: none;
}
.btn:focus-visible {
box-shadow: 0 0 0 2px var(--h-bg), 0 0 0 4px var(--h-accent);
}
.btn-primary {
background: var(--h-text);
color: var(--h-bg);
}
.btn-primary:hover { opacity: 0.9; }
.btn-primary:active { opacity: 0.8; transform: scale(0.98); }
.btn-ghost {
background: transparent;
color: var(--h-text-3);
border: 1px solid var(--h-border);
}
.btn-ghost:hover {
background: var(--h-bg-hover);
color: var(--h-text-2);
border-color: var(--h-border-mid);
}
.btn-accent {
background: var(--h-accent);
color: white;
}
.btn-accent:hover { filter: brightness(1.1); }
.btn-icon {
width: 2rem;
height: 2rem;
padding: 0;
border-radius: var(--h-radius);
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--h-text-3);
border: 1px solid var(--h-border);
cursor: pointer;
transition: all 0.15s ease;
font-size: 0.875rem;
}
.btn-icon:hover {
background: var(--h-bg-hover);
color: var(--h-text-2);
}
.btn-sm {
font-size: 0.75rem;
padding: 0.35rem 0.75rem;
height: 1.75rem;
}
/* ============================================
Input / Textarea
============================================ */
.input-group {
position: relative;
}
.input-group textarea {
width: 100%;
padding: 0.75rem 1rem;
background: var(--h-bg-surface);
border: 1px solid var(--h-border-mid);
border-radius: var(--h-radius);
color: var(--h-text);
font-family: var(--h-font);
font-size: 0.875rem;
line-height: 1.5;
resize: vertical;
min-height: 88px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.input-group textarea:focus {
outline: none;
border-color: var(--h-accent);
box-shadow: 0 0 0 3px var(--h-accent-glow);
}
.input-group textarea::placeholder {
color: var(--h-text-4);
}
.input-hint {
font-size: 0.6875rem;
color: var(--h-text-4);
margin-top: 0.375rem;
}
.button-row {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
/* ============================================
Example Chips
============================================ */
.section-label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--h-text-4);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.5rem;
}
.chip-grid {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
background: var(--h-bg-surface);
border: 1px solid var(--h-border);
border-radius: var(--h-radius-full);
font-size: 0.6875rem;
color: var(--h-text-3);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.chip:hover {
border-color: var(--h-accent);
color: var(--h-accent);
background: var(--h-accent-soft);
}
.chip .lang-tag {
font-size: 0.5625rem;
font-weight: 600;
background: var(--h-bg-active);
color: var(--h-text-4);
padding: 0.1rem 0.3rem;
border-radius: 3px;
letter-spacing: 0.03em;
}
.chip:hover .lang-tag {
background: var(--h-accent-soft);
color: var(--h-accent);
}
/* ============================================
Results
============================================ */
.results-section {
margin-top: 1.25rem;
}
.result-card {
background: var(--h-bg-surface);
border: 1px solid var(--h-border);
border-radius: var(--h-radius);
padding: 0.875rem 1rem;
margin-bottom: 0.5rem;
transition: all 0.2s ease;
animation: slideUp 0.25s ease forwards;
opacity: 0;
}
.result-card:hover {
border-color: var(--h-border-mid);
background: var(--h-bg-hover);
}
.result-card.top-result {
border-color: var(--h-accent);
box-shadow: 0 0 20px -8px var(--h-accent-glow);
}
.result-header {
display: flex;
align-items: center;
gap: 0.625rem;
}
.hs-code-tag {
font-family: var(--h-mono);
font-size: 0.9375rem;
font-weight: 600;
color: var(--h-text);
}
.confidence-bar-track {
flex: 1;
height: 4px;
background: var(--h-bg-active);
border-radius: 2px;
overflow: hidden;
}
.confidence-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.confidence-value {
font-family: var(--h-mono);
font-size: 0.75rem;
font-weight: 500;
min-width: 40px;
text-align: right;
}
.result-desc {
font-size: 0.8125rem;
color: var(--h-text-3);
margin-top: 0.35rem;
line-height: 1.4;
}
.result-meta {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
font-weight: 500;
background: var(--h-bg-active);
color: var(--h-text-4);
padding: 0.15rem 0.4rem;
border-radius: 4px;
margin-top: 0.35rem;
}
.inference-badge {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-family: var(--h-mono);
color: var(--h-text-4);
margin-top: 0.75rem;
justify-content: flex-end;
}
/* Similar examples */
.similar-section {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--h-border);
}
.similar-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.625rem;
background: var(--h-bg);
border-radius: var(--h-radius);
margin-bottom: 0.375rem;
font-size: 0.75rem;
}
.similar-item .sim-text {
color: var(--h-text-3);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.similar-item .sim-code {
font-family: var(--h-mono);
font-size: 0.6875rem;
color: var(--h-accent);
flex-shrink: 0;
}
.similar-item .sim-score {
font-family: var(--h-mono);
font-size: 0.625rem;
color: var(--h-text-4);
flex-shrink: 0;
}
/* ============================================
Visualization Panel
============================================ */
.viz-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--h-border);
background: var(--h-bg);
flex-wrap: wrap;
}
.viz-toolbar .toolbar-group {
display: flex;
align-items: center;
gap: 0.375rem;
}
.viz-toolbar .divider {
width: 1px;
height: 1.25rem;
background: var(--h-border-mid);
margin: 0 0.25rem;
}
.filter-select {
font-family: var(--h-font);
font-size: 0.6875rem;
padding: 0.3rem 0.5rem;
background: var(--h-bg-surface);
color: var(--h-text-3);
border: 1px solid var(--h-border);
border-radius: var(--h-radius);
cursor: pointer;
outline: none;
}
.filter-select:focus {
border-color: var(--h-accent);
}
.search-mini {
font-family: var(--h-font);
font-size: 0.6875rem;
padding: 0.3rem 0.5rem;
background: var(--h-bg-surface);
color: var(--h-text);
border: 1px solid var(--h-border);
border-radius: var(--h-radius);
width: 160px;
outline: none;
transition: border-color 0.15s;
}
.search-mini:focus {
border-color: var(--h-accent);
}
.search-mini::placeholder {
color: var(--h-text-4);
}
#umap-plot {
width: 100%;
flex: 1;
min-height: 400px;
}
.viz-container {
display: flex;
flex-direction: column;
height: 100%;
}
/* ============================================
Loading
============================================ */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 0.75rem;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--h-border-mid);
border-top-color: var(--h-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.spinner-sm {
width: 14px;
height: 14px;
border-width: 2px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: 0.8125rem;
color: var(--h-text-4);
}
/* ============================================
Empty state
============================================ */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
text-align: center;
}
.empty-state .empty-icon {
font-size: 2rem;
margin-bottom: 0.75rem;
opacity: 0.4;
}
.empty-state p {
font-size: 0.8125rem;
color: var(--h-text-4);
max-width: 280px;
}
/* ============================================
Point detail overlay
============================================ */
.point-detail {
position: absolute;
bottom: 1rem;
left: 1rem;
right: 1rem;
background: var(--h-overlay-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--h-border-mid);
border-radius: var(--h-radius-lg);
padding: 1rem;
z-index: 10;
animation: slideUp 0.2s ease;
display: none;
}
.point-detail.visible { display: block; }
.point-detail .pd-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.point-detail .pd-code {
font-family: var(--h-mono);
font-size: 1rem;
font-weight: 600;
}
.point-detail .pd-close {
cursor: pointer;
color: var(--h-text-4);
font-size: 0.875rem;
}
.point-detail .pd-close:hover { color: var(--h-text-2); }
.point-detail .pd-desc {
font-size: 0.8125rem;
color: var(--h-text-3);
}
.point-detail .pd-similar {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--h-border);
}
.point-detail .pd-similar-label {
font-size: 0.625rem;
font-weight: 600;
color: var(--h-text-4);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.375rem;
}
.latent-distance-panel {
position: absolute;
top: 1rem;
right: 1rem;
width: min(360px, calc(100% - 2rem));
background: var(--h-overlay-bg);
backdrop-filter: blur(12px);
border: 1px solid var(--h-border-mid);
border-radius: var(--h-radius-lg);
padding: 0.8rem;
z-index: 9;
display: none;
}
.latent-distance-panel.visible { display: block; }
.latent-distance-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.45rem;
}
.latent-distance-title {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--h-text-4);
}
.latent-distance-row {
display: grid;
grid-template-columns: 20px 1fr auto auto;
gap: 0.45rem;
align-items: center;
font-size: 0.6875rem;
padding: 0.25rem 0;
border-top: 1px solid rgba(255,255,255,0.03);
}
.latent-distance-row:first-child {
border-top: none;
padding-top: 0;
}
.latent-distance-rank {
font-family: var(--h-mono);
color: var(--h-text-4);
}
.latent-distance-code {
font-family: var(--h-mono);
color: var(--h-text);
white-space: nowrap;
}
.latent-distance-sim {
font-family: var(--h-mono);
color: var(--h-emerald);
white-space: nowrap;
}
.latent-distance-dist {
font-family: var(--h-mono);
color: var(--h-text-3);
white-space: nowrap;
}
.latent-distance-note {
font-size: 0.625rem;
color: var(--h-text-4);
margin-top: 0.45rem;
}
/* ============================================
Animations
============================================ */
@keyframes slideUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* ============================================
Fullscreen overlay
============================================ */
.fullscreen-mode .app-layout {
grid-template-columns: 1fr;
}
.fullscreen-mode .sidebar-panel { display: none; }
.fullscreen-mode #umap-plot { min-height: calc(100vh - 120px); }
/* ============================================
Scrollbar
============================================ */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--h-border-mid);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--h-border-strong);
}
/* ============================================
Responsive
============================================ */
@media (max-width: 1024px) {
.app-layout {
grid-template-columns: 1fr;
}
.sidebar-panel {
border-right: none;
border-bottom: 1px solid var(--h-border);
max-height: none;
}
.main-panel {
min-height: 500px;
}
}
@media (max-width: 640px) {
.header {
padding: 0.75rem 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.header-stats { display: none; }
.card-body { padding: 1rem; }
.chip-grid { gap: 0.25rem; }
.chip { font-size: 0.625rem; padding: 0.25rem 0.5rem; }
.viz-toolbar { flex-wrap: wrap; gap: 0.375rem; }
.search-mini { width: 120px; }
}
/* Utility */
.hidden { display: none !important; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
/* ============================================
Welcome Modal / First-use Explainer
============================================ */
.welcome-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--h-backdrop);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.25s ease;
}
.welcome-modal {
background: var(--h-modal-bg);
border: 1px solid var(--h-border-mid);
border-radius: var(--h-radius-xl);
width: min(520px, calc(100vw - 2rem));
max-height: calc(100vh - 4rem);
overflow: hidden;
box-shadow: var(--h-modal-shadow);
animation: slideUp 0.3s ease;
}
.welcome-header {
padding: 1.75rem 1.75rem 0;
text-align: center;
}
.welcome-header .welcome-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
display: block;
}
.welcome-header h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--h-text);
margin-bottom: 0.25rem;
}
.welcome-header p {
font-size: 0.8125rem;
color: var(--h-text-3);
line-height: 1.5;
}
.welcome-steps {
padding: 1.25rem 1.75rem;
overflow-y: auto;
}
.welcome-step {
display: none;
}
.welcome-step.active {
display: block;
animation: fadeIn 0.2s ease;
}
.step-card {
background: var(--h-bg-surface);
border: 1px solid var(--h-border);
border-radius: var(--h-radius-lg);
padding: 1rem 1.25rem;
margin-bottom: 0.625rem;
}
.step-card .step-number {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--h-accent);
margin-bottom: 0.375rem;
}
.step-card h3 {
font-size: 0.9375rem;
font-weight: 600;
color: var(--h-text);
margin-bottom: 0.375rem;
}
.step-card p {
font-size: 0.8125rem;
color: var(--h-text-3);
line-height: 1.55;
}
.step-card .step-keys {
display: inline-flex;
gap: 0.25rem;
margin-top: 0.5rem;
}
.step-card .step-keys kbd {
font-family: var(--h-mono);
font-size: 0.625rem;
padding: 0.15rem 0.4rem;
background: var(--h-bg-active);
border: 1px solid var(--h-border-mid);
border-radius: 4px;
color: var(--h-text-3);
}
.welcome-footer {
padding: 0 1.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.step-dots {
display: flex;
gap: 0.375rem;
}
.step-dots .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--h-border-mid);
transition: all 0.2s ease;
}
.step-dots .dot.active {
background: var(--h-accent);
box-shadow: 0 0 6px var(--h-accent-glow);
}
.welcome-footer .btn-row {
display: flex;
gap: 0.5rem;
}
@media (max-width: 640px) {
.welcome-modal { width: calc(100vw - 1.5rem); }
.welcome-header { padding: 1.25rem 1.25rem 0; }
.welcome-steps { padding: 1rem 1.25rem; }
.welcome-footer { padding: 0 1.25rem 1.25rem; }
}
/* ============================================
Document Upload Zone
============================================ */
.upload-zone {
position: relative;
border: 1.5px dashed var(--h-border-mid);
border-radius: var(--h-radius-lg);
padding: 1rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: transparent;
margin-top: 0.75rem;
}
.upload-zone:hover {
border-color: var(--h-accent);
background: var(--h-accent-soft);
}
.upload-zone.dragover {
border-color: var(--h-accent);
background: var(--h-accent-soft);
box-shadow: 0 0 20px -6px var(--h-accent-glow);
}
.upload-zone .upload-icon {
font-size: 1.25rem;
opacity: 0.4;
margin-bottom: 0.25rem;
}
.upload-zone p {
font-size: 0.75rem;
color: var(--h-text-4);
line-height: 1.4;
}
.upload-zone p strong {
color: var(--h-text-3);
}
.upload-zone input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.upload-result {
margin-top: 0.75rem;
padding: 0.875rem;
background: var(--h-bg-surface);
border: 1px solid var(--h-border);
border-radius: var(--h-radius);
animation: slideUp 0.25s ease;
}
.upload-result .upload-result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.upload-result .upload-result-header span {
font-size: 0.75rem;
font-weight: 600;
color: var(--h-emerald);
}
.upload-result .upload-result-header button {
background: none;
border: none;
color: var(--h-text-4);
cursor: pointer;
font-size: 0.75rem;
}
.upload-result .extracted-text {
font-size: 0.75rem;
color: var(--h-text-3);
line-height: 1.4;
max-height: 80px;
overflow-y: auto;
margin-bottom: 0.5rem;
white-space: pre-wrap;
word-break: break-word;
}
.upload-result .extracted-fields {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.upload-result .field-tag {
font-size: 0.625rem;
padding: 0.15rem 0.4rem;
background: var(--h-accent-soft);
color: var(--h-accent);
border-radius: 4px;
font-family: var(--h-mono);
}
/* ============================================
Feedback Buttons
============================================ */
.feedback-row {
display: flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.5rem;
}
.feedback-row .fb-label {
font-size: 0.625rem;
color: var(--h-text-4);
}
.feedback-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: var(--h-radius);
border: 1px solid var(--h-border);
background: transparent;
cursor: pointer;
font-size: 0.6875rem;
transition: all 0.15s ease;
color: var(--h-text-4);
}
.feedback-btn:hover {
border-color: var(--h-border-mid);
background: var(--h-bg-hover);
color: var(--h-text-2);
}
.feedback-btn.selected-up {
border-color: var(--h-emerald);
background: var(--h-emerald-soft);
color: var(--h-emerald);
}
.feedback-btn.selected-down {
border-color: var(--h-red);
background: var(--h-red-soft);
color: var(--h-red);
}
.feedback-toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
background: var(--h-overlay-bg);
border: 1px solid var(--h-border-mid);
border-radius: var(--h-radius-full);
padding: 0.5rem 1rem;
font-size: 0.75rem;
color: var(--h-text-2);
z-index: 200;
animation: slideUp 0.2s ease;
pointer-events: none;
}
/* ============================================
Mode Tabs (Text / Upload)
============================================ */
.input-tabs {
display: flex;
gap: 0;
margin-bottom: 0.75rem;
border: 1px solid var(--h-border);
border-radius: var(--h-radius);
overflow: hidden;
}
.input-tab {
flex: 1;
padding: 0.4rem 0.75rem;
font-family: var(--h-font);
font-size: 0.6875rem;
font-weight: 500;
color: var(--h-text-4);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
}
.input-tab:not(:last-child) {
border-right: 1px solid var(--h-border);
}
.input-tab.active {
background: var(--h-bg-surface);
color: var(--h-text);
}
.input-tab:hover:not(.active) {
color: var(--h-text-3);
background: var(--h-bg-raised);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
</head>
<body>
<!-- ============================================
Welcome / First-use Explainer
============================================ -->
<div class="welcome-overlay hidden" id="welcome-overlay" role="dialog" aria-modal="true" aria-label="Welcome guide">
<div class="welcome-modal">
<div class="welcome-header">
<span class="welcome-icon">&#x2693;</span>
<h2>Welcome to HS Code Classifier</h2>
<p>Classify product descriptions into standardized Harmonized System (HS) codes used in international trade and customs.</p>
</div>
<div class="welcome-steps">
<!-- Step 1: How it works -->
<div class="welcome-step active" data-step="0">
<div class="step-card">
<div class="step-number">Step 1 of 4 &mdash; How it works</div>
<h3>The ML pipeline</h3>
<p>Your product description is converted into a 384-dimensional vector using a <strong style="color:var(--h-text);">multilingual embedding model</strong> (multilingual-e5-small). A <strong style="color:var(--h-text);">K-Nearest Neighbors</strong> classifier then finds the closest training examples in that embedding space and votes on the most likely HS code. The confidence score reflects how strongly the neighbors agree.</p>
</div>
<div class="step-card" style="margin-top:0.5rem;">
<div class="step-number">Visualization</div>
<p>The scatter plot uses <strong style="color:var(--h-text);">UMAP</strong> (Uniform Manifold Approximation and Projection) to project the high-dimensional embeddings down to 2D, so you can see how product categories cluster together. Points that are close in the plot are semantically similar.</p>
</div>
</div>
<!-- Step 2: Input -->
<div class="welcome-step" data-step="1">
<div class="step-card">
<div class="step-number">Step 2 of 4 &mdash; Classify</div>
<h3>Describe a product</h3>
<p>Type or paste any product description in the text box on the left. The model supports multiple languages including English, Thai, Vietnamese, and Chinese. You'll get up to 5 predictions ranked by confidence, plus the nearest training examples used to make the decision.</p>
<div class="step-keys"><kbd>&#8984;/Ctrl</kbd><kbd>Enter</kbd> <span style="font-size:0.6875rem;color:var(--h-text-4);margin-left:0.25rem;">to classify</span></div>
</div>
</div>
<!-- Step 3: Examples -->
<div class="welcome-step" data-step="2">
<div class="step-card">
<div class="step-number">Step 3 of 4 &mdash; Examples</div>
<h3>Try the example chips</h3>
<p>Click any of the example chips below the input to instantly classify pre-built product descriptions across four languages. This is the quickest way to see the model in action and understand what the results look like.</p>
</div>
</div>
<!-- Step 4: Visualization -->
<div class="welcome-step" data-step="3">
<div class="step-card">
<div class="step-number">Step 4 of 4 &mdash; Explore</div>
<h3>Explore the latent space</h3>
<p>The UMAP visualization on the right shows all {{ metadata.get('n_examples_full_dataset', metadata.get('n_examples', '')) }} training examples projected into 2D. When you classify a product, your query appears as a diamond and the nearest neighbors light up. Filter by chapter or language, and click any point for details.</p>
<div class="step-keys"><kbd>&#8984;/Ctrl</kbd><kbd>K</kbd> <span style="font-size:0.6875rem;color:var(--h-text-4);margin-left:0.25rem;">to search points</span></div>
</div>
</div>
</div>
<div class="welcome-footer">
<div class="step-dots" aria-label="Step indicator">
<span class="dot active" data-dot="0"></span>
<span class="dot" data-dot="1"></span>
<span class="dot" data-dot="2"></span>
<span class="dot" data-dot="3"></span>
</div>
<div class="btn-row">
<button class="btn btn-ghost btn-sm" id="welcome-back-btn" onclick="welcomeStep(-1)" style="display:none;">Back</button>
<button class="btn btn-accent btn-sm" id="welcome-next-btn" onclick="welcomeStep(1)">Next</button>
</div>
</div>
</div>
</div>
<!-- ============================================
Header
============================================ -->
<header class="header" role="banner">
<div class="header-logo">
<h1>HS Code Classifier</h1>
<span class="header-badge">POC v1.0</span>
</div>
<div class="header-stats" aria-label="Model statistics">
<span class="stat-pill">{{ metadata.get('n_examples_full_dataset', metadata.get('n_examples', '?')) }} examples</span>
{% if metadata.get('n_examples_full_dataset') %}
<span class="stat-pill">{{ metadata.get('n_examples', '?') }} train</span>
{% endif %}
<span class="stat-pill">{{ metadata.get('n_codes', '?') }} codes</span>
<span class="stat-pill">{{ metadata.get('languages', [])|length }} langs</span>
<span class="stat-pill" style="color: var(--h-emerald);">{{ (metadata.get('accuracy', 0) * 100)|round(1) }}% acc</span>
</div>
<div class="header-controls">
<button class="btn-icon" onclick="showWelcome()" title="How it works" aria-label="Show help guide" id="help-btn">
?
</button>
<button class="btn-icon" onclick="toggleViewMode()" title="Toggle 2D/3D" aria-label="Toggle 2D/3D view" id="view-toggle-btn">
<span id="view-toggle-icon"></span>
</button>
<button class="btn-icon" onclick="toggleFullscreen()" title="Fullscreen visualization" aria-label="Toggle fullscreen" id="fullscreen-btn">
</button>
<button class="btn-icon" onclick="cycleTheme()" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle-btn">
</button>
</div>
</header>
<!-- ============================================
Main Layout
============================================ -->
<div class="app-layout" id="app-layout">
<!-- ——— Left: Input + Results ——— -->
<div class="sidebar-panel" role="complementary" aria-label="Classification input">
<div class="card" style="border: none; border-radius: 0; background: transparent;">
<div class="card-header">
<h2>Classification</h2>
</div>
<div class="card-body">
<!-- Input Mode Tabs -->
<div class="input-tabs" role="tablist">
<button class="input-tab active" role="tab" onclick="switchInputTab('text')" id="tab-text">Text Input</button>
<button class="input-tab" role="tab" onclick="switchInputTab('upload')" id="tab-upload">Document Upload</button>
</div>
<!-- Text Input Tab -->
<div class="tab-content active" id="tab-content-text">
<div class="input-group">
<label for="query-input" class="sr-only">Product description</label>
<textarea
id="query-input"
placeholder="Describe a product in any language…"
rows="3"
aria-label="Product description input"
></textarea>
<div class="input-hint">⌘+Enter to classify · Supports EN, TH, VI, ZH and more</div>
</div>
<div class="button-row">
<button class="btn btn-primary" onclick="classify()" id="classify-btn" aria-label="Classify product">
Classify
</button>
<button class="btn btn-ghost" onclick="clearAll()" aria-label="Clear input and results">
Clear
</button>
</div>
</div>
<!-- Document Upload Tab -->
<div class="tab-content" id="tab-content-upload">
<div class="upload-zone" id="upload-zone">
<div class="upload-icon">&#128196;</div>
<p><strong>Drop a document here</strong> or click to browse</p>
<p>Supports PDF, PNG, JPG, TIFF</p>
<input type="file" id="file-input" accept=".pdf,.png,.jpg,.jpeg,.tiff,.tif,.bmp,.webp" aria-label="Upload document">
</div>
<div id="upload-result-area"></div>
</div>
<!-- Examples -->
<div style="margin-top: 1.25rem;">
<div class="section-label">Examples</div>
<div class="chip-grid" role="list" aria-label="Example product descriptions">
<span class="chip" role="listitem" onclick="setExample('Fresh boneless beef cuts for restaurant supply')">Boneless beef <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('Laptop computer 14 inch 16GB RAM')">Laptop <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('ข้าวหอมมะลิไทย ขัดสี 5% หัก')">ข้าวหอมมะลิ <span class="lang-tag">TH</span></span>
<span class="chip" role="listitem" onclick="setExample('冷冻虾仁 去头去壳')">冷冻虾仁 <span class="lang-tag">ZH</span></span>
<span class="chip" role="listitem" onclick="setExample('Tôm đông lạnh xuất khẩu')">Tôm đông lạnh <span class="lang-tag">VI</span></span>
<span class="chip" role="listitem" onclick="setExample('Cotton T-shirt men printed knitted')">Cotton T-shirt <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('Lithium-ion battery pack for electric vehicles')">Li-ion battery <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('White refined cane sugar ICUMSA 45')">White sugar <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('New radial tyres for passenger cars 205/55R16')">Car tyres <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('智能手机 安卓系统 6.7英寸')">智能手机 <span class="lang-tag">ZH</span></span>
<span class="chip" role="listitem" onclick="setExample('Samsung Galaxy S24 5G smartphone')">Smartphone <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('Electric passenger car battery powered Tesla')">Electric car <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('สมาร์ทโฟน แอนดรอยด์ จอ 6.7 นิ้ว')">สมาร์ทโฟน <span class="lang-tag">TH</span></span>
<span class="chip" role="listitem" onclick="setExample('Cà phê nhân xanh chưa rang Robusta')">Cà phê <span class="lang-tag">VI</span></span>
<span class="chip" role="listitem" onclick="setExample('Hot rolled steel coil width 600mm')">Steel coil <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('Fresh gala apples premium grade 18kg cartons')">Fresh apples <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('Combined refrigerator freezer household compression type')">Refrigerator <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('Printed circuit boards for telecom equipment')">PCB <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('Toilet soap bars perfumed retail packaging')">Toilet soap <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('Polyethylene terephthalate PET resin bottle grade')">PET resin <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('Upholstered wooden dining chairs for home furniture')">Wood chairs <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('Natural gas liquefied LNG cargo shipment')">LNG <span class="lang-tag">EN</span></span>
<span class="chip" role="listitem" onclick="setExample('ข้าวขาวเมล็ดยาว บรรจุถุง 25 กก. สำหรับส่งออก')">ข้าวขาว <span class="lang-tag">TH</span></span>
<span class="chip" role="listitem" onclick="setExample('โทรทัศน์สี LED ขนาด 55 นิ้ว สำหรับใช้ในบ้าน')">โทรทัศน์สี <span class="lang-tag">TH</span></span>
<span class="chip" role="listitem" onclick="setExample('Điện thoại thông minh 5G màn hình 6.5 inch')">Điện thoại 5G <span class="lang-tag">VI</span></span>
<span class="chip" role="listitem" onclick="setExample('Lốp xe ô tô mới radial 205/55R16')">Lốp xe ô tô <span class="lang-tag">VI</span></span>
<span class="chip" role="listitem" onclick="setExample('锂离子蓄电池组 用于电动汽车')">锂电池组 <span class="lang-tag">ZH</span></span>
<span class="chip" role="listitem" onclick="setExample('冷藏苹果 优级 18公斤纸箱包装')">冷藏苹果 <span class="lang-tag">ZH</span></span>
</div>
</div>
<!-- Results (hidden until classify) -->
<div class="results-section hidden" id="results-section" aria-live="polite">
<div class="section-label" style="margin-top: 0.25rem;">Results</div>
<div id="results-container"></div>
<div class="inference-badge" id="inference-time"></div>
<div id="coverage-note" style="font-size:0.625rem;color:var(--h-text-4);margin-top:0.5rem;padding:0.5rem 0.625rem;background:var(--h-bg-surface);border-radius:var(--h-radius);border:1px solid var(--h-border);display:none;">
<strong style="color:var(--h-text-3);">Model Coverage:</strong>
{{ metadata.get('n_codes', '?') }} HS codes across {{ metadata.get('n_chapters', '?') }} chapters &middot; {{ (metadata.get('accuracy', 0) * 100)|round(1) }}% test accuracy &middot; {{ '{:,}'.format(metadata.get('n_examples', 0)) }} training examples.
</div>
<div class="similar-section hidden" id="similar-section">
<div class="section-label">Nearest Training Examples</div>
<div id="similar-container"></div>
</div>
</div>
</div>
</div>
</div>
<!-- ——— Right: Visualization ——— -->
<div class="main-panel" role="main" aria-label="Latent space visualization">
<div class="viz-container">
<!-- Toolbar -->
<div class="viz-toolbar">
<div class="toolbar-group">
<span style="font-size:0.6875rem;color:var(--h-text-4);">Filter:</span>
<select class="filter-select" id="chapter-filter" onchange="applyFilters()" aria-label="Filter by HS chapter">
<option value="all">All chapters</option>
</select>
<select class="filter-select" id="language-filter" onchange="applyFilters()" aria-label="Filter by language">
<option value="all">All languages</option>
</select>
</div>
<div class="divider"></div>
<div class="toolbar-group">
<input type="text" class="search-mini" id="point-search" placeholder="Search points…" oninput="searchPoints()" aria-label="Search visualization points">
</div>
<div class="toolbar-group">
<span id="point-count" class="stat-pill" style="font-size:0.5625rem;display:none;"></span>
</div>
<div style="margin-left:auto;" class="toolbar-group">
<button class="btn-icon" onclick="resetFilters()" title="Clear filters" aria-label="Clear all filters" id="clear-filters-btn" style="display:none;">&#10005;</button>
<button class="btn-icon" onclick="resetView()" title="Reset zoom" aria-label="Reset zoom"></button>
<button class="btn-icon" onclick="exportPlot('png')" title="Export PNG" aria-label="Export as PNG">📷</button>
<button class="btn-icon" onclick="exportPlot('svg')" title="Export SVG" aria-label="Export as SVG"></button>
</div>
</div>
<!-- Loading state -->
<div id="umap-loading" class="loading-state">
<div class="spinner"></div>
<span class="loading-text">Loading latent space…</span>
</div>
<!-- Empty state -->
<div id="umap-empty" class="empty-state hidden">
<div class="empty-icon">🌐</div>
<p>No visualization data available. Ensure UMAP projection has been computed.</p>
</div>
<!-- Plot -->
<div id="umap-plot" style="position:relative;">
<div class="latent-distance-panel" id="latent-distance-panel">
<div class="latent-distance-header">
<span class="latent-distance-title">Query Latent Distances</span>
</div>
<div id="latent-distance-body"></div>
</div>
<!-- Point detail overlay -->
<div class="point-detail" id="point-detail">
<div class="pd-header">
<span class="pd-code" id="pd-code"></span>
<span class="pd-close" onclick="closePointDetail()"></span>
</div>
<div class="pd-desc" id="pd-desc"></div>
<div class="pd-similar" id="pd-similar"></div>
</div>
</div>
</div>
</div>
</div>
<!-- ============================================
Scripts
============================================ -->
<script>
/* ——————————————————————————————————————————
Harbour Chart Colors — curated palette
mapped to HS chapters
—————————————————————————————————————————— */
const CHAPTER_COLORS = {
'Electronics': '#3b82f6',
'Vehicles': '#ef4444',
'Machinery': '#0ea5e9',
'Meat': '#dc2626',
'Fish': '#0284c7',
'Dairy': '#f59e0b',
'Vegetables': '#22c55e',
'Fruits': '#10b981',
'Coffee/Tea': '#92400e',
'Cereals': '#d97706',
'Sugar': '#fbbf24',
'Cocoa': '#78350f',
'Food Preparations': '#ea580c',
'Beverages': '#a855f7',
'Tobacco': '#6b7280',
'Mineral Fuels': '#334155',
'Minerals': '#94a3b8',
'Chemicals': '#14b8a6',
'Pharmaceuticals': '#0d9488',
'Cosmetics': '#ec4899',
'Plastics': '#06b6d4',
'Rubber': '#374151',
'Wood': '#a16207',
'Paper': '#d6d3d1',
'Textiles': '#f97316',
'Garments': '#e11d48',
'Footwear': '#78716c',
'Steel': '#64748b',
'Metals': '#94a3b8',
'Furniture': '#a8a29e',
'Toys': '#eab308',
'Instruments': '#06b6d4',
'Medical': '#22d3ee',
'Oils': '#ca8a04',
'Plants': '#16a34a',
'Oil Seeds': '#65a30d',
'Spices': '#c2410c',
'Aircraft': '#2563eb',
'Ships': '#1d4ed8',
};
const CATEGORY_PALETTE = [
'#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899',
'#06b6d4', '#84cc16', '#f97316', '#14b8a6', '#eab308',
'#ef4444', '#22c55e', '#6366f1', '#a855f7', '#0ea5e9',
];
/* ——————————————————————————————————————————
Theme Switching (system / dark / light)
—————————————————————————————————————————— */
const THEME_KEY = 'hsclassify_theme';
const THEME_CYCLE = ['system', 'dark', 'light'];
const THEME_ICONS = { system: '\u25D1', dark: '\u263E', light: '\u2600' };
const THEME_TITLES = { system: 'Theme: System', dark: 'Theme: Dark', light: 'Theme: Light' };
function getSystemPreference() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function resolveTheme(theme) {
return theme === 'system' ? getSystemPreference() : theme;
}
function applyTheme(theme) {
const resolved = resolveTheme(theme);
const root = document.documentElement;
root.classList.remove('dark', 'light');
root.classList.add(resolved);
const btn = document.getElementById('theme-toggle-btn');
if (btn) {
btn.textContent = THEME_ICONS[theme] || '\u25D1';
btn.title = THEME_TITLES[theme] || 'Toggle theme';
}
try { localStorage.setItem(THEME_KEY, theme); } catch(e) {}
// Guard against TDZ — plotInitialized is declared later in the script
try { if (plotInitialized) applyFilters(); } catch(e) {}
}
function cycleTheme() {
let current;
try { current = localStorage.getItem(THEME_KEY) || 'system'; } catch(e) { current = 'system'; }
const idx = THEME_CYCLE.indexOf(current);
const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length];
applyTheme(next);
}
// Listen for OS theme changes when in system mode
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
let current;
try { current = localStorage.getItem(THEME_KEY) || 'system'; } catch(e) { current = 'system'; }
if (current === 'system') applyTheme('system');
});
// Initialize theme immediately (before Plotly renders)
(function initTheme() {
let saved;
try { saved = localStorage.getItem(THEME_KEY); } catch(e) {}
applyTheme(saved || 'system');
})();
/* ——————————————————————————————————————————
Short cluster labels — official HS chapter
names are absurdly long; map to readable labels
—————————————————————————————————————————— */
const CHAPTER_SHORT_NAMES = {
'Animals; live': 'Live Animals',
'Meat and edible meat offal': 'Meat',
'Fish and crustaceans, molluscs and other aquatic invertebrates': 'Fish & Seafood',
"Dairy produce; birds' eggs; natural honey; edible products of animal origin, not elsewhere specified or included": 'Dairy & Eggs',
'Animal originated products; not elsewhere specified or included': 'Animal Products',
'Trees and other plants, live; bulbs, roots and the like; cut flowers and ornamental foliage': 'Live Plants',
'Vegetables and certain roots and tubers; edible': 'Vegetables',
'Fruit and nuts, edible; peel of citrus fruit or melons': 'Fruit & Nuts',
'Coffee, tea, mate and spices': 'Coffee & Tea',
'Cereals': 'Cereals',
'Products of the milling industry; malt, starches, inulin, wheat gluten': 'Milling & Starch',
'Oil seeds and oleaginous fruits; miscellaneous grains, seeds and fruit, industrial or medicinal plants; straw and fodder': 'Oil Seeds',
'Lac; gums, resins and other vegetable saps and extracts': 'Gums & Resins',
'Vegetable plaiting materials; vegetable products not elsewhere specified or included': 'Plaiting Materials',
'Animal, vegetable or microbial fats and oils and their cleavage products; prepared edible fats; animal or vegetable waxes': 'Fats & Oils',
'Meat, fish, crustaceans, molluscs or other aquatic invertebrates, or insects; preparations thereof': 'Prepared Meats',
'Sugars and sugar confectionery': 'Sugar',
'Cocoa and cocoa preparations': 'Cocoa',
"Preparations of cereals, flour, starch or milk; pastrycooks' products": 'Cereal Preps',
'Preparations of vegetables, fruit, nuts or other parts of plants': 'Preserved Foods',
'Miscellaneous edible preparations': 'Edible Preps',
'Beverages, spirits and vinegar': 'Beverages',
'Food industries, residues and wastes thereof; prepared animal fodder': 'Animal Feed',
'Tobacco and manufactured tobacco substitutes; products, whether or not containing nicotine, intended for inhalation without combustion; other nicotine containing products intended for the intake of nicotine into the human body': 'Tobacco',
'Salt; sulphur; earths, stone; plastering materials, lime and cement': 'Salt & Minerals',
'Ores, slag and ash': 'Ores & Slag',
'Mineral fuels, mineral oils and products of their distillation; bituminous substances; mineral waxes': 'Mineral Fuels',
'Inorganic chemicals; organic and inorganic compounds of precious metals; of rare earth metals, of radio-active elements and of isotopes': 'Inorganic Chem.',
'Organic chemicals': 'Organic Chem.',
'Pharmaceutical products': 'Pharma',
'Fertilizers': 'Fertilizers',
'Tanning or dyeing extracts; tannins and their derivatives; dyes, pigments and other colouring matter; paints, varnishes; putty, other mastics; inks': 'Dyes & Paints',
'Essential oils and resinoids; perfumery, cosmetic or toilet preparations': 'Cosmetics',
'Soap, organic surface-active agents; washing, lubricating, polishing or scouring preparations; artificial or prepared waxes, candles and similar articles, modelling pastes, dental waxes and dental preparations with a basis of plaster': 'Soap & Waxes',
'Albuminoidal substances; modified starches; glues; enzymes': 'Glues & Enzymes',
'Explosives; pyrotechnic products; matches; pyrophoric alloys; certain combustible preparations': 'Explosives',
'Photographic or cinematographic goods': 'Photo Goods',
'Chemical products n.e.c.': 'Chemicals n.e.c.',
'Plastics and articles thereof': 'Plastics',
'Rubber and articles thereof': 'Rubber',
'Raw hides and skins (other than furskins) and leather': 'Hides & Leather',
'Articles of leather; saddlery and harness; travel goods, handbags and similar containers; articles of animal gut (other than silk-worm gut)': 'Leather Goods',
'Furskins and artificial fur; manufactures thereof': 'Furskins',
'Wood and articles of wood; wood charcoal': 'Wood',
'Cork and articles of cork': 'Cork',
'Manufactures of straw, esparto or other plaiting materials; basketware and wickerwork': 'Basketware',
'Pulp of wood or other fibrous cellulosic material; recovered (waste and scrap) paper or paperboard': 'Wood Pulp',
'Paper and paperboard; articles of paper pulp, of paper or paperboard': 'Paper',
'Printed books, newspapers, pictures and other products of the printing industry; manuscripts, typescripts and plans': 'Books & Print',
'Silk': 'Silk',
'Wool, fine or coarse animal hair; horsehair yarn and woven fabric': 'Wool',
'Cotton': 'Cotton',
'Vegetable textile fibres; paper yarn and woven fabrics of paper yarn': 'Vegetal Fibres',
'Man-made filaments; strip and the like of man-made textile materials': 'Synthetic Fibres',
'Man-made staple fibres': 'Staple Fibres',
'Wadding, felt and nonwovens, special yarns; twine, cordage, ropes and cables and articles thereof': 'Wadding & Rope',
'Carpets and other textile floor coverings': 'Carpets',
'Fabrics; special woven fabrics, tufted textile fabrics, lace, tapestries, trimmings, embroidery': 'Woven Fabrics',
'Textile fabrics; impregnated, coated, covered or laminated; textile articles of a kind suitable for industrial use': 'Tech. Textiles',
'Fabrics; knitted or crocheted': 'Knit Fabrics',
'Apparel and clothing accessories; knitted or crocheted': 'Knitwear',
'Apparel and clothing accessories; not knitted or crocheted': 'Apparel',
'Textiles, made up articles; sets; worn clothing and worn textile articles; rags': 'Textile Articles',
'Footwear; gaiters and the like; parts of such articles': 'Footwear',
'Headgear and parts thereof': 'Headgear',
'Umbrellas, sun umbrellas, walking-sticks, seat sticks, whips, riding crops; and parts thereof': 'Umbrellas',
'Feathers and down, prepared; and articles made of feather or of down; artificial flowers; articles of human hair': 'Feathers & Down',
'Stone, plaster, cement, asbestos, mica or similar materials; articles thereof': 'Stone & Cement',
'Ceramic products': 'Ceramics',
'Glass and glassware': 'Glass',
'Natural, cultured pearls; precious, semi-precious stones; precious metals, metals clad with precious metal, and articles thereof; imitation jewellery; coin': 'Gems & Jewellery',
'Iron and steel': 'Iron & Steel',
'Iron or steel articles': 'Steel Articles',
'Copper and articles thereof': 'Copper',
'Nickel and articles thereof': 'Nickel',
'Aluminium and articles thereof': 'Aluminium',
'Lead and articles thereof': 'Lead',
'Zinc and articles thereof': 'Zinc',
'Tin; articles thereof': 'Tin',
'Metals; n.e.c., cermets and articles thereof': 'Other Metals',
'Tools, implements, cutlery, spoons and forks, of base metal; parts thereof, of base metal': 'Tools & Cutlery',
'Metal; miscellaneous products of base metal': 'Metal Products',
'Machinery and mechanical appliances, boilers, nuclear reactors; parts thereof': 'Machinery',
'Electrical machinery and equipment and parts thereof; sound recorders and reproducers; television image and sound recorders and reproducers, parts and accessories of such articles': 'Electronics',
'Railway, tramway locomotives, rolling-stock and parts thereof; railway or tramway track fixtures and fittings and parts thereof; mechanical (including electro-mechanical) traffic signalling equipment of all kinds': 'Railways',
'Vehicles; other than railway or tramway rolling stock, and parts and accessories thereof': 'Vehicles',
'Aircraft, spacecraft, and parts thereof': 'Aircraft',
'Ships, boats and floating structures': 'Ships',
'Optical, photographic, cinematographic, measuring, checking, medical or surgical instruments and apparatus; parts and accessories': 'Instruments',
'Clocks and watches and parts thereof': 'Clocks & Watches',
'Musical instruments; parts and accessories of such articles': 'Musical Instr.',
'Arms and ammunition; parts and accessories thereof': 'Arms & Ammo',
'Furniture; bedding, mattresses, mattress supports, cushions and similar stuffed furnishings; lamps and lighting fittings, n.e.c.; illuminated signs, illuminated name-plates and the like; prefabricated buildings': 'Furniture',
'Toys, games and sports requisites; parts and accessories thereof': 'Toys & Games',
'Miscellaneous manufactured articles': 'Misc. Goods',
"Works of art; collectors' pieces and antiques": 'Art & Antiques',
'Commodities not specified according to kind': 'Special Items',
};
function shortLabel(chapter) {
if (CHAPTER_SHORT_NAMES[chapter]) return CHAPTER_SHORT_NAMES[chapter];
const short = chapter.split(';')[0].split(',')[0].trim();
return short.length > 20 ? short.slice(0, 18) + '\u2026' : short;
}
/* ——————————————————————————————————————————
State
—————————————————————————————————————————— */
let plotInitialized = false;
let queryOverlayTraceCount = 0;
let allPoints = [];
let allChapters = [];
let allLanguages = [];
let is3D = false;
let isFullscreen = false;
let categoryColorMap = {};
/* ——————————————————————————————————————————
Visualization
—————————————————————————————————————————— */
let vizSampled = false;
let vizTotal = 0;
const VIZ_SAMPLE_LIMIT = 12000;
async function loadVisualization() {
try {
const response = await fetch('/visualization-data?max_points=' + VIZ_SAMPLE_LIMIT);
const data = await response.json();
vizSampled = !!data.sampled;
vizTotal = data.total || data.points?.length || 0;
if (!data.points || data.points.length === 0) {
document.getElementById('umap-loading').classList.add('hidden');
document.getElementById('umap-empty').classList.remove('hidden');
return;
}
allPoints = data.points.map(p => {
const category = (p.chapter_name && p.chapter_name.trim())
? p.chapter_name
: (p.chapter || 'Unknown');
return { ...p, category };
});
// Extract unique chapters and languages for filters
const chapSet = new Set();
const langSet = new Set();
allPoints.forEach(p => {
chapSet.add(p.category);
langSet.add(p.language);
});
allChapters = [...chapSet].sort();
allLanguages = [...langSet].sort();
categoryColorMap = buildCategoryColorMap(allChapters);
populateFilters();
renderPlot(allPoints);
} catch (error) {
document.getElementById('umap-loading').innerHTML =
`<span class="loading-text" style="color:var(--h-red);">Error: ${error.message}</span>`;
}
}
function populateFilters() {
const chapterSel = document.getElementById('chapter-filter');
allChapters.forEach(ch => {
const opt = document.createElement('option');
opt.value = ch;
opt.textContent = ch;
chapterSel.appendChild(opt);
});
const langSel = document.getElementById('language-filter');
allLanguages.forEach(lang => {
const opt = document.createElement('option');
opt.value = lang;
opt.textContent = lang.toUpperCase();
langSel.appendChild(opt);
});
}
function applyFilters() {
const chapter = document.getElementById('chapter-filter').value;
const language = document.getElementById('language-filter').value;
const searchTerm = document.getElementById('point-search').value.toLowerCase().trim();
let filtered = allPoints;
if (chapter !== 'all') filtered = filtered.filter(p => p.category === chapter);
if (language !== 'all') filtered = filtered.filter(p => p.language === language);
if (searchTerm) filtered = filtered.filter(p => p.text.toLowerCase().includes(searchTerm));
// Show/hide clear filters button
const hasFilter = chapter !== 'all' || language !== 'all' || searchTerm;
const clearBtn = document.getElementById('clear-filters-btn');
if (clearBtn) clearBtn.style.display = hasFilter ? '' : 'none';
renderPlot(filtered);
}
function resetFilters() {
document.getElementById('chapter-filter').value = 'all';
document.getElementById('language-filter').value = 'all';
document.getElementById('point-search').value = '';
document.getElementById('clear-filters-btn').style.display = 'none';
renderPlot(allPoints);
}
let _searchTimer = null;
function searchPoints() {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(applyFilters, 200);
}
function buildCategoryColorMap(categories) {
const map = {};
categories.forEach((category, i) => {
const short = shortLabel(category);
map[category] = CHAPTER_COLORS[short] || CHAPTER_COLORS[category] || CATEGORY_PALETTE[i % CATEGORY_PALETTE.length];
});
return map;
}
// ── Module-scope helpers for cluster labels (used by renderPlot + background density fetch) ──
const MIN_CLUSTER_SIZE = 40; // Only label clusters with 40+ points
const MAX_LABELS = 25; // Cap total labels to prevent visual noise
// Compute geometric median (iterative Weiszfeld algorithm, 10 iterations)
function geoMedian(xs, ys) {
let mx = xs.reduce((a,b) => a+b, 0) / xs.length;
let my = ys.reduce((a,b) => a+b, 0) / ys.length;
for (let iter = 0; iter < 10; iter++) {
let wx = 0, wy = 0, wsum = 0;
for (let i = 0; i < xs.length; i++) {
const d = Math.sqrt((xs[i]-mx)**2 + (ys[i]-my)**2) || 1e-8;
const w = 1 / d;
wx += xs[i] * w; wy += ys[i] * w; wsum += w;
}
mx = wx / wsum; my = wy / wsum;
}
return [mx, my];
}
// Build annotation objects for cluster labels from chapter entries [{name, {x,y}}]
/* ——————————————————————————————————————————
Plotly theme colors — adapts to current theme
—————————————————————————————————————————— */
function getPlotTheme() {
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
return {
paper_bgcolor: '#0a0a0a',
plot_bgcolor: '#0a0a0a',
fontColor: 'rgba(255,255,255,0.3)',
axisTitleColor: 'rgba(255,255,255,0.2)',
gridColor: 'rgba(255,255,255,0.03)',
zerolineColor: 'rgba(255,255,255,0.05)',
tickFontColor: 'rgba(255,255,255,0.15)',
legendBg: 'rgba(10,10,10,0.9)',
legendBorder: 'rgba(255,255,255,0.05)',
legendFont: 'rgba(255,255,255,0.5)',
hoverBg: 'rgba(10,10,10,0.95)',
hoverBorder: 'rgba(255,255,255,0.1)',
hoverFont: '#ffffff',
annotationBg: 'rgba(10,10,10,0.6)',
annotationFallbackColor: 'rgba(255,255,255,0.5)',
densityColorscale: [[0, 'rgba(0,0,0,0)'], [0.2, 'rgba(59,130,246,0.05)'], [0.5, 'rgba(59,130,246,0.12)'], [1, 'rgba(59,130,246,0.25)']],
neighborLineColor: 'rgba(255,255,255,0.65)',
queryMarkerColor: '#ffffff',
queryTextColor: '#ffffff',
};
}
return {
paper_bgcolor: '#ffffff',
plot_bgcolor: '#ffffff',
fontColor: 'rgba(0,0,0,0.35)',
axisTitleColor: 'rgba(0,0,0,0.3)',
gridColor: 'rgba(0,0,0,0.05)',
zerolineColor: 'rgba(0,0,0,0.08)',
tickFontColor: 'rgba(0,0,0,0.25)',
legendBg: 'rgba(255,255,255,0.95)',
legendBorder: 'rgba(0,0,0,0.08)',
legendFont: 'rgba(0,0,0,0.5)',
hoverBg: 'rgba(255,255,255,0.97)',
hoverBorder: 'rgba(0,0,0,0.12)',
hoverFont: '#111111',
annotationBg: 'rgba(255,255,255,0.7)',
annotationFallbackColor: 'rgba(0,0,0,0.4)',
densityColorscale: [[0, 'rgba(0,0,0,0)'], [0.2, 'rgba(37,99,235,0.04)'], [0.5, 'rgba(37,99,235,0.08)'], [1, 'rgba(37,99,235,0.18)']],
neighborLineColor: 'rgba(0,0,0,0.5)',
queryMarkerColor: '#111111',
queryTextColor: '#111111',
};
}
function buildClusterAnnotations(chapterEntries) {
const theme = getPlotTheme();
const candidates = chapterEntries
.filter(([_, pts]) => pts.x.length >= MIN_CLUSTER_SIZE)
.sort((a, b) => b[1].x.length - a[1].x.length)
.slice(0, MAX_LABELS);
const annotations = candidates.map(([chapter, pts]) => {
const [cx, cy] = geoMedian(pts.x, pts.y);
return {
x: cx,
y: cy,
text: shortLabel(chapter),
showarrow: false,
font: {
size: Math.min(12, 8 + Math.log2(pts.x.length)),
color: categoryColorMap[chapter] || theme.annotationFallbackColor,
family: "'Inter', sans-serif",
weight: 'bold',
},
opacity: 0.55,
xanchor: 'center',
yanchor: 'middle',
bgcolor: theme.annotationBg,
borderpad: 2,
};
});
// Simple collision avoidance: nudge overlapping labels apart
const NUDGE_DIST = 1.5;
for (let i = 0; i < annotations.length; i++) {
for (let j = i + 1; j < annotations.length; j++) {
const dx = annotations[j].x - annotations[i].x;
const dy = annotations[j].y - annotations[i].y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < NUDGE_DIST && dist > 0) {
const push = (NUDGE_DIST - dist) / 2;
const nx = (dx / dist) * push;
const ny = (dy / dist) * push;
annotations[i].x -= nx; annotations[i].y -= ny;
annotations[j].x += nx; annotations[j].y += ny;
}
}
}
return annotations;
}
function renderPlot(points) {
const theme = getPlotTheme();
// Group by chapter
const chapters = {};
points.forEach(p => {
if (!chapters[p.category]) chapters[p.category] = { x: [], y: [], text: [], customdata: [] };
chapters[p.category].x.push(p.x);
chapters[p.category].y.push(p.y);
chapters[p.category].text.push(p.text);
chapters[p.category].customdata.push([p.hs_code, p.hs_desc, p.language, p.text, p.category]);
});
// Density contour layer — renders behind scatter points
const densityTrace = is3D ? null : {
x: points.map(p => p.x),
y: points.map(p => p.y),
type: 'histogram2dcontour',
colorscale: theme.densityColorscale,
showscale: false,
hoverinfo: 'skip',
ncontours: 20,
contours: { showlines: false },
line: { width: 0 },
showlegend: false,
};
const chapterEntries = Object.entries(chapters);
const showLegend = chapterEntries.length <= 12;
const scatterTraces = chapterEntries.map(([chapter, pts]) => ({
x: pts.x,
y: pts.y,
mode: 'markers',
type: is3D ? 'scatter3d' : 'scattergl',
name: shortLabel(chapter),
showlegend: showLegend,
marker: {
size: is3D ? 2.5 : (points.length > 20000 ? 2 : 4),
color: categoryColorMap[chapter] || '#6b7280',
opacity: points.length > 20000 ? 0.5 : 0.65,
line: { width: 0, color: 'transparent' }
},
text: pts.text,
customdata: pts.customdata,
hovertemplate:
'<b style="font-family:monospace">%{customdata[0]}</b><br>' +
'%{customdata[1]}<br>' +
'<span style="color:#8da2ff">%{customdata[4]}</span><br>' +
'<span style="color:#999">%{customdata[3]}</span><br>' +
'<i>%{customdata[2]}</i>' +
'<extra>%{fullData.name}</extra>',
}));
const traces = densityTrace ? [densityTrace, ...scatterTraces] : scatterTraces;
const annotations = buildClusterAnnotations(chapterEntries);
// Update point count badge
const countBadge = document.getElementById('point-count');
if (countBadge) {
const label = vizSampled
? points.length.toLocaleString() + ' of ' + vizTotal.toLocaleString() + ' points (sampled)'
: points.length.toLocaleString() + ' points';
countBadge.textContent = label;
countBadge.style.display = '';
}
const layout = {
paper_bgcolor: theme.paper_bgcolor,
plot_bgcolor: theme.plot_bgcolor,
showlegend: showLegend,
annotations: annotations,
font: {
color: theme.fontColor,
family: "'Inter', sans-serif",
size: 11,
},
margin: { t: 8, r: 8, b: 36, l: 36 },
xaxis: {
title: { text: 'UMAP 1', font: { size: 10, color: theme.axisTitleColor } },
gridcolor: theme.gridColor,
zerolinecolor: theme.zerolineColor,
tickfont: { size: 9, color: theme.tickFontColor },
},
yaxis: {
title: { text: 'UMAP 2', font: { size: 10, color: theme.axisTitleColor } },
gridcolor: theme.gridColor,
zerolinecolor: theme.zerolineColor,
tickfont: { size: 9, color: theme.tickFontColor },
},
legend: {
bgcolor: theme.legendBg,
bordercolor: theme.legendBorder,
borderwidth: 1,
font: { size: 9, color: theme.legendFont },
itemsizing: 'constant',
orientation: 'v',
x: 1,
xanchor: 'right',
y: 1,
},
hovermode: 'closest',
hoverlabel: {
bgcolor: theme.hoverBg,
bordercolor: theme.hoverBorder,
font: { family: "'Inter', sans-serif", size: 11, color: theme.hoverFont },
},
dragmode: 'pan',
};
const config = {
responsive: true,
displayModeBar: false,
scrollZoom: true,
};
document.getElementById('umap-loading').classList.add('hidden');
Plotly.react('umap-plot', traces, layout, config);
plotInitialized = true;
queryOverlayTraceCount = 0;
renderLatentDistancePanel(null);
// Background fetch: upgrade density contour + cluster labels with full dataset
if (!is3D && densityTrace) {
fetch('/visualization-density')
.then(r => r.json())
.then(full => {
if (full.error || !full.chapters) return;
// Flatten all x,y for density trace (trace index 0)
const allX = [], allY = [];
Object.values(full.chapters).forEach(ch => {
allX.push(...ch.x);
allY.push(...ch.y);
});
Plotly.restyle('umap-plot', { x: [allX], y: [allY] }, [0]);
// Recompute cluster labels from full data
const fullEntries = Object.entries(full.chapters);
const newAnnotations = buildClusterAnnotations(fullEntries);
Plotly.relayout('umap-plot', { annotations: newAnnotations });
})
.catch(() => {}); // Silent fail — sampled data still works
}
// Click handler for points
const plotEl = document.getElementById('umap-plot');
plotEl.removeAllListeners?.('plotly_click');
plotEl.on('plotly_click', function(data) {
if (data.points.length > 0) {
const pt = data.points[0];
showPointDetail(pt.customdata);
}
});
}
function showPointDetail(customdata) {
const [code, desc, lang, text, category] = customdata;
document.getElementById('pd-code').textContent = `${code}${desc}`;
document.getElementById('pd-desc').textContent = text;
document.getElementById('pd-similar').innerHTML =
`<div class="pd-similar-label">Language: ${lang} · ${category}</div>`;
document.getElementById('point-detail').classList.add('visible');
}
function closePointDetail() {
document.getElementById('point-detail').classList.remove('visible');
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderLatentDistancePanel(payload) {
const panel = document.getElementById('latent-distance-panel');
const body = document.getElementById('latent-distance-body');
if (!payload || !payload.neighbors || payload.neighbors.length === 0) {
panel.classList.remove('visible');
body.innerHTML = '';
return;
}
const topPred = payload.predictions && payload.predictions.length > 0
? payload.predictions[0].hs_code
: null;
body.innerHTML = payload.neighbors.map((n, i) => {
const hsCode = String(n.hs_code || '').padStart(6, '0');
const similarity = n.similarity || 0;
const similarityPct = (similarity * 100).toFixed(1);
const distanceVal = (n.distance || 0).toFixed(4);
const topMarker = topPred === hsCode ? ' <span style="color:var(--h-amber);">★</span>' : '';
const category = escapeHtml(n.chapter_name || n.chapter || 'Unknown');
const desc = escapeHtml(n.hs_desc || 'Unknown');
const text = escapeHtml((n.text || '').substring(0, 60));
const barColor = similarity > 0.8 ? 'var(--h-emerald)' : similarity > 0.5 ? 'var(--h-amber)' : 'var(--h-red)';
return `
<div style="padding:0.35rem 0;border-top:1px solid var(--h-border);${i === 0 ? 'border-top:none;' : ''}">
<div class="latent-distance-row" style="margin-bottom:0.15rem;">
<span class="latent-distance-rank">${i + 1}</span>
<span class="latent-distance-code">${escapeHtml(hsCode)}${topMarker}</span>
<span class="latent-distance-sim">${similarityPct}%</span>
<span class="latent-distance-dist">d ${distanceVal}</span>
</div>
<div style="margin-left:20px;">
<div style="height:3px;background:var(--h-bg-hover);border-radius:2px;margin-bottom:0.2rem;">
<div style="height:100%;width:${similarityPct}%;background:${barColor};border-radius:2px;transition:width 0.4s ease;"></div>
</div>
<div style="font-size:0.5625rem;color:var(--h-text-4);line-height:1.3;">
${desc !== 'Unknown' ? '<span style="color:var(--h-text-3);">' + desc + '</span> · ' : ''}${category}${text ? '<br><span style="font-style:italic;">"' + text + '"</span>' : ''}
</div>
</div>
</div>
`;
}).join('') + '<div class="latent-distance-note">Cosine similarity in embedding space. Higher = closer match.</div>';
panel.classList.add('visible');
}
/* ——————————————————————————————————————————
Classification
—————————————————————————————————————————— */
async function classify() {
const input = document.getElementById('query-input');
const text = input.value.trim();
if (!text) return;
const btn = document.getElementById('classify-btn');
btn.disabled = true;
btn.innerHTML = '<div class="spinner spinner-sm" style="margin:0"></div> Classifying…';
try {
const response = await fetch('/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
const data = await response.json();
if (data.error) {
alert(data.error);
return;
}
showResults(data);
highlightOnPlot(text, data);
} catch (error) {
alert('Error: ' + error.message);
} finally {
btn.disabled = false;
btn.innerHTML = 'Classify';
}
}
function showResults(data) {
const container = document.getElementById('results-container');
const section = document.getElementById('results-section');
section.classList.remove('hidden');
let html = '';
data.predictions.forEach((pred, i) => {
const conf = (pred.confidence * 100).toFixed(1);
const barColor = pred.confidence > 0.7 ? 'var(--h-emerald)' :
pred.confidence > 0.3 ? 'var(--h-amber)' : 'var(--h-red)';
const isTop = i === 0;
html += `
<div class="result-card ${isTop ? 'top-result' : ''}" style="animation-delay:${i * 0.06}s">
<div class="result-header">
<span class="hs-code-tag">${escapeHtml(pred.hs_code)}</span>
<div class="confidence-bar-track">
<div class="confidence-bar-fill" style="width:${conf}%;background:${barColor}"></div>
</div>
<span class="confidence-value" style="color:${barColor}">${conf}%</span>
</div>
<div class="result-desc">${escapeHtml(pred.description)}</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:0.35rem;">
<span class="result-meta" style="margin-top:0">${escapeHtml(pred.chapter)} · Ch.${escapeHtml(pred.chapter_code)} · H.${escapeHtml(pred.heading_code)}</span>
<div class="feedback-row" style="margin-top:0">
<span class="fb-label">Correct?</span>
<button class="feedback-btn" onclick="sendFeedback(this,'up','${escapeHtml(pred.hs_code)}','${escapeHtml(data.query)}')" title="Correct prediction">&#9650;</button>
<button class="feedback-btn" onclick="sendFeedback(this,'down','${escapeHtml(pred.hs_code)}','${escapeHtml(data.query)}')" title="Wrong prediction">&#9660;</button>
</div>
</div>
</div>
`;
});
container.innerHTML = html;
document.getElementById('inference-time').innerHTML =
`⚡ ${data.inference_time_ms}ms`;
document.getElementById('coverage-note').style.display = 'block';
// Similar examples
const simSection = document.getElementById('similar-section');
const simContainer = document.getElementById('similar-container');
if (data.similar_examples && data.similar_examples.length > 0) {
simSection.classList.remove('hidden');
let simHtml = '';
data.similar_examples.forEach(ex => {
const score = ex.similarity ? (ex.similarity * 100).toFixed(0) + '%' : '';
simHtml += `
<div class="similar-item">
<span class="sim-text">${ex.text}</span>
<span class="sim-code">${ex.hs_code}</span>
${score ? `<span class="sim-score">${score}</span>` : ''}
</div>
`;
});
simContainer.innerHTML = simHtml;
}
}
/* ——————————————————————————————————————————
Plot highlighting
—————————————————————————————————————————— */
function clearQueryOverlay() {
if (!plotInitialized || queryOverlayTraceCount <= 0) return;
const plotEl = document.getElementById('umap-plot');
const totalTraces = (plotEl.data || []).length;
const startIdx = Math.max(totalTraces - queryOverlayTraceCount, 0);
const deleteIndices = [];
for (let i = startIdx; i < totalTraces; i++) {
deleteIndices.push(i);
}
if (deleteIndices.length > 0) {
Plotly.deleteTraces('umap-plot', deleteIndices);
}
queryOverlayTraceCount = 0;
renderLatentDistancePanel(null);
}
async function highlightOnPlot(text, resultData = null) {
if (!plotInitialized) return;
try {
const hlTheme = getPlotTheme();
const response = await fetch('/embed-query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
const data = await response.json();
if (data.error) return;
const neighbors = (data.neighbors || []).slice(0, 3);
if (neighbors.length === 0) return;
const neighborTrace = {
x: neighbors.map(n => n.x),
y: neighbors.map(n => n.y),
mode: 'markers',
type: 'scatter',
name: 'Nearest neighbors',
marker: {
size: neighbors.map(n => 8 + Math.max(0, (1 - n.distance) * 10)),
color: neighbors.map(n => n.similarity),
colorscale: [
[0, '#ef4444'],
[0.5, '#f59e0b'],
[1, '#10b981'],
],
cmin: 0,
cmax: 1,
line: { width: 1, color: hlTheme.neighborLineColor },
opacity: 0.95,
},
customdata: neighbors.map(n => [
String(n.hs_code || '').padStart(6, '0'),
n.hs_desc || 'Unknown',
n.distance || 0,
n.similarity || 0,
n.chapter_name || n.chapter || 'Unknown',
]),
hovertemplate:
'<b>%{customdata[0]}</b> · %{customdata[4]}<br>' +
'%{customdata[1]}<br>' +
'distance: %{customdata[2]:.4f}<br>' +
'similarity: %{customdata[3]:.2%}<extra></extra>',
showlegend: false,
};
const queryTrace = {
x: [data.x],
y: [data.y],
mode: 'markers+text',
type: 'scatter',
name: 'Your query',
marker: {
size: 14,
color: hlTheme.queryMarkerColor,
symbol: 'diamond',
line: { width: 2, color: 'var(--h-accent)' }
},
text: ['Query'],
textposition: 'top center',
textfont: { color: hlTheme.queryTextColor, size: 10, family: "'Inter', sans-serif" },
hovertemplate: '<b>Your Query</b><br>' + text.substring(0, 60) + '<extra></extra>',
showlegend: true,
};
// Connector lines from query to each neighbor
const connectorTraces = neighbors.map((n, i) => ({
x: [data.x, n.x],
y: [data.y, n.y],
mode: 'lines',
type: 'scatter',
line: {
color: `rgba(59, 130, 246, ${0.15 + (n.similarity || 0) * 0.35})`,
width: 1,
dash: i === 0 ? 'solid' : 'dot',
},
hoverinfo: 'skip',
showlegend: false,
}));
clearQueryOverlay();
// Get current plot center for animation start
const plotEl = document.getElementById('umap-plot');
const xRange = plotEl.layout.xaxis.range;
const yRange = plotEl.layout.yaxis.range;
const centerX = (xRange[0] + xRange[1]) / 2;
const centerY = (yRange[0] + yRange[1]) / 2;
// Start all traces at plot center (collapsed)
const startConnectors = connectorTraces.map(t => ({
...t,
x: [centerX, centerX],
y: [centerY, centerY],
}));
const startNeighbor = {
...neighborTrace,
x: neighbors.map(() => centerX),
y: neighbors.map(() => centerY),
};
const startQuery = {
...queryTrace,
x: [centerX],
y: [centerY],
};
Plotly.addTraces('umap-plot', [...startConnectors, startNeighbor, startQuery]);
queryOverlayTraceCount = connectorTraces.length + 2;
renderLatentDistancePanel({
neighbors,
predictions: resultData && resultData.predictions ? resultData.predictions : [],
});
// Build animation frame: move everything to real positions + zoom
const totalTraces = (plotEl.data || []).length;
const firstNewIdx = totalTraces - queryOverlayTraceCount;
const traceIndices = [];
for (let i = firstNewIdx; i < totalTraces; i++) traceIndices.push(i);
const animData = [];
// Connector traces (fan out from query to neighbors)
connectorTraces.forEach(t => {
animData.push({ x: t.x, y: t.y });
});
// Neighbor markers
animData.push({ x: neighborTrace.x, y: neighborTrace.y });
// Query diamond
animData.push({ x: queryTrace.x, y: queryTrace.y });
const padding = 3;
Plotly.animate('umap-plot', {
data: animData,
traces: traceIndices,
layout: {
'xaxis.range': [data.x - padding, data.x + padding],
'yaxis.range': [data.y - padding, data.y + padding],
}
}, {
transition: { duration: 600, easing: 'cubic-in-out' },
frame: { duration: 600 },
});
} catch (error) {
console.error('Plot highlight error:', error);
}
}
/* ——————————————————————————————————————————
Actions
—————————————————————————————————————————— */
function setExample(text) {
switchInputTab('text');
document.getElementById('query-input').value = text;
classify();
}
function clearAll() {
document.getElementById('query-input').value = '';
document.getElementById('results-section').classList.add('hidden');
document.getElementById('similar-section').classList.add('hidden');
if (plotInitialized && queryOverlayTraceCount > 0) {
clearQueryOverlay();
resetView();
}
}
function resetView() {
if (!plotInitialized) return;
Plotly.animate('umap-plot', {
layout: {
'xaxis.autorange': true,
'yaxis.autorange': true,
}
}, {
transition: { duration: 400, easing: 'cubic-in-out' },
frame: { duration: 400 },
});
}
function toggleViewMode() {
is3D = !is3D;
const icon = document.getElementById('view-toggle-icon');
icon.textContent = is3D ? '◈' : '◇';
// 3D not fully supported without z-coords; toggle is visual indicator for future
// For now re-render 2D
if (!is3D) applyFilters();
}
function toggleFullscreen() {
isFullscreen = !isFullscreen;
document.body.classList.toggle('fullscreen-mode', isFullscreen);
document.getElementById('fullscreen-btn').textContent = isFullscreen ? '⊟' : '⛶';
setTimeout(() => Plotly.Plots.resize(document.getElementById('umap-plot')), 100);
}
function exportPlot(format) {
if (!plotInitialized) return;
Plotly.downloadImage('umap-plot', {
format: format,
width: 1920,
height: 1080,
filename: 'hs-latent-space',
});
}
/* ——————————————————————————————————————————
Input Tabs (Text / Upload)
—————————————————————————————————————————— */
function switchInputTab(mode) {
document.querySelectorAll('.input-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.getElementById('tab-' + mode).classList.add('active');
document.getElementById('tab-content-' + mode).classList.add('active');
}
/* ——————————————————————————————————————————
Document Upload
—————————————————————————————————————————— */
(function initUpload() {
const zone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
if (!zone || !fileInput) return;
['dragenter', 'dragover'].forEach(evt => {
zone.addEventListener(evt, (e) => { e.preventDefault(); zone.classList.add('dragover'); });
});
['dragleave', 'drop'].forEach(evt => {
zone.addEventListener(evt, (e) => { e.preventDefault(); zone.classList.remove('dragover'); });
});
zone.addEventListener('drop', (e) => {
const files = e.dataTransfer.files;
if (files.length > 0) handleUpload(files[0]);
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length > 0) handleUpload(fileInput.files[0]);
fileInput.value = '';
});
})();
async function handleUpload(file) {
const zone = document.getElementById('upload-zone');
const resultArea = document.getElementById('upload-result-area');
zone.innerHTML = '<div class="spinner" style="margin:0.5rem auto;"></div><p style="font-size:0.75rem;color:var(--h-text-4);">Processing ' + escapeHtml(file.name) + '…</p>';
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch('/upload-document', { method: 'POST', body: formData });
const data = await resp.json();
// Restore the upload zone
zone.innerHTML = '<div class="upload-icon">&#128196;</div><p><strong>Drop a document here</strong> or click to browse</p><p>Supports PDF, PNG, JPG, TIFF</p><input type="file" id="file-input" accept=".pdf,.png,.jpg,.jpeg,.tiff,.tif,.bmp,.webp" aria-label="Upload document">';
// Re-attach file input listener
document.getElementById('file-input').addEventListener('change', function() {
if (this.files.length > 0) handleUpload(this.files[0]);
this.value = '';
});
if (data.error) {
resultArea.innerHTML = `<div class="upload-result"><span style="font-size:0.75rem;color:var(--h-red);">${escapeHtml(data.error)}</span></div>`;
return;
}
const fields = data.fields || {};
const fieldTags = Object.entries(fields)
.filter(([k, v]) => v && String(v).trim())
.map(([k, v]) => `<span class="field-tag">${escapeHtml(k)}: ${escapeHtml(String(v).substring(0, 50))}</span>`)
.join('');
const truncatedText = (data.raw_text || '').substring(0, 300);
resultArea.innerHTML = `
<div class="upload-result">
<div class="upload-result-header">
<span>&#10003; Extracted from ${escapeHtml(data.filename)}</span>
<button onclick="this.closest('.upload-result').remove()">&#10005;</button>
</div>
<div class="extracted-text">${escapeHtml(truncatedText)}${data.raw_text && data.raw_text.length > 300 ? '…' : ''}</div>
${fieldTags ? '<div class="extracted-fields">' + fieldTags + '</div>' : ''}
<div class="button-row" style="margin-top:0.625rem;">
<button class="btn btn-accent btn-sm" onclick="useExtractedText()">Classify Extracted Text</button>
</div>
</div>
`;
// Store the raw text for classification
resultArea.dataset.rawText = data.raw_text || '';
// If product description was extracted, store it separately
if (fields.product_description) {
resultArea.dataset.productDesc = fields.product_description;
}
} catch (error) {
zone.innerHTML = '<div class="upload-icon">&#128196;</div><p><strong>Drop a document here</strong> or click to browse</p><p>Supports PDF, PNG, JPG, TIFF</p><input type="file" id="file-input" accept=".pdf,.png,.jpg,.jpeg,.tiff,.tif,.bmp,.webp" aria-label="Upload document">';
resultArea.innerHTML = `<div class="upload-result"><span style="font-size:0.75rem;color:var(--h-red);">Upload failed: ${escapeHtml(error.message)}</span></div>`;
}
}
function useExtractedText() {
const resultArea = document.getElementById('upload-result-area');
const text = resultArea.dataset.productDesc || resultArea.dataset.rawText || '';
if (!text.trim()) return;
// Switch to text tab and populate
switchInputTab('text');
document.getElementById('query-input').value = text.substring(0, 500);
classify();
}
/* ——————————————————————————————————————————
Feedback
—————————————————————————————————————————— */
let feedbackLog = [];
function sendFeedback(btn, direction, hsCode, query) {
// Toggle buttons in the same row
const row = btn.closest('.feedback-row');
row.querySelectorAll('.feedback-btn').forEach(b => {
b.classList.remove('selected-up', 'selected-down');
});
btn.classList.add(direction === 'up' ? 'selected-up' : 'selected-down');
// Log feedback locally
const entry = {
timestamp: new Date().toISOString(),
query: query,
hs_code: hsCode,
feedback: direction === 'up' ? 'correct' : 'incorrect',
};
feedbackLog.push(entry);
// Persist to localStorage for demo purposes
try {
localStorage.setItem('hsclassify_feedback', JSON.stringify(feedbackLog));
} catch(e) {}
// Show toast
showToast(direction === 'up' ? 'Marked as correct — thanks!' : 'Marked as incorrect — noted for improvement');
}
function showToast(message) {
const existing = document.querySelector('.feedback-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'feedback-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2500);
}
// Load any previous feedback
try {
const saved = localStorage.getItem('hsclassify_feedback');
if (saved) feedbackLog = JSON.parse(saved);
} catch(e) {}
/* ——————————————————————————————————————————
Keyboard shortcuts
—————————————————————————————————————————— */
document.addEventListener('keydown', (e) => {
// Cmd/Ctrl+Enter to classify
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
classify();
}
// Escape to close detail / welcome / exit fullscreen
if (e.key === 'Escape') {
if (!document.getElementById('welcome-overlay').classList.contains('hidden')) {
dismissWelcome();
return;
}
closePointDetail();
if (isFullscreen) toggleFullscreen();
}
// Cmd/Ctrl+K to focus search
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
document.getElementById('point-search').focus();
}
});
/* ——————————————————————————————————————————
Welcome / First-use Explainer
—————————————————————————————————————————— */
let currentWelcomeStep = 0;
const TOTAL_WELCOME_STEPS = 4;
const WELCOME_STORAGE_KEY = 'hsclassify_welcomed';
function showWelcome() {
currentWelcomeStep = 0;
updateWelcomeUI();
document.getElementById('welcome-overlay').classList.remove('hidden');
}
function dismissWelcome() {
document.getElementById('welcome-overlay').classList.add('hidden');
try { localStorage.setItem(WELCOME_STORAGE_KEY, '1'); } catch(e) {}
}
function welcomeStep(direction) {
const next = currentWelcomeStep + direction;
if (next >= TOTAL_WELCOME_STEPS) {
dismissWelcome();
return;
}
if (next < 0) return;
currentWelcomeStep = next;
updateWelcomeUI();
}
function updateWelcomeUI() {
document.querySelectorAll('.welcome-step').forEach(el => el.classList.remove('active'));
const activeStep = document.querySelector(`.welcome-step[data-step="${currentWelcomeStep}"]`);
if (activeStep) activeStep.classList.add('active');
document.querySelectorAll('.step-dots .dot').forEach(d => d.classList.remove('active'));
const activeDot = document.querySelector(`.dot[data-dot="${currentWelcomeStep}"]`);
if (activeDot) activeDot.classList.add('active');
const backBtn = document.getElementById('welcome-back-btn');
const nextBtn = document.getElementById('welcome-next-btn');
backBtn.style.display = currentWelcomeStep > 0 ? '' : 'none';
nextBtn.textContent = currentWelcomeStep === TOTAL_WELCOME_STEPS - 1 ? 'Get Started' : 'Next';
}
// Close overlay on background click or Escape
document.getElementById('welcome-overlay').addEventListener('click', function(e) {
if (e.target === this) dismissWelcome();
});
/* ——————————————————————————————————————————
Init
—————————————————————————————————————————— */
window.addEventListener('load', () => {
loadVisualization();
// Show welcome on first visit
try {
if (!localStorage.getItem(WELCOME_STORAGE_KEY)) {
showWelcome();
}
} catch(e) {
showWelcome();
}
});
window.addEventListener('resize', () => {
if (plotInitialized) {
Plotly.Plots.resize(document.getElementById('umap-plot'));
}
});
</script>
</body>
</html>