Fix screenshot loading regression; add viewer nav; chart every ranked element
Browse filesThe previous fix routed every screenshot through fetch()+blob: to dodge a
hypothetical canvas-taint issue, but fetch() against file:// screenshots
turns out to be restricted in the packaged app's embedded webview even
though plain <img src> to the same path is not -- so every screenshot
started failing to load, producing the "Still loading this screenshot"
and "Could not load this session's screenshots" messages. Reverted to
direct <img>/Image src loading (the mechanism already proven to work)
for both the per-segment view and the full-session stitching; compositing
for export still works fine since it only draws already-displayed,
same-directory screenshots.
Screenshots are now clickable (not just the maximize button) and the
viewer gained Previous/Next controls (buttons, arrow keys) that swap the
displayed segment in place without closing the viewer.
Element Attention Analysis now plots every row of the existing Ranked
Attention data, in its existing order, instead of 5 aggregated categories
-- one point per ranked element, with the chart widening and the panel
scrolling horizontally once there are enough elements to need it.
Still confined entirely to the report's own HTML/CSS/JS template; no
tracking, screenshot capture, calibration, or analytics code touched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- analysis.py +129 -96
|
@@ -437,7 +437,8 @@ __THEME_CSS__
|
|
| 437 |
.hm-toolbar .slider-mini input { accent-color: var(--iux-primary-light); }
|
| 438 |
.hm-toolbar .iux-btn { padding: 7px 10px; font-size: 11.5px; margin-left: auto; }
|
| 439 |
|
| 440 |
-
.shot-wrap { position: relative; border-radius: var(--iux-radius); overflow: hidden; background: var(--iux-bg-alt); border: 1px solid var(--iux-border); }
|
|
|
|
| 441 |
.shot-wrap img, .shot-wrap canvas { display: block; width: 100%; height: auto; }
|
| 442 |
.shot-wrap canvas { position: absolute; top: 0; left: 0; }
|
| 443 |
.shot-nav { display: flex; align-items: center; justify-content: space-between;
|
|
@@ -455,7 +456,9 @@ __THEME_CSS__
|
|
| 455 |
.hm-caption { font-size: 11px; color: var(--iux-text-faint); margin-top: 8px; line-height: 1.5; }
|
| 456 |
|
| 457 |
.elem-chart-wrap { position: relative; }
|
| 458 |
-
.elem-chart-
|
|
|
|
|
|
|
| 459 |
.elem-tooltip {
|
| 460 |
position: absolute; pointer-events: none; background: var(--iux-surface-hi); color: var(--iux-text);
|
| 461 |
border: 1px solid var(--iux-border); border-radius: 8px; padding: 8px 12px; font-size: 11.5px;
|
|
@@ -480,6 +483,15 @@ __THEME_CSS__
|
|
| 480 |
.iv-stage { flex: 1 1 auto; position: relative; overflow: hidden; cursor: grab; }
|
| 481 |
.iv-stage.grabbing { cursor: grabbing; }
|
| 482 |
#ivImage { position: absolute; top: 50%; left: 50%; max-width: none; user-select: none; -webkit-user-select: none; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
.iv-bottombar {
|
| 484 |
display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 18px;
|
| 485 |
background: rgba(18,19,26,0.9); border-top: 1px solid rgba(255,255,255,0.1); flex-shrink: 0; flex-wrap: wrap;
|
|
@@ -568,11 +580,14 @@ __THEME_CSS__
|
|
| 568 |
</div>
|
| 569 |
</div>
|
| 570 |
<div class="panel iux-card full iux-fade-in">
|
| 571 |
-
<h2>__BARCHART_ICON__ Element Attention Analysis</h2>
|
| 572 |
<div class="elem-chart-wrap" id="elemChartWrap">
|
| 573 |
-
<
|
|
|
|
|
|
|
| 574 |
<div class="elem-tooltip" id="elemTooltip" style="display:none;"></div>
|
| 575 |
</div>
|
|
|
|
| 576 |
</div>
|
| 577 |
<div class="panel iux-card full iux-fade-in">
|
| 578 |
<h2>__CLOCK_ICON__ Attention Timeline</h2>
|
|
@@ -599,7 +614,9 @@ __THEME_CSS__
|
|
| 599 |
<button type="button" class="iux-btn" id="ivClose" title="Close (Esc)">__CLOSE_ICON__</button>
|
| 600 |
</div>
|
| 601 |
<div class="iv-stage" id="ivStage">
|
|
|
|
| 602 |
<img id="ivImage" draggable="false" alt="">
|
|
|
|
| 603 |
</div>
|
| 604 |
<div class="iv-bottombar">
|
| 605 |
<button type="button" class="iux-btn" id="ivZoomOut" title="Zoom out">−</button>
|
|
@@ -674,6 +691,9 @@ const Viewer = (function(){
|
|
| 674 |
let dragging = false, dragStartX = 0, dragStartY = 0, panStartX = 0, panStartY = 0;
|
| 675 |
let naturalW = 0, naturalH = 0;
|
| 676 |
let downloadName = 'insightux-image.png';
|
|
|
|
|
|
|
|
|
|
| 677 |
|
| 678 |
function applyTransform(){
|
| 679 |
img.style.transform = 'translate(-50%, -50%) translate(' + panX + 'px,' + panY + 'px) scale(' + scale + ')';
|
|
@@ -708,6 +728,8 @@ const Viewer = (function(){
|
|
| 708 |
|
| 709 |
function onKeydown(e){
|
| 710 |
if (e.key === 'Escape') close();
|
|
|
|
|
|
|
| 711 |
}
|
| 712 |
|
| 713 |
function open(opts){
|
|
@@ -721,6 +743,10 @@ const Viewer = (function(){
|
|
| 721 |
}
|
| 722 |
titleEl.textContent = opts.title || '';
|
| 723 |
downloadName = opts.downloadName || 'insightux-image.png';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
img.src = dataUrl;
|
| 725 |
img.onload = function(){
|
| 726 |
naturalW = img.naturalWidth; naturalH = img.naturalHeight;
|
|
@@ -773,6 +799,8 @@ const Viewer = (function(){
|
|
| 773 |
zoomSlider.addEventListener('input', function(e){ setScale(e.target.value / 100); });
|
| 774 |
document.getElementById('ivClose').addEventListener('click', close);
|
| 775 |
document.getElementById('ivBack').addEventListener('click', close);
|
|
|
|
|
|
|
| 776 |
document.getElementById('ivFullscreen').addEventListener('click', function(){
|
| 777 |
if (!document.fullscreenElement) { el.requestFullscreen().catch(function(){}); }
|
| 778 |
else { document.exitFullscreen().catch(function(){}); }
|
|
@@ -944,32 +972,22 @@ function paintHeatLayer(ctx, w, h, points, intensity, stops, opacity, composite)
|
|
| 944 |
}
|
| 945 |
|
| 946 |
// Loads a screenshot as a blob: URL rather than pointing <img> straight at
|
| 947 |
-
//
|
| 948 |
-
//
|
| 949 |
-
//
|
| 950 |
-
//
|
| 951 |
-
//
|
| 952 |
-
// the
|
| 953 |
-
//
|
|
|
|
|
|
|
| 954 |
function loadImage(src){
|
| 955 |
-
return
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
return new Promise(function(resolve, reject){
|
| 962 |
-
const url = URL.createObjectURL(blob);
|
| 963 |
-
const im = new Image();
|
| 964 |
-
im.onload = function(){ resolve(im); };
|
| 965 |
-
im.onerror = function(){ reject(new Error('image decode failed')); };
|
| 966 |
-
im.src = url;
|
| 967 |
-
});
|
| 968 |
-
})
|
| 969 |
-
.catch(function(err){
|
| 970 |
-
console.warn('[insightux-report] could not load screenshot:', src, err);
|
| 971 |
-
return null;
|
| 972 |
-
});
|
| 973 |
}
|
| 974 |
|
| 975 |
// Minimal toast so a failed export/viewer action is visible instead of
|
|
@@ -1077,22 +1095,40 @@ function showToast(message, isError){
|
|
| 1077 |
return out;
|
| 1078 |
}
|
| 1079 |
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1087 |
});
|
| 1088 |
-
}
|
|
|
|
|
|
|
| 1089 |
document.getElementById('hmDownload').addEventListener('click', function(){
|
| 1090 |
const canvas = composeSegmentCanvas();
|
| 1091 |
-
if (!canvas) { showToast('
|
| 1092 |
try {
|
| 1093 |
const a = document.createElement('a');
|
| 1094 |
a.href = canvas.toDataURL('image/png');
|
| 1095 |
-
a.download =
|
| 1096 |
a.click();
|
| 1097 |
} catch (err) {
|
| 1098 |
console.warn('[insightux-report] segment download failed:', err);
|
|
@@ -1100,11 +1136,18 @@ function showToast(message, isError){
|
|
| 1100 |
}
|
| 1101 |
});
|
| 1102 |
|
| 1103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1104 |
const seg = SEGMENTS[i];
|
| 1105 |
area.innerHTML = `
|
| 1106 |
-
<div class="shot-wrap" id="shotWrap">
|
| 1107 |
-
<img id="shotImg" alt="">
|
| 1108 |
<canvas id="shotCanvas"></canvas>
|
| 1109 |
</div>
|
| 1110 |
<div class="shot-nav">
|
|
@@ -1116,24 +1159,22 @@ function showToast(message, isError){
|
|
| 1116 |
|
| 1117 |
document.getElementById('prevBtn').onclick = () => { if (cur>0){ cur--; renderSegment(cur); } };
|
| 1118 |
document.getElementById('nextBtn').onclick = () => { if (cur<SEGMENTS.length-1){ cur++; renderSegment(cur); } };
|
|
|
|
| 1119 |
|
| 1120 |
const img = document.getElementById('shotImg');
|
| 1121 |
const cv = document.getElementById('shotCanvas');
|
| 1122 |
const ctx = cv.getContext('2d');
|
| 1123 |
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
if (!loaded || document.getElementById('shotImg') !== img) return; // segment may have changed while awaiting
|
| 1135 |
-
img.src = loaded.src;
|
| 1136 |
-
if (img.complete) paint(); else img.onload = paint;
|
| 1137 |
}
|
| 1138 |
|
| 1139 |
renderSegment(cur);
|
|
@@ -1204,59 +1245,44 @@ function showToast(message, isError){
|
|
| 1204 |
document.getElementById('exportCombined').addEventListener('click', function(){ exportFullSession('combined'); });
|
| 1205 |
})();
|
| 1206 |
|
| 1207 |
-
// ---------- Element Attention Analysis: line chart
|
| 1208 |
-
//
|
| 1209 |
-
//
|
|
|
|
| 1210 |
(function(){
|
| 1211 |
const wrap = document.getElementById('elemChartWrap');
|
|
|
|
| 1212 |
const cv = document.getElementById('elemChart');
|
| 1213 |
const tooltip = document.getElementById('elemTooltip');
|
|
|
|
|
|
|
| 1214 |
const ctx = cv.getContext('2d');
|
| 1215 |
|
| 1216 |
if (!RANKING.length){
|
| 1217 |
-
wrap.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('bar-chart', 26) + '</span>Not enough attention data yet to
|
| 1218 |
-
return;
|
| 1219 |
-
}
|
| 1220 |
-
const CATS = {
|
| 1221 |
-
image: { name: 'Images' },
|
| 1222 |
-
video: { name: 'Video & Embeds' },
|
| 1223 |
-
text: { name: 'Headings & Text' },
|
| 1224 |
-
nav: { name: 'Navigation' },
|
| 1225 |
-
other: { name: 'Buttons & Other' },
|
| 1226 |
-
};
|
| 1227 |
-
function categoryFor(label){
|
| 1228 |
-
if (label.indexOf('Image') === 0) return 'image';
|
| 1229 |
-
if (label.indexOf('Video') === 0 || label.indexOf('Embedded') === 0) return 'video';
|
| 1230 |
-
if (label.indexOf('heading') !== -1 || label.indexOf('Heading') === 0 || label.indexOf('Text') === 0) return 'text';
|
| 1231 |
-
if (label.indexOf('Navigation') === 0) return 'nav';
|
| 1232 |
-
return 'other';
|
| 1233 |
-
}
|
| 1234 |
-
const totals = {};
|
| 1235 |
-
RANKING.forEach(r => { const c = categoryFor(r.label); totals[c] = (totals[c] || 0) + r.seconds; });
|
| 1236 |
-
const grand = Object.values(totals).reduce((a,b) => a+b, 0) || 1;
|
| 1237 |
-
const points = Object.keys(totals).map(key => ({
|
| 1238 |
-
key, name: CATS[key].name,
|
| 1239 |
-
seconds: Math.round(totals[key] * 10) / 10,
|
| 1240 |
-
pct: Math.round(100 * totals[key] / grand),
|
| 1241 |
-
})).filter(r => r.seconds > 0).sort((a,b) => b.seconds - a.seconds);
|
| 1242 |
-
|
| 1243 |
-
if (!points.length){
|
| 1244 |
-
wrap.innerHTML = '<div class="empty">No categorized attention data yet.</div>';
|
| 1245 |
return;
|
| 1246 |
}
|
| 1247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1248 |
let animProgress = 0;
|
| 1249 |
let hoverIdx = -1;
|
| 1250 |
let raf = null;
|
| 1251 |
|
| 1252 |
function layout(){
|
| 1253 |
-
const
|
| 1254 |
-
|
| 1255 |
-
cv.
|
|
|
|
|
|
|
| 1256 |
}
|
| 1257 |
|
| 1258 |
function geometry(){
|
| 1259 |
-
const padLeft = 54, padRight = 30, padTop = 26, padBottom =
|
| 1260 |
const plotW = cv.width - padLeft - padRight;
|
| 1261 |
const plotH = cv.height - padTop - padBottom;
|
| 1262 |
const maxVal = Math.max.apply(null, points.map(p => p.seconds)) || 1;
|
|
@@ -1328,7 +1354,8 @@ function showToast(message, isError){
|
|
| 1328 |
ctx.stroke();
|
| 1329 |
}
|
| 1330 |
|
| 1331 |
-
// markers + x-axis labels
|
|
|
|
| 1332 |
pts.forEach((pt, i) => {
|
| 1333 |
const isHover = i === hoverIdx;
|
| 1334 |
ctx.beginPath();
|
|
@@ -1339,11 +1366,15 @@ function showToast(message, isError){
|
|
| 1339 |
ctx.strokeStyle = surface;
|
| 1340 |
ctx.stroke();
|
| 1341 |
|
|
|
|
|
|
|
|
|
|
| 1342 |
ctx.fillStyle = isHover ? strong : dim;
|
| 1343 |
-
ctx.font = isHover ? '600
|
| 1344 |
-
ctx.textAlign = '
|
| 1345 |
-
const label = pt.p.name.length >
|
| 1346 |
-
ctx.fillText(label,
|
|
|
|
| 1347 |
});
|
| 1348 |
|
| 1349 |
// axis baseline
|
|
@@ -1357,7 +1388,7 @@ function showToast(message, isError){
|
|
| 1357 |
ctx.fillStyle = faint;
|
| 1358 |
ctx.font = '600 10px -apple-system, Arial';
|
| 1359 |
ctx.textAlign = 'center';
|
| 1360 |
-
ctx.fillText('WEBPAGE
|
| 1361 |
ctx.save();
|
| 1362 |
ctx.translate(14, g.padTop + g.plotH / 2);
|
| 1363 |
ctx.rotate(-Math.PI / 2);
|
|
@@ -1539,6 +1570,8 @@ def generate_report(session_dir):
|
|
| 1539 |
html = html.replace("__BARCHART_ICON__", theme.icon("bar-chart", 15))
|
| 1540 |
html = html.replace("__BACK_ICON__", theme.icon("arrow-left", 14))
|
| 1541 |
html = html.replace("__CLOSE_ICON__", theme.icon("x", 14))
|
|
|
|
|
|
|
| 1542 |
|
| 1543 |
out_path = os.path.join(session_dir, "analysis_report.html")
|
| 1544 |
with open(out_path, "w", encoding="utf-8") as f:
|
|
|
|
| 437 |
.hm-toolbar .slider-mini input { accent-color: var(--iux-primary-light); }
|
| 438 |
.hm-toolbar .iux-btn { padding: 7px 10px; font-size: 11.5px; margin-left: auto; }
|
| 439 |
|
| 440 |
+
.shot-wrap { position: relative; border-radius: var(--iux-radius); overflow: hidden; background: var(--iux-bg-alt); border: 1px solid var(--iux-border); cursor: zoom-in; }
|
| 441 |
+
.shot-wrap:hover { box-shadow: var(--iux-shadow-glow); }
|
| 442 |
.shot-wrap img, .shot-wrap canvas { display: block; width: 100%; height: auto; }
|
| 443 |
.shot-wrap canvas { position: absolute; top: 0; left: 0; }
|
| 444 |
.shot-nav { display: flex; align-items: center; justify-content: space-between;
|
|
|
|
| 456 |
.hm-caption { font-size: 11px; color: var(--iux-text-faint); margin-top: 8px; line-height: 1.5; }
|
| 457 |
|
| 458 |
.elem-chart-wrap { position: relative; }
|
| 459 |
+
.elem-chart-scroll { overflow-x: auto; overflow-y: hidden; border-radius: var(--iux-radius-sm); }
|
| 460 |
+
.elem-chart-scroll canvas { height: 340px; display: block; }
|
| 461 |
+
.elem-chart-hint { font-size: 10.5px; color: var(--iux-text-faint); margin-top: 6px; text-align: right; }
|
| 462 |
.elem-tooltip {
|
| 463 |
position: absolute; pointer-events: none; background: var(--iux-surface-hi); color: var(--iux-text);
|
| 464 |
border: 1px solid var(--iux-border); border-radius: 8px; padding: 8px 12px; font-size: 11.5px;
|
|
|
|
| 483 |
.iv-stage { flex: 1 1 auto; position: relative; overflow: hidden; cursor: grab; }
|
| 484 |
.iv-stage.grabbing { cursor: grabbing; }
|
| 485 |
#ivImage { position: absolute; top: 50%; left: 50%; max-width: none; user-select: none; -webkit-user-select: none; }
|
| 486 |
+
.iv-nav {
|
| 487 |
+
position: absolute; top: 50%; transform: translateY(-50%); z-index: 5;
|
| 488 |
+
width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
| 489 |
+
color: #f1eefa; background: rgba(18,19,26,0.65); border-color: rgba(255,255,255,0.16);
|
| 490 |
+
}
|
| 491 |
+
.iv-nav:hover { background: rgba(139,92,246,0.55); }
|
| 492 |
+
.iv-nav-prev { left: 16px; }
|
| 493 |
+
.iv-nav-next { right: 16px; }
|
| 494 |
+
.iv-nav[disabled] { display: none; }
|
| 495 |
.iv-bottombar {
|
| 496 |
display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 18px;
|
| 497 |
background: rgba(18,19,26,0.9); border-top: 1px solid rgba(255,255,255,0.1); flex-shrink: 0; flex-wrap: wrap;
|
|
|
|
| 580 |
</div>
|
| 581 |
</div>
|
| 582 |
<div class="panel iux-card full iux-fade-in">
|
| 583 |
+
<h2>__BARCHART_ICON__ Element Attention Analysis <span class="n" id="elemChartCount"></span></h2>
|
| 584 |
<div class="elem-chart-wrap" id="elemChartWrap">
|
| 585 |
+
<div class="elem-chart-scroll" id="elemChartScroll">
|
| 586 |
+
<canvas id="elemChart" width="1100" height="340"></canvas>
|
| 587 |
+
</div>
|
| 588 |
<div class="elem-tooltip" id="elemTooltip" style="display:none;"></div>
|
| 589 |
</div>
|
| 590 |
+
<div class="elem-chart-hint" id="elemChartHint" style="display:none;">Scroll to see every element →</div>
|
| 591 |
</div>
|
| 592 |
<div class="panel iux-card full iux-fade-in">
|
| 593 |
<h2>__CLOCK_ICON__ Attention Timeline</h2>
|
|
|
|
| 614 |
<button type="button" class="iux-btn" id="ivClose" title="Close (Esc)">__CLOSE_ICON__</button>
|
| 615 |
</div>
|
| 616 |
<div class="iv-stage" id="ivStage">
|
| 617 |
+
<button type="button" class="iux-btn iv-nav iv-nav-prev" id="ivPrev" title="Previous screenshot">__CHEVRON_LEFT_ICON__</button>
|
| 618 |
<img id="ivImage" draggable="false" alt="">
|
| 619 |
+
<button type="button" class="iux-btn iv-nav iv-nav-next" id="ivNext" title="Next screenshot">__CHEVRON_RIGHT_ICON__</button>
|
| 620 |
</div>
|
| 621 |
<div class="iv-bottombar">
|
| 622 |
<button type="button" class="iux-btn" id="ivZoomOut" title="Zoom out">−</button>
|
|
|
|
| 691 |
let dragging = false, dragStartX = 0, dragStartY = 0, panStartX = 0, panStartY = 0;
|
| 692 |
let naturalW = 0, naturalH = 0;
|
| 693 |
let downloadName = 'insightux-image.png';
|
| 694 |
+
const prevBtn = document.getElementById('ivPrev');
|
| 695 |
+
const nextBtn = document.getElementById('ivNext');
|
| 696 |
+
let onPrev = null, onNext = null;
|
| 697 |
|
| 698 |
function applyTransform(){
|
| 699 |
img.style.transform = 'translate(-50%, -50%) translate(' + panX + 'px,' + panY + 'px) scale(' + scale + ')';
|
|
|
|
| 728 |
|
| 729 |
function onKeydown(e){
|
| 730 |
if (e.key === 'Escape') close();
|
| 731 |
+
else if (e.key === 'ArrowLeft' && onPrev) onPrev();
|
| 732 |
+
else if (e.key === 'ArrowRight' && onNext) onNext();
|
| 733 |
}
|
| 734 |
|
| 735 |
function open(opts){
|
|
|
|
| 743 |
}
|
| 744 |
titleEl.textContent = opts.title || '';
|
| 745 |
downloadName = opts.downloadName || 'insightux-image.png';
|
| 746 |
+
onPrev = opts.onPrev || null;
|
| 747 |
+
onNext = opts.onNext || null;
|
| 748 |
+
prevBtn.disabled = !onPrev;
|
| 749 |
+
nextBtn.disabled = !onNext;
|
| 750 |
img.src = dataUrl;
|
| 751 |
img.onload = function(){
|
| 752 |
naturalW = img.naturalWidth; naturalH = img.naturalHeight;
|
|
|
|
| 799 |
zoomSlider.addEventListener('input', function(e){ setScale(e.target.value / 100); });
|
| 800 |
document.getElementById('ivClose').addEventListener('click', close);
|
| 801 |
document.getElementById('ivBack').addEventListener('click', close);
|
| 802 |
+
prevBtn.addEventListener('click', function(){ if (onPrev) onPrev(); });
|
| 803 |
+
nextBtn.addEventListener('click', function(){ if (onNext) onNext(); });
|
| 804 |
document.getElementById('ivFullscreen').addEventListener('click', function(){
|
| 805 |
if (!document.fullscreenElement) { el.requestFullscreen().catch(function(){}); }
|
| 806 |
else { document.exitFullscreen().catch(function(){}); }
|
|
|
|
| 972 |
}
|
| 973 |
|
| 974 |
// Loads a screenshot as a blob: URL rather than pointing <img> straight at
|
| 975 |
+
// Direct load, same as the plain <img src="..."> that already displays
|
| 976 |
+
// these screenshots correctly elsewhere in this report — this is the
|
| 977 |
+
// mechanism confirmed to work in the packaged app. (An earlier version of
|
| 978 |
+
// this function routed everything through fetch()+blob: to dodge a
|
| 979 |
+
// hypothetical canvas-taint issue; that broke plain loading entirely in
|
| 980 |
+
// the real embedded webview, where fetch() against file:// screenshots is
|
| 981 |
+
// restricted even though <img src> to the same path is not. Compositing
|
| 982 |
+
// still works fine here because composeSegmentCanvas()/exportFullSession()
|
| 983 |
+
// only ever draw already-loaded, same-directory screenshots.)
|
| 984 |
function loadImage(src){
|
| 985 |
+
return new Promise(function(resolve){
|
| 986 |
+
const im = new Image();
|
| 987 |
+
im.onload = function(){ resolve(im); };
|
| 988 |
+
im.onerror = function(){ resolve(null); };
|
| 989 |
+
im.src = src;
|
| 990 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 991 |
}
|
| 992 |
|
| 993 |
// Minimal toast so a failed export/viewer action is visible instead of
|
|
|
|
| 1095 |
return out;
|
| 1096 |
}
|
| 1097 |
|
| 1098 |
+
function segmentTitle(i){
|
| 1099 |
+
return 'Scroll segment ' + (i+1) + ' of ' + SEGMENTS.length + ' — ' + mode.charAt(0).toUpperCase() + mode.slice(1) + ' heatmap';
|
| 1100 |
+
}
|
| 1101 |
+
function segmentDownloadName(i){
|
| 1102 |
+
return 'insightux-' + mode + '-heatmap-segment-' + (i+1) + '.png';
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
// Opens (or, if already open, updates in place) the shared viewer for
|
| 1106 |
+
// segment i — used by the maximize button, clicking the screenshot
|
| 1107 |
+
// itself, and the viewer's own Previous/Next controls.
|
| 1108 |
+
function openSegmentViewer(i){
|
| 1109 |
+
if (i < 0 || i >= SEGMENTS.length) return;
|
| 1110 |
+
if (i !== cur) { cur = i; renderSegment(cur); }
|
| 1111 |
+
renderPromise.then(function(){
|
| 1112 |
+
const canvas = composeSegmentCanvas();
|
| 1113 |
+
if (!canvas) { showToast('Could not prepare this screenshot for viewing.', true); return; }
|
| 1114 |
+
Viewer.open({
|
| 1115 |
+
source: canvas,
|
| 1116 |
+
title: segmentTitle(i),
|
| 1117 |
+
downloadName: segmentDownloadName(i),
|
| 1118 |
+
onPrev: i > 0 ? function(){ openSegmentViewer(i - 1); } : null,
|
| 1119 |
+
onNext: i < SEGMENTS.length - 1 ? function(){ openSegmentViewer(i + 1); } : null,
|
| 1120 |
+
});
|
| 1121 |
});
|
| 1122 |
+
}
|
| 1123 |
+
|
| 1124 |
+
document.getElementById('hmFullscreen').addEventListener('click', function(){ openSegmentViewer(cur); });
|
| 1125 |
document.getElementById('hmDownload').addEventListener('click', function(){
|
| 1126 |
const canvas = composeSegmentCanvas();
|
| 1127 |
+
if (!canvas) { showToast('Could not prepare this screenshot for export.', true); return; }
|
| 1128 |
try {
|
| 1129 |
const a = document.createElement('a');
|
| 1130 |
a.href = canvas.toDataURL('image/png');
|
| 1131 |
+
a.download = segmentDownloadName(cur);
|
| 1132 |
a.click();
|
| 1133 |
} catch (err) {
|
| 1134 |
console.warn('[insightux-report] segment download failed:', err);
|
|
|
|
| 1136 |
}
|
| 1137 |
});
|
| 1138 |
|
| 1139 |
+
let renderPromise = Promise.resolve();
|
| 1140 |
+
|
| 1141 |
+
// Renders segment i's screenshot + active heat layer(s) exactly as
|
| 1142 |
+
// before — plain <img src> is what reliably displays these screenshots
|
| 1143 |
+
// in the packaged app, so that part is unchanged. Returns a promise that
|
| 1144 |
+
// resolves once the image has actually painted, so callers that need the
|
| 1145 |
+
// pixels (the viewer, download) can wait for it instead of racing it.
|
| 1146 |
+
function renderSegment(i){
|
| 1147 |
const seg = SEGMENTS[i];
|
| 1148 |
area.innerHTML = `
|
| 1149 |
+
<div class="shot-wrap" id="shotWrap" title="Click to open in viewer">
|
| 1150 |
+
<img id="shotImg" src="${seg.screenshot}" alt="">
|
| 1151 |
<canvas id="shotCanvas"></canvas>
|
| 1152 |
</div>
|
| 1153 |
<div class="shot-nav">
|
|
|
|
| 1159 |
|
| 1160 |
document.getElementById('prevBtn').onclick = () => { if (cur>0){ cur--; renderSegment(cur); } };
|
| 1161 |
document.getElementById('nextBtn').onclick = () => { if (cur<SEGMENTS.length-1){ cur++; renderSegment(cur); } };
|
| 1162 |
+
document.getElementById('shotWrap').addEventListener('click', function(){ openSegmentViewer(i); });
|
| 1163 |
|
| 1164 |
const img = document.getElementById('shotImg');
|
| 1165 |
const cv = document.getElementById('shotCanvas');
|
| 1166 |
const ctx = cv.getContext('2d');
|
| 1167 |
|
| 1168 |
+
renderPromise = new Promise(function(resolve){
|
| 1169 |
+
function paint(){
|
| 1170 |
+
cv.width = img.naturalWidth;
|
| 1171 |
+
cv.height = img.naturalHeight;
|
| 1172 |
+
paintActive(ctx, cv.width, cv.height);
|
| 1173 |
+
resolve();
|
| 1174 |
+
}
|
| 1175 |
+
if (img.complete && img.naturalWidth) paint(); else img.onload = paint;
|
| 1176 |
+
});
|
| 1177 |
+
return renderPromise;
|
|
|
|
|
|
|
|
|
|
| 1178 |
}
|
| 1179 |
|
| 1180 |
renderSegment(cur);
|
|
|
|
| 1245 |
document.getElementById('exportCombined').addEventListener('click', function(){ exportFullSession('combined'); });
|
| 1246 |
})();
|
| 1247 |
|
| 1248 |
+
// ---------- Element Attention Analysis: line chart plotting EVERY row of
|
| 1249 |
+
// the already-computed RANKING array (compute_dwell_ranking() output, in
|
| 1250 |
+
// its existing order) — one point per ranked element, no aggregation, no
|
| 1251 |
+
// new metric, nothing dropped. ----------
|
| 1252 |
(function(){
|
| 1253 |
const wrap = document.getElementById('elemChartWrap');
|
| 1254 |
+
const scrollBox = document.getElementById('elemChartScroll');
|
| 1255 |
const cv = document.getElementById('elemChart');
|
| 1256 |
const tooltip = document.getElementById('elemTooltip');
|
| 1257 |
+
const countEl = document.getElementById('elemChartCount');
|
| 1258 |
+
const hint = document.getElementById('elemChartHint');
|
| 1259 |
const ctx = cv.getContext('2d');
|
| 1260 |
|
| 1261 |
if (!RANKING.length){
|
| 1262 |
+
wrap.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('bar-chart', 26) + '</span>Not enough attention data yet to plot.</div>';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1263 |
return;
|
| 1264 |
}
|
| 1265 |
|
| 1266 |
+
// One point per Ranked Attention row, same order, same values — RANKING
|
| 1267 |
+
// is already sorted by seconds descending by compute_dwell_ranking().
|
| 1268 |
+
const points = RANKING.map(r => ({ name: r.label, seconds: r.seconds, pct: r.pct }));
|
| 1269 |
+
countEl.textContent = points.length + (points.length === 1 ? ' element' : ' elements');
|
| 1270 |
+
|
| 1271 |
+
const MIN_POINT_GAP = 46; // px between points before labels start colliding
|
| 1272 |
let animProgress = 0;
|
| 1273 |
let hoverIdx = -1;
|
| 1274 |
let raf = null;
|
| 1275 |
|
| 1276 |
function layout(){
|
| 1277 |
+
const containerW = scrollBox.clientWidth || wrap.getBoundingClientRect().width;
|
| 1278 |
+
const needed = 84 + points.length * MIN_POINT_GAP;
|
| 1279 |
+
cv.width = Math.max(320, Math.round(containerW), needed);
|
| 1280 |
+
cv.height = 340;
|
| 1281 |
+
hint.style.display = needed > containerW ? 'block' : 'none';
|
| 1282 |
}
|
| 1283 |
|
| 1284 |
function geometry(){
|
| 1285 |
+
const padLeft = 54, padRight = 30, padTop = 26, padBottom = 56;
|
| 1286 |
const plotW = cv.width - padLeft - padRight;
|
| 1287 |
const plotH = cv.height - padTop - padBottom;
|
| 1288 |
const maxVal = Math.max.apply(null, points.map(p => p.seconds)) || 1;
|
|
|
|
| 1354 |
ctx.stroke();
|
| 1355 |
}
|
| 1356 |
|
| 1357 |
+
// markers + rotated x-axis labels (many ranked elements, so labels are
|
| 1358 |
+
// angled to stay readable without overlapping their neighbors)
|
| 1359 |
pts.forEach((pt, i) => {
|
| 1360 |
const isHover = i === hoverIdx;
|
| 1361 |
ctx.beginPath();
|
|
|
|
| 1366 |
ctx.strokeStyle = surface;
|
| 1367 |
ctx.stroke();
|
| 1368 |
|
| 1369 |
+
ctx.save();
|
| 1370 |
+
ctx.translate(pt.x, g.padTop + g.plotH + 10);
|
| 1371 |
+
ctx.rotate(-Math.PI / 4);
|
| 1372 |
ctx.fillStyle = isHover ? strong : dim;
|
| 1373 |
+
ctx.font = isHover ? '600 10.5px -apple-system, Arial' : '10.5px -apple-system, Arial';
|
| 1374 |
+
ctx.textAlign = 'right';
|
| 1375 |
+
const label = pt.p.name.length > 20 ? pt.p.name.slice(0,19) + '…' : pt.p.name;
|
| 1376 |
+
ctx.fillText(label, 0, 0);
|
| 1377 |
+
ctx.restore();
|
| 1378 |
});
|
| 1379 |
|
| 1380 |
// axis baseline
|
|
|
|
| 1388 |
ctx.fillStyle = faint;
|
| 1389 |
ctx.font = '600 10px -apple-system, Arial';
|
| 1390 |
ctx.textAlign = 'center';
|
| 1391 |
+
ctx.fillText('RANKED WEBPAGE ELEMENTS (' + pts.length + ')', g.padLeft + g.plotW / 2, cv.height - 6);
|
| 1392 |
ctx.save();
|
| 1393 |
ctx.translate(14, g.padTop + g.plotH / 2);
|
| 1394 |
ctx.rotate(-Math.PI / 2);
|
|
|
|
| 1570 |
html = html.replace("__BARCHART_ICON__", theme.icon("bar-chart", 15))
|
| 1571 |
html = html.replace("__BACK_ICON__", theme.icon("arrow-left", 14))
|
| 1572 |
html = html.replace("__CLOSE_ICON__", theme.icon("x", 14))
|
| 1573 |
+
html = html.replace("__CHEVRON_LEFT_ICON__", theme.icon("chevron-left", 20))
|
| 1574 |
+
html = html.replace("__CHEVRON_RIGHT_ICON__", theme.icon("chevron-right", 20))
|
| 1575 |
|
| 1576 |
out_path = os.path.join(session_dir, "analysis_report.html")
|
| 1577 |
with open(out_path, "w", encoding="utf-8") as f:
|