Sanyam0385 commited on
Commit ·
0f55048
1
Parent(s): 6709ff1
Add mouse tracking, ported feature-for-feature from the Mouse Chrome extension
Browse filesInjects a trail/heatmap/click/dwell tracking overlay alongside the gaze
overlay so a session captures both eye and mouse attention together, with
an in-page panel (timer, most-viewed element, click log, H/M hotkeys)
replacing the extension's popup, and the report now includes mouse
interest and click-log sections next to the gaze heatmap.
- analysis.py +91 -0
- browser_session.py +519 -3
analysis.py
CHANGED
|
@@ -52,6 +52,53 @@ def load_session(session_dir):
|
|
| 52 |
return gaze, dom
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
# =============================================================================
|
| 56 |
# HUMAN-READABLE LABELS
|
| 57 |
# Raw AOI labels come straight out of the DOM (tag names, CSS classes,
|
|
@@ -320,6 +367,14 @@ _TEMPLATE = r"""<!DOCTYPE html>
|
|
| 320 |
<h2>Attention Timeline</h2>
|
| 321 |
<canvas id="timeline" width="1240" height="320"></canvas>
|
| 322 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
</div>
|
| 324 |
|
| 325 |
<script>
|
|
@@ -327,6 +382,39 @@ const RANKING = __RANKING_JSON__;
|
|
| 327 |
const TIMELINE = __TIMELINE_JSON__;
|
| 328 |
const SEGMENTS = __SEGMENTS_JSON__;
|
| 329 |
const DURATION = __DURATION_JSON__;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
|
| 331 |
// ---------- Ranked table ----------
|
| 332 |
(function(){
|
|
@@ -491,6 +579,7 @@ def generate_report(session_dir):
|
|
| 491 |
timeline = compute_timeline(attributed)
|
| 492 |
segments = build_screenshot_segments(gaze, dom)
|
| 493 |
summary = session_summary(gaze, dom)
|
|
|
|
| 494 |
|
| 495 |
top_label = ranking[0]["label"] if ranking else "—"
|
| 496 |
|
|
@@ -504,6 +593,8 @@ def generate_report(session_dir):
|
|
| 504 |
html = html.replace("__TIMELINE_JSON__", json.dumps(timeline))
|
| 505 |
html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
|
| 506 |
html = html.replace("__DURATION_JSON__", json.dumps(summary["duration"]))
|
|
|
|
|
|
|
| 507 |
|
| 508 |
out_path = os.path.join(session_dir, "analysis_report.html")
|
| 509 |
with open(out_path, "w", encoding="utf-8") as f:
|
|
|
|
| 52 |
return gaze, dom
|
| 53 |
|
| 54 |
|
| 55 |
+
# =============================================================================
|
| 56 |
+
# MOUSE ACTIVITY (from mouse_log.jsonl — batches pushed by the in-page Mouse
|
| 57 |
+
# Tracker overlay, same trail/heatmap/click/dwell shape as the standalone
|
| 58 |
+
# "Mouse Tracker & Heatmap" Chrome extension this was ported from)
|
| 59 |
+
# =============================================================================
|
| 60 |
+
|
| 61 |
+
def load_mouse_batches(session_dir):
|
| 62 |
+
return [r for r in _load_jsonl(os.path.join(session_dir, "mouse_log.jsonl"))
|
| 63 |
+
if r.get("type") == "mouse_batch"]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def summarize_mouse(session_dir):
|
| 67 |
+
batches = load_mouse_batches(session_dir)
|
| 68 |
+
|
| 69 |
+
dwell_totals = {}
|
| 70 |
+
clicks = []
|
| 71 |
+
trail_points = 0
|
| 72 |
+
heatmap_points = 0
|
| 73 |
+
|
| 74 |
+
for b in batches:
|
| 75 |
+
for item in (b.get("dwell") or []):
|
| 76 |
+
element = item.get("element")
|
| 77 |
+
duration = item.get("duration", 0)
|
| 78 |
+
if not element:
|
| 79 |
+
continue
|
| 80 |
+
dwell_totals[element] = dwell_totals.get(element, 0) + duration
|
| 81 |
+
for c in (b.get("click") or []):
|
| 82 |
+
clicks.append(c)
|
| 83 |
+
trail_points += len(b.get("trail") or [])
|
| 84 |
+
heatmap_points += len(b.get("heatmap") or [])
|
| 85 |
+
|
| 86 |
+
interests = sorted(
|
| 87 |
+
({"element": k, "seconds": round(v / 1000.0, 1)} for k, v in dwell_totals.items()),
|
| 88 |
+
key=lambda r: -r["seconds"]
|
| 89 |
+
)[:10]
|
| 90 |
+
|
| 91 |
+
clicks.sort(key=lambda c: c.get("timestamp", ""))
|
| 92 |
+
|
| 93 |
+
return {
|
| 94 |
+
"interests": interests,
|
| 95 |
+
"clicks": clicks[-50:],
|
| 96 |
+
"click_count": len(clicks),
|
| 97 |
+
"trail_points": trail_points,
|
| 98 |
+
"heatmap_points": heatmap_points,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
# =============================================================================
|
| 103 |
# HUMAN-READABLE LABELS
|
| 104 |
# Raw AOI labels come straight out of the DOM (tag names, CSS classes,
|
|
|
|
| 367 |
<h2>Attention Timeline</h2>
|
| 368 |
<canvas id="timeline" width="1240" height="320"></canvas>
|
| 369 |
</div>
|
| 370 |
+
<div class="panel">
|
| 371 |
+
<h2>Mouse — Most Interacted Elements</h2>
|
| 372 |
+
<div id="mouseInterests"></div>
|
| 373 |
+
</div>
|
| 374 |
+
<div class="panel">
|
| 375 |
+
<h2>Mouse — Click Log</h2>
|
| 376 |
+
<div id="mouseClicks" style="max-height:260px;overflow-y:auto;"></div>
|
| 377 |
+
</div>
|
| 378 |
</div>
|
| 379 |
|
| 380 |
<script>
|
|
|
|
| 382 |
const TIMELINE = __TIMELINE_JSON__;
|
| 383 |
const SEGMENTS = __SEGMENTS_JSON__;
|
| 384 |
const DURATION = __DURATION_JSON__;
|
| 385 |
+
const MOUSE_INTERESTS = __MOUSE_INTERESTS_JSON__;
|
| 386 |
+
const MOUSE_CLICKS = __MOUSE_CLICKS_JSON__;
|
| 387 |
+
|
| 388 |
+
// ---------- Mouse interests / clicks (from the in-page Mouse Tracker) ----------
|
| 389 |
+
(function(){
|
| 390 |
+
const el = document.getElementById('mouseInterests');
|
| 391 |
+
if (!MOUSE_INTERESTS.length) {
|
| 392 |
+
el.innerHTML = '<div class="empty">No mouse dwell data recorded for this session.</div>';
|
| 393 |
+
} else {
|
| 394 |
+
let html = '<table><tr><th>#</th><th>Element</th><th>Time</th></tr>';
|
| 395 |
+
MOUSE_INTERESTS.forEach((r, i) => {
|
| 396 |
+
html += `<tr><td>${i+1}</td><td>${r.element}</td><td>${r.seconds}s</td></tr>`;
|
| 397 |
+
});
|
| 398 |
+
html += '</table>';
|
| 399 |
+
el.innerHTML = html;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
const clickEl = document.getElementById('mouseClicks');
|
| 403 |
+
if (!MOUSE_CLICKS.length) {
|
| 404 |
+
clickEl.innerHTML = '<div class="empty">No clicks recorded for this session.</div>';
|
| 405 |
+
} else {
|
| 406 |
+
let html = '<ul style="padding-left:0;margin:0;list-style:none;">';
|
| 407 |
+
MOUSE_CLICKS.slice().reverse().forEach(c => {
|
| 408 |
+
html += `<li style="margin-bottom:8px;border-bottom:1px solid #302a3d;padding-bottom:6px;">
|
| 409 |
+
<span style="color:var(--dim);font-size:11px;">${c.timestamp || ''}</span><br>
|
| 410 |
+
<b>${c.element || ''}</b><br>
|
| 411 |
+
<span style="color:var(--text);font-size:12px;">"${(c.text || '').replace(/</g,'<')}"</span>
|
| 412 |
+
</li>`;
|
| 413 |
+
});
|
| 414 |
+
html += '</ul>';
|
| 415 |
+
clickEl.innerHTML = html;
|
| 416 |
+
}
|
| 417 |
+
})();
|
| 418 |
|
| 419 |
// ---------- Ranked table ----------
|
| 420 |
(function(){
|
|
|
|
| 579 |
timeline = compute_timeline(attributed)
|
| 580 |
segments = build_screenshot_segments(gaze, dom)
|
| 581 |
summary = session_summary(gaze, dom)
|
| 582 |
+
mouse = summarize_mouse(session_dir)
|
| 583 |
|
| 584 |
top_label = ranking[0]["label"] if ranking else "—"
|
| 585 |
|
|
|
|
| 593 |
html = html.replace("__TIMELINE_JSON__", json.dumps(timeline))
|
| 594 |
html = html.replace("__SEGMENTS_JSON__", json.dumps(segments))
|
| 595 |
html = html.replace("__DURATION_JSON__", json.dumps(summary["duration"]))
|
| 596 |
+
html = html.replace("__MOUSE_INTERESTS_JSON__", json.dumps(mouse["interests"]))
|
| 597 |
+
html = html.replace("__MOUSE_CLICKS_JSON__", json.dumps(mouse["clicks"]))
|
| 598 |
|
| 599 |
out_path = os.path.join(session_dir, "analysis_report.html")
|
| 600 |
with open(out_path, "w", encoding="utf-8") as f:
|
browser_session.py
CHANGED
|
@@ -244,9 +244,9 @@ CONTROL_JS = r"""
|
|
| 244 |
<span style="display:flex;align-items:center;gap:7px;">
|
| 245 |
<span style="width:8px;height:8px;border-radius:50%;background:linear-gradient(135deg,#7B2FBE,#FF2DF0);display:inline-block;"></span>
|
| 246 |
<b style="color:#f0ecf7;letter-spacing:0.02em;">InsightUX</b>
|
| 247 |
-
<span style="color:#7a7288;">Eye
|
| 248 |
</span>
|
| 249 |
-
<span id="__insightux_status" style="color:#c9a6f5;">Press S to start
|
| 250 |
`;
|
| 251 |
document.documentElement.appendChild(banner);
|
| 252 |
|
|
@@ -510,6 +510,457 @@ TRACKING_JS = r"""
|
|
| 510 |
"""
|
| 511 |
|
| 512 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
# =============================================================================
|
| 514 |
# API exposed to JS — start/stop are the only two entry points
|
| 515 |
# =============================================================================
|
|
@@ -520,6 +971,8 @@ class Api:
|
|
| 520 |
self.tracking = False
|
| 521 |
self.stop_event = threading.Event()
|
| 522 |
self.thread = None
|
|
|
|
|
|
|
| 523 |
|
| 524 |
def start_tracking(self):
|
| 525 |
print("[browser_session] start_tracking() called from JS")
|
|
@@ -538,7 +991,10 @@ class Api:
|
|
| 538 |
print(f"[browser_session] starting tracking thread -> {session_dir}")
|
| 539 |
|
| 540 |
self._inject_tracking_overlay()
|
| 541 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 542 |
|
| 543 |
self.thread = threading.Thread(
|
| 544 |
target=gaze_worker, args=(self.window, self.stop_event, session_dir, self),
|
|
@@ -553,7 +1009,16 @@ class Api:
|
|
| 553 |
print("[browser_session] not currently tracking, ignoring")
|
| 554 |
return False
|
| 555 |
self._set_status("Wrapping up your session...")
|
|
|
|
| 556 |
self.stop_event.set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
return True
|
| 558 |
|
| 559 |
def _inject_tracking_overlay(self):
|
|
@@ -562,6 +1027,57 @@ class Api:
|
|
| 562 |
except Exception as e:
|
| 563 |
print(f"[browser_session] overlay inject failed: {e}")
|
| 564 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
def _set_status(self, text):
|
| 566 |
try:
|
| 567 |
self.window.evaluate_js(f"window.insightuxSetStatus && window.insightuxSetStatus({json.dumps(text)})")
|
|
|
|
| 244 |
<span style="display:flex;align-items:center;gap:7px;">
|
| 245 |
<span style="width:8px;height:8px;border-radius:50%;background:linear-gradient(135deg,#7B2FBE,#FF2DF0);display:inline-block;"></span>
|
| 246 |
<b style="color:#f0ecf7;letter-spacing:0.02em;">InsightUX</b>
|
| 247 |
+
<span style="color:#7a7288;">Eye + Mouse Research Browser</span>
|
| 248 |
</span>
|
| 249 |
+
<span id="__insightux_status" style="color:#c9a6f5;">Press S to start tracking · E to stop · H heatmap · M mouse panel</span>
|
| 250 |
`;
|
| 251 |
document.documentElement.appendChild(banner);
|
| 252 |
|
|
|
|
| 510 |
"""
|
| 511 |
|
| 512 |
|
| 513 |
+
# JS: mouse tracking overlay — ported feature-for-feature from the
|
| 514 |
+
# "Mouse Tracker & Heatmap" Chrome extension (Mouse/content.js + background.js
|
| 515 |
+
# + popup.js), adapted to run as a single injected script instead of a
|
| 516 |
+
# content-script/background/popup trio (pywebview has no extension host).
|
| 517 |
+
# Same trail/heatmap/dwell sampling loop, same click interest labeling, same
|
| 518 |
+
# heatmap render, plus an in-page panel that replaces the extension's popup
|
| 519 |
+
# (Start/Stop/Clear/Toggle Heatmap/View Logs, timer, "Most Viewed Element").
|
| 520 |
+
# =============================================================================
|
| 521 |
+
|
| 522 |
+
MOUSE_JS = r"""
|
| 523 |
+
(function(){
|
| 524 |
+
if (window.__insightuxMouse) { return; }
|
| 525 |
+
window.__insightuxMouse = true;
|
| 526 |
+
|
| 527 |
+
let isTracking = true;
|
| 528 |
+
let canvas = null;
|
| 529 |
+
let panelOpen = false;
|
| 530 |
+
let logsOpen = false;
|
| 531 |
+
|
| 532 |
+
// Per-sync buffers (flushed to Python every 2s for on-disk persistence)
|
| 533 |
+
let trailBuffer = [];
|
| 534 |
+
let heatmapBuffer = [];
|
| 535 |
+
let clickBuffer = [];
|
| 536 |
+
let dwellBuffer = [];
|
| 537 |
+
|
| 538 |
+
// Cumulative in-page state (mirrors the extension's background.js state)
|
| 539 |
+
let allTrail = [];
|
| 540 |
+
let allHeatmap = [];
|
| 541 |
+
let allClicks = [];
|
| 542 |
+
let dwellTotals = {};
|
| 543 |
+
|
| 544 |
+
let sessionStart = Date.now();
|
| 545 |
+
let sessionStop = null;
|
| 546 |
+
|
| 547 |
+
let lastClientX = 0, lastClientY = 0;
|
| 548 |
+
let lastPageX = 0, lastPageY = 0;
|
| 549 |
+
let lastSampleTime = 0;
|
| 550 |
+
const sampleRate = 50;
|
| 551 |
+
const DWELL_THRESHOLD = 5000;
|
| 552 |
+
let stationaryStart = 0;
|
| 553 |
+
let isStationary = false;
|
| 554 |
+
|
| 555 |
+
window.insightuxMouseSetTracking = function(on){
|
| 556 |
+
isTracking = !!on;
|
| 557 |
+
if (isTracking) {
|
| 558 |
+
sessionStart = Date.now();
|
| 559 |
+
sessionStop = null;
|
| 560 |
+
} else {
|
| 561 |
+
sessionStop = Date.now();
|
| 562 |
+
flushDwell();
|
| 563 |
+
}
|
| 564 |
+
updatePanel();
|
| 565 |
+
};
|
| 566 |
+
|
| 567 |
+
// ---- sampling loop (trail while moving, heatmap dwell points while still) ----
|
| 568 |
+
setInterval(function(){
|
| 569 |
+
if (!isTracking) return;
|
| 570 |
+
const currentScrollX = window.scrollX, currentScrollY = window.scrollY;
|
| 571 |
+
if (lastPageX === 0 && lastPageY === 0 && lastClientX !== 0) {
|
| 572 |
+
lastPageX = lastClientX + currentScrollX;
|
| 573 |
+
lastPageY = lastClientY + currentScrollY;
|
| 574 |
+
}
|
| 575 |
+
const currentPageX = (lastClientX !== 0) ? (lastClientX + currentScrollX) : lastPageX;
|
| 576 |
+
const currentPageY = (lastClientY !== 0) ? (lastClientY + currentScrollY) : lastPageY;
|
| 577 |
+
if (currentPageX === 0 && currentPageY === 0) return;
|
| 578 |
+
|
| 579 |
+
const now = Date.now();
|
| 580 |
+
const dt = now - lastSampleTime;
|
| 581 |
+
if (dt > 1000) { lastSampleTime = now; return; }
|
| 582 |
+
|
| 583 |
+
const dx = currentPageX - lastPageX, dy = currentPageY - lastPageY;
|
| 584 |
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
| 585 |
+
|
| 586 |
+
if (distance > 2) {
|
| 587 |
+
const pt = { x: currentPageX, y: currentPageY };
|
| 588 |
+
trailBuffer.push(pt); allTrail.push(pt);
|
| 589 |
+
lastPageX = currentPageX; lastPageY = currentPageY;
|
| 590 |
+
isStationary = false; stationaryStart = 0;
|
| 591 |
+
} else {
|
| 592 |
+
if (!isStationary) { isStationary = true; stationaryStart = now; }
|
| 593 |
+
else {
|
| 594 |
+
const dwellDuration = now - stationaryStart;
|
| 595 |
+
if (dwellDuration > DWELL_THRESHOLD) {
|
| 596 |
+
const pt = { x: currentPageX, y: currentPageY };
|
| 597 |
+
heatmapBuffer.push(pt); allHeatmap.push(pt);
|
| 598 |
+
}
|
| 599 |
+
}
|
| 600 |
+
}
|
| 601 |
+
lastSampleTime = now;
|
| 602 |
+
}, sampleRate);
|
| 603 |
+
|
| 604 |
+
// ---- dwell / element-interest tracking ----
|
| 605 |
+
let currentHoverLabel = null, currentHoverStartTime = 0;
|
| 606 |
+
function handleHoverChange(newLabel){
|
| 607 |
+
if (!isTracking) return;
|
| 608 |
+
if (newLabel !== currentHoverLabel) {
|
| 609 |
+
const now = Date.now();
|
| 610 |
+
if (currentHoverLabel && currentHoverStartTime > 0) {
|
| 611 |
+
const duration = now - currentHoverStartTime;
|
| 612 |
+
if (duration > 10) recordDwell(currentHoverLabel, duration);
|
| 613 |
+
}
|
| 614 |
+
currentHoverLabel = newLabel;
|
| 615 |
+
currentHoverStartTime = newLabel ? now : 0;
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
function recordDwell(element, duration){
|
| 619 |
+
dwellBuffer.push({ element: element, duration: duration });
|
| 620 |
+
dwellTotals[element] = (dwellTotals[element] || 0) + duration;
|
| 621 |
+
}
|
| 622 |
+
function flushDwell(){
|
| 623 |
+
if (currentHoverLabel && currentHoverStartTime > 0) {
|
| 624 |
+
const now = Date.now();
|
| 625 |
+
const duration = now - currentHoverStartTime;
|
| 626 |
+
if (duration > 50) recordDwell(currentHoverLabel, duration);
|
| 627 |
+
currentHoverStartTime = now;
|
| 628 |
+
}
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
// ---- sync to Python every 2s -> mouse_log.jsonl in the session folder ----
|
| 632 |
+
setInterval(function(){
|
| 633 |
+
flushDwell();
|
| 634 |
+
if (trailBuffer.length || heatmapBuffer.length || clickBuffer.length || dwellBuffer.length) {
|
| 635 |
+
const payload = {
|
| 636 |
+
trail: trailBuffer.length ? trailBuffer : null,
|
| 637 |
+
heatmap: heatmapBuffer.length ? heatmapBuffer : null,
|
| 638 |
+
click: clickBuffer.length ? clickBuffer : null,
|
| 639 |
+
dwell: dwellBuffer.length ? dwellBuffer : null,
|
| 640 |
+
};
|
| 641 |
+
trailBuffer = []; heatmapBuffer = []; clickBuffer = []; dwellBuffer = [];
|
| 642 |
+
if (window.pywebview && window.pywebview.api && window.pywebview.api.log_mouse_data) {
|
| 643 |
+
try { window.pywebview.api.log_mouse_data(payload).catch(function(){}); } catch (e) {}
|
| 644 |
+
}
|
| 645 |
+
}
|
| 646 |
+
updatePanel();
|
| 647 |
+
}, 2000);
|
| 648 |
+
|
| 649 |
+
// ---- mouse position + hover tracking ----
|
| 650 |
+
function updateMousePos(e){
|
| 651 |
+
lastClientX = e.clientX; lastClientY = e.clientY;
|
| 652 |
+
if (lastPageX === 0) lastPageX = e.pageX;
|
| 653 |
+
if (lastPageY === 0) lastPageY = e.pageY;
|
| 654 |
+
if (isTracking) handleHoverChange(getSmartLabel(e.target));
|
| 655 |
+
}
|
| 656 |
+
document.addEventListener('mousemove', updateMousePos, true);
|
| 657 |
+
document.addEventListener('mouseenter', updateMousePos, true);
|
| 658 |
+
document.addEventListener('mouseover', updateMousePos, true);
|
| 659 |
+
document.addEventListener('click', updateMousePos, true);
|
| 660 |
+
document.addEventListener('mouseleave', function(){
|
| 661 |
+
lastClientX = 0; lastClientY = 0;
|
| 662 |
+
handleHoverChange(null);
|
| 663 |
+
}, true);
|
| 664 |
+
|
| 665 |
+
document.addEventListener('click', function(e){
|
| 666 |
+
if (!isTracking) return;
|
| 667 |
+
if (e.target === canvas) return;
|
| 668 |
+
if (!isInteractable(e.target)) return;
|
| 669 |
+
|
| 670 |
+
const x = e.pageX, y = e.pageY;
|
| 671 |
+
const label = getSmartLabel(e.target) || e.target.tagName;
|
| 672 |
+
if (['BODY', 'HTML', 'DIV', 'SPAN'].includes(label) && !e.target.innerText.trim()) return;
|
| 673 |
+
|
| 674 |
+
const logEntry = {
|
| 675 |
+
timestamp: new Date().toLocaleTimeString(),
|
| 676 |
+
x: x, y: y, element: label,
|
| 677 |
+
text: e.target.innerText ? e.target.innerText.substring(0, 30).replace(/(\r\n|\n|\r)/gm, ' ').trim() : '',
|
| 678 |
+
url: window.location.href
|
| 679 |
+
};
|
| 680 |
+
clickBuffer.push(logEntry); allClicks.push(logEntry);
|
| 681 |
+
if (canvas) drawClick(x, y);
|
| 682 |
+
updatePanel();
|
| 683 |
+
}, true);
|
| 684 |
+
|
| 685 |
+
function isInteractable(el){
|
| 686 |
+
if (!el) return false;
|
| 687 |
+
const tag = el.tagName.toLowerCase();
|
| 688 |
+
if (['a', 'button', 'input', 'select', 'textarea', 'details', 'summary', 'label'].includes(tag)) return true;
|
| 689 |
+
const role = el.getAttribute('role');
|
| 690 |
+
if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') return true;
|
| 691 |
+
try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch (e) {}
|
| 692 |
+
let parent = el.parentElement, depth = 0;
|
| 693 |
+
while (parent && depth < 3) {
|
| 694 |
+
const pTag = parent.tagName.toLowerCase();
|
| 695 |
+
if (['a', 'button'].includes(pTag)) return true;
|
| 696 |
+
if (parent.getAttribute('role') === 'button') return true;
|
| 697 |
+
parent = parent.parentElement; depth++;
|
| 698 |
+
}
|
| 699 |
+
if (tag === 'code' || tag === 'pre' || el.classList.contains('code') || el.closest('pre')) return true;
|
| 700 |
+
return false;
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
function getSmartLabel(el){
|
| 704 |
+
if (!el) return null;
|
| 705 |
+
const tag = el.tagName.toLowerCase();
|
| 706 |
+
if (['body', 'html', 'main', 'div', 'span', 'section', 'article'].includes(tag)) {
|
| 707 |
+
if (el.id && (el.id.includes('logo') || el.id.includes('wrapper') || el.id.includes('container'))) return null;
|
| 708 |
+
if (el.className && typeof el.className === 'string' &&
|
| 709 |
+
(el.className.toLowerCase().includes('logo') || el.className.toLowerCase().includes('brand'))) return null;
|
| 710 |
+
const aria = el.getAttribute('aria-label');
|
| 711 |
+
if (aria) return 'Element: ' + aria;
|
| 712 |
+
if (el.children.length === 0) {
|
| 713 |
+
const txt = el.innerText.trim();
|
| 714 |
+
if (txt.length > 2 && txt.length < 50 && /[a-zA-Z0-9]/.test(txt)) return 'Element: ' + txt;
|
| 715 |
+
}
|
| 716 |
+
return null;
|
| 717 |
+
}
|
| 718 |
+
if (tag === 'a') return 'Link: ' + (el.innerText.trim().substring(0, 30) || 'Link');
|
| 719 |
+
if (tag === 'button') return 'Button: ' + (el.innerText.trim().substring(0, 30) || 'Button');
|
| 720 |
+
if (tag === 'input') return 'Input: ' + (el.placeholder || el.name || el.id || 'Input');
|
| 721 |
+
if (tag === 'textarea') return 'Input: Text Area';
|
| 722 |
+
if (tag === 'img') return 'Image: ' + (el.alt || 'Image');
|
| 723 |
+
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) return tag.toUpperCase() + ': ' + el.innerText.trim().substring(0, 40);
|
| 724 |
+
if (tag === 'code' || tag === 'pre') return 'Code: ' + el.innerText.trim().substring(0, 30);
|
| 725 |
+
const text = el.innerText.trim();
|
| 726 |
+
if (text && text.length > 2) {
|
| 727 |
+
if (el.classList.contains('code') || el.closest('pre')) return 'Code: ' + text.substring(0, 30);
|
| 728 |
+
if (text.toLowerCase().includes('no message found')) return null;
|
| 729 |
+
const isHex = /^#[0-9A-F]{6}$/i.test(text) || /^#[0-9A-F]{3}$/i.test(text);
|
| 730 |
+
const isColorName = ['red', 'blue', 'green', 'yellow', 'black', 'white', 'orange', 'purple', 'gray', 'grey', 'pink', 'brown', 'cyan', 'magenta'].includes(text.toLowerCase());
|
| 731 |
+
if (isHex || isColorName) return null;
|
| 732 |
+
if (text.length > 50) return 'Text: ' + text.substring(0, 47) + '...';
|
| 733 |
+
return 'Text: ' + text;
|
| 734 |
+
}
|
| 735 |
+
return null;
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
// ---- heatmap overlay canvas (ported 1:1 from the extension) ----
|
| 739 |
+
window.insightuxMouseToggleHeatmap = function(){
|
| 740 |
+
if (canvas) { document.body.removeChild(canvas); canvas = null; return; }
|
| 741 |
+
canvas = document.createElement('canvas');
|
| 742 |
+
canvas.style.position = 'absolute';
|
| 743 |
+
canvas.style.top = '0'; canvas.style.left = '0';
|
| 744 |
+
canvas.style.zIndex = '2147483645';
|
| 745 |
+
canvas.style.pointerEvents = 'none';
|
| 746 |
+
canvas.width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, document.documentElement.offsetWidth);
|
| 747 |
+
canvas.height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight);
|
| 748 |
+
document.body.appendChild(canvas);
|
| 749 |
+
drawHeatmapHighQuality(allHeatmap, allTrail, allClicks);
|
| 750 |
+
};
|
| 751 |
+
|
| 752 |
+
function drawClick(x, y){
|
| 753 |
+
if (!canvas) return;
|
| 754 |
+
const ctx = canvas.getContext('2d');
|
| 755 |
+
ctx.save();
|
| 756 |
+
ctx.beginPath();
|
| 757 |
+
ctx.strokeStyle = '#00FF00'; ctx.lineWidth = 3;
|
| 758 |
+
ctx.shadowColor = 'black'; ctx.shadowBlur = 2;
|
| 759 |
+
const size = 10;
|
| 760 |
+
ctx.moveTo(x - size, y - size); ctx.lineTo(x + size, y + size);
|
| 761 |
+
ctx.moveTo(x + size, y - size); ctx.lineTo(x - size, y + size);
|
| 762 |
+
ctx.stroke();
|
| 763 |
+
ctx.beginPath(); ctx.arc(x, y, size + 5, 0, Math.PI * 2); ctx.stroke();
|
| 764 |
+
ctx.restore();
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
function drawHeatmapHighQuality(heatmapData, trailData, clickData){
|
| 768 |
+
if (!canvas) return;
|
| 769 |
+
const ctx = canvas.getContext('2d');
|
| 770 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 771 |
+
let allHeatPoints = [];
|
| 772 |
+
if (heatmapData && heatmapData.length) allHeatPoints = allHeatPoints.concat(heatmapData);
|
| 773 |
+
if (trailData && trailData.length) allHeatPoints = allHeatPoints.concat(trailData);
|
| 774 |
+
if (clickData && clickData.length) {
|
| 775 |
+
clickData.forEach(function(c){ for (let i = 0; i < 5; i++) allHeatPoints.push({ x: c.x, y: c.y }); });
|
| 776 |
+
}
|
| 777 |
+
if (!allHeatPoints.length) return;
|
| 778 |
+
const radius = 60;
|
| 779 |
+
const brushCanvas = document.createElement('canvas');
|
| 780 |
+
brushCanvas.width = radius * 2; brushCanvas.height = radius * 2;
|
| 781 |
+
const brushCtx = brushCanvas.getContext('2d');
|
| 782 |
+
const g = brushCtx.createRadialGradient(radius, radius, 0, radius, radius, radius);
|
| 783 |
+
g.addColorStop(0, 'rgba(0, 0, 0, 0.05)'); g.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
| 784 |
+
brushCtx.fillStyle = g; brushCtx.fillRect(0, 0, radius * 2, radius * 2);
|
| 785 |
+
allHeatPoints.forEach(function(point){ ctx.drawImage(brushCanvas, point.x - radius, point.y - radius); });
|
| 786 |
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
| 787 |
+
const data = imageData.data;
|
| 788 |
+
const gradientMap = createGradientMap();
|
| 789 |
+
for (let i = 0; i < data.length; i += 4) {
|
| 790 |
+
const alpha = data[i + 3];
|
| 791 |
+
if (alpha > 0) {
|
| 792 |
+
let mapIndex = Math.floor(alpha * 1.5);
|
| 793 |
+
if (mapIndex > 255) mapIndex = 255;
|
| 794 |
+
const cIndex = mapIndex * 4;
|
| 795 |
+
data[i] = gradientMap[cIndex]; data[i + 1] = gradientMap[cIndex + 1]; data[i + 2] = gradientMap[cIndex + 2];
|
| 796 |
+
data[i + 3] = Math.min(255, 150 + alpha);
|
| 797 |
+
}
|
| 798 |
+
}
|
| 799 |
+
ctx.putImageData(imageData, 0, 0);
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
function createGradientMap(){
|
| 803 |
+
const c = document.createElement('canvas'); c.width = 256; c.height = 1;
|
| 804 |
+
const ctx = c.getContext('2d');
|
| 805 |
+
const g = ctx.createLinearGradient(0, 0, 256, 0);
|
| 806 |
+
g.addColorStop(0.0, 'rgba(0, 0, 255, 0)');
|
| 807 |
+
g.addColorStop(0.1, 'rgba(0, 0, 255, 1)');
|
| 808 |
+
g.addColorStop(0.4, 'rgba(0, 255, 255, 1)');
|
| 809 |
+
g.addColorStop(0.6, 'rgba(0, 255, 0, 1)');
|
| 810 |
+
g.addColorStop(0.8, 'rgba(255, 255, 0, 1)');
|
| 811 |
+
g.addColorStop(1.0, 'rgba(255, 0, 0, 1)');
|
| 812 |
+
ctx.fillStyle = g; ctx.fillRect(0, 0, 256, 1);
|
| 813 |
+
return ctx.getImageData(0, 0, 256, 1).data;
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
window.addEventListener('resize', function(){
|
| 817 |
+
if (canvas) {
|
| 818 |
+
canvas.width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, document.documentElement.offsetWidth);
|
| 819 |
+
canvas.height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight);
|
| 820 |
+
}
|
| 821 |
+
});
|
| 822 |
+
|
| 823 |
+
// ---- insights panel: in-page port of the extension's popup.html/popup.js ----
|
| 824 |
+
const style = document.createElement('style');
|
| 825 |
+
style.textContent = `
|
| 826 |
+
#__insightux_mouse_panel {
|
| 827 |
+
position: fixed; top: 44px; right: 16px; width: 260px; z-index: 2147483647;
|
| 828 |
+
background: linear-gradient(180deg, rgba(28,24,38,0.97), rgba(20,18,26,0.97));
|
| 829 |
+
border: 1px solid #3a3348; border-radius: 12px; box-shadow: 0 8px 28px rgba(0,0,0,0.45);
|
| 830 |
+
font: 12px -apple-system, 'Segoe UI', Arial; color: #f0ecf7; padding: 14px;
|
| 831 |
+
pointer-events: auto; display: none;
|
| 832 |
+
}
|
| 833 |
+
#__insightux_mouse_panel.open { display: block; }
|
| 834 |
+
#__insightux_mouse_panel h3 { margin: 0 0 8px 0; font-size: 13px; color: #f0ecf7;
|
| 835 |
+
display:flex; align-items:center; justify-content:space-between; }
|
| 836 |
+
#__insightux_mouse_panel .muted { color: #9a92ad; font-size: 11px; }
|
| 837 |
+
#__insightux_mouse_panel .row { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
| 838 |
+
#__insightux_mouse_panel button {
|
| 839 |
+
flex: 1 1 auto; padding: 6px 8px; font-size: 11px; border-radius: 6px; border: 1px solid #3a3348;
|
| 840 |
+
background: #241f2e; color: #f0ecf7; cursor: pointer;
|
| 841 |
+
}
|
| 842 |
+
#__insightux_mouse_panel button:hover { background: #302a3d; }
|
| 843 |
+
#__insightux_mouse_panel .timer { font-weight: 600; margin-bottom: 6px; }
|
| 844 |
+
#__insightux_mouse_panel .hero {
|
| 845 |
+
background: rgba(123,47,190,0.15); border: 1px solid #7B2FBE; border-radius: 8px;
|
| 846 |
+
padding: 8px; text-align: center; margin-bottom: 8px;
|
| 847 |
+
}
|
| 848 |
+
#__insightux_mouse_panel .hero .lbl { font-size: 9px; text-transform: uppercase; color: #c9a6f5; letter-spacing: 0.05em; }
|
| 849 |
+
#__insightux_mouse_panel .hero .el { font-weight: 600; margin: 3px 0; color: #FF2DF0; }
|
| 850 |
+
#__insightux_mouse_panel .interest-item, #__insightux_mouse_panel .log-item {
|
| 851 |
+
background: #241f2e; border-radius: 6px; padding: 5px 7px; margin-bottom: 4px; font-size: 11px;
|
| 852 |
+
}
|
| 853 |
+
#__insightux_mouse_panel .list { max-height: 160px; overflow-y: auto; }
|
| 854 |
+
`;
|
| 855 |
+
document.head.appendChild(style);
|
| 856 |
+
|
| 857 |
+
const panel = document.createElement('div');
|
| 858 |
+
panel.id = '__insightux_mouse_panel';
|
| 859 |
+
panel.innerHTML = `
|
| 860 |
+
<h3>Mouse Tracker <span class="muted" id="__mt_status">idle</span></h3>
|
| 861 |
+
<div class="timer" id="__mt_timer">Session Time: 00:00</div>
|
| 862 |
+
<div class="row">
|
| 863 |
+
<button id="__mt_clear">Clear</button>
|
| 864 |
+
<button id="__mt_heatmap">Heatmap</button>
|
| 865 |
+
<button id="__mt_logs">Logs</button>
|
| 866 |
+
</div>
|
| 867 |
+
<div id="__mt_hero" class="hero" style="display:none;">
|
| 868 |
+
<div class="lbl">Most Viewed Element</div>
|
| 869 |
+
<div class="el" id="__mt_hero_el">-</div>
|
| 870 |
+
<div id="__mt_hero_time">0s</div>
|
| 871 |
+
</div>
|
| 872 |
+
<div id="__mt_interests" class="list"></div>
|
| 873 |
+
<div id="__mt_logs_list" class="list" style="display:none;"></div>
|
| 874 |
+
`;
|
| 875 |
+
document.documentElement.appendChild(panel);
|
| 876 |
+
|
| 877 |
+
document.getElementById('__mt_clear').addEventListener('click', function(){
|
| 878 |
+
trailBuffer = []; heatmapBuffer = []; clickBuffer = []; dwellBuffer = [];
|
| 879 |
+
allTrail = []; allHeatmap = []; allClicks = []; dwellTotals = {};
|
| 880 |
+
sessionStart = Date.now(); sessionStop = null;
|
| 881 |
+
if (canvas) { document.body.removeChild(canvas); canvas = null; }
|
| 882 |
+
updatePanel();
|
| 883 |
+
});
|
| 884 |
+
document.getElementById('__mt_heatmap').addEventListener('click', function(){ window.insightuxMouseToggleHeatmap(); });
|
| 885 |
+
document.getElementById('__mt_logs').addEventListener('click', function(){
|
| 886 |
+
logsOpen = !logsOpen;
|
| 887 |
+
document.getElementById('__mt_logs_list').style.display = logsOpen ? 'block' : 'none';
|
| 888 |
+
document.getElementById('__mt_interests').style.display = logsOpen ? 'none' : 'block';
|
| 889 |
+
updatePanel();
|
| 890 |
+
});
|
| 891 |
+
|
| 892 |
+
window.insightuxMouseTogglePanel = function(){
|
| 893 |
+
panelOpen = !panelOpen;
|
| 894 |
+
panel.classList.toggle('open', panelOpen);
|
| 895 |
+
if (panelOpen) updatePanel();
|
| 896 |
+
};
|
| 897 |
+
|
| 898 |
+
function formatTime(ms){ return Math.round(ms / 1000) + 's'; }
|
| 899 |
+
|
| 900 |
+
function updatePanel(){
|
| 901 |
+
if (!panelOpen) return;
|
| 902 |
+
document.getElementById('__mt_status').textContent = isTracking ? 'tracking' : 'stopped';
|
| 903 |
+
const startTs = sessionStart;
|
| 904 |
+
const diff = isTracking ? Math.floor((Date.now() - startTs) / 1000)
|
| 905 |
+
: (sessionStop ? Math.floor((sessionStop - startTs) / 1000) : 0);
|
| 906 |
+
const mins = Math.floor(diff / 60).toString().padStart(2, '0');
|
| 907 |
+
const secs = (diff % 60).toString().padStart(2, '0');
|
| 908 |
+
document.getElementById('__mt_timer').textContent = 'Session Time: ' + mins + ':' + secs;
|
| 909 |
+
|
| 910 |
+
const interests = Object.entries(dwellTotals).map(function(e){ return { element: e[0], duration: e[1] }; })
|
| 911 |
+
.sort(function(a, b){ return b.duration - a.duration; }).slice(0, 6);
|
| 912 |
+
|
| 913 |
+
const hero = document.getElementById('__mt_hero');
|
| 914 |
+
if (interests.length) {
|
| 915 |
+
hero.style.display = 'block';
|
| 916 |
+
document.getElementById('__mt_hero_el').textContent = interests[0].element;
|
| 917 |
+
document.getElementById('__mt_hero_time').textContent = formatTime(interests[0].duration);
|
| 918 |
+
} else {
|
| 919 |
+
hero.style.display = 'none';
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
const interestsEl = document.getElementById('__mt_interests');
|
| 923 |
+
if (!logsOpen) {
|
| 924 |
+
if (interests.length > 1) {
|
| 925 |
+
interestsEl.innerHTML = interests.slice(1).map(function(it, i){
|
| 926 |
+
return '<div class="interest-item">' + (i + 2) + '. <b>' + it.element + '</b> — ' + formatTime(it.duration) + '</div>';
|
| 927 |
+
}).join('');
|
| 928 |
+
} else {
|
| 929 |
+
interestsEl.innerHTML = '<div class="muted">No other interests yet.</div>';
|
| 930 |
+
}
|
| 931 |
+
}
|
| 932 |
+
|
| 933 |
+
const logsEl = document.getElementById('__mt_logs_list');
|
| 934 |
+
if (logsOpen) {
|
| 935 |
+
if (allClicks.length) {
|
| 936 |
+
logsEl.innerHTML = allClicks.slice().reverse().slice(0, 20).map(function(l){
|
| 937 |
+
return '<div class="log-item"><span class="muted">' + l.timestamp + '</span><br><b>' + l.element + '</b><br>"' + l.text + '"</div>';
|
| 938 |
+
}).join('');
|
| 939 |
+
} else {
|
| 940 |
+
logsEl.innerHTML = '<div class="muted">No clicks recorded.</div>';
|
| 941 |
+
}
|
| 942 |
+
}
|
| 943 |
+
}
|
| 944 |
+
setInterval(updatePanel, 1000);
|
| 945 |
+
|
| 946 |
+
// ---- hotkeys: H = toggle heatmap, M = toggle insights panel ----
|
| 947 |
+
function isTypingTarget(el){
|
| 948 |
+
if (!el) return false;
|
| 949 |
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
| 950 |
+
return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable === true;
|
| 951 |
+
}
|
| 952 |
+
document.addEventListener('keydown', function(e){
|
| 953 |
+
const typingBlocked = isTypingTarget(e.target) || isTypingTarget(document.activeElement);
|
| 954 |
+
if (typingBlocked) return;
|
| 955 |
+
if (e.key === 'h' || e.key === 'H') window.insightuxMouseToggleHeatmap();
|
| 956 |
+
else if (e.key === 'm' || e.key === 'M') window.insightuxMouseTogglePanel();
|
| 957 |
+
}, true);
|
| 958 |
+
|
| 959 |
+
updatePanel();
|
| 960 |
+
})();
|
| 961 |
+
"""
|
| 962 |
+
|
| 963 |
+
|
| 964 |
# =============================================================================
|
| 965 |
# API exposed to JS — start/stop are the only two entry points
|
| 966 |
# =============================================================================
|
|
|
|
| 971 |
self.tracking = False
|
| 972 |
self.stop_event = threading.Event()
|
| 973 |
self.thread = None
|
| 974 |
+
self.mouse_log_f = None
|
| 975 |
+
self.mouse_close_timer = None
|
| 976 |
|
| 977 |
def start_tracking(self):
|
| 978 |
print("[browser_session] start_tracking() called from JS")
|
|
|
|
| 991 |
print(f"[browser_session] starting tracking thread -> {session_dir}")
|
| 992 |
|
| 993 |
self._inject_tracking_overlay()
|
| 994 |
+
self._inject_mouse_overlay()
|
| 995 |
+
self._open_mouse_log(session_dir)
|
| 996 |
+
self._set_mouse_tracking(True)
|
| 997 |
+
self._set_status("Recording gaze + mouse... Press E to stop, H for heatmap, M for mouse panel")
|
| 998 |
|
| 999 |
self.thread = threading.Thread(
|
| 1000 |
target=gaze_worker, args=(self.window, self.stop_event, session_dir, self),
|
|
|
|
| 1009 |
print("[browser_session] not currently tracking, ignoring")
|
| 1010 |
return False
|
| 1011 |
self._set_status("Wrapping up your session...")
|
| 1012 |
+
self._set_mouse_tracking(False)
|
| 1013 |
self.stop_event.set()
|
| 1014 |
+
|
| 1015 |
+
# Give the page a moment to flush its last batch of mouse data over
|
| 1016 |
+
# log_mouse_data() before the file handle is closed.
|
| 1017 |
+
if self.mouse_close_timer:
|
| 1018 |
+
self.mouse_close_timer.cancel()
|
| 1019 |
+
self.mouse_close_timer = threading.Timer(2.5, self._close_mouse_log)
|
| 1020 |
+
self.mouse_close_timer.daemon = True
|
| 1021 |
+
self.mouse_close_timer.start()
|
| 1022 |
return True
|
| 1023 |
|
| 1024 |
def _inject_tracking_overlay(self):
|
|
|
|
| 1027 |
except Exception as e:
|
| 1028 |
print(f"[browser_session] overlay inject failed: {e}")
|
| 1029 |
|
| 1030 |
+
def _inject_mouse_overlay(self):
|
| 1031 |
+
try:
|
| 1032 |
+
self.window.evaluate_js(MOUSE_JS)
|
| 1033 |
+
except Exception as e:
|
| 1034 |
+
print(f"[browser_session] mouse overlay inject failed: {e}")
|
| 1035 |
+
|
| 1036 |
+
def _set_mouse_tracking(self, on):
|
| 1037 |
+
try:
|
| 1038 |
+
flag = "true" if on else "false"
|
| 1039 |
+
self.window.evaluate_js(
|
| 1040 |
+
f"window.insightuxMouseSetTracking && window.insightuxMouseSetTracking({flag})"
|
| 1041 |
+
)
|
| 1042 |
+
except Exception:
|
| 1043 |
+
pass
|
| 1044 |
+
|
| 1045 |
+
def _open_mouse_log(self, session_dir):
|
| 1046 |
+
self._close_mouse_log()
|
| 1047 |
+
path = os.path.join(session_dir, "mouse_log.jsonl")
|
| 1048 |
+
try:
|
| 1049 |
+
self.mouse_log_f = open(path, "w", buffering=1)
|
| 1050 |
+
self.mouse_log_f.write(json.dumps({"type": "meta", "t": round(time.time(), 4)}) + "\n")
|
| 1051 |
+
print(f"[browser_session] writing mouse stream to {path}")
|
| 1052 |
+
except Exception as e:
|
| 1053 |
+
print(f"[browser_session] could not open mouse log: {e}")
|
| 1054 |
+
self.mouse_log_f = None
|
| 1055 |
+
|
| 1056 |
+
def _close_mouse_log(self):
|
| 1057 |
+
if self.mouse_log_f:
|
| 1058 |
+
try:
|
| 1059 |
+
self.mouse_log_f.close()
|
| 1060 |
+
print("[browser_session] mouse log closed")
|
| 1061 |
+
except Exception:
|
| 1062 |
+
pass
|
| 1063 |
+
self.mouse_log_f = None
|
| 1064 |
+
|
| 1065 |
+
def log_mouse_data(self, payload):
|
| 1066 |
+
"""Called from the injected Mouse Tracker overlay (MOUSE_JS) roughly
|
| 1067 |
+
every 2s while tracking, with a batch of trail / heatmap / click /
|
| 1068 |
+
dwell records — same cadence as the extension's background.js sync
|
| 1069 |
+
loop, just persisted to mouse_log.jsonl instead of chrome.storage."""
|
| 1070 |
+
if not self.mouse_log_f:
|
| 1071 |
+
return False
|
| 1072 |
+
try:
|
| 1073 |
+
rec = dict(payload) if isinstance(payload, dict) else {}
|
| 1074 |
+
rec["type"] = "mouse_batch"
|
| 1075 |
+
rec["t"] = round(time.time(), 4)
|
| 1076 |
+
self.mouse_log_f.write(json.dumps(rec) + "\n")
|
| 1077 |
+
except Exception as e:
|
| 1078 |
+
print(f"[browser_session] mouse log write failed: {e}")
|
| 1079 |
+
return True
|
| 1080 |
+
|
| 1081 |
def _set_status(self, text):
|
| 1082 |
try:
|
| 1083 |
self.window.evaluate_js(f"window.insightuxSetStatus && window.insightuxSetStatus({json.dumps(text)})")
|