pdf-accessibility/js/page-viewer.js
Vadym Samoilenko e0f961ffb9 Fix pin-click navigation, cap image quality noise, drop Google Vision label spam
Page viewer:
- loadVisualPage() now accepts highlightNum; highlights marker after image onload
  (was using fixed 300ms timeout which fired before GCS image finished loading)
- viewOnPage() passes markerNum directly to loadVisualPage()

Image analysis:
- Quality concerns downgraded WARNING → INFO (advisory, not WCAG violations)
- Cap at 2 concerns per image (was unlimited)
- Google Vision label detections removed — not actionable accessibility issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 18:56:41 +00:00

192 lines
8.7 KiB
JavaScript

/* Visual Page Inspector — image viewer with SVG marker overlays */
let currentPageData = null;
let currentZoom = 1.0;
let currentVisualPage = 1;
let tooltipDiv = null;
function initializePageViewer(data) {
if (!data.page_images || Object.keys(data.page_images).length === 0) return;
document.getElementById('pageViewerCard').style.display = 'block';
currentPageData = data;
const pageSelector = document.getElementById('pageSelector');
const pageNumbers = Object.keys(data.page_images).map(Number).sort((a, b) => a - b);
pageSelector.innerHTML = pageNumbers.map(pn => {
const pi = data.issues.filter(i => i.page_number === pn);
let color = '#10b981';
if (pi.some(i => i.severity === 'CRITICAL')) color = '#dc2626';
else if (pi.some(i => i.severity === 'ERROR')) color = '#ef4444';
else if (pi.some(i => i.severity === 'WARNING')) color = '#f59e0b';
return `<button onclick="loadVisualPage(${pn})" id="pageBtn${pn}" aria-label="View page ${pn}, ${pi.length} issues"
style="padding:10px;border:2px solid #ddd;background:var(--surface);border-radius:6px;cursor:pointer;text-align:left;transition:all 0.2s;display:flex;justify-content:space-between;align-items:center;color:var(--text);">
<span>Page ${pn}</span>
${pi.length > 0 ? `<span style="background:${color};color:white;padding:2px 6px;border-radius:12px;font-size:11px;">${pi.length}</span>` : ''}
</button>`;
}).join('');
const firstWithIssues = pageNumbers.find(p => data.issues.some(i => i.page_number === p));
loadVisualPage(firstWithIssues || pageNumbers[0]);
}
function loadVisualPage(pageNum, highlightNum) {
if (!currentPageData || !currentPageData.page_images[pageNum]) return;
currentVisualPage = pageNum;
document.getElementById('currentPageTitle').textContent = `Page ${pageNum}`;
document.querySelectorAll('[id^="pageBtn"]').forEach(btn => {
btn.style.background = 'var(--surface)';
btn.style.fontWeight = 'normal';
});
const sel = document.getElementById(`pageBtn${pageNum}`);
if (sel) { sel.style.background = 'var(--accent-subtle)'; sel.style.fontWeight = '600'; }
const img = document.getElementById('pageImage');
img.onload = () => {
drawMarkers(pageNum);
if (highlightNum !== undefined) {
// Markers are drawn synchronously in drawMarkers — highlight immediately after
setTimeout(() => highlightMarker(highlightNum), 50);
}
};
// Use GCS URL directly if available, otherwise fall back to api.php
const imageUrl = currentPageData.page_images[pageNum];
if (imageUrl && (imageUrl.startsWith('http://') || imageUrl.startsWith('https://'))) {
img.src = imageUrl;
} else {
img.src = `api.php?action=image&job_id=${currentJobId}&page=${pageNum}`;
}
}
function drawMarkers(pageNum) {
const svg = document.getElementById('markerOverlay');
const img = document.getElementById('pageImage');
svg.innerHTML = '';
const imgW = img.naturalWidth;
const imgH = img.naturalHeight;
const dispW = img.clientWidth;
const dispH = img.clientHeight;
const dpi = currentPageData.page_image_dpi || 150;
const scale = dpi / 72.0;
svg.setAttribute('viewBox', `0 0 ${imgW} ${imgH}`);
svg.setAttribute('width', dispW);
svg.setAttribute('height', dispH);
const allWithCoords = currentPageData.issues.filter(i => i.coordinates && i.page_number);
const pageIssues = allWithCoords.filter(i => i.page_number === pageNum);
if (pageIssues.length === 0) return;
// Group by coordinates
const groups = {};
pageIssues.forEach(issue => {
const gIdx = allWithCoords.indexOf(issue) + 1;
const key = `${issue.coordinates.x0}-${issue.coordinates.y0}-${issue.coordinates.x1}-${issue.coordinates.y1}`;
if (!groups[key]) groups[key] = { coords: issue.coordinates, issues: [], numbers: [], primary: issue };
groups[key].issues.push(issue);
groups[key].numbers.push(gIdx);
});
Object.values(groups).forEach(group => {
const coords = group.coords;
const nums = group.numbers;
const cnt = group.issues.length;
const x0 = coords.x0 * scale;
const y0 = coords.y0 * scale;
const x1 = coords.x1 * scale;
const y1 = coords.y1 * scale;
let stroke, fill;
switch (group.primary.severity) {
case 'CRITICAL': stroke = '#dc2626'; fill = 'rgba(220,38,38,0.2)'; break;
case 'ERROR': stroke = '#ef4444'; fill = 'rgba(239,68,68,0.2)'; break;
case 'WARNING': stroke = '#f59e0b'; fill = 'rgba(245,158,11,0.2)'; break;
default: stroke = '#3b82f6'; fill = 'rgba(59,130,246,0.2)';
}
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', x0); rect.setAttribute('y', y0);
rect.setAttribute('width', x1 - x0); rect.setAttribute('height', y1 - y0);
rect.setAttribute('fill', fill); rect.setAttribute('stroke', stroke);
rect.setAttribute('stroke-width', '3'); rect.setAttribute('stroke-dasharray', '5,5');
rect.setAttribute('rx', '4');
rect.style.cursor = 'pointer'; rect.style.pointerEvents = 'all';
rect.addEventListener('mouseenter', e => showIssueTooltip(e, group.issues));
rect.addEventListener('mouseleave', hideIssueTooltip);
svg.appendChild(rect);
const label = cnt > 1 ? `${nums[0]}+${cnt - 1}` : `${nums[0]}`;
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', x0 + 20); circle.setAttribute('cy', y0 + 20);
circle.setAttribute('r', cnt > 1 ? '18' : '16');
circle.setAttribute('fill', stroke); circle.setAttribute('stroke', 'white');
circle.setAttribute('stroke-width', '2'); circle.setAttribute('id', `marker-${nums[0]}`);
svg.appendChild(circle);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', x0 + 20); text.setAttribute('y', y0 + 26);
text.setAttribute('text-anchor', 'middle'); text.setAttribute('fill', 'white');
text.setAttribute('font-size', cnt > 1 ? '11' : '13'); text.setAttribute('font-weight', 'bold');
text.textContent = label;
svg.appendChild(text);
});
}
function showIssueTooltip(event, issues) {
if (!Array.isArray(issues)) issues = [issues];
if (!tooltipDiv) {
tooltipDiv = document.createElement('div');
Object.assign(tooltipDiv.style, {
position: 'fixed', background: 'rgba(0,0,0,0.95)', color: 'white',
padding: '12px', borderRadius: '8px', maxWidth: '400px', maxHeight: '400px',
overflowY: 'auto', zIndex: '10000', fontSize: '13px', pointerEvents: 'none'
});
document.body.appendChild(tooltipDiv);
}
const html = issues.map((issue, idx) => `
<div style="margin-bottom:${idx < issues.length - 1 ? '10px' : '0'};padding-bottom:${idx < issues.length - 1 ? '10px' : '0'};border-bottom:${idx < issues.length - 1 ? '1px solid #444' : 'none'};">
<div style="font-weight:bold;margin-bottom:3px;color:${getSeverityColor(issue.severity)};">${issue.severity}: ${issue.category}</div>
<div style="margin-bottom:3px;font-size:12px;">${issue.description}</div>
${issue.recommendation ? `<div style="font-size:11px;opacity:0.9;"><strong>Tip:</strong> ${issue.recommendation}</div>` : ''}
</div>
`).join('');
tooltipDiv.innerHTML = issues.length > 1
? `<div style="font-size:11px;opacity:0.8;margin-bottom:8px;">${issues.length} issues at this location:</div>` + html
: html;
tooltipDiv.style.display = 'block';
tooltipDiv.style.left = (event.clientX + 15) + 'px';
tooltipDiv.style.top = (event.clientY + 15) + 'px';
}
function hideIssueTooltip() {
if (tooltipDiv) tooltipDiv.style.display = 'none';
}
function zoomIn() { currentZoom = Math.min(currentZoom + 0.25, 3.0); applyZoom(); }
function zoomOut() { currentZoom = Math.max(currentZoom - 0.25, 0.5); applyZoom(); }
function resetZoom() { currentZoom = 1.0; applyZoom(); }
function applyZoom() {
document.getElementById('zoomContainer').style.transform = `scale(${currentZoom})`;
document.getElementById('zoomLevel').textContent = `${Math.round(currentZoom * 100)}%`;
}
function highlightMarker(issueNumber) {
const marker = document.getElementById(`marker-${issueNumber}`);
if (marker) {
const r = marker.getAttribute('r');
marker.setAttribute('r', parseFloat(r) * 1.5);
setTimeout(() => marker.setAttribute('r', r), 300);
marker.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}