Sanyam0385 commited on
Commit
0f55048
·
1 Parent(s): 6709ff1

Add mouse tracking, ported feature-for-feature from the Mouse Chrome extension

Browse files

Injects 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.

Files changed (2) hide show
  1. analysis.py +91 -0
  2. 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,'&lt;')}"</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-Tracking Research Browser</span>
248
  </span>
249
- <span id="__insightux_status" style="color:#c9a6f5;">Press S to start eye-tracking on this page</span>
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._set_status("Recording... Press E to stop")
 
 
 
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 &middot; E to stop &middot; H heatmap &middot; 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)})")