Aryaman25 Claude Sonnet 5 commited on
Commit
1b1f57b
·
1 Parent(s): dda0490

Fix screenshot loading regression; add viewer nav; chart every ranked element

Browse files

The 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>

Files changed (1) hide show
  1. analysis.py +129 -96
analysis.py CHANGED
@@ -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-wrap canvas { width: 100%; height: 320px; display: block; }
 
 
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
- <canvas id="elemChart" width="1100" height="320"></canvas>
 
 
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">&minus;</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
- // the file:// path. Some embedded webviews (this app's included) treat a
948
- // file:// image drawn onto a canvas as tainted, so a later toDataURL()/
949
- // getImageData() throws a SecurityError and the calling click handler dies
950
- // silently — the exact "button does nothing" symptom. Fetching the same
951
- // file and handing the canvas a blob: URL instead sidesteps that entirely;
952
- // the bytes on disk are identical, only how the browser tags their origin
953
- // changes. Every screenshot used for compositing/export goes through this.
 
 
954
  function loadImage(src){
955
- return fetch(src)
956
- .then(function(resp){
957
- if (!resp.ok) throw new Error('HTTP ' + resp.status);
958
- return resp.blob();
959
- })
960
- .then(function(blob){
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
- document.getElementById('hmFullscreen').addEventListener('click', function(){
1081
- const canvas = composeSegmentCanvas();
1082
- if (!canvas) { showToast('Still loading this screenshot — try again in a moment.', true); return; }
1083
- Viewer.open({
1084
- source: canvas,
1085
- title: 'Scroll segment ' + (cur+1) + ' of ' + SEGMENTS.length + ' — ' + mode.charAt(0).toUpperCase() + mode.slice(1) + ' heatmap',
1086
- downloadName: 'insightux-' + mode + '-heatmap-segment-' + (cur+1) + '.png',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1087
  });
1088
- });
 
 
1089
  document.getElementById('hmDownload').addEventListener('click', function(){
1090
  const canvas = composeSegmentCanvas();
1091
- if (!canvas) { showToast('Still loading this screenshot — try again in a moment.', true); return; }
1092
  try {
1093
  const a = document.createElement('a');
1094
  a.href = canvas.toDataURL('image/png');
1095
- a.download = 'insightux-' + mode + '-heatmap-segment-' + (cur+1) + '.png';
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
- async function renderSegment(i){
 
 
 
 
 
 
 
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
- function paint(){
1125
- cv.width = img.naturalWidth;
1126
- cv.height = img.naturalHeight;
1127
- paintActive(ctx, cv.width, cv.height);
1128
- }
1129
-
1130
- // Loaded as a blob: URL (see loadImage()) so the <img> that ends up on
1131
- // screen is already non-tainting for the compose/download/fullscreen
1132
- // canvas operations above — same fix as the full-session export below.
1133
- const loaded = await loadImage(seg.screenshot);
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, client-side
1208
- // re-aggregation of the already-computed RANKING array (compute_dwell_ranking()
1209
- // output) — no new metric, just grouped by element type and charted. ----------
 
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 categorize by element type.</div>';
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 rect = wrap.getBoundingClientRect();
1254
- cv.width = Math.max(320, Math.round(rect.width));
1255
- cv.height = 320;
 
 
1256
  }
1257
 
1258
  function geometry(){
1259
- const padLeft = 54, padRight = 30, padTop = 26, padBottom = 46;
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 11px -apple-system, Arial' : '11px -apple-system, Arial';
1344
- ctx.textAlign = 'center';
1345
- const label = pt.p.name.length > 15 ? pt.p.name.slice(0,14) + '…' : pt.p.name;
1346
- ctx.fillText(label, pt.x, cv.height - g.padBottom + 18);
 
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 ELEMENT TYPE', g.padLeft + g.plotW / 2, cv.height - 6);
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 &rarr;</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">&minus;</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: