Aryaman25 Claude Sonnet 5 commited on
Commit
65ccc31
·
1 Parent(s): baebe6b

Fix report viewer, image export, and add navigation/analytics polish

Browse files

Root-caused the "downloaded images are black" bug: the download/export
code was reading pixels only from the transparent heat-overlay canvas,
never the underlying screenshot it's layered on top of via CSS -- a
mostly-transparent PNG renders as black in most viewers. Added a compose
step (screenshot + heat drawn onto one opaque canvas) used by every
export path, plus a white background fill in the full-session stitcher
to cover small gaps between periodically-captured screenshots.

Replaced the dead-end CSS-only "fullscreen" (no way back once entered)
with a real image viewer: wheel zoom, drag-to-pan, double-click zoom,
fit width/screen/actual-size, native Fullscreen API with a visible exit
control, and ESC to close. The three full-session export buttons now
open a result in this viewer instead of silently forcing a download.

Replaced the Element Attention progress-bar rows with an actual canvas
bar chart (gridlines, axis labels, hover tooltips, entrance animation,
theme-aware repaint) over the same category totals as before, and added
a "Back to Browser" button that just calls history.back() -- no tracking
state touched. Everything here is confined to the report's own HTML/CSS/JS
template; no tracking, calibration, analytics, or data-collection code
was touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files changed (1) hide show
  1. analysis.py +384 -42
analysis.py CHANGED
@@ -440,8 +440,6 @@ __THEME_CSS__
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-wrap.fullscreen { position: fixed; inset: 16px; z-index: 999999; border-radius: var(--iux-radius-lg); }
444
- .shot-wrap.fullscreen img, .shot-wrap.fullscreen canvas { width: 100%; height: 100%; object-fit: contain; }
445
  .shot-nav { display: flex; align-items: center; justify-content: space-between;
446
  margin-top: 10px; font-size: 12px; color: var(--iux-text-dim); }
447
  .shot-nav .iux-btn { padding: 7px 14px; font-size: 12px; }
@@ -453,17 +451,48 @@ __THEME_CSS__
453
  .hm-export-row { display: flex; align-items: center; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
454
  .hm-export-row .hm-export-label { font-size: 11.5px; color: var(--iux-text-faint); margin-right: 2px; display: flex; align-items: center; gap: 5px; }
455
  .hm-export-row .iux-btn { padding: 7px 12px; font-size: 11.5px; }
 
456
  .hm-caption { font-size: 11px; color: var(--iux-text-faint); margin-top: 8px; line-height: 1.5; }
457
 
458
- .elem-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
459
- .elem-label { display: flex; align-items: center; gap: 7px; width: 180px; flex-shrink: 0; font-size: 12.5px; color: var(--iux-text-dim); }
460
- .elem-bar-bg { flex: 1 1 auto; background: var(--iux-surface-hi); border-radius: 6px; height: 14px; overflow: hidden; }
461
- .elem-bar-fill { height: 100%; background: var(--iux-accent-grad); border-radius: 6px; width: 0; transition: width .6s var(--iux-ease); }
462
- .elem-val { width: 56px; text-align: right; font-size: 12px; color: var(--iux-text-faint); flex-shrink: 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  </style>
464
  </head>
465
  <body>
466
 
 
 
467
  <div class="topbar iux-fade-in">
468
  <div class="brand">
469
  <div class="mark">__EYE_ICON__</div>
@@ -540,7 +569,10 @@ __THEME_CSS__
540
  </div>
541
  <div class="panel iux-card full iux-fade-in">
542
  <h2>__BARCHART_ICON__ Element Attention Analysis</h2>
543
- <div id="elementChart"></div>
 
 
 
544
  </div>
545
  <div class="panel iux-card full iux-fade-in">
546
  <h2>__CLOCK_ICON__ Attention Timeline</h2>
@@ -557,6 +589,29 @@ __THEME_CSS__
557
  </div>
558
  </div>
559
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  <script>
561
  __ICONS_JS__
562
  __THEME_JS__
@@ -593,6 +648,139 @@ document.getElementById('btnFilter').addEventListener('click', function(){
593
  input.focus();
594
  });
595
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  // ---------- icon inference from friendly_label() output ----------
597
  function iconForLabel(label){
598
  if (!label) return 'click';
@@ -773,7 +961,6 @@ function loadImage(src){
773
 
774
  let cur = 0;
775
  let mode = 'eye'; // 'eye' | 'mouse' | 'combined'
776
- let fullscreen = false;
777
  const heatState = {
778
  eye: { opacity: 0.9, intensity: 0.09 },
779
  mouse: { opacity: 0.9, intensity: 0.12 },
@@ -815,16 +1002,42 @@ function loadImage(src){
815
  heatState[mode].intensity = e.target.value / 33;
816
  renderSegment(cur);
817
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
818
  document.getElementById('hmFullscreen').addEventListener('click', function(){
819
- fullscreen = !fullscreen;
820
- const wrap = document.getElementById('shotWrap');
821
- if (wrap) wrap.classList.toggle('fullscreen', fullscreen);
 
 
 
 
822
  });
823
  document.getElementById('hmDownload').addEventListener('click', function(){
824
- const cv = document.getElementById('shotCanvas');
825
- if (!cv) return;
826
  const a = document.createElement('a');
827
- a.href = cv.toDataURL('image/png');
828
  a.download = 'insightux-' + mode + '-heatmap-segment-' + (cur+1) + '.png';
829
  a.click();
830
  });
@@ -832,7 +1045,7 @@ function loadImage(src){
832
  function renderSegment(i){
833
  const seg = SEGMENTS[i];
834
  area.innerHTML = `
835
- <div class="shot-wrap${fullscreen ? ' fullscreen' : ''}" id="shotWrap">
836
  <img id="shotImg" src="${seg.screenshot}">
837
  <canvas id="shotCanvas"></canvas>
838
  </div>
@@ -878,6 +1091,13 @@ function loadImage(src){
878
  const full = document.createElement('canvas');
879
  full.width = width; full.height = height;
880
  const fctx = full.getContext('2d');
 
 
 
 
 
 
 
881
  SEGMENTS.forEach((s, i) => { if (imgs[i]) fctx.drawImage(imgs[i], 0, s.scrollY); });
882
 
883
  const st = heatState[exportMode] || heatState.eye;
@@ -891,10 +1111,15 @@ function loadImage(src){
891
  paintHeatLayer(fctx, width, height, mouseFull, st.intensity, MOUSE_STOPS, st.opacity, exportMode === 'combined' ? 'lighter' : 'source-over');
892
  }
893
 
894
- const a = document.createElement('a');
895
- a.href = full.toDataURL('image/png');
896
- a.download = 'insightux-full-session-' + exportMode + '-heatmap.png';
897
- a.click();
 
 
 
 
 
898
  } finally {
899
  if (btn) { btn.innerHTML = original; btn.disabled = false; }
900
  }
@@ -905,19 +1130,25 @@ function loadImage(src){
905
  document.getElementById('exportCombined').addEventListener('click', function(){ exportFullSession('combined'); });
906
  })();
907
 
908
- // ---------- Element Attention Analysis (client-side re-aggregation of RANKING) ----------
 
 
909
  (function(){
910
- const el = document.getElementById('elementChart');
 
 
 
 
911
  if (!RANKING.length){
912
- el.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('bar-chart', 26) + '</span>Not enough attention data yet to categorize by element type.</div>';
913
  return;
914
  }
915
  const CATS = {
916
- image: { name: 'Images', icon: 'image' },
917
- video: { name: 'Video & Embeds', icon: 'video' },
918
- text: { name: 'Headings & Text', icon: 'type' },
919
- nav: { name: 'Navigation', icon: 'menu' },
920
- other: { name: 'Buttons & Other', icon: 'click' },
921
  };
922
  function categoryFor(label){
923
  if (label.indexOf('Image') === 0) return 'image';
@@ -929,27 +1160,136 @@ function loadImage(src){
929
  const totals = {};
930
  RANKING.forEach(r => { const c = categoryFor(r.label); totals[c] = (totals[c] || 0) + r.seconds; });
931
  const grand = Object.values(totals).reduce((a,b) => a+b, 0) || 1;
932
- const rows = Object.keys(totals).map(key => ({
933
- key, name: CATS[key].name, icon: CATS[key].icon,
934
  seconds: Math.round(totals[key] * 10) / 10,
935
  pct: Math.round(100 * totals[key] / grand),
936
  })).filter(r => r.seconds > 0).sort((a,b) => b.seconds - a.seconds);
937
 
938
- if (!rows.length){
939
- el.innerHTML = '<div class="empty">No categorized attention data yet.</div>';
940
  return;
941
  }
942
- const maxSeconds = rows[0].seconds || 1;
943
- el.innerHTML = rows.map(r => `
944
- <div class="elem-row" title="${r.name}: ${r.seconds}s (${r.pct}% of attributed attention)">
945
- <div class="elem-label">${iuxIcon(r.icon, 15)}<span>${r.name}</span></div>
946
- <div class="elem-bar-bg"><div class="elem-bar-fill" data-w="${Math.round(100 * r.seconds / maxSeconds)}"></div></div>
947
- <div class="elem-val">${r.seconds}s &middot; ${r.pct}%</div>
948
- </div>
949
- `).join('');
950
- requestAnimationFrame(function(){
951
- document.querySelectorAll('.elem-bar-fill').forEach(function(bar){ bar.style.width = bar.dataset.w + '%'; });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
952
  });
 
 
 
 
 
 
953
  })();
954
 
955
  // ---------- Timeline ----------
@@ -1083,6 +1423,8 @@ def generate_report(session_dir):
1083
  html = html.replace("__CURSOR_ICON_SM__", theme.icon("cursor", 13))
1084
  html = html.replace("__LAYERS_ICON_SM__", theme.icon("layers", 13))
1085
  html = html.replace("__BARCHART_ICON__", theme.icon("bar-chart", 15))
 
 
1086
 
1087
  out_path = os.path.join(session_dir, "analysis_report.html")
1088
  with open(out_path, "w", encoding="utf-8") as f:
 
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;
444
  margin-top: 10px; font-size: 12px; color: var(--iux-text-dim); }
445
  .shot-nav .iux-btn { padding: 7px 14px; font-size: 12px; }
 
451
  .hm-export-row { display: flex; align-items: center; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
452
  .hm-export-row .hm-export-label { font-size: 11.5px; color: var(--iux-text-faint); margin-right: 2px; display: flex; align-items: center; gap: 5px; }
453
  .hm-export-row .iux-btn { padding: 7px 12px; font-size: 11.5px; }
454
+ .hm-export-row .iux-btn[disabled] { opacity: .6; cursor: default; }
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;
462
+ box-shadow: var(--iux-shadow); z-index: 10; line-height: 1.5; white-space: nowrap;
463
+ }
464
+
465
+ .back-nav { position: fixed; top: 18px; left: 18px; z-index: 500; padding: 9px 16px; font-size: 12.5px; }
466
+
467
+ .iux-viewer {
468
+ position: fixed; inset: 0; z-index: 2000000; display: none; flex-direction: column;
469
+ background: rgba(10,8,20,0.92); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
470
+ }
471
+ .iux-viewer.open { display: flex; }
472
+ .iv-topbar {
473
+ display: flex; align-items: center; gap: 10px; padding: 12px 18px;
474
+ background: rgba(18,19,26,0.9); border-bottom: 1px solid rgba(255,255,255,0.1); flex-shrink: 0;
475
+ }
476
+ .iv-topbar .iux-btn { color: #f1eefa; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.14); }
477
+ .iv-topbar .iux-btn:hover { background: rgba(255,255,255,0.18); }
478
+ .iv-title { color: #f1eefa; font-size: 13px; font-weight: 600; margin-right: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
479
+ .iv-zoom-pct { color: #cabdf0; font-size: 12px; min-width: 44px; text-align: center; flex-shrink: 0; }
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;
486
+ }
487
+ .iv-bottombar .iux-btn { color: #f1eefa; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.14); padding: 7px 12px; font-size: 12px; }
488
+ .iv-bottombar .iux-btn:hover { background: rgba(255,255,255,0.18); }
489
+ .iv-bottombar input[type=range] { accent-color: var(--iux-primary-light); width: 120px; }
490
  </style>
491
  </head>
492
  <body>
493
 
494
+ <button type="button" class="iux-btn back-nav iux-glass" id="backNav" title="Return to the browser without restarting tracking">__BACK_ICON__ Back to Browser</button>
495
+
496
  <div class="topbar iux-fade-in">
497
  <div class="brand">
498
  <div class="mark">__EYE_ICON__</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>
 
589
  </div>
590
  </div>
591
 
592
+ <div id="iuxViewer" class="iux-viewer" aria-hidden="true">
593
+ <div class="iv-topbar">
594
+ <button type="button" class="iux-btn" id="ivBack" title="Back">__BACK_ICON__ Back</button>
595
+ <div class="iv-title" id="ivTitle"></div>
596
+ <div class="iv-zoom-pct" id="ivZoomPct">100%</div>
597
+ <button type="button" class="iux-btn" id="ivFullscreen" title="Toggle fullscreen">__MAXIMIZE_ICON__</button>
598
+ <button type="button" class="iux-btn" id="ivDownload" title="Download">__DOWNLOAD_ICON__</button>
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>
606
+ <input type="range" id="ivZoomSlider" min="10" max="400" value="100">
607
+ <button type="button" class="iux-btn" id="ivZoomIn" title="Zoom in">+</button>
608
+ <button type="button" class="iux-btn" id="ivReset" title="Reset zoom">Reset</button>
609
+ <button type="button" class="iux-btn" id="ivFitWidth" title="Fit width">Fit Width</button>
610
+ <button type="button" class="iux-btn" id="ivFitScreen" title="Fit screen">Fit Screen</button>
611
+ <button type="button" class="iux-btn" id="ivActual" title="Actual size">100%</button>
612
+ </div>
613
+ </div>
614
+
615
  <script>
616
  __ICONS_JS__
617
  __THEME_JS__
 
648
  input.focus();
649
  });
650
 
651
+ // ---------- Back to Browser: pure client-side history navigation, never
652
+ // touches the Api/tracking state — just returns to the previously loaded
653
+ // page in this same pywebview window, same as a normal browser back. ----------
654
+ document.getElementById('backNav').addEventListener('click', function(){
655
+ if (window.history.length > 1) {
656
+ window.history.back();
657
+ } else if (document.referrer) {
658
+ window.location.href = document.referrer;
659
+ }
660
+ });
661
+
662
+ // ---------- Shared image viewer: fullscreen heatmap segments and the
663
+ // full-session stitched exports both open through here instead of forcing
664
+ // an immediate download or a CSS-only "fullscreen" with no way out. ----------
665
+ const Viewer = (function(){
666
+ const el = document.getElementById('iuxViewer');
667
+ const img = document.getElementById('ivImage');
668
+ const stage = document.getElementById('ivStage');
669
+ const titleEl = document.getElementById('ivTitle');
670
+ const zoomPct = document.getElementById('ivZoomPct');
671
+ const zoomSlider = document.getElementById('ivZoomSlider');
672
+ const MIN_SCALE = 0.1, MAX_SCALE = 4;
673
+ let scale = 1, panX = 0, panY = 0;
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 + ')';
680
+ zoomPct.textContent = Math.round(scale * 100) + '%';
681
+ zoomSlider.value = Math.round(scale * 100);
682
+ }
683
+ function setScale(next){
684
+ scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, next));
685
+ applyTransform();
686
+ }
687
+ function stageSize(){
688
+ const r = stage.getBoundingClientRect();
689
+ return { w: r.width, h: r.height };
690
+ }
691
+ function fitScreen(){
692
+ if (!naturalW || !naturalH) return;
693
+ const s = stageSize();
694
+ const next = Math.min((s.w - 32) / naturalW, (s.h - 32) / naturalH, MAX_SCALE);
695
+ scale = (isFinite(next) && next > 0) ? next : 1;
696
+ panX = 0; panY = 0;
697
+ applyTransform();
698
+ }
699
+ function fitWidth(){
700
+ if (!naturalW) return;
701
+ const s = stageSize();
702
+ const next = Math.min((s.w - 32) / naturalW, MAX_SCALE);
703
+ scale = (isFinite(next) && next > 0) ? next : 1;
704
+ panX = 0; panY = 0;
705
+ applyTransform();
706
+ }
707
+ function actualSize(){ scale = 1; panX = 0; panY = 0; applyTransform(); }
708
+
709
+ function onKeydown(e){
710
+ if (e.key === 'Escape') close();
711
+ }
712
+
713
+ function open(opts){
714
+ titleEl.textContent = opts.title || '';
715
+ downloadName = opts.downloadName || 'insightux-image.png';
716
+ const source = opts.source;
717
+ img.src = (typeof source === 'string') ? source : source.toDataURL('image/png');
718
+ img.onload = function(){
719
+ naturalW = img.naturalWidth; naturalH = img.naturalHeight;
720
+ fitScreen();
721
+ };
722
+ el.classList.add('open');
723
+ el.setAttribute('aria-hidden', 'false');
724
+ document.addEventListener('keydown', onKeydown, true);
725
+ }
726
+ function close(){
727
+ if (document.fullscreenElement) { document.exitFullscreen().catch(function(){}); }
728
+ el.classList.remove('open');
729
+ el.setAttribute('aria-hidden', 'true');
730
+ document.removeEventListener('keydown', onKeydown, true);
731
+ }
732
+
733
+ el.addEventListener('wheel', function(e){
734
+ if (!el.classList.contains('open')) return;
735
+ e.preventDefault();
736
+ setScale(scale + (e.deltaY > 0 ? -1 : 1) * 0.12 * scale);
737
+ }, { passive: false });
738
+
739
+ stage.addEventListener('mousedown', function(e){
740
+ dragging = true;
741
+ dragStartX = e.clientX; dragStartY = e.clientY;
742
+ panStartX = panX; panStartY = panY;
743
+ stage.classList.add('grabbing');
744
+ });
745
+ window.addEventListener('mousemove', function(e){
746
+ if (!dragging) return;
747
+ panX = panStartX + (e.clientX - dragStartX);
748
+ panY = panStartY + (e.clientY - dragStartY);
749
+ applyTransform();
750
+ });
751
+ window.addEventListener('mouseup', function(){
752
+ dragging = false;
753
+ stage.classList.remove('grabbing');
754
+ });
755
+ stage.addEventListener('dblclick', function(){
756
+ if (scale > 1.05) fitScreen(); else setScale(2);
757
+ });
758
+
759
+ document.getElementById('ivZoomIn').addEventListener('click', function(){ setScale(scale + 0.25); });
760
+ document.getElementById('ivZoomOut').addEventListener('click', function(){ setScale(scale - 0.25); });
761
+ document.getElementById('ivReset').addEventListener('click', actualSize);
762
+ document.getElementById('ivFitWidth').addEventListener('click', fitWidth);
763
+ document.getElementById('ivFitScreen').addEventListener('click', fitScreen);
764
+ document.getElementById('ivActual').addEventListener('click', actualSize);
765
+ zoomSlider.addEventListener('input', function(e){ setScale(e.target.value / 100); });
766
+ document.getElementById('ivClose').addEventListener('click', close);
767
+ document.getElementById('ivBack').addEventListener('click', close);
768
+ document.getElementById('ivFullscreen').addEventListener('click', function(){
769
+ if (!document.fullscreenElement) { el.requestFullscreen().catch(function(){}); }
770
+ else { document.exitFullscreen().catch(function(){}); }
771
+ });
772
+ document.getElementById('ivDownload').addEventListener('click', function(){
773
+ const a = document.createElement('a');
774
+ a.href = img.src;
775
+ a.download = downloadName;
776
+ a.click();
777
+ });
778
+ el.addEventListener('click', function(e){ if (e.target === el) close(); });
779
+ window.addEventListener('resize', function(){ if (el.classList.contains('open')) fitScreen(); });
780
+
781
+ return { open: open, close: close };
782
+ })();
783
+
784
  // ---------- icon inference from friendly_label() output ----------
785
  function iconForLabel(label){
786
  if (!label) return 'click';
 
961
 
962
  let cur = 0;
963
  let mode = 'eye'; // 'eye' | 'mouse' | 'combined'
 
964
  const heatState = {
965
  eye: { opacity: 0.9, intensity: 0.09 },
966
  mouse: { opacity: 0.9, intensity: 0.12 },
 
1002
  heatState[mode].intensity = e.target.value / 33;
1003
  renderSegment(cur);
1004
  });
1005
+ // Flattens the visible screenshot + its heat overlay into ONE opaque
1006
+ // canvas. The on-screen view layers a transparent <canvas> on top of an
1007
+ // <img> purely via CSS positioning — exporting the heat canvas alone (as
1008
+ // earlier code did) produces a mostly-transparent PNG that most viewers
1009
+ // render as solid black. This is the single source both the fullscreen
1010
+ // viewer and the direct download button use, so both always show the
1011
+ // real composited picture.
1012
+ function composeSegmentCanvas(){
1013
+ const shotImg = document.getElementById('shotImg');
1014
+ const heatCv = document.getElementById('shotCanvas');
1015
+ if (!shotImg || !heatCv || !shotImg.complete || !shotImg.naturalWidth) return null;
1016
+ const out = document.createElement('canvas');
1017
+ out.width = shotImg.naturalWidth;
1018
+ out.height = shotImg.naturalHeight;
1019
+ const octx = out.getContext('2d');
1020
+ octx.fillStyle = '#ffffff';
1021
+ octx.fillRect(0, 0, out.width, out.height);
1022
+ octx.drawImage(shotImg, 0, 0);
1023
+ octx.drawImage(heatCv, 0, 0);
1024
+ return out;
1025
+ }
1026
+
1027
  document.getElementById('hmFullscreen').addEventListener('click', function(){
1028
+ const canvas = composeSegmentCanvas();
1029
+ if (!canvas) return;
1030
+ Viewer.open({
1031
+ source: canvas,
1032
+ title: 'Scroll segment ' + (cur+1) + ' of ' + SEGMENTS.length + ' — ' + mode.charAt(0).toUpperCase() + mode.slice(1) + ' heatmap',
1033
+ downloadName: 'insightux-' + mode + '-heatmap-segment-' + (cur+1) + '.png',
1034
+ });
1035
  });
1036
  document.getElementById('hmDownload').addEventListener('click', function(){
1037
+ const canvas = composeSegmentCanvas();
1038
+ if (!canvas) return;
1039
  const a = document.createElement('a');
1040
+ a.href = canvas.toDataURL('image/png');
1041
  a.download = 'insightux-' + mode + '-heatmap-segment-' + (cur+1) + '.png';
1042
  a.click();
1043
  });
 
1045
  function renderSegment(i){
1046
  const seg = SEGMENTS[i];
1047
  area.innerHTML = `
1048
+ <div class="shot-wrap" id="shotWrap">
1049
  <img id="shotImg" src="${seg.screenshot}">
1050
  <canvas id="shotCanvas"></canvas>
1051
  </div>
 
1091
  const full = document.createElement('canvas');
1092
  full.width = width; full.height = height;
1093
  const fctx = full.getContext('2d');
1094
+ // Fill first: screenshots are only captured periodically, so there can
1095
+ // be small gaps between stitched segments. Left transparent, those
1096
+ // gaps render as solid black in most image viewers/downloads — a
1097
+ // plain white fill (matching typical page background) fixes that
1098
+ // without touching how or when screenshots are captured.
1099
+ fctx.fillStyle = '#ffffff';
1100
+ fctx.fillRect(0, 0, width, height);
1101
  SEGMENTS.forEach((s, i) => { if (imgs[i]) fctx.drawImage(imgs[i], 0, s.scrollY); });
1102
 
1103
  const st = heatState[exportMode] || heatState.eye;
 
1111
  paintHeatLayer(fctx, width, height, mouseFull, st.intensity, MOUSE_STOPS, st.opacity, exportMode === 'combined' ? 'lighter' : 'source-over');
1112
  }
1113
 
1114
+ // Opens the composed image in the shared viewer instead of forcing an
1115
+ // immediate download — the viewer's own Download button saves it
1116
+ // once the user has actually looked at it, same as a PDF/image
1117
+ // preview rather than a silent file-save.
1118
+ Viewer.open({
1119
+ source: full,
1120
+ title: 'Full Session — ' + exportMode.charAt(0).toUpperCase() + exportMode.slice(1) + ' Heatmap',
1121
+ downloadName: 'insightux-full-session-' + exportMode + '-heatmap.png',
1122
+ });
1123
  } finally {
1124
  if (btn) { btn.innerHTML = original; btn.disabled = false; }
1125
  }
 
1130
  document.getElementById('exportCombined').addEventListener('click', function(){ exportFullSession('combined'); });
1131
  })();
1132
 
1133
+ // ---------- Element Attention Analysis: vertical bar chart, client-side
1134
+ // re-aggregation of the already-computed RANKING array (compute_dwell_ranking()
1135
+ // output) — no new metric, just grouped by element type and charted. ----------
1136
  (function(){
1137
+ const wrap = document.getElementById('elemChartWrap');
1138
+ const cv = document.getElementById('elemChart');
1139
+ const tooltip = document.getElementById('elemTooltip');
1140
+ const ctx = cv.getContext('2d');
1141
+
1142
  if (!RANKING.length){
1143
+ wrap.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('bar-chart', 26) + '</span>Not enough attention data yet to categorize by element type.</div>';
1144
  return;
1145
  }
1146
  const CATS = {
1147
+ image: { name: 'Images' },
1148
+ video: { name: 'Video & Embeds' },
1149
+ text: { name: 'Headings & Text' },
1150
+ nav: { name: 'Navigation' },
1151
+ other: { name: 'Buttons & Other' },
1152
  };
1153
  function categoryFor(label){
1154
  if (label.indexOf('Image') === 0) return 'image';
 
1160
  const totals = {};
1161
  RANKING.forEach(r => { const c = categoryFor(r.label); totals[c] = (totals[c] || 0) + r.seconds; });
1162
  const grand = Object.values(totals).reduce((a,b) => a+b, 0) || 1;
1163
+ const bars = Object.keys(totals).map(key => ({
1164
+ key, name: CATS[key].name,
1165
  seconds: Math.round(totals[key] * 10) / 10,
1166
  pct: Math.round(100 * totals[key] / grand),
1167
  })).filter(r => r.seconds > 0).sort((a,b) => b.seconds - a.seconds);
1168
 
1169
+ if (!bars.length){
1170
+ wrap.innerHTML = '<div class="empty">No categorized attention data yet.</div>';
1171
  return;
1172
  }
1173
+
1174
+ const COLORS = ['#6366F1', '#8B5CF6', '#C084FC', '#67E8F9', '#FBBF24', '#34D399'];
1175
+ let animProgress = 0;
1176
+ let hoverIdx = -1;
1177
+ let raf = null;
1178
+
1179
+ function layout(){
1180
+ const rect = wrap.getBoundingClientRect();
1181
+ cv.width = Math.max(320, Math.round(rect.width));
1182
+ cv.height = 320;
1183
+ }
1184
+
1185
+ function geometry(){
1186
+ const padLeft = 54, padRight = 20, padTop = 20, padBottom = 46;
1187
+ const plotW = cv.width - padLeft - padRight;
1188
+ const plotH = cv.height - padTop - padBottom;
1189
+ const gap = plotW / bars.length;
1190
+ const barW = Math.min(70, gap * 0.55);
1191
+ const maxVal = bars[0].seconds || 1;
1192
+ return { padLeft, padRight, padTop, padBottom, plotW, plotH, gap, barW, maxVal };
1193
+ }
1194
+
1195
+ function paint(){
1196
+ const dim = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-dim').trim() || '#9a95b3';
1197
+ const faint = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-faint').trim() || '#6e6885';
1198
+ const border = getComputedStyle(document.documentElement).getPropertyValue('--iux-border').trim() || 'rgba(148,138,179,0.16)';
1199
+ const g = geometry();
1200
+ ctx.clearRect(0, 0, cv.width, cv.height);
1201
+
1202
+ // gridlines + y-axis labels
1203
+ const gridSteps = 4;
1204
+ ctx.lineWidth = 1;
1205
+ ctx.strokeStyle = border;
1206
+ for (let i = 0; i <= gridSteps; i++){
1207
+ const v = (g.maxVal / gridSteps) * i;
1208
+ const y = g.padTop + g.plotH - (v / g.maxVal) * g.plotH;
1209
+ ctx.beginPath();
1210
+ ctx.moveTo(g.padLeft, y);
1211
+ ctx.lineTo(cv.width - g.padRight, y);
1212
+ ctx.stroke();
1213
+ ctx.fillStyle = faint;
1214
+ ctx.font = '10.5px -apple-system, Arial';
1215
+ ctx.textAlign = 'right';
1216
+ ctx.fillText(v.toFixed(1) + 's', g.padLeft - 8, y + 3);
1217
+ }
1218
+
1219
+ // bars (rounded top only) + x-axis labels
1220
+ bars.forEach((b, i) => {
1221
+ const x = g.padLeft + i * g.gap + (g.gap - g.barW) / 2;
1222
+ const targetH = (b.seconds / g.maxVal) * g.plotH;
1223
+ const h = Math.max(0, targetH * animProgress);
1224
+ const y = g.padTop + g.plotH - h;
1225
+ const grad = ctx.createLinearGradient(0, y, 0, g.padTop + g.plotH);
1226
+ const base = COLORS[i % COLORS.length];
1227
+ grad.addColorStop(0, base);
1228
+ grad.addColorStop(1, 'rgba(99,102,241,0.32)');
1229
+ ctx.fillStyle = grad;
1230
+ ctx.globalAlpha = (hoverIdx === -1 || hoverIdx === i) ? 1 : 0.55;
1231
+ const rr = Math.min(8, g.barW / 2, Math.max(0, h));
1232
+ ctx.beginPath();
1233
+ ctx.moveTo(x, y + rr);
1234
+ ctx.arcTo(x, y, x + rr, y, rr);
1235
+ ctx.arcTo(x + g.barW, y, x + g.barW, y + rr, rr);
1236
+ ctx.lineTo(x + g.barW, g.padTop + g.plotH);
1237
+ ctx.lineTo(x, g.padTop + g.plotH);
1238
+ ctx.closePath();
1239
+ ctx.fill();
1240
+ ctx.globalAlpha = 1;
1241
+
1242
+ ctx.fillStyle = i === hoverIdx ? (document.documentElement.getAttribute('data-theme') === 'light' ? '#211D33' : '#F1EEFA') : dim;
1243
+ ctx.font = i === hoverIdx ? '600 11px -apple-system, Arial' : '11px -apple-system, Arial';
1244
+ ctx.textAlign = 'center';
1245
+ const label = b.name.length > 15 ? b.name.slice(0,14) + '…' : b.name;
1246
+ ctx.fillText(label, x + g.barW/2, cv.height - g.padBottom + 18);
1247
+ });
1248
+
1249
+ // axis baseline
1250
+ ctx.strokeStyle = border;
1251
+ ctx.beginPath();
1252
+ ctx.moveTo(g.padLeft, g.padTop + g.plotH);
1253
+ ctx.lineTo(cv.width - g.padRight, g.padTop + g.plotH);
1254
+ ctx.stroke();
1255
+ }
1256
+
1257
+ function animate(){
1258
+ animProgress = Math.min(1, animProgress + 0.06);
1259
+ paint();
1260
+ if (animProgress < 1) { raf = requestAnimationFrame(animate); }
1261
+ }
1262
+
1263
+ function redraw(){ layout(); animProgress = 1; paint(); }
1264
+ window.insightuxRepaintCanvases.push(redraw);
1265
+
1266
+ cv.addEventListener('mousemove', function(e){
1267
+ const rect = cv.getBoundingClientRect();
1268
+ const scaleX = cv.width / rect.width;
1269
+ const mx = (e.clientX - rect.left) * scaleX;
1270
+ const g = geometry();
1271
+ let idx = -1;
1272
+ bars.forEach((b, i) => {
1273
+ const x = g.padLeft + i * g.gap;
1274
+ if (mx >= x && mx < x + g.gap) idx = i;
1275
+ });
1276
+ if (idx !== hoverIdx) { hoverIdx = idx; paint(); }
1277
+ if (idx >= 0) {
1278
+ const b = bars[idx];
1279
+ tooltip.style.display = 'block';
1280
+ tooltip.style.left = Math.min(cv.clientWidth - 140, Math.max(0, e.clientX - rect.left + 12)) + 'px';
1281
+ tooltip.style.top = (e.clientY - rect.top - 12) + 'px';
1282
+ tooltip.innerHTML = '<b>' + b.name + '</b><br>' + b.seconds + 's &middot; ' + b.pct + '% of attention';
1283
+ } else {
1284
+ tooltip.style.display = 'none';
1285
+ }
1286
  });
1287
+ cv.addEventListener('mouseleave', function(){ hoverIdx = -1; tooltip.style.display = 'none'; paint(); });
1288
+
1289
+ layout();
1290
+ if (raf) cancelAnimationFrame(raf);
1291
+ requestAnimationFrame(animate);
1292
+ window.addEventListener('resize', function(){ layout(); paint(); });
1293
  })();
1294
 
1295
  // ---------- Timeline ----------
 
1423
  html = html.replace("__CURSOR_ICON_SM__", theme.icon("cursor", 13))
1424
  html = html.replace("__LAYERS_ICON_SM__", theme.icon("layers", 13))
1425
  html = html.replace("__BARCHART_ICON__", theme.icon("bar-chart", 15))
1426
+ html = html.replace("__BACK_ICON__", theme.icon("arrow-left", 14))
1427
+ html = html.replace("__CLOSE_ICON__", theme.icon("x", 14))
1428
 
1429
  out_path = os.path.join(session_dir, "analysis_report.html")
1430
  with open(out_path, "w", encoding="utf-8") as f: