Spaces:
Sleeping
Sleeping
| <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 ; } | |
| .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">⚓</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 — 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 — 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>⌘/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 — 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 — 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>⌘/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">📄</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 · {{ (metadata.get('accuracy', 0) * 100)|round(1) }}% test accuracy · {{ '{:,}'.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;">✕</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, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| 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">▲</button> | |
| <button class="feedback-btn" onclick="sendFeedback(this,'down','${escapeHtml(pred.hs_code)}','${escapeHtml(data.query)}')" title="Wrong prediction">▼</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">📄</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>✓ Extracted from ${escapeHtml(data.filename)}</span> | |
| <button onclick="this.closest('.upload-result').remove()">✕</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">📄</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> | |