Paraphrasis / paraphrasis_annotator.html
TdelaSelle's picture
html page scaling fix
12777ba
Raw
History Blame Contribute Delete
54.1 kB
<!DOCTYPE html>
<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&nbsp;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&nbsp;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&nbsp;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&nbsp;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 =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>