first commit
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
// Doc viewer with polygon visualization
|
||||
const DOC_VIEWER = {
|
||||
svgEl: null,
|
||||
canvasEl: null,
|
||||
|
||||
init() {
|
||||
this.svgEl = $("#polygon-svg");
|
||||
this.canvasEl = $("#polygon-canvas");
|
||||
|
||||
$("#viewer-prev").addEventListener("click", () => this.goPage(-1));
|
||||
$("#viewer-next").addEventListener("click", () => this.goPage(1));
|
||||
$("#show-polygons").addEventListener("change", () => this.redraw());
|
||||
$("#show-text-layers").addEventListener("change", () => this.redraw());
|
||||
$("#show-chunk-labels").addEventListener("change", () => this.redraw());
|
||||
|
||||
$("input[id='viewer-page-input']").addEventListener("change", (e) => {
|
||||
const page = parseInt(e.target.value, 10);
|
||||
if (page >= 0 && page <= STORE.totalPages) {
|
||||
this.currentPage = page;
|
||||
this.loadPage(page);
|
||||
}
|
||||
});
|
||||
|
||||
// Click on polygon to show chunk info
|
||||
this.svgEl.addEventListener("click", (e) => {
|
||||
const target = e.target;
|
||||
if (target.dataset.chunkId) {
|
||||
this.showChunkInfo(target.dataset.chunkId);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async loadDocument(docId) {
|
||||
STORE.currentDocId = docId;
|
||||
const res = await API.get(`/api/documents/${docId}/chunks`);
|
||||
STORE.viewerDoc = res.chunks || [];
|
||||
|
||||
// Compute page range
|
||||
const pages = new Set(STORE.viewerDoc.map(c => c.page_num));
|
||||
STORE.totalPages = Math.max(...pages) + 1;
|
||||
STORE.currentPage = 0;
|
||||
|
||||
$("#viewer-doc-name").textContent = STORE.documents.find(d => d.id === docId)?.filename || "Unknown";
|
||||
$("#viewer-page-input").max = STORE.totalPages - 1;
|
||||
this.updatePageInfo();
|
||||
|
||||
await this.loadPage(0);
|
||||
},
|
||||
|
||||
async loadPage(page) {
|
||||
STORE.currentPage = page;
|
||||
this.updatePageInfo();
|
||||
|
||||
// Get chunks for this page
|
||||
const chunks = STORE.viewerDoc.filter(c => c.page_num === page);
|
||||
if (!chunks.length) {
|
||||
$("#viewer-toolbar > span:nth-child(2)").textContent = "No content";
|
||||
this.clearSvg();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get polygon data
|
||||
const polyRes = await API.get(`/api/documents/${STORE.currentDocId}/polygon-view?page=${page}`);
|
||||
STORE.polygons = polyRes.blocks || chunks.map((c, i) => ({
|
||||
...c,
|
||||
polygon: c.polygon || { x_min: 50, y_min: 50 + (i * 30), width: 900, height: 25 },
|
||||
}));
|
||||
|
||||
this.redraw();
|
||||
},
|
||||
|
||||
updatePageInfo() {
|
||||
$("#viewer-page-input").value = STORE.currentPage;
|
||||
$("#viewer-page-info").textContent = `Page ${STORE.currentPage + 1} / ${STORE.totalPages}`;
|
||||
},
|
||||
|
||||
goPage(dir) {
|
||||
const newPage = STORE.currentPage + dir;
|
||||
if (newPage >= 0 && newPage < STORE.totalPages) {
|
||||
this.loadPage(newPage);
|
||||
}
|
||||
},
|
||||
|
||||
clearSvg() {
|
||||
if (this.svgEl) {
|
||||
this.svgEl.innerHTML = "";
|
||||
}
|
||||
},
|
||||
|
||||
redraw() {
|
||||
const chunks = STORE.polygons.filter(c => c.polygon);
|
||||
if (!chunks.length || !this.svgEl) return;
|
||||
|
||||
this.clearSvg();
|
||||
|
||||
// Calculate bounding box from all polygons
|
||||
const bbox = chunks.reduce((acc, c) => {
|
||||
const p = c.polygon;
|
||||
if (!p || !p.bbox) return acc;
|
||||
const [x1, y1, x2, y2] = p.bbox;
|
||||
acc.x1 = Math.min(acc.x1, x1);
|
||||
acc.y1 = Math.min(acc.y1, y1);
|
||||
acc.x2 = Math.max(acc.x2, x2);
|
||||
acc.y2 = Math.max(acc.y2, y2);
|
||||
return acc;
|
||||
}, { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity });
|
||||
|
||||
const padding = 20;
|
||||
const svgWidth = 1000;
|
||||
const svgHeight = 1200;
|
||||
const scaleX = svgWidth / Math.max(bbox.x2 - bbox.x1, 100);
|
||||
const scaleY = svgHeight / Math.max(bbox.y2 - bbox.y1, 100);
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
this.svgEl.setAttribute("viewBox", `0 0 ${svgWidth} ${svgHeight}`);
|
||||
|
||||
// Page background
|
||||
const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
bg.setAttribute("x", padding);
|
||||
bg.setAttribute("y", padding);
|
||||
bg.setAttribute("width", svgWidth - padding * 2);
|
||||
bg.setAttribute("height", svgHeight - padding * 2);
|
||||
bg.setAttribute("fill", "#1e1e2e");
|
||||
bg.setAttribute("rx", "4");
|
||||
this.svgEl.appendChild(bg);
|
||||
|
||||
const showPolys = $("#show-polygons").checked;
|
||||
const showText = $("#show-text-layers").checked;
|
||||
const showLabels = $("#show-chunk-labels").checked;
|
||||
|
||||
// Draw each chunk as a polygon/rect
|
||||
chunks.forEach((chunk, idx) => {
|
||||
let rect;
|
||||
let x, y, w, h;
|
||||
|
||||
// Handle both bbox format [x1, y1, x2, y2] and explicit polygon format
|
||||
let coords;
|
||||
if (chunk.polygon.bbox && Array.isArray(chunk.polygon.bbox)) {
|
||||
const [x1, y1, x2, y2] = chunk.polygon.bbox;
|
||||
x = padding + (x1 - bbox.x1) * scale;
|
||||
y = padding + (y1 - bbox.y1) * scale;
|
||||
w = Math.max((x2 - x1) * scale, 10);
|
||||
h = Math.max((y2 - y1) * scale, 5);
|
||||
} else if (chunk.polygon.polygon && Array.isArray(chunk.polygon.polygon)) {
|
||||
const pol = chunk.polygon.polygon;
|
||||
const minX = Math.min(...pol.map(p => p[0]));
|
||||
const minY = Math.min(...pol.map(p => p[1]));
|
||||
const maxX = Math.max(...pol.map(p => p[0]));
|
||||
const maxY = Math.max(...pol.map(p => p[1]));
|
||||
x = padding + (minX - bbox.x1) * scale;
|
||||
y = padding + (minY - bbox.y1) * scale;
|
||||
w = Math.max((maxX - minX) * scale, 10);
|
||||
h = Math.max((maxY - minY) * scale, 5);
|
||||
} else if (chunk.polygon.x_min != null) {
|
||||
x = padding + (chunk.polygon.x_min - bbox.x1) * scale;
|
||||
y = padding + (chunk.polygon.y_min - bbox.y1) * scale;
|
||||
w = Math.max((chunk.polygon.width || 100) * scale, 50);
|
||||
h = Math.max((chunk.polygon.height || 20) * scale, 5);
|
||||
} else {
|
||||
return; // Skip if no polygon data
|
||||
}
|
||||
|
||||
// Polygon shape
|
||||
if (showPolys) {
|
||||
rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
rect.setAttribute("x", x);
|
||||
rect.setAttribute("y", y);
|
||||
rect.setAttribute("width", w);
|
||||
rect.setAttribute("height", h);
|
||||
rect.setAttribute("rx", "2");
|
||||
|
||||
// Color by chunk type
|
||||
const colors = {
|
||||
"text": "#58a6ff",
|
||||
"figure": "#3fb950",
|
||||
"table": "#a371f7",
|
||||
"footnote": "#f0883e",
|
||||
"equation": "#f85149",
|
||||
"list": "#d29922",
|
||||
"caption": "#39d2c0",
|
||||
"title": "#e6edf3",
|
||||
};
|
||||
const color = colors[chunk.type] || colors["text"];
|
||||
|
||||
rect.setAttribute("fill", `${color}15`);
|
||||
rect.setAttribute("stroke", `${color}80`);
|
||||
rect.setAttribute("stroke-width", "1");
|
||||
rect.setAttribute("stroke-dasharray", "3,2");
|
||||
rect.dataset.chunkId = chunk.id;
|
||||
rect.style.cursor = "pointer";
|
||||
rect.addEventListener("mouseenter", () => {
|
||||
rect.setAttribute("stroke-width", "2");
|
||||
rect.setAttribute("fill", `${color}30`);
|
||||
});
|
||||
rect.addEventListener("mouseleave", () => {
|
||||
rect.setAttribute("stroke-width", "1");
|
||||
rect.setAttribute("fill", `${color}15`);
|
||||
});
|
||||
|
||||
// Store text content as tooltip
|
||||
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
||||
title.textContent = chunk.content ? chunk.content.substring(0, 100) : "";
|
||||
rect.appendChild(title);
|
||||
|
||||
this.svgEl.appendChild(rect);
|
||||
}
|
||||
|
||||
// Text label overlay
|
||||
if (showText && chunk.content) {
|
||||
const fontSize = Math.max(6, Math.min(12, h * 0.6));
|
||||
if (h > 12) {
|
||||
const textEl = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
textEl.setAttribute("x", x + 3);
|
||||
textEl.setAttribute("y", y + fontSize + 1);
|
||||
textEl.setAttribute("fill", "#e6edf3");
|
||||
textEl.setAttribute("font-size", fontSize);
|
||||
textEl.setAttribute("font-family", "monospace");
|
||||
|
||||
// Truncate text to fit
|
||||
const maxChars = Math.max(2, Math.floor(w / (fontSize * 0.55)));
|
||||
let displayText = chunk.content.substring(0, maxChars);
|
||||
if (chunk.content.length > maxChars) displayText += "...";
|
||||
|
||||
textEl.textContent = displayText;
|
||||
this.svgEl.appendChild(textEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk index label
|
||||
if (showLabels) {
|
||||
const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
label.setAttribute("x", x);
|
||||
label.setAttribute("y", Math.max(y - 4, 12));
|
||||
label.setAttribute("fill", `${color || "#58a6ff"}aa`);
|
||||
label.setAttribute("font-size", "8");
|
||||
label.setAttribute("font-family", "monospace");
|
||||
label.textContent = `${idx}`;
|
||||
this.svgEl.appendChild(label);
|
||||
}
|
||||
});
|
||||
|
||||
// Legend
|
||||
const legendY = svgHeight - 30;
|
||||
const legendBg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
legendBg.setAttribute("x", padding);
|
||||
legendBg.setAttribute("y", legendY);
|
||||
legendBg.setAttribute("width", "180");
|
||||
legendBg.setAttribute("height", "22");
|
||||
legendBg.setAttribute("fill", "var(--bg-primary)");
|
||||
legendBg.setAttribute("rx", "3");
|
||||
this.svgEl.appendChild(legendBg);
|
||||
|
||||
const legendText = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
legendText.setAttribute("x", padding + 8);
|
||||
legendText.setAttribute("y", legendY + 14);
|
||||
legendText.setAttribute("fill", "#8b949e");
|
||||
legendText.setAttribute("font-size", "9");
|
||||
legendText.textContent = "Click a region to view content";
|
||||
this.svgEl.appendChild(legendText);
|
||||
},
|
||||
|
||||
showChunkInfo(chunkId) {
|
||||
const chunk = STORE.viewerDoc.find(c => c.id === chunkId) ||
|
||||
STORE.polygons.find(c => c.id === chunkId);
|
||||
if (!chunk) return;
|
||||
|
||||
// Switch to analysis tab and show chunk
|
||||
const content = chunk.content || "(no text content)";
|
||||
const page = chunk.page_num ?? "?";
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "block";
|
||||
div.style.cursor = "pointer";
|
||||
div.innerHTML = `<div style="font-size:10px;color:var(--accent-cyan);margin-bottom:4px">[p${page}]${chunk.polygon ? ' (polygon)' : ''}</div><div style="font-size:12px;color:var(--text-primary)">${content.substring(0, 300)}</div>`;
|
||||
|
||||
div.addEventListener("click", () => {
|
||||
// Open in research tab
|
||||
const output = $("#research-output");
|
||||
const section = document.createElement("div");
|
||||
section.className = "research-section";
|
||||
section.innerHTML = `<h3>Chunk Viewer - Page ${page}</h3>
|
||||
<div style="background:var(--bg-tertiary);padding:10px;border-radius:6px;margin:8px 0;font-family:monospace;font-size:12px;color:var(--text-secondary);white-space:pre-wrap;max-height:300px;overflow-y:auto">${content}</div>`;
|
||||
|
||||
if (output.querySelector(".empty-state")) {
|
||||
output.innerHTML = "";
|
||||
}
|
||||
output.appendChild(section);
|
||||
|
||||
// Switch to analysis tab
|
||||
$$(".tab").forEach(t => t.classList.remove("active"));
|
||||
$$(".tab-pane").forEach(p => p.classList.remove("active"));
|
||||
document.querySelector('[data-tab="analysis"]').classList.add("active");
|
||||
$("#tab-analysis").classList.add("active");
|
||||
});
|
||||
|
||||
showToast(`Chunk p${page}: ${content.substring(0, 50)}...`, "info");
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user