""" analysis.py Turns a raw InsightUX session (gaze_log.jsonl + dom_log.jsonl + screenshots/) into a self-contained HTML report: ranked attention (in plain language) and per-scroll-position heatmaps drawn directly on top of what the page actually looked like. Usage: from analysis import generate_report report_path = generate_report(session_dir) # returns abs path to html PAD_PX must match the value used in browser_session.py's injected TRACKING_JS, or the post-hoc AOI attribution here will disagree with what the user visually saw highlighted during the session. """ import os import re import json import bisect import base64 import html as html_escape from datetime import datetime import theme PAD_PX = 90 # keep identical to TRACKING_JS's PAD_PX in browser_session.py # Report/print item caps — presentation limits only, never applied to the # underlying computed data (compute_dwell_ranking/summarize_mouse still # return every item; this only bounds how many rows get *displayed*). MAX_ATTENTION_ITEMS = 20 # ============================================================================= # LOADING # ============================================================================= def _load_jsonl(path): records = [] if not os.path.exists(path): return records with open(path, "r") as f: for line in f: line = line.strip() if not line: continue try: records.append(json.loads(line)) except json.JSONDecodeError: continue return records def load_session(session_dir): gaze = [r for r in _load_jsonl(os.path.join(session_dir, "gaze_log.jsonl")) if r.get("type") == "gaze"] dom = [r for r in _load_jsonl(os.path.join(session_dir, "dom_log.jsonl")) if r.get("type") == "dom"] gaze.sort(key=lambda r: r["t"]) dom.sort(key=lambda r: r["t"]) return gaze, dom # ============================================================================= # MOUSE ACTIVITY (from mouse_log.jsonl — batches pushed by the in-page Mouse # Tracker overlay, same trail/heatmap/click/dwell shape as the standalone # "Mouse Tracker & Heatmap" Chrome extension this was ported from) # ============================================================================= def load_mouse_batches(session_dir): return [r for r in _load_jsonl(os.path.join(session_dir, "mouse_log.jsonl")) if r.get("type") == "mouse_batch"] def summarize_mouse(session_dir): batches = load_mouse_batches(session_dir) dwell_totals = {} clicks = [] trail_points = 0 heatmap_points = 0 for b in batches: for item in (b.get("dwell") or []): element = item.get("element") duration = item.get("duration", 0) if not element: continue dwell_totals[element] = dwell_totals.get(element, 0) + duration for c in (b.get("click") or []): clicks.append(c) trail_points += len(b.get("trail") or []) heatmap_points += len(b.get("heatmap") or []) interests = sorted( ({"element": k, "seconds": round(v / 1000.0, 1)} for k, v in dwell_totals.items()), key=lambda r: -r["seconds"] )[:MAX_ATTENTION_ITEMS] clicks.sort(key=lambda c: c.get("timestamp", "")) return { "interests": interests, "clicks": clicks[-50:], "click_count": len(clicks), "trail_points": trail_points, "heatmap_points": heatmap_points, } # ============================================================================= # HUMAN-READABLE LABELS # Raw AOI labels come straight out of the DOM (tag names, CSS classes, # truncated text). Fine for matching, unreadable for a report. This maps # them to plain language without losing which element they refer to. # ============================================================================= def friendly_label(raw): if not raw: return "Unlabeled area" if raw == "navbar": return "Navigation bar" if raw == "header": return "Page header" if raw == "footer": return "Page footer" if raw == "video": return "Video" if raw.startswith("img: "): return "Image — " + raw[5:] m = re.match(r"^(h[123]):\s*(.*)$", raw) if m: level = {"h1": "Main heading", "h2": "Heading", "h3": "Sub-heading"}[m.group(1)] return f"{level} — “{m.group(2)}”" m = re.match(r"^p \((.*)\)$", raw) if m: return f"Text — “{m.group(1)}…”" if raw.startswith("#"): return "Section: " + raw[1:] if raw.startswith("."): return "Block: " + raw[1:] return raw[0].upper() + raw[1:] if raw else raw # ============================================================================= # GAZE -> AOI ATTRIBUTION # ============================================================================= def _find_aoi(px, py, aois): """Smallest padded AOI containing (px, py), or None. Mirrors the JS hit-test. Skips raw label "embed": TRACKING_JS's insightuxAOIs() tags every