Add tabbed heatmaps, full-session export, adaptive chrome, element chart
Browse filesRecent Sessions on the landing page now map to their actual generated
report (session-id -> analysis_report.html, reusing analysis.py's own
load_session()/session_summary() for domain/duration/sample-count) instead
of being static, unclickable timestamp rows. Report's "Back to Browser"
now navigates directly to the tracked page's own URL (already recorded
per-session) instead of leaning on window.history, which is unreliable to
reason about after a Python-triggered navigation in an embedded webview.
Sidebar and toolbar now auto-collapse into a compact bar the moment a
session starts (reusing the sidebar's existing collapse mode + a new
toolbar compact mode), maximizing space for the tracked page, with a
small always-visible toggle to reveal full controls temporarily; both
restore automatically when tracking stops.
Report: removed the Scroll Depth stat card (kept compute_scroll_depth()
itself untouched, just stopped surfacing it) and added a Gaze Samples
card in its place; enriched the header with session timestamp and
duration alongside the URL; added a styled error+Retry state for
screenshots that fail to load and Retry actions on toast failures for
full-session export, instead of things just silently not working.
Investigated the "Mouse Events: 0 / no clicks recorded" report: dwell
(hover) data and click data are collected separately in MOUSE_JS -- hover
tracking (getSmartLabel, populates "Most Interacted Elements") has no
element-type restriction, but click logging is gated by isInteractable(),
which only records clicks on genuine links/buttons/inputs/cursor:pointer
elements. That gate is mouse-tracking data-collection logic, out of scope
for this frontend-only pass, so it was left untouched; this is a data-
availability characteristic, not a frontend rendering bug -- the click
log correctly displays whatever clicks the existing collector captured.
No tracking, calibration, validation, screenshot-capture, or analytics
code was touched -- confined entirely to browser_session.py's landing
page/chrome and analysis.py's report template.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- analysis.py +76 -20
- browser_session.py +119 -14
|
@@ -18,6 +18,7 @@ import os
|
|
| 18 |
import re
|
| 19 |
import json
|
| 20 |
import bisect
|
|
|
|
| 21 |
|
| 22 |
import theme
|
| 23 |
|
|
@@ -510,7 +511,7 @@ __THEME_CSS__
|
|
| 510 |
<div class="mark">__EYE_ICON__</div>
|
| 511 |
<div>
|
| 512 |
<h1>InsightUX Session Report</h1>
|
| 513 |
-
<div class="sub">__URL__ · __SAMPLES__ gaze samples</div>
|
| 514 |
</div>
|
| 515 |
</div>
|
| 516 |
<div class="actions">
|
|
@@ -526,7 +527,7 @@ __THEME_CSS__
|
|
| 526 |
<div class="stat iux-card hoverable"><div class="icon-badge">__LAYERS_ICON__</div><div class="v">__NUM_ELEMENTS__</div><div class="l">Elements Fixated</div></div>
|
| 527 |
<div class="stat iux-card hoverable"><div class="icon-badge">__TARGET_ICON__</div><div class="v" style="font-size:14px;">__TOP_LABEL__</div><div class="l">Most Attended</div></div>
|
| 528 |
<div class="stat iux-card hoverable"><div class="icon-badge">__CURSOR_ICON__</div><div class="v">__CLICK_COUNT__</div><div class="l">Mouse Events</div></div>
|
| 529 |
-
<div class="stat iux-card hoverable"><div class="icon-badge">
|
| 530 |
</div>
|
| 531 |
|
| 532 |
<div class="grid">
|
|
@@ -665,14 +666,23 @@ document.getElementById('btnFilter').addEventListener('click', function(){
|
|
| 665 |
input.focus();
|
| 666 |
});
|
| 667 |
|
| 668 |
-
// ---------- Back to Browser:
|
| 669 |
-
//
|
| 670 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
document.getElementById('backNav').addEventListener('click', function(){
|
| 672 |
-
if (
|
|
|
|
|
|
|
| 673 |
window.history.back();
|
| 674 |
} else if (document.referrer) {
|
| 675 |
window.location.href = document.referrer;
|
|
|
|
|
|
|
| 676 |
}
|
| 677 |
});
|
| 678 |
|
|
@@ -844,7 +854,7 @@ function iconForLabel(label){
|
|
| 844 |
|
| 845 |
const clickEl = document.getElementById('mouseClicks');
|
| 846 |
if (!MOUSE_CLICKS.length) {
|
| 847 |
-
clickEl.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('click', 26) + '</span>No clicks recorded
|
| 848 |
} else {
|
| 849 |
let html = '<ul style="padding-left:0;margin:0;list-style:none;">';
|
| 850 |
MOUSE_CLICKS.slice().reverse().forEach(c => {
|
|
@@ -992,8 +1002,9 @@ function loadImage(src){
|
|
| 992 |
|
| 993 |
// Minimal toast so a failed export/viewer action is visible instead of
|
| 994 |
// silently doing nothing — reuses the .iux-toast style already shared with
|
| 995 |
-
// the toolbar/landing page design system.
|
| 996 |
-
|
|
|
|
| 997 |
let toast = document.getElementById('iuxReportToast');
|
| 998 |
if (!toast) {
|
| 999 |
toast = document.createElement('div');
|
|
@@ -1005,14 +1016,27 @@ function showToast(message, isError){
|
|
| 1005 |
toast.style.transform = 'translateX(-50%)';
|
| 1006 |
toast.style.zIndex = '3000000';
|
| 1007 |
toast.style.display = 'none';
|
|
|
|
| 1008 |
document.body.appendChild(toast);
|
| 1009 |
}
|
| 1010 |
-
toast.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1011 |
toast.style.borderColor = isError ? 'var(--iux-danger)' : 'var(--iux-border)';
|
| 1012 |
toast.style.color = isError ? 'var(--iux-danger)' : 'var(--iux-text)';
|
| 1013 |
toast.style.display = 'flex';
|
|
|
|
| 1014 |
clearTimeout(toast._hideTimer);
|
| 1015 |
-
toast._hideTimer = setTimeout(function(){ toast.style.display = 'none'; }, 3200);
|
| 1016 |
}
|
| 1017 |
|
| 1018 |
// ---------- Screenshot-backed heatmaps: Eye / Mouse / Combined tabs, paginated ----------
|
|
@@ -1110,7 +1134,10 @@ function showToast(message, isError){
|
|
| 1110 |
if (i !== cur) { cur = i; renderSegment(cur); }
|
| 1111 |
renderPromise.then(function(){
|
| 1112 |
const canvas = composeSegmentCanvas();
|
| 1113 |
-
if (!canvas) {
|
|
|
|
|
|
|
|
|
|
| 1114 |
Viewer.open({
|
| 1115 |
source: canvas,
|
| 1116 |
title: segmentTitle(i),
|
|
@@ -1123,8 +1150,12 @@ function showToast(message, isError){
|
|
| 1123 |
|
| 1124 |
document.getElementById('hmFullscreen').addEventListener('click', function(){ openSegmentViewer(cur); });
|
| 1125 |
document.getElementById('hmDownload').addEventListener('click', function(){
|
|
|
|
| 1126 |
const canvas = composeSegmentCanvas();
|
| 1127 |
-
if (!canvas) {
|
|
|
|
|
|
|
|
|
|
| 1128 |
try {
|
| 1129 |
const a = document.createElement('a');
|
| 1130 |
a.href = canvas.toDataURL('image/png');
|
|
@@ -1172,7 +1203,22 @@ function showToast(message, isError){
|
|
| 1172 |
paintActive(ctx, cv.width, cv.height);
|
| 1173 |
resolve();
|
| 1174 |
}
|
| 1175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1176 |
});
|
| 1177 |
return renderPromise;
|
| 1178 |
}
|
|
@@ -1188,14 +1234,14 @@ function showToast(message, isError){
|
|
| 1188 |
const imgs = await Promise.all(SEGMENTS.map(s => loadImage(s.screenshot)));
|
| 1189 |
const valid = imgs.filter(Boolean);
|
| 1190 |
if (!valid.length) {
|
| 1191 |
-
showToast('Could not load this session’s screenshots for a full-page export.', true);
|
| 1192 |
return;
|
| 1193 |
}
|
| 1194 |
const width = valid[0].naturalWidth;
|
| 1195 |
let height = 0;
|
| 1196 |
SEGMENTS.forEach((s, i) => { if (imgs[i]) height = Math.max(height, s.scrollY + imgs[i].naturalHeight); });
|
| 1197 |
if (!height) {
|
| 1198 |
-
showToast('Could not determine the page size for this export.', true);
|
| 1199 |
return;
|
| 1200 |
}
|
| 1201 |
|
|
@@ -1231,10 +1277,10 @@ function showToast(message, isError){
|
|
| 1231 |
title: 'Full Session — ' + exportMode.charAt(0).toUpperCase() + exportMode.slice(1) + ' Heatmap',
|
| 1232 |
downloadName: 'insightux-full-session-' + exportMode + '-heatmap.png',
|
| 1233 |
});
|
| 1234 |
-
if (!opened) showToast('Could not open the ' + exportMode + ' heatmap viewer.', true);
|
| 1235 |
} catch (err) {
|
| 1236 |
console.warn('[insightux-report] full-session export failed:', exportMode, err);
|
| 1237 |
-
showToast('Could not build the full-session ' + exportMode + ' heatmap.', true);
|
| 1238 |
} finally {
|
| 1239 |
if (btn) { btn.innerHTML = original; btn.disabled = false; }
|
| 1240 |
}
|
|
@@ -1524,20 +1570,30 @@ def generate_report(session_dir):
|
|
| 1524 |
segments = build_screenshot_segments(gaze, dom)
|
| 1525 |
summary = session_summary(gaze, dom)
|
| 1526 |
mouse = summarize_mouse(session_dir)
|
| 1527 |
-
scroll_depth = compute_scroll_depth(dom)
|
| 1528 |
mouse_points = mouse_heat_points(session_dir)
|
| 1529 |
mouse_segments = build_mouse_screenshot_segments(segments, dom, session_dir)
|
| 1530 |
|
| 1531 |
top_label = ranking[0]["label"] if ranking else "—"
|
| 1532 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1533 |
html = _TEMPLATE
|
| 1534 |
html = html.replace("__URL__", summary["url"] or "Unknown page")
|
|
|
|
|
|
|
| 1535 |
html = html.replace("__SAMPLES__", str(summary["samples"]))
|
| 1536 |
html = html.replace("__DURATION__", str(summary["duration"]))
|
| 1537 |
html = html.replace("__NUM_ELEMENTS__", str(len(ranking)))
|
| 1538 |
html = html.replace("__TOP_LABEL__", top_label)
|
| 1539 |
html = html.replace("__CLICK_COUNT__", str(mouse["click_count"]))
|
| 1540 |
-
html = html.replace("__SCROLL_DEPTH__", str(scroll_depth))
|
| 1541 |
html = html.replace("__RANKING_JSON__", json.dumps(ranking))
|
| 1542 |
html = html.replace("__TIMELINE_JSON__", json.dumps(timeline))
|
| 1543 |
html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
|
|
|
|
| 18 |
import re
|
| 19 |
import json
|
| 20 |
import bisect
|
| 21 |
+
from datetime import datetime
|
| 22 |
|
| 23 |
import theme
|
| 24 |
|
|
|
|
| 511 |
<div class="mark">__EYE_ICON__</div>
|
| 512 |
<div>
|
| 513 |
<h1>InsightUX Session Report</h1>
|
| 514 |
+
<div class="sub">__URL__ · __SESSION_TIMESTAMP__ · __DURATION__s · __SAMPLES__ gaze samples</div>
|
| 515 |
</div>
|
| 516 |
</div>
|
| 517 |
<div class="actions">
|
|
|
|
| 527 |
<div class="stat iux-card hoverable"><div class="icon-badge">__LAYERS_ICON__</div><div class="v">__NUM_ELEMENTS__</div><div class="l">Elements Fixated</div></div>
|
| 528 |
<div class="stat iux-card hoverable"><div class="icon-badge">__TARGET_ICON__</div><div class="v" style="font-size:14px;">__TOP_LABEL__</div><div class="l">Most Attended</div></div>
|
| 529 |
<div class="stat iux-card hoverable"><div class="icon-badge">__CURSOR_ICON__</div><div class="v">__CLICK_COUNT__</div><div class="l">Mouse Events</div></div>
|
| 530 |
+
<div class="stat iux-card hoverable"><div class="icon-badge">__TYPE_ICON__</div><div class="v">__SAMPLES__</div><div class="l">Gaze Samples</div></div>
|
| 531 |
</div>
|
| 532 |
|
| 533 |
<div class="grid">
|
|
|
|
| 666 |
input.focus();
|
| 667 |
});
|
| 668 |
|
| 669 |
+
// ---------- Back to Browser: navigates straight back to the website that
|
| 670 |
+
// was actually being tracked (same URL session_summary() already recorded
|
| 671 |
+
// for this session — the __URL__ shown in the header above) rather than
|
| 672 |
+
// leaning on window.history, whose stack can be unreliable to reason about
|
| 673 |
+
// inside an embedded webview after a Python-triggered navigation. Falls
|
| 674 |
+
// back to real history/referrer only if that URL is unknown. Pure
|
| 675 |
+
// client-side navigation — never touches the Api or tracking state. ----------
|
| 676 |
+
const SESSION_URL = __URL_JSON__;
|
| 677 |
document.getElementById('backNav').addEventListener('click', function(){
|
| 678 |
+
if (SESSION_URL) {
|
| 679 |
+
window.location.href = SESSION_URL;
|
| 680 |
+
} else if (window.history.length > 1) {
|
| 681 |
window.history.back();
|
| 682 |
} else if (document.referrer) {
|
| 683 |
window.location.href = document.referrer;
|
| 684 |
+
} else {
|
| 685 |
+
showToast('No previous page to return to.', true);
|
| 686 |
}
|
| 687 |
});
|
| 688 |
|
|
|
|
| 854 |
|
| 855 |
const clickEl = document.getElementById('mouseClicks');
|
| 856 |
if (!MOUSE_CLICKS.length) {
|
| 857 |
+
clickEl.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('click', 26) + '</span>No mouse clicks were recorded during this session.</div>';
|
| 858 |
} else {
|
| 859 |
let html = '<ul style="padding-left:0;margin:0;list-style:none;">';
|
| 860 |
MOUSE_CLICKS.slice().reverse().forEach(c => {
|
|
|
|
| 1002 |
|
| 1003 |
// Minimal toast so a failed export/viewer action is visible instead of
|
| 1004 |
// silently doing nothing — reuses the .iux-toast style already shared with
|
| 1005 |
+
// the toolbar/landing page design system. Pass onRetry to add a Retry
|
| 1006 |
+
// action instead of the toast just auto-dismissing.
|
| 1007 |
+
function showToast(message, isError, onRetry){
|
| 1008 |
let toast = document.getElementById('iuxReportToast');
|
| 1009 |
if (!toast) {
|
| 1010 |
toast = document.createElement('div');
|
|
|
|
| 1016 |
toast.style.transform = 'translateX(-50%)';
|
| 1017 |
toast.style.zIndex = '3000000';
|
| 1018 |
toast.style.display = 'none';
|
| 1019 |
+
toast.style.gap = '12px';
|
| 1020 |
document.body.appendChild(toast);
|
| 1021 |
}
|
| 1022 |
+
toast.innerHTML = '<span></span>';
|
| 1023 |
+
toast.querySelector('span').textContent = message;
|
| 1024 |
+
if (onRetry) {
|
| 1025 |
+
const retryBtn = document.createElement('button');
|
| 1026 |
+
retryBtn.type = 'button';
|
| 1027 |
+
retryBtn.className = 'iux-btn';
|
| 1028 |
+
retryBtn.style.padding = '4px 10px';
|
| 1029 |
+
retryBtn.style.fontSize = '11.5px';
|
| 1030 |
+
retryBtn.textContent = 'Retry';
|
| 1031 |
+
retryBtn.addEventListener('click', function(){ toast.style.display = 'none'; clearTimeout(toast._hideTimer); onRetry(); });
|
| 1032 |
+
toast.appendChild(retryBtn);
|
| 1033 |
+
}
|
| 1034 |
toast.style.borderColor = isError ? 'var(--iux-danger)' : 'var(--iux-border)';
|
| 1035 |
toast.style.color = isError ? 'var(--iux-danger)' : 'var(--iux-text)';
|
| 1036 |
toast.style.display = 'flex';
|
| 1037 |
+
toast.style.alignItems = 'center';
|
| 1038 |
clearTimeout(toast._hideTimer);
|
| 1039 |
+
toast._hideTimer = setTimeout(function(){ toast.style.display = 'none'; }, onRetry ? 6000 : 3200);
|
| 1040 |
}
|
| 1041 |
|
| 1042 |
// ---------- Screenshot-backed heatmaps: Eye / Mouse / Combined tabs, paginated ----------
|
|
|
|
| 1134 |
if (i !== cur) { cur = i; renderSegment(cur); }
|
| 1135 |
renderPromise.then(function(){
|
| 1136 |
const canvas = composeSegmentCanvas();
|
| 1137 |
+
if (!canvas) {
|
| 1138 |
+
showToast('Could not prepare this screenshot for viewing.', true, function(){ renderSegment(i); openSegmentViewer(i); });
|
| 1139 |
+
return;
|
| 1140 |
+
}
|
| 1141 |
Viewer.open({
|
| 1142 |
source: canvas,
|
| 1143 |
title: segmentTitle(i),
|
|
|
|
| 1150 |
|
| 1151 |
document.getElementById('hmFullscreen').addEventListener('click', function(){ openSegmentViewer(cur); });
|
| 1152 |
document.getElementById('hmDownload').addEventListener('click', function(){
|
| 1153 |
+
const theCur = cur;
|
| 1154 |
const canvas = composeSegmentCanvas();
|
| 1155 |
+
if (!canvas) {
|
| 1156 |
+
showToast('Could not prepare this screenshot for export.', true, function(){ renderSegment(theCur); });
|
| 1157 |
+
return;
|
| 1158 |
+
}
|
| 1159 |
try {
|
| 1160 |
const a = document.createElement('a');
|
| 1161 |
a.href = canvas.toDataURL('image/png');
|
|
|
|
| 1203 |
paintActive(ctx, cv.width, cv.height);
|
| 1204 |
resolve();
|
| 1205 |
}
|
| 1206 |
+
function fail(){
|
| 1207 |
+
const shotWrap = document.getElementById('shotWrap');
|
| 1208 |
+
if (shotWrap) {
|
| 1209 |
+
shotWrap.innerHTML =
|
| 1210 |
+
'<div class="empty"><span class="ei">' + iuxIcon('info', 26) + '</span>' +
|
| 1211 |
+
'Could not load the screenshot for segment ' + (i+1) + '.<br>' +
|
| 1212 |
+
'<button type="button" class="iux-btn" id="retryShotBtn" style="margin-top:10px;">' +
|
| 1213 |
+
iuxIcon('refresh', 13) + ' Retry</button></div>';
|
| 1214 |
+
shotWrap.removeAttribute('title');
|
| 1215 |
+
const retryBtn = document.getElementById('retryShotBtn');
|
| 1216 |
+
if (retryBtn) retryBtn.addEventListener('click', function(e){ e.stopPropagation(); renderSegment(i); });
|
| 1217 |
+
}
|
| 1218 |
+
resolve(); // let any pending viewer/download callers proceed — composeSegmentCanvas will see no #shotImg and surface its own toast
|
| 1219 |
+
}
|
| 1220 |
+
if (img.complete && img.naturalWidth) paint();
|
| 1221 |
+
else { img.onload = paint; img.onerror = fail; }
|
| 1222 |
});
|
| 1223 |
return renderPromise;
|
| 1224 |
}
|
|
|
|
| 1234 |
const imgs = await Promise.all(SEGMENTS.map(s => loadImage(s.screenshot)));
|
| 1235 |
const valid = imgs.filter(Boolean);
|
| 1236 |
if (!valid.length) {
|
| 1237 |
+
showToast('Could not load this session’s screenshots for a full-page export.', true, function(){ exportFullSession(exportMode); });
|
| 1238 |
return;
|
| 1239 |
}
|
| 1240 |
const width = valid[0].naturalWidth;
|
| 1241 |
let height = 0;
|
| 1242 |
SEGMENTS.forEach((s, i) => { if (imgs[i]) height = Math.max(height, s.scrollY + imgs[i].naturalHeight); });
|
| 1243 |
if (!height) {
|
| 1244 |
+
showToast('Could not determine the page size for this export.', true, function(){ exportFullSession(exportMode); });
|
| 1245 |
return;
|
| 1246 |
}
|
| 1247 |
|
|
|
|
| 1277 |
title: 'Full Session — ' + exportMode.charAt(0).toUpperCase() + exportMode.slice(1) + ' Heatmap',
|
| 1278 |
downloadName: 'insightux-full-session-' + exportMode + '-heatmap.png',
|
| 1279 |
});
|
| 1280 |
+
if (!opened) showToast('Could not open the ' + exportMode + ' heatmap viewer.', true, function(){ exportFullSession(exportMode); });
|
| 1281 |
} catch (err) {
|
| 1282 |
console.warn('[insightux-report] full-session export failed:', exportMode, err);
|
| 1283 |
+
showToast('Could not build the full-session ' + exportMode + ' heatmap.', true, function(){ exportFullSession(exportMode); });
|
| 1284 |
} finally {
|
| 1285 |
if (btn) { btn.innerHTML = original; btn.disabled = false; }
|
| 1286 |
}
|
|
|
|
| 1570 |
segments = build_screenshot_segments(gaze, dom)
|
| 1571 |
summary = session_summary(gaze, dom)
|
| 1572 |
mouse = summarize_mouse(session_dir)
|
|
|
|
| 1573 |
mouse_points = mouse_heat_points(session_dir)
|
| 1574 |
mouse_segments = build_mouse_screenshot_segments(segments, dom, session_dir)
|
| 1575 |
|
| 1576 |
top_label = ranking[0]["label"] if ranking else "—"
|
| 1577 |
|
| 1578 |
+
# session_dir's own folder name is already the session's timestamp
|
| 1579 |
+
# (browser_session.py names it that way) — reused here only to print a
|
| 1580 |
+
# human-readable date in the header, same convention _recent_sessions_json()
|
| 1581 |
+
# already parses on the landing page.
|
| 1582 |
+
try:
|
| 1583 |
+
session_ts = datetime.strptime(os.path.basename(session_dir.rstrip("/\\")), "%Y%m%d_%H%M%S")
|
| 1584 |
+
session_timestamp = session_ts.strftime("%b %d, %Y — %I:%M %p").replace(" 0", " ")
|
| 1585 |
+
except ValueError:
|
| 1586 |
+
session_timestamp = "Unknown time"
|
| 1587 |
+
|
| 1588 |
html = _TEMPLATE
|
| 1589 |
html = html.replace("__URL__", summary["url"] or "Unknown page")
|
| 1590 |
+
html = html.replace("__URL_JSON__", json.dumps(summary["url"] or ""))
|
| 1591 |
+
html = html.replace("__SESSION_TIMESTAMP__", session_timestamp)
|
| 1592 |
html = html.replace("__SAMPLES__", str(summary["samples"]))
|
| 1593 |
html = html.replace("__DURATION__", str(summary["duration"]))
|
| 1594 |
html = html.replace("__NUM_ELEMENTS__", str(len(ranking)))
|
| 1595 |
html = html.replace("__TOP_LABEL__", top_label)
|
| 1596 |
html = html.replace("__CLICK_COUNT__", str(mouse["click_count"]))
|
|
|
|
| 1597 |
html = html.replace("__RANKING_JSON__", json.dumps(ranking))
|
| 1598 |
html = html.replace("__TIMELINE_JSON__", json.dumps(timeline))
|
| 1599 |
html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
|
|
@@ -60,7 +60,7 @@ from preprocessing.preprocessing_pipeline import (
|
|
| 60 |
)
|
| 61 |
from inference_pipeline import InsightUXPipeline, GazeAngleSmoother
|
| 62 |
from session_logger import GazeLogger
|
| 63 |
-
from analysis import generate_report
|
| 64 |
import theme
|
| 65 |
|
| 66 |
|
|
@@ -182,11 +182,25 @@ __THEME_CSS__
|
|
| 182 |
}
|
| 183 |
.recent-list { display: flex; flex-direction: column; gap: 6px; }
|
| 184 |
.recent-item {
|
| 185 |
-
display: flex; align-items: center; gap:
|
| 186 |
-
color: var(--iux-text-dim); font-size: 12.5px;
|
| 187 |
}
|
|
|
|
| 188 |
.recent-item:hover { background: var(--iux-surface-hi); }
|
| 189 |
-
.recent-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
.recent-empty { color: var(--iux-text-faint); font-size: 12px; text-align: center; padding: 14px 0; }
|
| 191 |
|
| 192 |
.hint {
|
|
@@ -258,10 +272,30 @@ __THEME_JS__
|
|
| 258 |
if (!RECENT_SESSIONS.length) {
|
| 259 |
recentList.innerHTML = '<div class="recent-empty">No sessions yet — start tracking on any page to create your first one.</div>';
|
| 260 |
} else {
|
| 261 |
-
recentList.innerHTML = RECENT_SESSIONS.map(function(s){
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
}
|
| 266 |
</script>
|
| 267 |
</body>
|
|
@@ -272,8 +306,18 @@ __THEME_JS__
|
|
| 272 |
def _recent_sessions_json(limit=5):
|
| 273 |
"""Read-only directory listing of sessions/<timestamp> folders (already
|
| 274 |
a well-known constant, SESSIONS_ROOT) for the landing page's "Recent
|
| 275 |
-
Sessions" strip. Never touches session contents or tracking state.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
import datetime as _dt
|
|
|
|
|
|
|
| 277 |
entries = []
|
| 278 |
if os.path.isdir(SESSIONS_ROOT):
|
| 279 |
for name in os.listdir(SESSIONS_ROOT):
|
|
@@ -284,13 +328,36 @@ def _recent_sessions_json(limit=5):
|
|
| 284 |
ts = _dt.datetime.strptime(name, "%Y%m%d_%H%M%S")
|
| 285 |
except ValueError:
|
| 286 |
continue
|
| 287 |
-
entries.append((ts,
|
| 288 |
entries.sort(key=lambda pair: pair[0], reverse=True)
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
|
| 296 |
def _write_landing_page():
|
|
@@ -433,6 +500,16 @@ __THEME_CSS__
|
|
| 433 |
#__insightux_toolbar .iux-navbtn {
|
| 434 |
flex:0 0 auto; width:32px; height:32px; border-radius:10px; padding:0;
|
| 435 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
#__iux_addr_wrap {
|
| 437 |
flex:1 1 auto; min-width:0; position:relative; display:flex; align-items:center;
|
| 438 |
}
|
|
@@ -531,6 +608,7 @@ __THEME_CSS__
|
|
| 531 |
</div>
|
| 532 |
<span id="__insightux_update" title="Click to open the download page"></span>
|
| 533 |
<span id="__insightux_status" class="iux-pill"><span class="txt">Press S to start · H heatmap · M panel · X quit</span></span>
|
|
|
|
| 534 |
<button type="button" class="iux-btn" id="__iux_theme_toggle" title="Toggle theme" style="width:32px;height:32px;"></button>
|
| 535 |
`;
|
| 536 |
document.documentElement.appendChild(toolbar);
|
|
@@ -585,6 +663,30 @@ __THEME_CSS__
|
|
| 585 |
window.insightuxSetPref('sidebarCollapsed', collapsed);
|
| 586 |
});
|
| 587 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
// -- settings popover --
|
| 589 |
const settingsBtn = document.getElementById('__iux_settings_btn');
|
| 590 |
const settingsPop = document.getElementById('__iux_settings_pop');
|
|
@@ -625,6 +727,9 @@ __THEME_CSS__
|
|
| 625 |
}
|
| 626 |
document.getElementById('__iux_pill_eye').classList.toggle('on', isTracking);
|
| 627 |
document.getElementById('__iux_pill_mouse').classList.toggle('on', isTracking);
|
|
|
|
|
|
|
|
|
|
| 628 |
if (isTracking) {
|
| 629 |
sessionStartedAt = Date.now();
|
| 630 |
if (timerInterval) clearInterval(timerInterval);
|
|
|
|
| 60 |
)
|
| 61 |
from inference_pipeline import InsightUXPipeline, GazeAngleSmoother
|
| 62 |
from session_logger import GazeLogger
|
| 63 |
+
from analysis import generate_report, load_session, session_summary
|
| 64 |
import theme
|
| 65 |
|
| 66 |
|
|
|
|
| 182 |
}
|
| 183 |
.recent-list { display: flex; flex-direction: column; gap: 6px; }
|
| 184 |
.recent-item {
|
| 185 |
+
display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-radius: 10px;
|
| 186 |
+
color: var(--iux-text-dim); font-size: 12.5px; transition: background .12s ease;
|
| 187 |
}
|
| 188 |
+
.recent-item.clickable { cursor: pointer; }
|
| 189 |
.recent-item:hover { background: var(--iux-surface-hi); }
|
| 190 |
+
.recent-icon { flex-shrink: 0; color: var(--iux-primary-light); display: flex; }
|
| 191 |
+
.recent-info { flex: 1 1 auto; min-width: 0; }
|
| 192 |
+
.recent-label { color: var(--iux-text); font-weight: 600; font-size: 12.5px; }
|
| 193 |
+
.recent-domain {
|
| 194 |
+
color: var(--iux-text-dim); font-size: 11.5px; margin-top: 2px;
|
| 195 |
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
| 196 |
+
}
|
| 197 |
+
.recent-stats { color: var(--iux-text-faint); font-size: 11px; margin-top: 2px; }
|
| 198 |
+
.recent-action {
|
| 199 |
+
flex-shrink: 0; display: flex; align-items: center; gap: 5px;
|
| 200 |
+
font-size: 11.5px; font-weight: 600; color: var(--iux-primary-light); white-space: nowrap;
|
| 201 |
+
}
|
| 202 |
+
.recent-item:hover .recent-action { color: var(--iux-lavender); }
|
| 203 |
+
.recent-item .rt { color: var(--iux-text-faint); margin-left: auto; font-size: 11px; flex-shrink: 0; }
|
| 204 |
.recent-empty { color: var(--iux-text-faint); font-size: 12px; text-align: center; padding: 14px 0; }
|
| 205 |
|
| 206 |
.hint {
|
|
|
|
| 272 |
if (!RECENT_SESSIONS.length) {
|
| 273 |
recentList.innerHTML = '<div class="recent-empty">No sessions yet — start tracking on any page to create your first one.</div>';
|
| 274 |
} else {
|
| 275 |
+
recentList.innerHTML = RECENT_SESSIONS.map(function(s, i){
|
| 276 |
+
const stats = [];
|
| 277 |
+
if (s.duration != null) stats.push(s.duration + 's');
|
| 278 |
+
if (s.samples != null) stats.push(s.samples + ' gaze samples');
|
| 279 |
+
const hasReport = !!s.reportUrl;
|
| 280 |
+
return '<div class="recent-item' + (hasReport ? ' clickable' : '') + '" data-idx="' + i + '" title="' +
|
| 281 |
+
(hasReport ? 'Open the report for this session' : 'Report not available for this session') + '">' +
|
| 282 |
+
'<span class="recent-icon">' + iuxIcon('clock', 16) + '</span>' +
|
| 283 |
+
'<div class="recent-info">' +
|
| 284 |
+
'<div class="recent-label">' + s.label + '</div>' +
|
| 285 |
+
(s.domain ? '<div class="recent-domain">' + s.domain + '</div>' : '') +
|
| 286 |
+
(stats.length ? '<div class="recent-stats">' + stats.join(' · ') + '</div>' : '') +
|
| 287 |
+
'</div>' +
|
| 288 |
+
(hasReport
|
| 289 |
+
? '<span class="recent-action">View Report ' + iuxIcon('arrow-right', 13) + '</span>'
|
| 290 |
+
: '<span class="rt">' + s.when + '</span>') +
|
| 291 |
+
'</div>';
|
| 292 |
}).join('');
|
| 293 |
+
recentList.querySelectorAll('.recent-item.clickable').forEach(function(el){
|
| 294 |
+
el.addEventListener('click', function(){
|
| 295 |
+
const s = RECENT_SESSIONS[parseInt(el.dataset.idx, 10)];
|
| 296 |
+
if (s && s.reportUrl) location.href = s.reportUrl;
|
| 297 |
+
});
|
| 298 |
+
});
|
| 299 |
}
|
| 300 |
</script>
|
| 301 |
</body>
|
|
|
|
| 306 |
def _recent_sessions_json(limit=5):
|
| 307 |
"""Read-only directory listing of sessions/<timestamp> folders (already
|
| 308 |
a well-known constant, SESSIONS_ROOT) for the landing page's "Recent
|
| 309 |
+
Sessions" strip. Never touches session contents or tracking state.
|
| 310 |
+
|
| 311 |
+
Each entry is matched to its own already-generated analysis_report.html
|
| 312 |
+
(written by generate_report() at session end, under that same session's
|
| 313 |
+
folder — the existing session-id -> report mapping already used
|
| 314 |
+
elsewhere) so "View Report" always opens THAT session's report, never
|
| 315 |
+
just the most recent one. Duration/sample-count/domain reuse analysis.py's
|
| 316 |
+
existing load_session()/session_summary() — same read-only computation
|
| 317 |
+
the report itself already does, not a new data path."""
|
| 318 |
import datetime as _dt
|
| 319 |
+
from urllib.parse import urlparse
|
| 320 |
+
|
| 321 |
entries = []
|
| 322 |
if os.path.isdir(SESSIONS_ROOT):
|
| 323 |
for name in os.listdir(SESSIONS_ROOT):
|
|
|
|
| 328 |
ts = _dt.datetime.strptime(name, "%Y%m%d_%H%M%S")
|
| 329 |
except ValueError:
|
| 330 |
continue
|
| 331 |
+
entries.append((ts, full))
|
| 332 |
entries.sort(key=lambda pair: pair[0], reverse=True)
|
| 333 |
+
|
| 334 |
+
out = []
|
| 335 |
+
for ts, full in entries[:limit]:
|
| 336 |
+
report_path = os.path.join(full, "analysis_report.html")
|
| 337 |
+
report_url = None
|
| 338 |
+
if os.path.exists(report_path):
|
| 339 |
+
report_url = "file://" + report_path.replace(os.sep, "/")
|
| 340 |
+
|
| 341 |
+
domain, duration, samples = None, None, None
|
| 342 |
+
try:
|
| 343 |
+
gaze, dom = load_session(full)
|
| 344 |
+
summary = session_summary(gaze, dom)
|
| 345 |
+
duration = summary["duration"] or None
|
| 346 |
+
samples = summary["samples"] or None
|
| 347 |
+
if summary["url"]:
|
| 348 |
+
domain = urlparse(summary["url"]).netloc or summary["url"]
|
| 349 |
+
except Exception:
|
| 350 |
+
pass # malformed/partial session folder — still list it, just without stats
|
| 351 |
+
|
| 352 |
+
out.append({
|
| 353 |
+
"label": ts.strftime("%b %d, %Y — %I:%M %p").replace(" 0", " "),
|
| 354 |
+
"when": ts.strftime("%Y-%m-%d %H:%M"),
|
| 355 |
+
"domain": domain,
|
| 356 |
+
"duration": duration,
|
| 357 |
+
"samples": samples,
|
| 358 |
+
"reportUrl": report_url,
|
| 359 |
+
})
|
| 360 |
+
return out
|
| 361 |
|
| 362 |
|
| 363 |
def _write_landing_page():
|
|
|
|
| 500 |
#__insightux_toolbar .iux-navbtn {
|
| 501 |
flex:0 0 auto; width:32px; height:32px; border-radius:10px; padding:0;
|
| 502 |
}
|
| 503 |
+
/* Tracking mode: shrink the toolbar down to status pills + a small
|
| 504 |
+
"reveal" handle so the tracked page gets the screen back, without
|
| 505 |
+
ever removing the controls — __iux_reveal_toggle brings them back. */
|
| 506 |
+
#__insightux_toolbar.tracking-compact { height:38px; gap:6px; padding:0 10px; }
|
| 507 |
+
#__insightux_toolbar.tracking-compact .iux-navbtn,
|
| 508 |
+
#__insightux_toolbar.tracking-compact #__iux_addr_wrap,
|
| 509 |
+
#__insightux_toolbar.tracking-compact #__insightux_update,
|
| 510 |
+
#__insightux_toolbar.tracking-compact #__insightux_status { display:none; }
|
| 511 |
+
#__insightux_toolbar.tracking-compact #__iux_reveal_toggle { display:flex !important; }
|
| 512 |
+
body.iux-tracking-compact { padding-top:38px !important; }
|
| 513 |
#__iux_addr_wrap {
|
| 514 |
flex:1 1 auto; min-width:0; position:relative; display:flex; align-items:center;
|
| 515 |
}
|
|
|
|
| 608 |
</div>
|
| 609 |
<span id="__insightux_update" title="Click to open the download page"></span>
|
| 610 |
<span id="__insightux_status" class="iux-pill"><span class="txt">Press S to start · H heatmap · M panel · X quit</span></span>
|
| 611 |
+
<button type="button" class="iux-btn" id="__iux_reveal_toggle" title="Show controls" style="width:32px;height:32px;display:none;">${iuxIcon('menu',15)}</button>
|
| 612 |
<button type="button" class="iux-btn" id="__iux_theme_toggle" title="Toggle theme" style="width:32px;height:32px;"></button>
|
| 613 |
`;
|
| 614 |
document.documentElement.appendChild(toolbar);
|
|
|
|
| 663 |
window.insightuxSetPref('sidebarCollapsed', collapsed);
|
| 664 |
});
|
| 665 |
|
| 666 |
+
// -- tracking-mode compact chrome: sidebar + toolbar shrink automatically
|
| 667 |
+
// once a session starts, so the tracked page gets maximum space, without
|
| 668 |
+
// ever removing the controls — __iux_reveal_toggle (only shown while
|
| 669 |
+
// tracking) brings the full toolbar/sidebar back temporarily. --
|
| 670 |
+
const revealToggle = document.getElementById('__iux_reveal_toggle');
|
| 671 |
+
let isTrackingNow = false;
|
| 672 |
+
let controlsRevealed = false;
|
| 673 |
+
function applyTrackingChrome(){
|
| 674 |
+
const compact = isTrackingNow && !controlsRevealed;
|
| 675 |
+
toolbar.classList.toggle('tracking-compact', compact);
|
| 676 |
+
document.body.classList.toggle('iux-tracking-compact', compact);
|
| 677 |
+
if (isTrackingNow) {
|
| 678 |
+
sidebar.classList.toggle('collapsed', !controlsRevealed);
|
| 679 |
+
} else {
|
| 680 |
+
applyCollapsed(window.insightuxGetPrefs().sidebarCollapsed === true);
|
| 681 |
+
}
|
| 682 |
+
revealToggle.innerHTML = iuxIcon(controlsRevealed ? 'x' : 'menu', 15);
|
| 683 |
+
revealToggle.title = controlsRevealed ? 'Hide controls' : 'Show controls';
|
| 684 |
+
}
|
| 685 |
+
revealToggle.addEventListener('click', function(){
|
| 686 |
+
controlsRevealed = !controlsRevealed;
|
| 687 |
+
applyTrackingChrome();
|
| 688 |
+
});
|
| 689 |
+
|
| 690 |
// -- settings popover --
|
| 691 |
const settingsBtn = document.getElementById('__iux_settings_btn');
|
| 692 |
const settingsPop = document.getElementById('__iux_settings_pop');
|
|
|
|
| 727 |
}
|
| 728 |
document.getElementById('__iux_pill_eye').classList.toggle('on', isTracking);
|
| 729 |
document.getElementById('__iux_pill_mouse').classList.toggle('on', isTracking);
|
| 730 |
+
isTrackingNow = isTracking;
|
| 731 |
+
controlsRevealed = false;
|
| 732 |
+
applyTrackingChrome();
|
| 733 |
if (isTracking) {
|
| 734 |
sessionStartedAt = Date.now();
|
| 735 |
if (timerInterval) clearInterval(timerInterval);
|