Redesign the PDF/print report into a paginated, professional layout
Browse filesAdds 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>
- analysis.py +677 -19
- browser_session.py +26 -1
|
@@ -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 |
-
)[:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
ctx.
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
const
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 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') + ' · ' + esc(SESSION.timestamp) +
|
| 1985 |
+
' · ' + SESSION.duration + 's session · ' + 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"]))
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(),
|