Spaces:
Running on Zero
Running on Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>Paraphrasis annotator</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=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --parchment: #f7f2e8; | |
| --parchment-2: #fdf9f0; | |
| --paper: #fffcf5; | |
| --ink: #1e1915; | |
| --ink-soft: #3a312a; | |
| --ink-mute: #7a6b5d; | |
| --ink-faint: #a89786; | |
| --rule: #e6ddcc; | |
| --rule-strong: #d5c9b2; | |
| --accent: #7a3b1f; /* sienna / manuscript rubric */ | |
| --accent-soft: #b5694a; | |
| --validate: #2d6a4f; | |
| --validate-bg: #d8e7dd; | |
| --reject: #8a1c2b; | |
| --reject-bg: #efd5d7; | |
| --highlight: rgba(218, 165, 32, 0.28); /* warm gold */ | |
| --highlight-border: #c58b18; | |
| --selected: #f0e5cf; | |
| --selected-strong: #e7d5ad; | |
| --shadow: 0 1px 2px rgba(30, 25, 21, 0.06), 0 2px 8px rgba(30, 25, 21, 0.04); | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; margin: 0; padding: 0; } | |
| body { | |
| font-family: "IBM Plex Sans", system-ui, sans-serif; | |
| font-size: 15px; | |
| background: var(--parchment); | |
| color: var(--ink); | |
| overflow: hidden; | |
| /* subtle paper grain */ | |
| background-image: | |
| radial-gradient(ellipse at 20% 10%, rgba(200, 170, 120, 0.08), transparent 60%), | |
| radial-gradient(ellipse at 80% 90%, rgba(180, 140, 80, 0.06), transparent 50%); | |
| } | |
| /* ============ TOP BAR ============ */ | |
| .topbar { | |
| display: grid; | |
| grid-template-columns: auto 1fr auto; | |
| align-items: center; | |
| gap: 20px; | |
| padding: 10px 22px; | |
| background: linear-gradient(180deg, var(--parchment-2), var(--parchment)); | |
| border-bottom: 1px solid var(--rule-strong); | |
| box-shadow: 0 1px 0 rgba(255,255,255,0.6) inset, 0 2px 8px rgba(30,25,21,0.04); | |
| height: 64px; | |
| position: relative; | |
| z-index: 10; | |
| } | |
| .brand { | |
| display: flex; align-items: center; | |
| padding-right: 20px; | |
| border-right: 1px solid var(--rule); | |
| height: 100%; | |
| } | |
| .brand-mark { | |
| font-family: "Fraunces", serif; | |
| font-weight: 600; | |
| font-size: 24px; | |
| letter-spacing: -0.01em; | |
| color: var(--ink); | |
| line-height: 1; | |
| white-space: nowrap; | |
| } | |
| .brand-mark::first-letter { | |
| font-size: 33px; | |
| color: var(--accent); | |
| margin-right: 1px; | |
| } | |
| .controls { | |
| display: flex; align-items: center; gap: 14px; | |
| flex-wrap: wrap; | |
| } | |
| .ctrl-group { | |
| display: flex; align-items: center; gap: 8px; | |
| padding: 6px 12px; | |
| background: var(--paper); | |
| border: 1px solid var(--rule-strong); | |
| border-radius: 6px; | |
| box-shadow: var(--shadow); | |
| } | |
| .ctrl-label { | |
| font-size: 12px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--ink-mute); | |
| font-weight: 600; | |
| } | |
| .btn { | |
| font-family: inherit; | |
| font-size: 14px; | |
| font-weight: 500; | |
| padding: 7px 14px; | |
| background: var(--paper); | |
| color: var(--ink); | |
| border: 1px solid var(--rule-strong); | |
| border-radius: 5px; | |
| cursor: pointer; | |
| transition: all 0.12s; | |
| box-shadow: var(--shadow); | |
| display: inline-flex; align-items: center; gap: 6px; | |
| } | |
| .btn:hover { background: var(--selected); border-color: var(--ink-faint); } | |
| .btn:active { transform: translateY(1px); } | |
| .btn-primary { | |
| background: var(--ink); | |
| color: var(--parchment); | |
| border-color: var(--ink); | |
| } | |
| .btn-primary:hover { background: var(--ink-soft); border-color: var(--ink-soft); } | |
| .btn svg { width: 14px; height: 14px; } | |
| .btn-toggle.active { | |
| background: var(--accent); | |
| color: var(--parchment); | |
| border-color: var(--accent); | |
| box-shadow: inset 0 1px 3px rgba(0,0,0,0.18), 0 1px 0 rgba(255,255,255,0.04); | |
| } | |
| .btn-toggle.active:hover { | |
| background: var(--accent-soft); | |
| border-color: var(--accent-soft); | |
| } | |
| .btn-toggle.disabled { | |
| opacity: 0.55; | |
| cursor: not-allowed; | |
| } | |
| input[type="number"], input[type="range"] { | |
| font-family: "IBM Plex Mono", monospace; | |
| font-size: 13px; | |
| padding: 4px 6px; | |
| background: var(--parchment-2); | |
| border: 1px solid var(--rule-strong); | |
| border-radius: 4px; | |
| color: var(--ink); | |
| } | |
| input[type="number"] { width: 68px; } | |
| input[type="range"] { | |
| width: 120px; padding: 0; | |
| -webkit-appearance: none; appearance: none; | |
| height: 4px; | |
| background: var(--rule-strong); | |
| border: none; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 14px; height: 14px; border-radius: 50%; | |
| background: var(--accent); cursor: pointer; | |
| border: 2px solid var(--paper); | |
| box-shadow: 0 0 0 1px var(--accent); | |
| } | |
| input[type="range"]::-moz-range-thumb { | |
| width: 14px; height: 14px; border-radius: 50%; | |
| background: var(--accent); cursor: pointer; | |
| border: 2px solid var(--paper); | |
| box-shadow: 0 0 0 1px var(--accent); | |
| } | |
| .stats { | |
| display: flex; gap: 16px; align-items: center; | |
| font-family: "IBM Plex Mono", monospace; | |
| font-size: 13px; | |
| } | |
| .stat { | |
| display: flex; flex-direction: column; | |
| align-items: flex-end; | |
| line-height: 1.1; | |
| } | |
| .stat-val { font-weight: 600; color: var(--ink); font-size: 16px; } | |
| .stat-val.val-v { color: var(--validate); } | |
| .stat-val.val-r { color: var(--reject); } | |
| .stat-lab { | |
| font-size: 10px; text-transform: uppercase; | |
| letter-spacing: 0.1em; color: var(--ink-mute); | |
| font-family: "IBM Plex Sans", sans-serif; | |
| font-weight: 600; | |
| margin-top: 2px; | |
| } | |
| /* ============ MAIN WORKSPACE ============ */ | |
| .workspace { | |
| display: grid; | |
| grid-template-columns: 340px 1fr; | |
| height: calc(100vh - 64px); | |
| overflow: hidden; | |
| } | |
| /* ============ LEFT PANEL ============ */ | |
| .pairs-panel { | |
| display: flex; flex-direction: column; | |
| background: var(--parchment-2); | |
| border-right: 1px solid var(--rule-strong); | |
| min-height: 0; | |
| } | |
| .pairs-toolbar { | |
| padding: 8px 10px; | |
| border-bottom: 1px solid var(--rule); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| background: linear-gradient(180deg, var(--parchment-2), var(--parchment)); | |
| } | |
| .pairs-title { | |
| font-family: "Fraunces", serif; | |
| font-size: 17px; | |
| font-weight: 600; | |
| color: var(--ink); | |
| } | |
| .pairs-title-sub { | |
| font-family: "EB Garamond", serif; | |
| font-style: italic; | |
| font-size: 13px; | |
| color: var(--ink-mute); | |
| margin-left: 4px; | |
| display: block; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .filter-box { | |
| width: 100%; | |
| } | |
| .filter-box input { | |
| width: 100%; | |
| font-family: "IBM Plex Mono", monospace; | |
| font-size: 12px; | |
| padding: 4px 8px; | |
| background: var(--paper); | |
| border: 1px solid var(--rule-strong); | |
| border-radius: 4px; | |
| color: var(--ink); | |
| } | |
| .threshold-box { | |
| display: grid; | |
| grid-template-columns: auto 1fr 60px; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 6px 8px; | |
| background: var(--paper); | |
| border: 1px solid var(--rule-strong); | |
| border-radius: 4px; | |
| margin-top: 2px; | |
| } | |
| .threshold-label { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--ink-mute); | |
| font-weight: 600; | |
| white-space: nowrap; | |
| } | |
| .threshold-box input[type="range"] { | |
| width: 100%; | |
| padding: 0; | |
| background: var(--rule-strong); | |
| } | |
| .threshold-box input[type="number"] { | |
| width: 100%; | |
| font-size: 12px; | |
| padding: 3px 4px; | |
| } | |
| .pairs-legend { | |
| display: grid; | |
| grid-template-columns: 1fr auto 16px; | |
| gap: 6px; | |
| padding: 6px 10px; | |
| font-size: 11px; | |
| color: var(--ink-mute); | |
| background: var(--parchment); | |
| border-bottom: 1px solid var(--rule); | |
| font-family: "IBM Plex Mono", monospace; | |
| } | |
| .pairs-legend .col-id { text-align: left; } | |
| .pairs-legend .col-sim { text-align: right; width: 46px; } | |
| .pairs-legend .col-tag { text-align: center; } | |
| .pairs-scroll { | |
| flex: 1; | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| position: relative; | |
| scrollbar-width: thin; | |
| scrollbar-color: var(--rule-strong) transparent; | |
| } | |
| .pairs-scroll::-webkit-scrollbar { width: 10px; } | |
| .pairs-scroll::-webkit-scrollbar-track { background: transparent; } | |
| .pairs-scroll::-webkit-scrollbar-thumb { | |
| background: var(--rule-strong); border-radius: 5px; | |
| border: 2px solid var(--parchment-2); | |
| } | |
| .pairs-viewport { position: relative; } | |
| .pair-row { | |
| position: absolute; left: 0; right: 0; | |
| display: grid; | |
| grid-template-columns: 1fr 16px; | |
| grid-template-rows: auto auto auto; | |
| gap: 2px 6px; | |
| align-items: start; | |
| padding: 8px 10px; | |
| height: 72px; | |
| border-bottom: 1px solid var(--rule); | |
| cursor: pointer; | |
| user-select: none; | |
| transition: background-color 0.08s; | |
| } | |
| .pair-row:hover { background: rgba(218, 165, 32, 0.06); } | |
| .pair-row.selected { | |
| background: var(--selected); | |
| border-bottom-color: var(--rule-strong); | |
| } | |
| .pair-row.selected-strong { | |
| background: var(--selected-strong); | |
| } | |
| .pair-row.pivot::before { | |
| content: ""; | |
| position: absolute; | |
| left: 0; top: 0; bottom: 0; | |
| width: 3px; | |
| background: var(--accent); | |
| } | |
| .pair-head { | |
| display: flex; align-items: baseline; justify-content: space-between; | |
| gap: 6px; | |
| font-family: "IBM Plex Mono", monospace; | |
| min-width: 0; | |
| } | |
| .pair-id { | |
| font-size: 13px; | |
| font-weight: 600; | |
| color: var(--ink); | |
| white-space: nowrap; | |
| } | |
| .pair-id .id-hash { | |
| color: var(--ink-faint); | |
| font-weight: 400; | |
| margin-right: 1px; | |
| } | |
| .pair-sim { | |
| font-family: "IBM Plex Mono", monospace; | |
| font-size: 12px; | |
| color: var(--ink-mute); | |
| text-align: right; | |
| font-variant-numeric: tabular-nums; | |
| white-space: nowrap; | |
| } | |
| .pair-sim.sim-high { color: var(--accent); font-weight: 600; } | |
| .pair-preview { | |
| font-family: "EB Garamond", serif; | |
| font-size: 13px; | |
| line-height: 1.25; | |
| color: var(--ink-soft); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| min-width: 0; | |
| grid-column: 1 / span 2; | |
| } | |
| .pair-preview.prev-a { color: var(--ink); } | |
| .pair-preview.prev-b { color: var(--ink-mute); font-style: italic; } | |
| .pair-preview .prev-badge { | |
| display: inline-block; | |
| font-family: "IBM Plex Mono", monospace; | |
| font-size: 9px; | |
| font-weight: 600; | |
| padding: 0 4px; | |
| margin-right: 4px; | |
| background: var(--parchment); | |
| color: var(--accent); | |
| border-radius: 2px; | |
| border: 1px solid var(--rule-strong); | |
| vertical-align: 1px; | |
| letter-spacing: 0.04em; | |
| } | |
| .pair-preview.prev-b .prev-badge { | |
| background: var(--parchment-2); | |
| } | |
| .pair-tag-indicator { | |
| width: 14px; height: 14px; | |
| border-radius: 50%; | |
| border: 1.5px solid var(--rule-strong); | |
| background: var(--paper); | |
| justify-self: center; | |
| align-self: center; | |
| transition: all 0.15s; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 8px; | |
| color: transparent; | |
| grid-column: 2; | |
| grid-row: 1; | |
| } | |
| .pair-tag-indicator.tag-v { | |
| background: var(--validate); | |
| border-color: var(--validate); | |
| color: white; | |
| } | |
| .pair-tag-indicator.tag-r { | |
| background: var(--reject); | |
| border-color: var(--reject); | |
| color: white; | |
| } | |
| .pair-tag-indicator.tag-v::after { content: "✓"; font-weight: 700; } | |
| .pair-tag-indicator.tag-r::after { content: "✗"; font-weight: 700; } | |
| /* floating tag buttons on selected row */ | |
| .pair-row.pivot .tag-buttons { | |
| display: flex; | |
| } | |
| .tag-buttons { | |
| display: none; | |
| position: absolute; | |
| right: 6px; | |
| top: 6px; | |
| bottom: 6px; | |
| z-index: 2; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| gap: 3px; | |
| padding: 2px; | |
| background: linear-gradient(90deg, transparent, var(--selected-strong) 35%); | |
| border-radius: 4px; | |
| } | |
| .tag-btn { | |
| width: 26px; height: 18px; | |
| border-radius: 4px; | |
| border: none; | |
| cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 12px; | |
| font-weight: 700; | |
| transition: all 0.1s; | |
| box-shadow: var(--shadow); | |
| padding: 0; | |
| } | |
| .tag-btn.tag-btn-v { | |
| background: var(--validate); color: white; | |
| } | |
| .tag-btn.tag-btn-v:hover { background: #1f5239; transform: scale(1.08); } | |
| .tag-btn.tag-btn-r { | |
| background: var(--reject); color: white; | |
| } | |
| .tag-btn.tag-btn-r:hover { background: #6b1321; transform: scale(1.08); } | |
| .tag-btn.tag-btn-c { | |
| background: var(--paper); color: var(--ink-mute); | |
| border: 1px solid var(--rule-strong); | |
| font-size: 10px; | |
| } | |
| .tag-btn.tag-btn-c:hover { background: var(--parchment); color: var(--ink); } | |
| .empty-state { | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| height: 100%; | |
| padding: 40px 24px; | |
| text-align: center; | |
| color: var(--ink-mute); | |
| font-family: "EB Garamond", serif; | |
| } | |
| .empty-state h3 { | |
| font-family: "Fraunces", serif; | |
| font-weight: 500; | |
| color: var(--ink); | |
| margin: 0 0 8px; | |
| font-size: 18px; | |
| } | |
| .empty-state p { | |
| font-size: 14px; | |
| line-height: 1.5; | |
| margin: 4px 0; | |
| max-width: 240px; | |
| } | |
| .empty-state .empty-mark { | |
| font-family: "Fraunces", serif; | |
| font-size: 52px; | |
| color: var(--accent); | |
| opacity: 0.6; | |
| margin-bottom: 12px; | |
| line-height: 1; | |
| } | |
| /* ============ RIGHT PANEL ============ */ | |
| .texts-panel { | |
| display: grid; | |
| /* minmax(0, 1fr) instead of 1fr: 1fr resolves to minmax(auto, 1fr), whose | |
| content-based minimum lets the panel with more context text grow past | |
| half (visible once embedded, where height is content-driven). The 0 | |
| minimum keeps both rows exactly equal and defers overflow to each | |
| panel's inner .text-content scroll. */ | |
| grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); | |
| min-height: 0; | |
| background: var(--parchment); | |
| } | |
| .text-panel { | |
| display: flex; flex-direction: column; | |
| min-height: 0; | |
| overflow: hidden; | |
| position: relative; | |
| } | |
| .text-panel.text-top { border-bottom: 1px solid var(--rule-strong); } | |
| .text-header { | |
| padding: 10px 32px; | |
| background: linear-gradient(180deg, var(--parchment-2), var(--parchment)); | |
| border-bottom: 1px solid var(--rule); | |
| display: flex; justify-content: space-between; align-items: baseline; | |
| flex-shrink: 0; | |
| } | |
| .text-header-label { | |
| font-family: "Fraunces", serif; | |
| font-weight: 600; | |
| font-size: 15px; | |
| color: var(--ink); | |
| display: flex; align-items: baseline; gap: 8px; | |
| } | |
| .text-header-label .side-badge { | |
| font-family: "IBM Plex Mono", monospace; | |
| font-size: 12px; | |
| padding: 1px 7px; | |
| background: var(--accent); | |
| color: var(--parchment); | |
| border-radius: 3px; | |
| font-weight: 600; | |
| letter-spacing: 0.05em; | |
| text-transform: uppercase; | |
| } | |
| .text-header-ref { | |
| font-family: "IBM Plex Mono", monospace; | |
| font-size: 14px; | |
| color: var(--accent); | |
| font-weight: 500; | |
| } | |
| .text-content { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 28px 48px 36px; | |
| font-family: "EB Garamond", serif; | |
| font-size: 22px; | |
| line-height: 1.78; | |
| color: var(--ink); | |
| background: var(--paper); | |
| position: relative; | |
| scrollbar-width: thin; | |
| scrollbar-color: var(--rule-strong) transparent; | |
| } | |
| .text-content::-webkit-scrollbar { width: 10px; } | |
| .text-content::-webkit-scrollbar-track { background: transparent; } | |
| .text-content::-webkit-scrollbar-thumb { | |
| background: var(--rule-strong); border-radius: 5px; | |
| border: 2px solid var(--paper); | |
| } | |
| .text-content::before { | |
| /* decorative paper edge */ | |
| content: ""; | |
| position: absolute; | |
| top: 0; left: 0; right: 0; height: 1px; | |
| background: linear-gradient(90deg, transparent, rgba(200,170,120,0.4), transparent); | |
| } | |
| .ctx-sentence { | |
| display: inline; | |
| color: var(--ink-mute); | |
| transition: all 0.2s; | |
| } | |
| .ctx-sentence .ctx-ref { | |
| font-family: "IBM Plex Mono", monospace; | |
| font-size: 11px; | |
| color: var(--ink-faint); | |
| vertical-align: super; | |
| line-height: 0; | |
| padding: 0 2px; | |
| user-select: none; | |
| } | |
| .ctx-sentence.ctx-highlight { | |
| color: var(--ink); | |
| background: var(--highlight); | |
| padding: 2px 4px; | |
| border-radius: 3px; | |
| box-shadow: 0 0 0 1px var(--highlight-border) inset; | |
| font-weight: 500; | |
| } | |
| .ctx-sentence.ctx-highlight .ctx-ref { | |
| color: var(--accent); | |
| } | |
| .text-empty { | |
| display: flex; align-items: center; justify-content: center; | |
| height: 100%; | |
| font-family: "EB Garamond", serif; | |
| font-style: italic; | |
| color: var(--ink-faint); | |
| font-size: 17px; | |
| } | |
| .text-warn { | |
| padding: 6px 12px; | |
| margin: 12px 0; | |
| background: rgba(218, 165, 32, 0.12); | |
| border-left: 2px solid var(--highlight-border); | |
| font-size: 13px; | |
| font-family: "IBM Plex Sans", sans-serif; | |
| color: var(--ink-mute); | |
| font-style: normal; | |
| border-radius: 2px; | |
| } | |
| .text-credit { | |
| position: absolute; | |
| right: 22px; | |
| bottom: 10px; | |
| font-family: "EB Garamond", serif; | |
| font-size: 12px; | |
| font-style: italic; | |
| color: var(--ink-faint); | |
| letter-spacing: 0.01em; | |
| white-space: nowrap; | |
| pointer-events: none; | |
| } | |
| /* ============ MODALS / TOASTS ============ */ | |
| .toast { | |
| position: fixed; | |
| bottom: 20px; left: 50%; | |
| transform: translateX(-50%); | |
| background: var(--ink); | |
| color: var(--parchment); | |
| padding: 10px 18px; | |
| border-radius: 6px; | |
| font-size: 14px; | |
| font-family: "IBM Plex Sans", sans-serif; | |
| box-shadow: 0 8px 24px rgba(30,25,21,0.25); | |
| z-index: 100; | |
| opacity: 0; | |
| transition: opacity 0.25s, transform 0.25s; | |
| pointer-events: none; | |
| } | |
| .toast.show { | |
| opacity: 1; | |
| transform: translateX(-50%) translateY(-4px); | |
| } | |
| .hint-bar { | |
| position: absolute; | |
| bottom: 6px; | |
| right: 14px; | |
| font-family: "IBM Plex Mono", monospace; | |
| font-size: 11px; | |
| color: var(--ink-faint); | |
| pointer-events: none; | |
| display: flex; gap: 12px; | |
| } | |
| .hint-bar kbd { | |
| font-family: "IBM Plex Mono", monospace; | |
| background: var(--paper); | |
| border: 1px solid var(--rule-strong); | |
| border-bottom-width: 2px; | |
| border-radius: 3px; | |
| padding: 0 4px; | |
| font-size: 10px; | |
| color: var(--ink-soft); | |
| margin: 0 2px; | |
| } | |
| .divider-v { | |
| width: 1px; | |
| background: var(--rule); | |
| align-self: stretch; | |
| margin: 4px 0; | |
| } | |
| /* ============ RESPONSIVE ============ */ | |
| @media (max-width: 980px) { | |
| .text-content { padding: 20px 28px; font-size: 20px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header class="topbar"> | |
| <div class="brand"> | |
| <div class="brand-mark">Paraphrasis annotator</div> | |
| </div> | |
| <div class="controls"> | |
| <div class="ctrl-group"> | |
| <input type="file" id="filePairs" accept=".tsv,.csv,.txt" hidden> | |
| <input type="file" id="fileC1" accept=".tsv,.csv,.txt" hidden> | |
| <input type="file" id="fileC2" accept=".tsv,.csv,.txt" hidden> | |
| <button class="btn" onclick="document.getElementById('filePairs').click()" title="Load sentence pairs TSV/CSV"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg> | |
| Pairs | |
| </button> | |
| <button class="btn" onclick="document.getElementById('fileC1').click()" title="Load corpus 1 (source column context)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg> | |
| Corpus 1 | |
| </button> | |
| <button class="btn" onclick="document.getElementById('fileC2').click()" title="Load corpus 2 (target column context)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg> | |
| Corpus 2 | |
| </button> | |
| <div class="divider-v"></div> | |
| <button class="btn btn-primary" id="btnExport" title="Export annotated pairs"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> | |
| Export | |
| </button> | |
| </div> | |
| <div class="ctrl-group"> | |
| <button class="btn btn-toggle" id="btnSort" title="Click to switch sorting rule (Id ⇄ Similarity)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h12"/><path d="M3 12h9"/><path d="M3 18h6"/><path d="M17 4v16"/><polyline points="13 16 17 20 21 16"/></svg> | |
| <span id="btnSortLabel">Similarity</span> | |
| </button> | |
| </div> | |
| <div class="ctrl-group"> | |
| <span class="ctrl-label">Context</span> | |
| <input type="number" id="contextNum" min="0" max="100" step="1" value="2"> | |
| <span class="ctrl-label" style="font-size:10px">± sent.</span> | |
| </div> | |
| </div> | |
| <div class="stats"> | |
| <div class="stat"><span class="stat-val" id="statTotal">0</span><span class="stat-lab">Total</span></div> | |
| <div class="stat"><span class="stat-val val-v" id="statValid">0</span><span class="stat-lab">Validated</span></div> | |
| <div class="stat"><span class="stat-val val-r" id="statReject">0</span><span class="stat-lab">Rejected</span></div> | |
| <div class="stat"><span class="stat-val" id="statPending">0</span><span class="stat-lab">Pending</span></div> | |
| </div> | |
| </header> | |
| <main class="workspace"> | |
| <aside class="pairs-panel"> | |
| <div class="pairs-toolbar"> | |
| <div> | |
| <span class="pairs-title">Pairs</span> | |
| <span class="pairs-title-sub" id="pairsTitleSub"></span> | |
| </div> | |
| <div class="filter-box"> | |
| <input type="text" id="filterBox" placeholder="Filter id…"> | |
| </div> | |
| <div class="threshold-box"> | |
| <label class="threshold-label" for="thresholdRange">Threshold</label> | |
| <input type="range" id="thresholdRange" min="0" max="1" step="0.001" value="0.95"> | |
| <input type="number" id="thresholdNum" min="0" max="1" step="0.01" value="0.95"> | |
| </div> | |
| </div> | |
| <div class="pairs-legend"> | |
| <span class="col-id">id · source / target</span> | |
| <span class="col-sim">sim.</span> | |
| <span class="col-tag">✓</span> | |
| </div> | |
| <div class="pairs-scroll" id="pairsScroll"> | |
| <div class="pairs-viewport" id="pairsViewport"> | |
| <div class="empty-state" id="emptyState"> | |
| <div class="empty-mark">¶</div> | |
| <h3>No pairs loaded</h3> | |
| <p>Click <strong>Pairs</strong> above to load a paraphrase list (TSV/CSV with <em>source</em> / <em>target</em> columns).</p> | |
| <p style="margin-top:14px; font-size:13px;">Then load <strong>Corpus 1</strong> and <strong>Corpus 2</strong> to enrich each sentence with its surrounding context.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </aside> | |
| <section class="texts-panel"> | |
| <article class="text-panel text-top"> | |
| <header class="text-header"> | |
| <div class="text-header-label"> | |
| <span class="side-badge">A</span> | |
| <span>Source in corpus 1</span> | |
| </div> | |
| <div class="text-header-ref" id="refA">—</div> | |
| </header> | |
| <div class="text-content" id="textA"> | |
| <div class="text-empty">Select a pair to display the text.</div> | |
| </div> | |
| <div class="text-credit">by T. de la Selle, CNRS</div> | |
| </article> | |
| <article class="text-panel text-bottom"> | |
| <header class="text-header"> | |
| <div class="text-header-label"> | |
| <span class="side-badge">B</span> | |
| <span>Target in corpus 2</span> | |
| </div> | |
| <div class="text-header-ref" id="refB">—</div> | |
| </header> | |
| <div class="text-content" id="textB"> | |
| <div class="text-empty">Select a pair to display the text.</div> | |
| </div> | |
| </article> | |
| </section> | |
| </main> | |
| <div class="hint-bar"> | |
| <span><kbd>V</kbd> validate</span> | |
| <span><kbd>R</kbd> reject</span> | |
| <span><kbd>C</kbd> clear</span> | |
| <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span> | |
| <span><kbd>Ctrl</kbd>+click multi-select</span> | |
| </div> | |
| <div class="toast" id="toast"></div> | |
| <script> | |
| /* ========================================================= | |
| STATE | |
| ========================================================= */ | |
| const state = { | |
| pairs: [], // {id, idA, idB, refA, refB, sim, textA, textB, tag} | |
| filtered: [], // indices into pairs after filter + sort | |
| c1Map: new Map(), // id / text -> index in c1List | |
| c1List: [], // [{id, ref, text}] | |
| c2Map: new Map(), | |
| c2List: [], | |
| selection: new Set(), | |
| pivot: -1, | |
| contextSize: 2, | |
| threshold: 0.95, | |
| filterText: '', | |
| sortMode: 'similarity', // 'similarity' (file order) | 'id' (by Source_ID) | |
| pairsFileName: null, | |
| c1FileName: null, | |
| c2FileName: null, | |
| }; | |
| const ROW_HEIGHT = 72; | |
| /* ========================================================= | |
| TSV / CSV PARSING | |
| ========================================================= */ | |
| function detectDelimiter(text) { | |
| const line = text.slice(0, 4000).split(/\r?\n/)[0] || ''; | |
| const tabs = (line.match(/\t/g) || []).length; | |
| const commas = (line.match(/,/g) || []).length; | |
| const semis = (line.match(/;/g) || []).length; | |
| const m = Math.max(tabs, commas, semis); | |
| if (m === 0) return '\t'; | |
| if (tabs === m) return '\t'; | |
| if (semis === m) return ';'; | |
| return ','; | |
| } | |
| function parseDelimited(text, delim) { | |
| // Minimal RFC-4180-ish parser that handles quoted fields. | |
| const rows = []; | |
| let field = '', row = [], inQuotes = false; | |
| for (let i = 0; i < text.length; i++) { | |
| const c = text[i]; | |
| if (inQuotes) { | |
| if (c === '"') { | |
| if (text[i+1] === '"') { field += '"'; i++; } | |
| else inQuotes = false; | |
| } else field += c; | |
| } else { | |
| if (c === '"' && field === '') inQuotes = true; | |
| else if (c === delim) { row.push(field); field = ''; } | |
| else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; } | |
| else if (c === '\r') { /* skip */ } | |
| else field += c; | |
| } | |
| } | |
| if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); } | |
| return rows; | |
| } | |
| function parseTable(text) { | |
| // Strip UTF-8 BOM if present (otherwise the first header cell becomes "\uFEFF..."). | |
| if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); | |
| const delim = detectDelimiter(text); | |
| const rows = parseDelimited(text, delim); | |
| if (rows.length === 0) return { header: [], data: [], delim }; | |
| const header = rows[0].map(h => h.trim()); | |
| const data = rows.slice(1).filter(r => r.some(c => c && c.length)); | |
| return { header, data, delim }; | |
| } | |
| /* ========================================================= | |
| LOADING: sentence pairs | |
| ========================================================= */ | |
| function loadPairsFromText(text, filename) { | |
| const { header, data } = parseTable(text); | |
| const lc = header.map(h => h.toLowerCase()); | |
| // Column discovery — recognises the three known schemas: | |
| // (a) New dual-id schema: Source_ID, Source_Sentence, Target_ID, Target_Sentence, Score | |
| // (b) Export schema: "", source, target, similarity (unnamed index) | |
| // (c) Legacy VG schema: reference_a, reference_b, cosine_similarity, text_a, text_b | |
| // | |
| // Each pair carries both an `idA` / `idB` used for corpus lookup AND a | |
| // user-facing `refA` / `refB` identical to the ids (so the left column | |
| // and the subpanel headers display the same string). | |
| const idx = { | |
| idA: lc.findIndex(h => h === 'source_id' || h === 'source_ids' || h === 'id_a' || h === 'source id'), | |
| idB: lc.findIndex(h => h === 'target_id' || h === 'target_ids' || h === 'id_b' || h === 'target id'), | |
| refA: lc.findIndex(h => h === 'reference_a' || h === 'ref_a' || h === 'refa'), | |
| refB: lc.findIndex(h => h === 'reference_b' || h === 'ref_b' || h === 'refb'), | |
| rowId: lc.findIndex(h => h === '' || h === 'id' || h === 'index' || h === '#' || h === 'n'), | |
| sim: lc.findIndex(h => h === 'cosine_similarity' || h === 'similarity' || h === 'sim' || h === 'score'), | |
| txtA: lc.findIndex(h => h === 'source_sentence' || h === 'text_a' || h === 'texta' || h === 'sentence_a' || h === 'source' || h === 'source sentence'), | |
| txtB: lc.findIndex(h => h === 'target_sentence' || h === 'text_b' || h === 'textb' || h === 'sentence_b' || h === 'target' || h === 'target sentence'), | |
| tag: lc.findIndex(h => h === 'tag' || h === 'label' || h === 'annotation'), | |
| }; | |
| if (idx.txtA < 0 || idx.txtB < 0) { | |
| toast('Could not find source/target sentence columns.', true); | |
| return; | |
| } | |
| state.pairs = data.map((r, i) => { | |
| const rowId = idx.rowId >= 0 ? (r[idx.rowId] || '').trim() : ''; | |
| const rowKey = rowId !== '' ? rowId : String(i); | |
| // Prefer explicit Source_ID / Target_ID, then reference_a / reference_b, | |
| // then the row index as a last resort. | |
| const idA = idx.idA >= 0 ? (r[idx.idA] || '').trim() | |
| : idx.refA >= 0 ? (r[idx.refA] || '').trim() | |
| : `#${rowKey}`; | |
| const idB = idx.idB >= 0 ? (r[idx.idB] || '').trim() | |
| : idx.refB >= 0 ? (r[idx.refB] || '').trim() | |
| : `#${rowKey}`; | |
| return { | |
| id: rowKey, | |
| idA, | |
| idB, | |
| refA: idA, // displayed label = the id itself | |
| refB: idB, | |
| sim: idx.sim >= 0 ? parseFloat(r[idx.sim]) || 0 : 0, | |
| textA: r[idx.txtA] || '', | |
| textB: r[idx.txtB] || '', | |
| tag: idx.tag >= 0 ? (['v','validated','true','1','ok','yes'].includes((r[idx.tag]||'').toLowerCase()) ? 'v' | |
| : ['r','rejected','false','0','no'].includes((r[idx.tag]||'').toLowerCase()) ? 'r' | |
| : null) : null, | |
| }; | |
| }); | |
| state.pairsFileName = filename; | |
| state.selection.clear(); | |
| state.pivot = -1; | |
| adaptThresholdRange(); | |
| applyFilter(); | |
| if (state.filtered.length > 0) selectOne(0); | |
| toast(`Loaded ${state.pairs.length.toLocaleString()} pairs from “${filename}”`); | |
| updateStats(); | |
| const pairsTitleSub = document.getElementById('pairsTitleSub'); | |
| const normalizedFile = String(filename || '').trim().toLowerCase(); | |
| pairsTitleSub.textContent = normalizedFile === 'pairs.csv' ? '' : `· ${filename}`; | |
| } | |
| /* ========================================================= | |
| THRESHOLD RANGE ADAPTATION | |
| ========================================================= */ | |
| function adaptThresholdRange() { | |
| if (state.pairs.length === 0) return; | |
| let minSim = Infinity, maxSim = -Infinity; | |
| for (const p of state.pairs) { | |
| if (isFinite(p.sim)) { | |
| if (p.sim < minSim) minSim = p.sim; | |
| if (p.sim > maxSim) maxSim = p.sim; | |
| } | |
| } | |
| if (!isFinite(minSim) || !isFinite(maxSim) || minSim === maxSim) return; | |
| // Round outward so endpoints are always reachable by the slider. | |
| const pad = (maxSim - minSim) * 0.001; | |
| minSim = Math.floor((minSim - pad) * 1e6) / 1e6; | |
| maxSim = Math.ceil((maxSim + pad) * 1e6) / 1e6; | |
| const range = maxSim - minSim; | |
| // Pick a clean step: 1000 subdivisions across the range. | |
| const step = Math.max(1e-6, Math.pow(10, Math.floor(Math.log10(range / 1000)))); | |
| const thR = document.getElementById('thresholdRange'); | |
| const thN = document.getElementById('thresholdNum'); | |
| thR.min = minSim; thR.max = maxSim; thR.step = step; | |
| thN.min = minSim; thN.max = maxSim; thN.step = step; | |
| // Snap current value into the new range (clamp & round to step). | |
| let val = parseFloat(thR.value); | |
| if (!isFinite(val) || val < minSim || val > maxSim) val = maxSim; | |
| const decimals = Math.max(0, -Math.floor(Math.log10(step))); | |
| val = parseFloat(val.toFixed(decimals)); | |
| thR.value = val; thN.value = val; state.threshold = val; | |
| } | |
| /* ========================================================= | |
| LOADING: VG corpus | |
| ========================================================= */ | |
| /* ========================================================= | |
| LOADING: CORPUS (two slots) | |
| ========================================================= | |
| Corpus 1 provides context for SOURCE sentences (column A); | |
| Corpus 2 provides context for TARGET sentences (column B). | |
| Matching strategy. Each pair now carries an explicit `idA` | |
| and `idB` which correspond to sentence ids inside the | |
| corresponding corpus file. Corpus lookup is keyed by that | |
| id as its primary index. As a fallback we also register the | |
| normalized sentence text, which lets the annotator keep | |
| working with legacy corpus files that only provide a text | |
| column (no id). | |
| ========================================================= */ | |
| function normalizeText(s) { | |
| if (!s) return ''; | |
| return s.normalize('NFKC').replace(/\s+/g, ' ').trim(); | |
| } | |
| function loadCorpusFromText(slot, text, filename) { | |
| const { header, data } = parseTable(text); | |
| const lc = header.map(h => h.toLowerCase()); | |
| let idIdx = lc.findIndex(h => | |
| h === 'id' || | |
| h === 'source_id' || h === 'source id' || | |
| h === 'target_id' || h === 'target id' || | |
| h === 'sentence_id' || h === 'sentence id' || | |
| h === 'index' || h === '#' || h === 'n' | |
| ); | |
| let refIdx = lc.findIndex(h => h === 'reference' || h === 'ref' || h === 'verse' || h === 'citation'); | |
| let txtIdx = lc.findIndex(h => | |
| h === 'sentence' || | |
| h === 'text' || | |
| h === 'content' || | |
| h === 'passage' || | |
| h === 'verse_text' || h === 'verse text' || | |
| h === 'source' || h === 'source_text' || h === 'source text' || h === 'source_sentence' || h === 'source sentence' || | |
| h === 'target' || h === 'target_text' || h === 'target text' || h === 'target_sentence' || h === 'target sentence' | |
| ); | |
| // Heuristic fallback: longest non-id column becomes the text column. | |
| if (txtIdx < 0) { | |
| const sample = data.slice(0, 50); | |
| const colAvgLen = header.map((_, ci) => sample.reduce((s,r) => s + ((r[ci]||'').length), 0) / (sample.length||1)); | |
| let best = -1, bestLen = 0; | |
| colAvgLen.forEach((l, i) => { if (i !== idIdx && i !== refIdx && l > bestLen) { bestLen = l; best = i; } }); | |
| if (best >= 0) txtIdx = best; | |
| } | |
| // If there's still no explicit id column, fall back to reference. | |
| if (idIdx < 0 && refIdx >= 0) idIdx = refIdx; | |
| if (txtIdx < 0) { | |
| toast(`Could not detect a text column in corpus ${slot}.`, true); | |
| return; | |
| } | |
| const list = data.map((r, i) => ({ | |
| id: idIdx >= 0 ? (r[idIdx] || '').trim() : String(i + 1), | |
| ref: refIdx >= 0 ? (r[refIdx] || '').trim() : '', | |
| text: (r[txtIdx] || '').trim(), | |
| })).filter(x => x.text || x.id); | |
| // Primary key: the id. Secondary keys: reference, normalized text. | |
| const map = new Map(); | |
| list.forEach((x, i) => { | |
| if (x.id) map.set(x.id, i); | |
| if (x.ref) map.set(x.ref, i); | |
| const norm = normalizeText(x.text); | |
| if (norm && !map.has(norm)) map.set(norm, i); | |
| }); | |
| if (slot === 1) { state.c1List = list; state.c1Map = map; state.c1FileName = filename; } | |
| else { state.c2List = list; state.c2Map = map; state.c2FileName = filename; } | |
| toast(`Loaded corpus ${slot}: ${list.length.toLocaleString()} sentences from “${filename}”`); | |
| renderCurrentTexts(); | |
| } | |
| /* ========================================================= | |
| FILTER / SORT | |
| ========================================================= */ | |
| const idCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); | |
| function applyFilter() { | |
| const t = state.filterText.toLowerCase(); | |
| state.filtered = []; | |
| for (let i = 0; i < state.pairs.length; i++) { | |
| const p = state.pairs[i]; | |
| if (t && !(String(p.idA).toLowerCase().includes(t) || String(p.idB).toLowerCase().includes(t))) continue; | |
| state.filtered.push(i); | |
| } | |
| if (state.sortMode === 'id') { | |
| state.filtered.sort((a, b) => { | |
| const c = idCollator.compare(String(state.pairs[a].idA), String(state.pairs[b].idA)); | |
| if (c !== 0) return c; | |
| // Tie-break on idB, then original order for stability. | |
| const c2 = idCollator.compare(String(state.pairs[a].idB), String(state.pairs[b].idB)); | |
| return c2 !== 0 ? c2 : a - b; | |
| }); | |
| } | |
| renderList(); | |
| } | |
| /* ========================================================= | |
| VIRTUAL-SCROLL RENDERING OF PAIRS LIST | |
| ========================================================= */ | |
| function renderList() { | |
| const viewport = document.getElementById('pairsViewport'); | |
| const scroll = document.getElementById('pairsScroll'); | |
| const emptyState = document.getElementById('emptyState'); | |
| if (state.filtered.length === 0) { | |
| viewport.style.height = ''; | |
| if (state.pairs.length === 0) { | |
| viewport.innerHTML = ''; | |
| const empty = document.createElement('div'); | |
| empty.className = 'empty-state'; | |
| empty.innerHTML = ` | |
| <div class="empty-mark">¶</div> | |
| <h3>No pairs loaded</h3> | |
| <p>Click <strong>Pairs</strong> above to load a paraphrase list (TSV/CSV with <em>source</em> / <em>target</em> columns).</p> | |
| <p style="margin-top:14px; font-size:13px;">Then load <strong>Corpus 1</strong> and <strong>Corpus 2</strong> to enrich each sentence with its surrounding context.</p>`; | |
| viewport.appendChild(empty); | |
| } else { | |
| viewport.innerHTML = `<div class="empty-state"><div class="empty-mark">?</div><h3>No matches</h3><p>No pairs match the current filter.</p></div>`; | |
| } | |
| return; | |
| } | |
| viewport.style.height = (state.filtered.length * ROW_HEIGHT) + 'px'; | |
| renderVisibleRows(); | |
| } | |
| function renderVisibleRows() { | |
| const viewport = document.getElementById('pairsViewport'); | |
| const scroll = document.getElementById('pairsScroll'); | |
| if (state.filtered.length === 0) return; | |
| const scrollTop = scroll.scrollTop; | |
| const viewH = scroll.clientHeight; | |
| const buffer = 8; | |
| const first = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - buffer); | |
| const last = Math.min(state.filtered.length - 1, | |
| Math.ceil((scrollTop + viewH) / ROW_HEIGHT) + buffer); | |
| // Build a doc fragment of rows currently in range. | |
| const frag = document.createDocumentFragment(); | |
| for (let i = first; i <= last; i++) { | |
| frag.appendChild(buildRow(i)); | |
| } | |
| // Replace content (empty state / stale rows). | |
| viewport.replaceChildren(frag); | |
| } | |
| function buildRow(filteredIdx) { | |
| const pairIdx = state.filtered[filteredIdx]; | |
| const p = state.pairs[pairIdx]; | |
| const row = document.createElement('div'); | |
| row.className = 'pair-row'; | |
| if (state.selection.has(filteredIdx)) row.classList.add('selected'); | |
| if (state.pivot === filteredIdx) row.classList.add('pivot', 'selected-strong'); | |
| row.style.top = (filteredIdx * ROW_HEIGHT) + 'px'; | |
| row.dataset.idx = filteredIdx; | |
| const simPct = isFinite(p.sim) ? p.sim.toFixed(4) : '—'; | |
| const simClass = p.sim >= 0.98 ? 'sim-high' : ''; | |
| const tagCls = p.tag === 'v' ? 'tag-v' : p.tag === 'r' ? 'tag-r' : ''; | |
| // Visible id: use the pair's id when it differs from its refA (new format), | |
| // otherwise show refA (legacy format). | |
| const displayId = (p.id !== undefined && p.refA === `#${p.id}`) | |
| ? `<span class="id-hash">#</span>${escapeHTML(p.id)}` | |
| : escapeHTML(p.refA); | |
| // Preview snippets — first N characters, truncated with CSS ellipsis for safety. | |
| const prevA = previewText(p.textA, 150); | |
| const prevB = previewText(p.textB, 150); | |
| row.innerHTML = ` | |
| <div class="pair-head"> | |
| <span class="pair-id">${displayId}</span> | |
| <span class="pair-sim ${simClass}">${simPct}</span> | |
| </div> | |
| <div class="pair-tag-indicator ${tagCls}"></div> | |
| <div class="pair-preview prev-a" title="${escapeHTML(p.textA)}"><span class="prev-badge">A</span>${escapeHTML(prevA)}</div> | |
| <div class="pair-preview prev-b" title="${escapeHTML(p.textB)}"><span class="prev-badge">B</span>${escapeHTML(prevB)}</div> | |
| <div class="tag-buttons"> | |
| <button class="tag-btn tag-btn-v" title="Validate (V)" data-tag="v">✓</button> | |
| <button class="tag-btn tag-btn-r" title="Reject (R)" data-tag="r">✗</button> | |
| <button class="tag-btn tag-btn-c" title="Clear (C)" data-tag="c">∅</button> | |
| </div> | |
| `; | |
| return row; | |
| } | |
| function previewText(t, max) { | |
| if (!t) return ''; | |
| const s = String(t).replace(/\s+/g, ' ').trim(); | |
| return s.length > max ? s.slice(0, max - 1) + '…' : s; | |
| } | |
| /* ========================================================= | |
| SELECTION | |
| ========================================================= */ | |
| function selectOne(filteredIdx, {scrollIntoView = true} = {}) { | |
| state.selection.clear(); | |
| state.selection.add(filteredIdx); | |
| state.pivot = filteredIdx; | |
| renderVisibleRows(); | |
| if (scrollIntoView) scrollFilteredIntoView(filteredIdx); | |
| renderCurrentTexts(); | |
| } | |
| function toggleSelect(filteredIdx) { | |
| if (state.selection.has(filteredIdx) && state.selection.size > 1) { | |
| state.selection.delete(filteredIdx); | |
| // If we removed pivot, pick any remaining as pivot. | |
| if (state.pivot === filteredIdx) state.pivot = state.selection.values().next().value; | |
| } else { | |
| state.selection.add(filteredIdx); | |
| state.pivot = filteredIdx; | |
| } | |
| renderVisibleRows(); | |
| renderCurrentTexts(); | |
| } | |
| function rangeSelect(filteredIdx) { | |
| if (state.pivot < 0) { selectOne(filteredIdx); return; } | |
| const [a, b] = state.pivot < filteredIdx ? [state.pivot, filteredIdx] : [filteredIdx, state.pivot]; | |
| state.selection.clear(); | |
| for (let i = a; i <= b; i++) state.selection.add(i); | |
| state.pivot = filteredIdx; | |
| renderVisibleRows(); | |
| renderCurrentTexts(); | |
| } | |
| function scrollFilteredIntoView(filteredIdx) { | |
| const scroll = document.getElementById('pairsScroll'); | |
| const top = filteredIdx * ROW_HEIGHT; | |
| const bottom = top + ROW_HEIGHT; | |
| if (top < scroll.scrollTop) scroll.scrollTop = top - 4; | |
| else if (bottom > scroll.scrollTop + scroll.clientHeight) { | |
| scroll.scrollTop = bottom - scroll.clientHeight + 4; | |
| } | |
| } | |
| /* ========================================================= | |
| TAGGING | |
| ========================================================= */ | |
| function applyTag(tag) { | |
| if (state.selection.size === 0) return; | |
| const indices = [...state.selection].sort((a,b) => a-b); | |
| indices.forEach(fi => { | |
| const pi = state.filtered[fi]; | |
| state.pairs[pi].tag = tag === 'c' ? null : tag; | |
| }); | |
| updateStats(); | |
| // Normal auto-advance to the next row. | |
| const last = indices[indices.length - 1]; | |
| const next = last + 1; | |
| if (next < state.filtered.length) { | |
| selectOne(next); | |
| } else { | |
| renderVisibleRows(); | |
| renderCurrentTexts(); | |
| } | |
| } | |
| /* ========================================================= | |
| CONTEXT RENDERING (right panel) | |
| ========================================================= */ | |
| function renderCurrentTexts() { | |
| const refAEl = document.getElementById('refA'); | |
| const refBEl = document.getElementById('refB'); | |
| const textAEl = document.getElementById('textA'); | |
| const textBEl = document.getElementById('textB'); | |
| if (state.pivot < 0 || state.filtered.length === 0) { | |
| refAEl.textContent = '—'; | |
| refBEl.textContent = '—'; | |
| textAEl.innerHTML = '<div class="text-empty">Select a pair to display the text.</div>'; | |
| textBEl.innerHTML = '<div class="text-empty">Select a pair to display the text.</div>'; | |
| return; | |
| } | |
| const pair = state.pairs[state.filtered[state.pivot]]; | |
| refAEl.textContent = pair.idA; | |
| refBEl.textContent = pair.idB; | |
| const selCount = state.selection.size; | |
| const multiBanner = selCount > 1 | |
| ? `<div class="text-warn">${selCount} rows selected — tagging applies to all.</div>` | |
| : ''; | |
| textAEl.innerHTML = multiBanner + buildContextHTML('A', pair.idA, pair.textA); | |
| textBEl.innerHTML = multiBanner + buildContextHTML('B', pair.idB, pair.textB); | |
| } | |
| function buildContextHTML(side, sentenceId, pairText) { | |
| const n = state.contextSize; | |
| const map = side === 'A' ? state.c1Map : state.c2Map; | |
| const list = side === 'A' ? state.c1List : state.c2List; | |
| const corpusLabel = side === 'A' ? 'corpus 1' : 'corpus 2'; | |
| // No corpus loaded for this side → show the pair text as-is, with the id. | |
| if (map.size === 0) { | |
| if (!pairText) return `<div class="text-empty">No text available. Load ${corpusLabel} to display context.</div>`; | |
| return `<span class="ctx-sentence ctx-highlight"><span class="ctx-ref">${escapeHTML(sentenceId)}</span>${escapeHTML(pairText)}</span>`; | |
| } | |
| // Lookup strategies, in order of preference: | |
| // (1) by explicit id (new schema — the primary key) | |
| // (2) by normalized pair text (legacy / exported CSV) | |
| let idx = map.get(String(sentenceId)); | |
| if (idx === undefined) idx = map.get(normalizeText(pairText)); | |
| if (idx === undefined) { | |
| const fallback = pairText | |
| ? `<span class="ctx-sentence ctx-highlight"><span class="ctx-ref">${escapeHTML(sentenceId)}</span>${escapeHTML(pairText)}</span>` | |
| : `<div class="text-empty">Id “${escapeHTML(sentenceId)}” not found in ${corpusLabel}.</div>`; | |
| return `<div class="text-warn">Id “${escapeHTML(sentenceId)}” not found in ${corpusLabel} — showing pair text only.</div>${fallback}`; | |
| } | |
| const start = Math.max(0, idx - n); | |
| const end = Math.min(list.length - 1, idx + n); | |
| const parts = []; | |
| for (let i = start; i <= end; i++) { | |
| const s = list[i]; | |
| const isTarget = i === idx; | |
| const label = s.id || s.ref || String(i + 1); | |
| const classes = 'ctx-sentence' + (isTarget ? ' ctx-highlight' : ''); | |
| parts.push( | |
| `<span class="${classes}">` + | |
| `<span class="ctx-ref">${escapeHTML(label)}</span>` + | |
| escapeHTML(s.text) + | |
| `</span>` | |
| ); | |
| } | |
| return parts.join(' '); | |
| } | |
| /* ========================================================= | |
| STATS | |
| ========================================================= */ | |
| function updateStats() { | |
| document.getElementById('statTotal').textContent = state.pairs.length.toLocaleString(); | |
| let v = 0, r = 0; | |
| for (const p of state.pairs) { if (p.tag === 'v') v++; else if (p.tag === 'r') r++; } | |
| document.getElementById('statValid').textContent = v.toLocaleString(); | |
| document.getElementById('statReject').textContent = r.toLocaleString(); | |
| document.getElementById('statPending').textContent = (state.pairs.length - v - r).toLocaleString(); | |
| } | |
| /* ========================================================= | |
| EXPORT | |
| ========================================================= */ | |
| function exportPairs() { | |
| if (state.pairs.length === 0) { toast('Nothing to export.', true); return; } | |
| const header = ['reference_a','reference_b','cosine_similarity','text_a','text_b','tag']; | |
| const rows = [header.join('\t')]; | |
| for (const p of state.pairs) { | |
| const tag = p.tag === 'v' ? 'validated' : p.tag === 'r' ? 'rejected' : ''; | |
| rows.push([p.refA, p.refB, p.sim, p.textA, p.textB, tag] | |
| .map(v => (typeof v === 'string' && (v.includes('\t') || v.includes('"') || v.includes('\n'))) | |
| ? '"' + v.replace(/"/g,'""') + '"' | |
| : v).join('\t')); | |
| } | |
| const blob = new Blob([rows.join('\n')], { type: 'text/tab-separated-values;charset=utf-8' }); | |
| const base = (state.pairsFileName || 'sentence_pairs').replace(/\.(tsv|csv|txt)$/i,''); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = `${base}_annotated.tsv`; | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(a.href), 2000); | |
| toast(`Exported ${state.pairs.length.toLocaleString()} pairs as ${a.download}`); | |
| } | |
| /* ========================================================= | |
| THRESHOLD: auto-scroll to first row >= threshold | |
| (assumes list may be sorted desc by sim; we find nearest) | |
| ========================================================= */ | |
| function jumpToThreshold() { | |
| if (state.filtered.length === 0) return; | |
| const t = state.threshold; | |
| // Find closest filtered row whose sim <= t (since data is typically sorted desc). | |
| // Strategy: scan to find first row with sim <= threshold (coming from high values). | |
| // If not found, land on last row. | |
| let target = -1; | |
| for (let i = 0; i < state.filtered.length; i++) { | |
| const p = state.pairs[state.filtered[i]]; | |
| if (p.sim <= t + 1e-9) { target = i; break; } | |
| } | |
| if (target < 0) target = state.filtered.length - 1; | |
| selectOne(target); | |
| } | |
| /* ========================================================= | |
| UTILITIES | |
| ========================================================= */ | |
| function escapeHTML(s) { | |
| return String(s || '').replace(/[&<>"']/g, c => | |
| ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| } | |
| let toastTimer = null; | |
| function toast(msg, isErr = false) { | |
| const el = document.getElementById('toast'); | |
| el.textContent = msg; | |
| el.style.background = isErr ? 'var(--reject)' : 'var(--ink)'; | |
| el.classList.add('show'); | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => el.classList.remove('show'), 2400); | |
| } | |
| function readFile(file, cb) { | |
| const r = new FileReader(); | |
| r.onload = () => cb(r.result); | |
| r.onerror = () => toast('Failed to read file.', true); | |
| r.readAsText(file, 'utf-8'); | |
| } | |
| /* ========================================================= | |
| TOGGLES: SORT | |
| ========================================================= */ | |
| function toggleSort() { | |
| const pivotPairIdx = state.pivot >= 0 ? state.filtered[state.pivot] : -1; | |
| const selPairs = new Set([...state.selection].map(fi => state.filtered[fi])); | |
| state.sortMode = state.sortMode === 'id' ? 'similarity' : 'id'; | |
| applyFilter(); | |
| // Remap selection. | |
| state.selection.clear(); | |
| for (let i = 0; i < state.filtered.length; i++) { | |
| if (selPairs.has(state.filtered[i])) state.selection.add(i); | |
| } | |
| // Remap pivot. | |
| if (pivotPairIdx >= 0) { | |
| const newPivot = state.filtered.indexOf(pivotPairIdx); | |
| state.pivot = newPivot; | |
| if (newPivot >= 0) scrollFilteredIntoView(newPivot); | |
| } | |
| renderVisibleRows(); | |
| updateToggleButtons(); | |
| toast(state.sortMode === 'id' | |
| ? 'Sorted by Source_ID.' | |
| : 'Restored similarity order.'); | |
| } | |
| function updateToggleButtons() { | |
| const bS = document.getElementById('btnSort'); | |
| const bSLabel = document.getElementById('btnSortLabel'); | |
| if (bS && bSLabel) { | |
| // The label always reflects the *current* sort rule. | |
| bSLabel.textContent = state.sortMode === 'id' ? 'Id' : 'Similarity'; | |
| // Visually mark "Id" as the accent (non-default) mode. | |
| bS.classList.toggle('active', state.sortMode === 'id'); | |
| } | |
| } | |
| document.getElementById('filePairs').addEventListener('change', e => { | |
| const f = e.target.files[0]; if (!f) return; | |
| readFile(f, text => loadPairsFromText(text, f.name)); | |
| e.target.value = ''; | |
| }); | |
| document.getElementById('fileC1').addEventListener('change', e => { | |
| const f = e.target.files[0]; if (!f) return; | |
| readFile(f, text => loadCorpusFromText(1, text, f.name)); | |
| e.target.value = ''; | |
| }); | |
| document.getElementById('fileC2').addEventListener('change', e => { | |
| const f = e.target.files[0]; if (!f) return; | |
| readFile(f, text => loadCorpusFromText(2, text, f.name)); | |
| e.target.value = ''; | |
| }); | |
| document.getElementById('btnExport').addEventListener('click', exportPairs); | |
| document.getElementById('btnSort').addEventListener('click', toggleSort); | |
| document.getElementById('pairsScroll').addEventListener('scroll', () => { | |
| requestAnimationFrame(renderVisibleRows); | |
| }); | |
| // Click on a row (with ctrl/shift for multi-select). | |
| document.getElementById('pairsViewport').addEventListener('click', e => { | |
| // Tag button intercept | |
| const tagBtn = e.target.closest('.tag-btn'); | |
| if (tagBtn) { | |
| e.stopPropagation(); | |
| applyTag(tagBtn.dataset.tag); | |
| return; | |
| } | |
| const row = e.target.closest('.pair-row'); | |
| if (!row) return; | |
| const idx = parseInt(row.dataset.idx, 10); | |
| if (e.shiftKey) rangeSelect(idx); | |
| else if (e.ctrlKey || e.metaKey) toggleSelect(idx); | |
| else selectOne(idx); | |
| }); | |
| // Threshold controls | |
| const thR = document.getElementById('thresholdRange'); | |
| const thN = document.getElementById('thresholdNum'); | |
| thR.addEventListener('input', () => { thN.value = thR.value; }); | |
| thR.addEventListener('change', () => { state.threshold = parseFloat(thR.value); jumpToThreshold(); }); | |
| thN.addEventListener('change', () => { | |
| let v = parseFloat(thN.value); | |
| if (!isFinite(v)) v = 0; v = Math.max(0, Math.min(1, v)); | |
| thN.value = v; thR.value = v; state.threshold = v; jumpToThreshold(); | |
| }); | |
| // Context size | |
| document.getElementById('contextNum').addEventListener('change', e => { | |
| let v = parseInt(e.target.value, 10); if (!isFinite(v)) v = 0; | |
| v = Math.max(0, Math.min(100, v)); e.target.value = v; | |
| state.contextSize = v; | |
| renderCurrentTexts(); | |
| }); | |
| // Filter | |
| document.getElementById('filterBox').addEventListener('input', e => { | |
| state.filterText = e.target.value.trim(); | |
| applyFilter(); | |
| state.selection.clear(); | |
| state.pivot = -1; | |
| if (state.filtered.length > 0) selectOne(0); | |
| else renderCurrentTexts(); | |
| }); | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', e => { | |
| if (e.target.matches('input, textarea')) return; | |
| if (state.filtered.length === 0) return; | |
| const key = e.key.toLowerCase(); | |
| if (key === 'v') { applyTag('v'); e.preventDefault(); } | |
| else if (key === 'r') { applyTag('r'); e.preventDefault(); } | |
| else if (key === 'c') { applyTag('c'); e.preventDefault(); } | |
| else if (key === 'arrowdown') { | |
| const next = Math.min(state.filtered.length - 1, state.pivot + 1); | |
| if (e.shiftKey) rangeSelect(next); else selectOne(next); | |
| e.preventDefault(); | |
| } else if (key === 'arrowup') { | |
| const prev = Math.max(0, state.pivot - 1); | |
| if (e.shiftKey) rangeSelect(prev); else selectOne(prev); | |
| e.preventDefault(); | |
| } else if (key === 'pagedown') { | |
| const next = Math.min(state.filtered.length - 1, state.pivot + 15); | |
| selectOne(next); e.preventDefault(); | |
| } else if (key === 'pageup') { | |
| const prev = Math.max(0, state.pivot - 15); | |
| selectOne(prev); e.preventDefault(); | |
| } | |
| }); | |
| // Window resize → recompute visible rows | |
| window.addEventListener('resize', () => requestAnimationFrame(renderVisibleRows)); | |
| // Init | |
| updateStats(); | |
| updateToggleButtons(); | |
| </script> | |
| </body> | |
| </html> | |