Aryaman25 Claude Sonnet 5 commited on
Commit
32067d6
·
1 Parent(s): cb8f02c

Redesign the PDF/print report into a paginated, professional layout

Browse files

Adds a JS-driven print engine that builds fixed-size, deliberately
paginated pages from the same RANKING/SEGMENTS/MOUSE_* data the
dashboard already uses: cover + metrics + attention summary, a ranked
attention table (top 20, with repeated-header continuation pages),
attention visualization, heatmaps, element details, mouse interaction,
and session insights. Tables/cards/images are measured and moved whole
to a fresh page rather than split, fixing the on-screen overlap in the
mouse interaction panel along the way (a mis-sized, non-resizing
click-timeline canvas). Also hides InsightUX's own toolbar/sidebar/gaze
overlay right before each session screenshot capture so heatmap images
show only the tracked page, and raises the mouse "most interacted"
cap from 10 to 20 to match the ranked-attention cap.

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

Files changed (2) hide show
  1. analysis.py +677 -19
  2. browser_session.py +26 -1
analysis.py CHANGED
@@ -25,6 +25,11 @@ import theme
25
 
26
  PAD_PX = 90 # keep identical to TRACKING_JS's PAD_PX in browser_session.py
27
 
 
 
 
 
 
28
 
29
  # =============================================================================
30
  # LOADING
@@ -90,7 +95,7 @@ def summarize_mouse(session_dir):
90
  interests = sorted(
91
  ({"element": k, "seconds": round(v / 1000.0, 1)} for k, v in dwell_totals.items()),
92
  key=lambda r: -r["seconds"]
93
- )[:10]
94
 
95
  clicks.sort(key=lambda c: c.get("timestamp", ""))
96
 
@@ -462,7 +467,7 @@ __THEME_CSS__
462
  .shot-nav .iux-btn { padding: 7px 14px; font-size: 12px; }
463
  .shot-nav .iux-btn:disabled { opacity: 0.35; cursor: default; transform:none; }
464
 
465
- .click-timeline { width: 100%; display: block; margin-top: 8px; }
466
 
467
  .hm-tab { display: flex; align-items: center; gap: 6px; }
468
 
@@ -568,6 +573,105 @@ __THEME_CSS__
568
  .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; }
569
  .iv-bottombar .iux-btn:hover { background: rgba(255,255,255,0.18); }
570
  .iv-bottombar input[type=range] { accent-color: var(--iux-primary-light); width: 120px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
  </style>
572
  </head>
573
  <body>
@@ -709,6 +813,8 @@ __THEME_CSS__
709
  </div>
710
  </div>
711
 
 
 
712
  <script>
713
  __ICONS_JS__
714
  __THEME_JS__
@@ -725,8 +831,18 @@ const SEGMENTS = __SEGMENTS_JSON__;
725
  const MOUSE_INTERESTS = __MOUSE_INTERESTS_JSON__;
726
  const MOUSE_CLICKS = __MOUSE_CLICKS_JSON__;
727
  const MOUSE_SEGMENTS = __MOUSE_SEGMENTS_JSON__;
728
-
729
- document.getElementById('btnExport').addEventListener('click', function(){ window.print(); });
 
 
 
 
 
 
 
 
 
 
730
 
731
  // ---------- Back to Browser: the report was reached by a REAL navigation
732
  // (Python's window.load_url() away from the tracked page, once the session
@@ -945,22 +1061,30 @@ function iconForLabel(label){
945
  // ---- click timeline mini-chart (derived from the same click timestamps) ----
946
  const cv = document.getElementById('clickTimeline');
947
  const ctx = cv.getContext('2d');
948
- function resizeClickCanvas(){ cv.width = cv.clientWidth || 560; cv.height = 60; }
949
- resizeClickCanvas();
950
- ctx.clearRect(0, 0, cv.width, cv.height);
951
- if (MOUSE_CLICKS.length > 1) {
952
- const times = MOUSE_CLICKS.map(c => { const p = (c.timestamp||'').split(':').map(Number); return (p[0]||0)*3600+(p[1]||0)*60+(p[2]||0); });
953
- const tmin = Math.min(...times), tmax = Math.max(...times) || tmin + 1;
954
- ctx.strokeStyle = 'rgba(139,92,246,0.25)';
955
- ctx.beginPath(); ctx.moveTo(0, cv.height - 10); ctx.lineTo(cv.width, cv.height - 10); ctx.stroke();
956
- times.forEach(function(t){
957
- const x = ((t - tmin) / (tmax - tmin || 1)) * (cv.width - 12) + 6;
958
- const grad = ctx.createLinearGradient(x, 6, x, cv.height - 10);
959
- grad.addColorStop(0, '#C084FC'); grad.addColorStop(1, '#6366F1');
960
- ctx.fillStyle = grad;
961
- ctx.beginPath(); ctx.arc(x, cv.height - 10, 4, 0, 2*Math.PI); ctx.fill();
962
- });
 
 
 
 
 
963
  }
 
 
 
964
  })();
965
 
966
  // ---------- Ranked table (with live filter) ----------
@@ -1657,6 +1781,538 @@ function showToast(message, isError, onRetry){
1657
  window.addEventListener('resize', function(){ layout(); paint(); });
1658
  })();
1659
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1660
  </script>
1661
  </body>
1662
  </html>
@@ -1689,11 +2345,13 @@ def generate_report(session_dir):
1689
  html = html.replace("__URL__", summary["url"] or "Unknown page")
1690
  html = html.replace("__URL_JSON__", json.dumps(summary["url"] or ""))
1691
  html = html.replace("__SESSION_TIMESTAMP__", session_timestamp)
 
1692
  html = html.replace("__SAMPLES__", str(summary["samples"]))
1693
  html = html.replace("__DURATION__", str(summary["duration"]))
1694
  html = html.replace("__NUM_ELEMENTS__", str(len(ranking)))
1695
  html = html.replace("__TOP_LABEL__", top_label)
1696
  html = html.replace("__CLICK_COUNT__", str(mouse["click_count"]))
 
1697
  html = html.replace("__RANKING_JSON__", json.dumps(ranking))
1698
  html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
1699
  html = html.replace("__MOUSE_INTERESTS_JSON__", json.dumps(mouse["interests"]))
 
25
 
26
  PAD_PX = 90 # keep identical to TRACKING_JS's PAD_PX in browser_session.py
27
 
28
+ # Report/print item caps — presentation limits only, never applied to the
29
+ # underlying computed data (compute_dwell_ranking/summarize_mouse still
30
+ # return every item; this only bounds how many rows get *displayed*).
31
+ MAX_ATTENTION_ITEMS = 20
32
+
33
 
34
  # =============================================================================
35
  # LOADING
 
95
  interests = sorted(
96
  ({"element": k, "seconds": round(v / 1000.0, 1)} for k, v in dwell_totals.items()),
97
  key=lambda r: -r["seconds"]
98
+ )[:MAX_ATTENTION_ITEMS]
99
 
100
  clicks.sort(key=lambda c: c.get("timestamp", ""))
101
 
 
467
  .shot-nav .iux-btn { padding: 7px 14px; font-size: 12px; }
468
  .shot-nav .iux-btn:disabled { opacity: 0.35; cursor: default; transform:none; }
469
 
470
+ .click-timeline { width: 100%; height: 60px; display: block; margin-top: 8px; }
471
 
472
  .hm-tab { display: flex; align-items: center; gap: 6px; }
473
 
 
573
  .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; }
574
  .iv-bottombar .iux-btn:hover { background: rgba(255,255,255,0.18); }
575
  .iv-bottombar input[type=range] { accent-color: var(--iux-primary-light); width: 120px; }
576
+
577
+ /* =========================================================================
578
+ PRINT / PDF REPORT
579
+ A self-contained set of fixed-size "pages" built by buildPrintReport()
580
+ (see the inline <script> at the bottom of this document) from the exact
581
+ same RANKING/SEGMENTS/MOUSE_* data the on-screen dashboard uses — never
582
+ a separate data source. Kept visually hidden (off-screen, not
583
+ display:none, so it can still be measured/laid out for pagination) on
584
+ screen, and swapped in for everything else only under @media print.
585
+ Colors are hardcoded to the dark palette here (not var(--iux-*)) so the
586
+ exported report always matches the intended report design regardless of
587
+ which theme is active on screen at export time. ========================= */
588
+ #printReport {
589
+ position: absolute; left: -10000px; top: 0; width: 794px;
590
+ font-family: var(--iux-font);
591
+ }
592
+ @page { size: A4; margin: 0; }
593
+ @media print {
594
+ html, body { background: #12131A !important; }
595
+ body > .back-nav, body > .topbar, body > .stat-row, body > .grid,
596
+ #iuxViewer, #elemFullscreen, #iuxReportToast { display: none !important; }
597
+ #printReport { position: static !important; left: auto !important; width: auto !important; }
598
+ }
599
+
600
+ .print-page {
601
+ position: relative; width: 794px; height: 1123px;
602
+ background: #12131A; color: #F1EEFA;
603
+ box-sizing: border-box; overflow: hidden;
604
+ break-after: page; page-break-after: always;
605
+ font-family: var(--iux-font);
606
+ }
607
+ .print-page:last-child { break-after: auto; page-break-after: auto; }
608
+
609
+ .pp-accent { position: absolute; top: 0; left: 0; right: 0; height: 6px;
610
+ background: linear-gradient(90deg, #6366F1, #8B5CF6 55%, #C084FC); }
611
+ .pp-header { position: absolute; top: 16px; left: 46px; right: 46px; height: 20px;
612
+ display: flex; align-items: center; justify-content: space-between; }
613
+ .pp-header .pp-brand { font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; color: #F1EEFA; }
614
+ .pp-header .pp-tag { font-size: 9px; font-weight: 600; letter-spacing: 0.12em; color: #6E6885; text-transform: uppercase; }
615
+ .pp-body { position: absolute; top: 46px; left: 46px; right: 46px; bottom: 52px; overflow: hidden; }
616
+ .pp-footer { position: absolute; bottom: 0; left: 46px; right: 46px; height: 40px;
617
+ display: flex; align-items: center; justify-content: space-between;
618
+ border-top: 1px solid rgba(148,138,179,0.16); font-size: 8.5px; color: #6E6885; }
619
+
620
+ .pp-cover-title { font-size: 25px; font-weight: 700; color: #F1EEFA; margin: 8px 0 4px; }
621
+ .pp-cover-meta { font-size: 10.5px; color: #9B95B3; margin-bottom: 20px; }
622
+
623
+ .pp-metric-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 18px; }
624
+ .pp-metric { background: #1E2030; border: 1px solid rgba(148,138,179,0.16); border-radius: 12px; padding: 12px 14px; break-inside: avoid; }
625
+ .pp-metric .pp-m-label { font-size: 8.5px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: #6E6885; margin-bottom: 6px; }
626
+ .pp-metric .pp-m-value { font-size: 19px; font-weight: 700; color: #F1EEFA; }
627
+ .pp-metric .pp-m-sub { font-size: 8.5px; color: #6E6885; margin-top: 2px; }
628
+
629
+ .pp-card { background: #1E2030; border: 1px solid rgba(148,138,179,0.16); border-radius: 12px; padding: 16px 18px; margin-bottom: 14px; break-inside: avoid; }
630
+ .pp-section-title { font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #F1EEFA; margin: 0 0 10px; }
631
+ .pp-section-sub { font-size: 10px; color: #9B95B3; margin: -6px 0 12px; line-height: 1.5; }
632
+ .pp-body-text { font-size: 10.5px; color: #B7B2CC; line-height: 1.65; }
633
+ .pp-body-text b { color: #F1EEFA; }
634
+ .pp-two-col { display: grid; grid-template-columns: 1.35fr 1fr; gap: 14px; }
635
+
636
+ .pp-table { width: 100%; border-collapse: collapse; font-size: 10px; }
637
+ .pp-table th { text-align: left; color: #6E6885; font-weight: 700; padding: 6px 8px; font-size: 8.5px;
638
+ text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid rgba(148,138,179,0.25); }
639
+ .pp-table td { padding: 7px 8px; border-bottom: 1px solid rgba(148,138,179,0.12); vertical-align: middle; }
640
+ .pp-table tr:nth-child(even) td { background: rgba(148,138,179,0.05); }
641
+ .pp-table .pp-num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
642
+ .pp-table .pp-rank { color: #6E6885; width: 22px; }
643
+ .pp-table .pp-el-name { word-break: break-word; }
644
+
645
+ .pp-snap-item { margin-bottom: 12px; }
646
+ .pp-snap-item .pp-snap-label { font-size: 8.5px; text-transform: uppercase; letter-spacing: 0.05em; color: #6E6885; margin-bottom: 3px; }
647
+ .pp-snap-item .pp-snap-value { font-size: 13px; font-weight: 700; color: #F1EEFA; }
648
+
649
+ .pp-shot-frame { width: 100%; border-radius: 10px; overflow: hidden; border: 1px solid rgba(148,138,179,0.2); background: #171923; }
650
+ .pp-shot-frame img { display: block; width: 100%; height: auto; }
651
+ .pp-shot-label { font-size: 9.5px; color: #9B95B3; margin-bottom: 8px; display: flex; justify-content: space-between; }
652
+ .pp-shot-row { display: grid; gap: 12px; margin-bottom: 14px; break-inside: avoid; }
653
+ .pp-shot-row.pp-cols-1 { grid-template-columns: 1fr; }
654
+ .pp-shot-row.pp-cols-2 { grid-template-columns: 1fr 1fr; }
655
+
656
+ .pp-chart-caption { font-size: 9px; color: #6E6885; margin-top: 8px; text-align: center; }
657
+
658
+ .pp-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
659
+ .pp-detail-card { background: #171923; border: 1px solid rgba(148,138,179,0.14); border-radius: 10px; padding: 10px 12px; break-inside: avoid; }
660
+ .pp-detail-card .pp-d-name { font-size: 10.5px; font-weight: 600; color: #F1EEFA; margin-bottom: 6px; }
661
+ .pp-detail-card .pp-d-row { display: flex; justify-content: space-between; font-size: 9px; color: #9B95B3; padding: 2px 0; }
662
+ .pp-detail-card .pp-d-row b { color: #F1EEFA; font-weight: 600; }
663
+
664
+ .pp-click-item { border-bottom: 1px solid rgba(148,138,179,0.12); padding: 7px 0; font-size: 10px; break-inside: avoid; }
665
+ .pp-click-item .pp-click-time { font-size: 8.5px; color: #6E6885; }
666
+ .pp-click-item .pp-click-el { font-weight: 600; color: #F1EEFA; }
667
+ .pp-click-item .pp-click-text { color: #9B95B3; font-size: 9.5px; }
668
+
669
+ .pp-insight-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
670
+ .pp-insight-card { background: #1E2030; border: 1px solid rgba(148,138,179,0.16); border-radius: 12px; padding: 16px; break-inside: avoid; }
671
+ .pp-insight-card .pp-i-label { font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; color: #6E6885; margin-bottom: 6px; }
672
+ .pp-insight-card .pp-i-value { font-size: 17px; font-weight: 700; color: #F1EEFA; }
673
+
674
+ .pp-empty { font-size: 10.5px; color: #6E6885; padding: 10px 0; }
675
  </style>
676
  </head>
677
  <body>
 
813
  </div>
814
  </div>
815
 
816
+ <div id="printReport"></div>
817
+
818
  <script>
819
  __ICONS_JS__
820
  __THEME_JS__
 
831
  const MOUSE_INTERESTS = __MOUSE_INTERESTS_JSON__;
832
  const MOUSE_CLICKS = __MOUSE_CLICKS_JSON__;
833
  const MOUSE_SEGMENTS = __MOUSE_SEGMENTS_JSON__;
834
+ const MAX_ATTENTION_ITEMS = __MAX_ATTENTION_ITEMS__;
835
+ const SESSION = {
836
+ url: __URL_JSON__,
837
+ timestamp: __SESSION_TIMESTAMP_JSON__,
838
+ duration: __DURATION__,
839
+ samples: __SAMPLES__,
840
+ clickCount: __CLICK_COUNT__
841
+ };
842
+
843
+ document.getElementById('btnExport').addEventListener('click', function(){
844
+ Promise.resolve(window.__insightuxPrintReady).then(function(){ window.print(); });
845
+ });
846
 
847
  // ---------- Back to Browser: the report was reached by a REAL navigation
848
  // (Python's window.load_url() away from the tracked page, once the session
 
1061
  // ---- click timeline mini-chart (derived from the same click timestamps) ----
1062
  const cv = document.getElementById('clickTimeline');
1063
  const ctx = cv.getContext('2d');
1064
+ function paintClickTimeline(){
1065
+ // CSS fixes the box at width:100%/height:60px, so clientWidth is stable
1066
+ // post-layout but re-measure on every call (resize/print/theme) rather
1067
+ // than once, so the canvas never locks in a stale width.
1068
+ cv.width = cv.clientWidth || 560;
1069
+ cv.height = 60;
1070
+ ctx.clearRect(0, 0, cv.width, cv.height);
1071
+ if (MOUSE_CLICKS.length > 1) {
1072
+ const times = MOUSE_CLICKS.map(c => { const p = (c.timestamp||'').split(':').map(Number); return (p[0]||0)*3600+(p[1]||0)*60+(p[2]||0); });
1073
+ const tmin = Math.min(...times), tmax = Math.max(...times) || tmin + 1;
1074
+ ctx.strokeStyle = 'rgba(139,92,246,0.25)';
1075
+ ctx.beginPath(); ctx.moveTo(0, cv.height - 10); ctx.lineTo(cv.width, cv.height - 10); ctx.stroke();
1076
+ times.forEach(function(t){
1077
+ const x = ((t - tmin) / (tmax - tmin || 1)) * (cv.width - 12) + 6;
1078
+ const grad = ctx.createLinearGradient(x, 6, x, cv.height - 10);
1079
+ grad.addColorStop(0, '#C084FC'); grad.addColorStop(1, '#6366F1');
1080
+ ctx.fillStyle = grad;
1081
+ ctx.beginPath(); ctx.arc(x, cv.height - 10, 4, 0, 2*Math.PI); ctx.fill();
1082
+ });
1083
+ }
1084
  }
1085
+ paintClickTimeline();
1086
+ window.addEventListener('resize', paintClickTimeline, {passive:true});
1087
+ window.insightuxRepaintCanvases.push(paintClickTimeline);
1088
  })();
1089
 
1090
  // ---------- Ranked table (with live filter) ----------
 
1781
  window.addEventListener('resize', function(){ layout(); paint(); });
1782
  })();
1783
 
1784
+ // =============================================================================
1785
+ // PRINT / PDF REPORT — builds a fixed set of paginated .print-page elements
1786
+ // inside #printReport from the exact same RANKING/SEGMENTS/MOUSE_*/SESSION
1787
+ // data every on-screen panel above already uses (nothing recomputed, nothing
1788
+ // fabricated). Pagination is done here in JS rather than left to the
1789
+ // browser's default print reflow: each atomic block (a table row, a card, an
1790
+ // image) is appended and measured, and moved whole to a fresh page the
1791
+ // instant it would overflow, so a card/row/image is never split across a
1792
+ // page boundary and a heading never ends up alone at the bottom of one.
1793
+ // window.__insightuxPrintReady is a Promise the Export button (and anyone
1794
+ // calling window.print() directly) can await first, since the heatmap
1795
+ // images are composited (screenshot + eye heat layer, flattened to a single
1796
+ // PNG) asynchronously before layout runs.
1797
+ // =============================================================================
1798
+ (function(){
1799
+ const root = document.getElementById('printReport');
1800
+ if (!root) { window.__insightuxPrintReady = Promise.resolve(); return; }
1801
+
1802
+ function esc(s){
1803
+ return (s == null ? '' : String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
1804
+ }
1805
+ function fmtS(n){ return (Math.round((n||0)*100)/100).toFixed(2) + 's'; }
1806
+ function fmtPct(n){ return (n||0).toFixed(1) + '%'; }
1807
+
1808
+ // Composites each segment's screenshot with its eye-attention heat layer
1809
+ // (the exact same paintHeatLayer()/EYE_STOPS the live Attention Heatmaps
1810
+ // panel uses) into one flattened PNG, so print doesn't depend on a live
1811
+ // canvas repainting correctly inside the print engine.
1812
+ function compositeSegmentImage(seg){
1813
+ return new Promise(function(resolve){
1814
+ const im = new Image();
1815
+ im.onload = function(){
1816
+ const cv = document.createElement('canvas');
1817
+ cv.width = im.naturalWidth || 1;
1818
+ cv.height = im.naturalHeight || 1;
1819
+ const cx = cv.getContext('2d');
1820
+ cx.drawImage(im, 0, 0);
1821
+ try { paintHeatLayer(cx, cv.width, cv.height, seg.points || [], 0.09, EYE_STOPS, 0.9); } catch(e){}
1822
+ let dataUrl = seg.screenshotData;
1823
+ try { dataUrl = cv.toDataURL('image/png'); } catch(e){}
1824
+ resolve({ dataUrl: dataUrl, w: cv.width, h: cv.height });
1825
+ };
1826
+ im.onerror = function(){ resolve({ dataUrl: seg.screenshotData, w: 16, h: 9 }); };
1827
+ im.src = seg.screenshotData;
1828
+ });
1829
+ }
1830
+
1831
+ window.__insightuxPrintReady = Promise.all(SEGMENTS.map(compositeSegmentImage))
1832
+ .then(buildPrintPages)
1833
+ .catch(function(){ /* best-effort — export still works with whatever got built */ });
1834
+
1835
+ function buildPrintPages(composited){
1836
+ const BODY_BUDGET = 1025; // must match .pp-body's CSS height (1123 - 46 top - 52 bottom)
1837
+ const PAGES = [];
1838
+ let curBody = null;
1839
+
1840
+ function newPage(){
1841
+ const page = document.createElement('div');
1842
+ page.className = 'print-page';
1843
+ page.innerHTML =
1844
+ '<div class="pp-accent"></div>' +
1845
+ '<div class="pp-header"><span class="pp-brand">INSIGHTUX</span><span class="pp-tag">Session Report</span></div>' +
1846
+ '<div class="pp-body"></div>' +
1847
+ '<div class="pp-footer"><span class="pp-f-meta"></span><span class="pp-f-page"></span></div>';
1848
+ root.appendChild(page);
1849
+ curBody = page.querySelector('.pp-body');
1850
+ PAGES.push(page);
1851
+ return page;
1852
+ }
1853
+
1854
+ // Appends one atomic block; if it overflows AND the page already holds
1855
+ // other content, moves it whole to a fresh page instead (break-inside:
1856
+ // avoid on .pp-card/.pp-metric/etc. is a defensive backstop — this is
1857
+ // the actual mechanism preventing split cards/images/charts).
1858
+ function fitAppend(el){
1859
+ curBody.appendChild(el);
1860
+ if (curBody.scrollHeight > BODY_BUDGET && curBody.children.length > 1) {
1861
+ curBody.removeChild(el);
1862
+ newPage();
1863
+ curBody.appendChild(el);
1864
+ }
1865
+ }
1866
+
1867
+ // Generic "table that may need to continue onto further pages" flow:
1868
+ // tries the full row set first (the common case — fits on one page),
1869
+ // and only if that overflows does it trim back to however many rows
1870
+ // actually fit, repeat the header + heading with " — Continued" on a
1871
+ // fresh page, and carry on with what's left. A heading is never left
1872
+ // without at least its header row + one data row, and a solitary
1873
+ // last row is folded back a page rather than stranded alone.
1874
+ function flowTable(title, headHtml, rows, rowHtmlFn){
1875
+ function build(rowsSlice, continued){
1876
+ const card = document.createElement('div');
1877
+ card.className = 'pp-card';
1878
+ const t = document.createElement('div');
1879
+ t.className = 'pp-section-title';
1880
+ t.textContent = title + (continued ? ' — Continued' : '');
1881
+ card.appendChild(t);
1882
+ const table = document.createElement('table');
1883
+ table.className = 'pp-table';
1884
+ table.innerHTML = '<thead><tr>' + headHtml + '</tr></thead>';
1885
+ const tbody = document.createElement('tbody');
1886
+ rowsSlice.forEach(function(r, i){
1887
+ const tr = document.createElement('tr');
1888
+ tr.innerHTML = rowHtmlFn(r, i);
1889
+ tbody.appendChild(tr);
1890
+ });
1891
+ table.appendChild(tbody);
1892
+ card.appendChild(table);
1893
+ return { card: card, tbody: tbody };
1894
+ }
1895
+ let remaining = rows.slice();
1896
+ let continued = false;
1897
+ while (remaining.length) {
1898
+ const built = build(remaining, continued);
1899
+ curBody.appendChild(built.card);
1900
+ if (curBody.scrollHeight <= BODY_BUDGET) { remaining = []; break; }
1901
+ let fitCount = remaining.length;
1902
+ while (fitCount > 0) {
1903
+ while (built.tbody.children.length > fitCount) built.tbody.removeChild(built.tbody.lastChild);
1904
+ if (curBody.scrollHeight <= BODY_BUDGET) break;
1905
+ fitCount--;
1906
+ }
1907
+ if (fitCount === 0) {
1908
+ curBody.removeChild(built.card);
1909
+ newPage();
1910
+ continue;
1911
+ }
1912
+ if (remaining.length - fitCount === 1 && fitCount > 1) {
1913
+ fitCount--;
1914
+ while (built.tbody.children.length > fitCount) built.tbody.removeChild(built.tbody.lastChild);
1915
+ }
1916
+ remaining = remaining.slice(fitCount);
1917
+ continued = true;
1918
+ if (remaining.length) newPage();
1919
+ }
1920
+ }
1921
+
1922
+ // Same continuation-safe flow as flowTable, for a heading + a list of
1923
+ // non-tabular item cards (the click log) — the heading is bundled with
1924
+ // at least its first item so it's never left alone at a page's bottom.
1925
+ function flowList(title, items, renderFn){
1926
+ function build(itemsSlice, continued){
1927
+ const wrap = document.createElement('div');
1928
+ const h = document.createElement('div');
1929
+ h.className = 'pp-section-title';
1930
+ h.style.fontSize = '15px';
1931
+ h.style.marginBottom = '10px';
1932
+ h.textContent = title + (continued ? ' — Continued' : '');
1933
+ wrap.appendChild(h);
1934
+ const list = document.createElement('div');
1935
+ itemsSlice.forEach(function(it){ list.appendChild(renderFn(it)); });
1936
+ wrap.appendChild(list);
1937
+ return { wrap: wrap, list: list };
1938
+ }
1939
+ let remaining = items.slice();
1940
+ let continued = false;
1941
+ while (remaining.length) {
1942
+ const built = build(remaining, continued);
1943
+ curBody.appendChild(built.wrap);
1944
+ if (curBody.scrollHeight <= BODY_BUDGET) { remaining = []; break; }
1945
+ let fitCount = remaining.length;
1946
+ while (fitCount > 0) {
1947
+ while (built.list.children.length > fitCount) built.list.removeChild(built.list.lastChild);
1948
+ if (curBody.scrollHeight <= BODY_BUDGET) break;
1949
+ fitCount--;
1950
+ }
1951
+ if (fitCount === 0) {
1952
+ curBody.removeChild(built.wrap);
1953
+ newPage();
1954
+ continue;
1955
+ }
1956
+ remaining = remaining.slice(fitCount);
1957
+ continued = true;
1958
+ if (remaining.length) newPage();
1959
+ }
1960
+ }
1961
+
1962
+ function sectionHeading(text, sub){
1963
+ const wrap = document.createElement('div');
1964
+ wrap.innerHTML = '<div class="pp-section-title" style="font-size:15px;margin-bottom:' + (sub ? '4px' : '10px') + ';">' + esc(text) + '</div>' +
1965
+ (sub ? '<div class="pp-section-sub">' + esc(sub) + '</div>' : '');
1966
+ return wrap;
1967
+ }
1968
+
1969
+ const items = RANKING.slice(0, MAX_ATTENTION_ITEMS);
1970
+ items.forEach(function(r, i){ r._rank = i + 1; });
1971
+ const totalGaze = RANKING.reduce(function(s, r){ return s + r.seconds; }, 0);
1972
+ const avgPerElement = RANKING.length ? totalGaze / RANKING.length : 0;
1973
+ const topLabel = RANKING.length ? RANKING[0].label : '—';
1974
+ const topPct = RANKING.length ? RANKING[0].pct : 0;
1975
+
1976
+ // ======================================================================
1977
+ // PAGE 1 — cover, metric cards, attention summary, ranked attention
1978
+ // ======================================================================
1979
+ newPage();
1980
+
1981
+ const cover = document.createElement('div');
1982
+ cover.innerHTML =
1983
+ '<div class="pp-cover-title">InsightUX Session Report</div>' +
1984
+ '<div class="pp-cover-meta">' + esc(SESSION.url || 'Unknown page') + ' &middot; ' + esc(SESSION.timestamp) +
1985
+ ' &middot; ' + SESSION.duration + 's session &middot; ' + SESSION.samples + ' gaze samples</div>';
1986
+ fitAppend(cover);
1987
+
1988
+ const metrics = document.createElement('div');
1989
+ metrics.className = 'pp-metric-row';
1990
+ metrics.innerHTML = [
1991
+ ['Session Length', SESSION.duration + 's', 'recorded session'],
1992
+ ['Gaze Samples', String(SESSION.samples), 'eye-tracking samples'],
1993
+ ['Elements Tracked', String(RANKING.length), 'ranked attention targets'],
1994
+ ['Mouse Events', String(SESSION.clickCount), 'recorded clicks']
1995
+ ].map(function(m){
1996
+ return '<div class="pp-metric"><div class="pp-m-label">' + m[0] + '</div><div class="pp-m-value">' + m[1] + '</div><div class="pp-m-sub">' + m[2] + '</div></div>';
1997
+ }).join('');
1998
+ fitAppend(metrics);
1999
+
2000
+ const summaryCard = document.createElement('div');
2001
+ summaryCard.className = 'pp-card pp-two-col';
2002
+ let summaryText;
2003
+ if (RANKING.length) {
2004
+ summaryText = 'During this session, InsightUX recorded <b>' + fmtS(totalGaze) + '</b> of element-level gaze attention across ' +
2005
+ '<b>' + RANKING.length + '</b> tracked webpage element' + (RANKING.length === 1 ? '' : 's') + '. ' +
2006
+ 'The highest-attention element was <b>' + esc(topLabel) + '</b>, receiving <b>' + fmtPct(topPct) + '</b> of recorded attention.';
2007
+ if (SESSION.clickCount > 0) {
2008
+ summaryText += ' The session also recorded <b>' + SESSION.clickCount + '</b> mouse click' + (SESSION.clickCount === 1 ? '' : 's') + '.';
2009
+ }
2010
+ } else {
2011
+ summaryText = 'No elements were fixated long enough to register during this session.';
2012
+ }
2013
+ summaryCard.innerHTML =
2014
+ '<div>' +
2015
+ '<div class="pp-section-title">Attention Summary</div>' +
2016
+ '<div class="pp-body-text">' + summaryText + '</div>' +
2017
+ '</div>' +
2018
+ '<div>' +
2019
+ '<div class="pp-section-title">Session Snapshot</div>' +
2020
+ '<div class="pp-snap-item"><div class="pp-snap-label">Top Element</div><div class="pp-snap-value">' + esc(topLabel) + '</div></div>' +
2021
+ '<div class="pp-snap-item"><div class="pp-snap-label">Top Share</div><div class="pp-snap-value">' + fmtPct(topPct) + '</div></div>' +
2022
+ '<div class="pp-snap-item"><div class="pp-snap-label">Average / Element</div><div class="pp-snap-value">' + fmtS(avgPerElement) + '</div></div>' +
2023
+ '</div>';
2024
+ fitAppend(summaryCard);
2025
+
2026
+ if (items.length) {
2027
+ flowTable('Ranked Attention',
2028
+ '<th style="width:22px;">#</th><th>Element</th><th class="pp-num">Gaze Attention</th><th class="pp-num">Share</th><th class="pp-num">Hits</th>',
2029
+ items,
2030
+ function(r){
2031
+ return '<td class="pp-rank">' + r._rank + '</td>' +
2032
+ '<td class="pp-el-name">' + esc(r.label) + '</td>' +
2033
+ '<td class="pp-num">' + fmtS(r.seconds) + '</td>' +
2034
+ '<td class="pp-num">' + fmtPct(r.pct) + '</td>' +
2035
+ '<td class="pp-num">' + r.hits + '</td>';
2036
+ });
2037
+ } else {
2038
+ const empty = document.createElement('div');
2039
+ empty.className = 'pp-card';
2040
+ empty.innerHTML = '<div class="pp-section-title">Ranked Attention</div><div class="pp-empty">No elements were fixated long enough to register.</div>';
2041
+ fitAppend(empty);
2042
+ }
2043
+
2044
+ // ======================================================================
2045
+ // PAGE — Attention Visualization: session view + element attention chart
2046
+ // ======================================================================
2047
+ newPage();
2048
+ fitAppend(sectionHeading('Attention Visualization', 'Visual representation of the recorded gaze attention across webpage elements.'));
2049
+
2050
+ if (SEGMENTS.length) {
2051
+ const seg = SEGMENTS[0];
2052
+ const c = composited[0];
2053
+ const shotCard = document.createElement('div');
2054
+ shotCard.className = 'pp-card';
2055
+ shotCard.innerHTML =
2056
+ '<div class="pp-section-title" style="font-size:10.5px;">Session View</div>' +
2057
+ '<div class="pp-shot-label"><span>Scroll segment 1 of ' + SEGMENTS.length + '</span><span>~' + seg.duration + 's of attention here</span></div>' +
2058
+ '<div class="pp-shot-frame"><img width="' + c.w + '" height="' + c.h + '" src="' + c.dataUrl + '" alt=""></div>';
2059
+ fitAppend(shotCard);
2060
+ }
2061
+
2062
+ const chartCard = document.createElement('div');
2063
+ chartCard.className = 'pp-card';
2064
+ const chartTitle = document.createElement('div');
2065
+ chartTitle.className = 'pp-section-title';
2066
+ chartTitle.style.fontSize = '10.5px';
2067
+ chartTitle.textContent = 'Element Attention Analysis';
2068
+ chartCard.appendChild(chartTitle);
2069
+ if (items.length) {
2070
+ const chartWrap = document.createElement('div');
2071
+ chartWrap.style.cssText = 'display:flex;justify-content:center;';
2072
+ const chartCanvas = document.createElement('canvas');
2073
+ chartWrap.appendChild(chartCanvas);
2074
+ chartCard.appendChild(chartWrap);
2075
+ renderPrintChart(chartCanvas, items);
2076
+ if (items.length > 8) {
2077
+ const caption = document.createElement('div');
2078
+ caption.className = 'pp-chart-caption';
2079
+ caption.textContent = 'Reference numbers correspond to rank in the Ranked Attention table.';
2080
+ chartCard.appendChild(caption);
2081
+ }
2082
+ } else {
2083
+ const emptyChart = document.createElement('div');
2084
+ emptyChart.className = 'pp-empty';
2085
+ emptyChart.textContent = 'No elements were fixated long enough to chart.';
2086
+ chartCard.appendChild(emptyChart);
2087
+ }
2088
+ fitAppend(chartCard);
2089
+
2090
+ const vizStats = document.createElement('div');
2091
+ vizStats.className = 'pp-metric-row';
2092
+ vizStats.innerHTML = [
2093
+ ['Total Gaze Time', fmtS(totalGaze)],
2094
+ ['Top Element Share', fmtPct(topPct)],
2095
+ ['Total Elements', String(RANKING.length)],
2096
+ ['Avg. / Element', fmtS(avgPerElement)]
2097
+ ].map(function(m){
2098
+ return '<div class="pp-metric"><div class="pp-m-label">' + m[0] + '</div><div class="pp-m-value" style="font-size:15px;">' + m[1] + '</div></div>';
2099
+ }).join('');
2100
+ fitAppend(vizStats);
2101
+
2102
+ // ======================================================================
2103
+ // PAGE(S) — Attention Heatmaps
2104
+ // ======================================================================
2105
+ newPage();
2106
+ fitAppend(sectionHeading('Attention Heatmaps', 'Heatmaps show where visual attention was concentrated within the recorded webpage viewport.'));
2107
+
2108
+ if (SEGMENTS.length) {
2109
+ const perRow = SEGMENTS.length <= 3 ? 1 : 2;
2110
+ for (let i = 0; i < SEGMENTS.length; i += perRow) {
2111
+ const row = document.createElement('div');
2112
+ row.className = 'pp-shot-row pp-cols-' + perRow;
2113
+ for (let j = i; j < Math.min(i + perRow, SEGMENTS.length); j++) {
2114
+ const seg = SEGMENTS[j], c = composited[j];
2115
+ const card = document.createElement('div');
2116
+ card.innerHTML =
2117
+ '<div class="pp-shot-label"><span>Scroll Segment ' + (j + 1) + '</span><span>~' + seg.duration + 's attention</span></div>' +
2118
+ '<div class="pp-shot-frame"><img width="' + c.w + '" height="' + c.h + '" src="' + c.dataUrl + '" alt=""></div>';
2119
+ row.appendChild(card);
2120
+ }
2121
+ fitAppend(row);
2122
+ }
2123
+ } else {
2124
+ const empty = document.createElement('div');
2125
+ empty.className = 'pp-card';
2126
+ empty.innerHTML = '<div class="pp-empty">No page screenshots were captured for this session (older session, or screen-capture failed).</div>';
2127
+ fitAppend(empty);
2128
+ }
2129
+
2130
+ // ======================================================================
2131
+ // PAGE — Element Attention Details (a compact reference sheet; only
2132
+ // worth its own page once there are more ranked elements than the
2133
+ // Ranked Attention table on page 1 can be quickly scanned at a glance)
2134
+ // ======================================================================
2135
+ if (items.length > 6) {
2136
+ newPage();
2137
+ fitAppend(sectionHeading('Element Attention Details'));
2138
+ let grid = document.createElement('div');
2139
+ grid.className = 'pp-detail-grid';
2140
+ curBody.appendChild(grid);
2141
+ items.forEach(function(r){
2142
+ const card = document.createElement('div');
2143
+ card.className = 'pp-detail-card';
2144
+ card.innerHTML =
2145
+ '<div class="pp-d-name">#' + r._rank + ' · ' + esc(r.label) + '</div>' +
2146
+ '<div class="pp-d-row"><span>Gaze Time</span><b>' + fmtS(r.seconds) + '</b></div>' +
2147
+ '<div class="pp-d-row"><span>Share of Attention</span><b>' + fmtPct(r.pct) + '</b></div>' +
2148
+ '<div class="pp-d-row"><span>Hits</span><b>' + r.hits + '</b></div>';
2149
+ grid.appendChild(card);
2150
+ if (curBody.scrollHeight > BODY_BUDGET && grid.children.length > 1) {
2151
+ grid.removeChild(card);
2152
+ newPage();
2153
+ grid = document.createElement('div');
2154
+ grid.className = 'pp-detail-grid';
2155
+ curBody.appendChild(grid);
2156
+ grid.appendChild(card);
2157
+ }
2158
+ });
2159
+ }
2160
+
2161
+ // ======================================================================
2162
+ // PAGE(S) — Mouse Interaction Analysis
2163
+ // ======================================================================
2164
+ if (MOUSE_INTERESTS.length || MOUSE_CLICKS.length) {
2165
+ newPage();
2166
+ fitAppend(sectionHeading('Mouse Interaction Analysis'));
2167
+
2168
+ if (MOUSE_INTERESTS.length) {
2169
+ flowTable('Most Interacted Elements',
2170
+ '<th style="width:22px;">#</th><th>Element</th><th class="pp-num">Interaction Time</th>',
2171
+ MOUSE_INTERESTS,
2172
+ function(r, i){ return '<td class="pp-rank">' + (i + 1) + '</td><td class="pp-el-name">' + esc(r.element) + '</td><td class="pp-num">' + fmtS(r.seconds) + '</td>'; });
2173
+ } else {
2174
+ const empty = document.createElement('div');
2175
+ empty.className = 'pp-card';
2176
+ empty.innerHTML = '<div class="pp-section-title">Most Interacted Elements</div><div class="pp-empty">No mouse dwell data recorded for this session.</div>';
2177
+ fitAppend(empty);
2178
+ }
2179
+
2180
+ if (MOUSE_CLICKS.length) {
2181
+ flowList('Mouse — Click Timeline & Log', MOUSE_CLICKS, function(c){
2182
+ const item = document.createElement('div');
2183
+ item.className = 'pp-click-item';
2184
+ item.innerHTML =
2185
+ '<div class="pp-click-time">' + esc(c.timestamp || '') + '</div>' +
2186
+ '<div class="pp-click-el">' + esc(c.element || '') + '</div>' +
2187
+ '<div class="pp-click-text">"' + esc(c.text || '') + '"</div>';
2188
+ return item;
2189
+ });
2190
+ } else {
2191
+ fitAppend(sectionHeading('Mouse — Click Timeline & Log'));
2192
+ const empty = document.createElement('div');
2193
+ empty.className = 'pp-card';
2194
+ empty.innerHTML = '<div class="pp-empty">No mouse clicks were recorded during this session.</div>';
2195
+ fitAppend(empty);
2196
+ }
2197
+ }
2198
+
2199
+ // ======================================================================
2200
+ // FINAL PAGE — Session Insights (objective, derived-only wording)
2201
+ // ======================================================================
2202
+ if (RANKING.length) {
2203
+ newPage();
2204
+ fitAppend(sectionHeading('Session Insights'));
2205
+ const grid = document.createElement('div');
2206
+ grid.className = 'pp-insight-grid';
2207
+ grid.innerHTML = [
2208
+ ['Highest Attention', esc(topLabel) + ' — ' + fmtS(RANKING[0].seconds)],
2209
+ ['Largest Attention Share', fmtPct(topPct)],
2210
+ ['Tracked Elements', String(RANKING.length)],
2211
+ ['Total Gaze Attention', fmtS(totalGaze)]
2212
+ ].map(function(x){
2213
+ return '<div class="pp-insight-card"><div class="pp-i-label">' + x[0] + '</div><div class="pp-i-value">' + x[1] + '</div></div>';
2214
+ }).join('');
2215
+ fitAppend(grid);
2216
+ }
2217
+
2218
+ // ---- footers, now that the final page count is known ----
2219
+ PAGES.forEach(function(page, i){
2220
+ page.querySelector('.pp-f-meta').textContent =
2221
+ 'InsightUX · Session Report · ' + (SESSION.url || '') + ' · ' + (SESSION.timestamp || '');
2222
+ page.querySelector('.pp-f-page').textContent = 'Page ' + (i + 1) + ' of ' + PAGES.length;
2223
+ });
2224
+ }
2225
+
2226
+ // Clean, static line chart for the Element Attention Analysis print page:
2227
+ // every ranked element gets one point, numbered #1.. to match the Ranked
2228
+ // Attention table's rank column (full names are already on that table —
2229
+ // repeating all of them here gets unreadable past ~8 points).
2230
+ function renderPrintChart(canvas, items){
2231
+ const cssW = 690, cssH = 300, dpr = 2;
2232
+ canvas.width = cssW * dpr;
2233
+ canvas.height = cssH * dpr;
2234
+ canvas.style.width = cssW + 'px';
2235
+ canvas.style.height = cssH + 'px';
2236
+ const ctx = canvas.getContext('2d');
2237
+ ctx.scale(dpr, dpr);
2238
+ const font = getComputedStyle(document.body).fontFamily || 'sans-serif';
2239
+
2240
+ const padL = 54, padR = 16, padT = 16, padB = 40;
2241
+ const plotW = cssW - padL - padR, plotH = cssH - padT - padB;
2242
+
2243
+ const maxVal = items.reduce(function(m, r){ return Math.max(m, r.seconds); }, 0) || 1;
2244
+ function niceCeil(v){
2245
+ if (v <= 0) return 1;
2246
+ const mag = Math.pow(10, Math.floor(Math.log10(v)));
2247
+ const norm = v / mag;
2248
+ const step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
2249
+ return step * mag;
2250
+ }
2251
+ const top = niceCeil(maxVal * 1.15);
2252
+ const TICKS = 5;
2253
+
2254
+ ctx.clearRect(0, 0, cssW, cssH);
2255
+
2256
+ ctx.strokeStyle = 'rgba(148,138,179,0.16)';
2257
+ ctx.fillStyle = '#6E6885';
2258
+ ctx.font = '9px ' + font;
2259
+ ctx.textAlign = 'right';
2260
+ ctx.textBaseline = 'middle';
2261
+ for (let i = 0; i <= TICKS; i++){
2262
+ const v = top * i / TICKS;
2263
+ const y = padT + plotH - (v / top) * plotH;
2264
+ ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y); ctx.stroke();
2265
+ ctx.fillText(v.toFixed(1) + 's', padL - 8, y);
2266
+ }
2267
+
2268
+ const n = items.length;
2269
+ const stepX = n > 1 ? plotW / (n - 1) : 0;
2270
+ const xFor = function(i){ return n > 1 ? padL + i * stepX : padL + plotW / 2; };
2271
+ const yFor = function(v){ return padT + plotH - (v / top) * plotH; };
2272
+
2273
+ ctx.strokeStyle = '#8B5CF6';
2274
+ ctx.lineWidth = 2;
2275
+ ctx.beginPath();
2276
+ items.forEach(function(r, i){
2277
+ const x = xFor(i), y = yFor(r.seconds);
2278
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
2279
+ });
2280
+ ctx.stroke();
2281
+
2282
+ const showValues = n <= 10;
2283
+ items.forEach(function(r, i){
2284
+ const x = xFor(i), y = yFor(r.seconds);
2285
+ const grad = ctx.createRadialGradient(x, y, 0, x, y, 5);
2286
+ grad.addColorStop(0, '#C084FC'); grad.addColorStop(1, '#6366F1');
2287
+ ctx.fillStyle = grad;
2288
+ ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill();
2289
+ if (showValues) {
2290
+ ctx.fillStyle = '#F1EEFA';
2291
+ ctx.font = '600 9px ' + font;
2292
+ ctx.textAlign = 'center';
2293
+ ctx.textBaseline = 'alphabetic';
2294
+ ctx.fillText(r.seconds.toFixed(2) + 's', x, Math.max(10, y - 10));
2295
+ }
2296
+ });
2297
+
2298
+ ctx.fillStyle = '#9B95B3';
2299
+ ctx.font = '8.5px ' + font;
2300
+ ctx.textAlign = 'center';
2301
+ ctx.textBaseline = 'top';
2302
+ items.forEach(function(r, i){ ctx.fillText('#' + (i + 1), xFor(i), padT + plotH + 8); });
2303
+
2304
+ ctx.fillStyle = '#6E6885';
2305
+ ctx.font = '700 8px ' + font;
2306
+ ctx.textAlign = 'center';
2307
+ ctx.fillText('WEBPAGE ELEMENTS', padL + plotW / 2, cssH - 8);
2308
+ ctx.save();
2309
+ ctx.translate(12, padT + plotH / 2);
2310
+ ctx.rotate(-Math.PI / 2);
2311
+ ctx.fillText('GAZE ATTENTION (SEC)', 0, 0);
2312
+ ctx.restore();
2313
+ }
2314
+ })();
2315
+
2316
  </script>
2317
  </body>
2318
  </html>
 
2345
  html = html.replace("__URL__", summary["url"] or "Unknown page")
2346
  html = html.replace("__URL_JSON__", json.dumps(summary["url"] or ""))
2347
  html = html.replace("__SESSION_TIMESTAMP__", session_timestamp)
2348
+ html = html.replace("__SESSION_TIMESTAMP_JSON__", json.dumps(session_timestamp))
2349
  html = html.replace("__SAMPLES__", str(summary["samples"]))
2350
  html = html.replace("__DURATION__", str(summary["duration"]))
2351
  html = html.replace("__NUM_ELEMENTS__", str(len(ranking)))
2352
  html = html.replace("__TOP_LABEL__", top_label)
2353
  html = html.replace("__CLICK_COUNT__", str(mouse["click_count"]))
2354
+ html = html.replace("__MAX_ATTENTION_ITEMS__", str(MAX_ATTENTION_ITEMS))
2355
  html = html.replace("__RANKING_JSON__", json.dumps(ranking))
2356
  html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
2357
  html = html.replace("__MOUSE_INTERESTS_JSON__", json.dumps(mouse["interests"]))
browser_session.py CHANGED
@@ -994,6 +994,20 @@ TRACKING_JS = r"""
994
  if (!document.body.contains(cv)) document.body.appendChild(cv);
995
  }, 1000);
996
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
997
  function roundRectPath(c, x, y, w, h, r){
998
  c.beginPath();
999
  c.moveTo(x + r, y);
@@ -2106,7 +2120,18 @@ def gaze_worker(window, stop_event, session_dir, api_ref):
2106
  if not (scrolled_enough or time_elapsed):
2107
  return None
2108
  try:
2109
- img = pyautogui.screenshot()
 
 
 
 
 
 
 
 
 
 
 
2110
  # Force to the same pixel space as gaze coordinates (SCREEN_W x
2111
  # SCREEN_H) — Windows DPI scaling can otherwise return a
2112
  # screenshot at a different resolution than pyautogui.size(),
 
994
  if (!document.body.contains(cv)) document.body.appendChild(cv);
995
  }, 1000);
996
 
997
+ // Momentarily hides InsightUX's own injected UI (toolbar, sidebar, this
998
+ // gaze/AOI canvas, the mouse HUD panel) so pyautogui's full-screen grab in
999
+ // maybe_capture_screenshot() captures only the tracked page underneath —
1000
+ // called from Python right before/after each screenshot, never during
1001
+ // normal tracking.
1002
+ window.__insightuxSetCaptureMode = function(hidden){
1003
+ const vis = hidden ? 'hidden' : '';
1004
+ cv.style.visibility = vis;
1005
+ ['__insightux_toolbar', '__insightux_sidebar', '__insightux_mouse_panel'].forEach(function(id){
1006
+ const el = document.getElementById(id);
1007
+ if (el) el.style.visibility = vis;
1008
+ });
1009
+ };
1010
+
1011
  function roundRectPath(c, x, y, w, h, r){
1012
  c.beginPath();
1013
  c.moveTo(x + r, y);
 
2120
  if not (scrolled_enough or time_elapsed):
2121
  return None
2122
  try:
2123
+ try:
2124
+ window.evaluate_js("window.__insightuxSetCaptureMode && window.__insightuxSetCaptureMode(true)")
2125
+ except Exception:
2126
+ pass
2127
+ time.sleep(0.05) # let the hide actually repaint before the OS-level grab
2128
+ try:
2129
+ img = pyautogui.screenshot()
2130
+ finally:
2131
+ try:
2132
+ window.evaluate_js("window.__insightuxSetCaptureMode && window.__insightuxSetCaptureMode(false)")
2133
+ except Exception:
2134
+ pass
2135
  # Force to the same pixel space as gaze coordinates (SCREEN_W x
2136
  # SCREEN_H) — Windows DPI scaling can otherwise return a
2137
  # screenshot at a different resolution than pyautogui.size(),