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>
192 lines
8.7 KiB
JavaScript
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' });
|
|
}
|
|
}
|