| """ |
| 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), |
| a dwell timeline, 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 |
|
|
| PAD_PX = 90 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| 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"] |
| )[:10] |
|
|
| 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, |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| 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 == "embed": |
| return "Embedded content (PDF, player, or widget)" |
| 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} — \u201c{m.group(2)}\u201d" |
| m = re.match(r"^p \((.*)\)$", raw) |
| if m: |
| return f"Text — \u201c{m.group(1)}\u2026\u201d" |
| if raw.startswith("#"): |
| return "Section: " + raw[1:] |
| if raw.startswith("."): |
| return "Block: " + raw[1:] |
| return raw[0].upper() + raw[1:] if raw else raw |
|
|
|
|
| |
| |
| |
|
|
| def _find_aoi(px, py, aois): |
| """Smallest padded AOI containing (px, py), or None. Mirrors the JS hit-test.""" |
| best = None |
| best_area = None |
| for a in aois: |
| x, y, w, h = a["x"], a["y"], a["w"], a["h"] |
| if (x - PAD_PX) <= px <= (x + w + PAD_PX) and (y - PAD_PX) <= py <= (y + h + PAD_PX): |
| area = w * h |
| if best is None or area < best_area: |
| best = a |
| best_area = area |
| return best["label"] if best else None |
|
|
|
|
| def attribute_gaze(gaze, dom): |
| """For each gaze sample, find the most recent dom snapshot at/before it |
| and test which AOI the point falls in. Returns [(t, raw_label_or_None)].""" |
| if not dom: |
| return [(g["t"], None) for g in gaze] |
|
|
| dom_times = [d["t"] for d in dom] |
| out = [] |
| for g in gaze: |
| idx = bisect.bisect_right(dom_times, g["t"]) - 1 |
| if idx < 0: |
| out.append((g["t"], None)) |
| continue |
| snap = dom[idx] |
| label = _find_aoi(g["sx"], g["sy"], snap.get("aois", [])) |
| out.append((g["t"], label)) |
| return out |
|
|
|
|
| |
| |
| |
|
|
| def compute_dwell_ranking(attributed): |
| if len(attributed) < 2: |
| return [] |
| totals, hits = {}, {} |
| for i in range(len(attributed) - 1): |
| t0, label = attributed[i] |
| t1, _ = attributed[i + 1] |
| dt = max(0.0, t1 - t0) |
| if label: |
| totals[label] = totals.get(label, 0.0) + dt |
| hits[label] = hits.get(label, 0) + 1 |
| total_time = sum(totals.values()) or 1.0 |
| ranking = sorted( |
| ({"label": friendly_label(k), "seconds": round(v, 2), |
| "pct": round(100 * v / total_time, 1), "hits": hits[k]} |
| for k, v in totals.items()), |
| key=lambda r: -r["seconds"] |
| ) |
| return ranking |
|
|
|
|
| def compute_timeline(attributed): |
| segments = [] |
| if not attributed: |
| return segments |
| cur_raw = attributed[0][1] |
| seg_start = attributed[0][0] |
| last_t = attributed[0][0] |
| for t, label in attributed[1:]: |
| if label != cur_raw: |
| segments.append({"start": round(seg_start, 2), "end": round(last_t, 2), |
| "label": friendly_label(cur_raw) if cur_raw else None}) |
| cur_raw = label |
| seg_start = t |
| last_t = t |
| segments.append({"start": round(seg_start, 2), "end": round(last_t, 2), |
| "label": friendly_label(cur_raw) if cur_raw else None}) |
| return segments |
|
|
|
|
| def session_summary(gaze, dom): |
| if not gaze: |
| return {"duration": 0.0, "url": None, "samples": 0} |
| duration = gaze[-1]["t"] - gaze[0]["t"] |
| url = dom[-1]["url"] if dom else None |
| return {"duration": round(duration, 1), "url": url, "samples": len(gaze)} |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| def build_screenshot_segments(gaze, dom): |
| shot_events = [d for d in dom if d.get("screenshot")] |
| if not shot_events: |
| return [] |
|
|
| shot_times = [d["t"] for d in shot_events] |
| buckets = [[] for _ in shot_events] |
|
|
| for g in gaze: |
| idx = bisect.bisect_right(shot_times, g["t"]) - 1 |
| if idx < 0: |
| idx = 0 |
| buckets[idx].append({"sx": round(g["sx"], 1), "sy": round(g["sy"], 1)}) |
|
|
| segments = [] |
| for ev, pts in zip(shot_events, buckets): |
| if not pts: |
| continue |
| segments.append({ |
| "screenshot": ev["screenshot"], |
| "scrollY": ev.get("scrollY", 0), |
| "points": pts, |
| "duration": 0.0, |
| }) |
|
|
| if segments: |
| total_pts = sum(len(s["points"]) for s in segments) |
| total_time = (gaze[-1]["t"] - gaze[0]["t"]) if len(gaze) > 1 else 0.0 |
| for s in segments: |
| share = (len(s["points"]) / total_pts) if total_pts else 0 |
| s["duration"] = round(share * total_time, 1) |
|
|
| return segments |
|
|
|
|
| |
| |
| |
|
|
| _TEMPLATE = r"""<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="insightux-report" content="true"> |
| <title>InsightUX Session Report</title> |
| <style> |
| :root { |
| --purple: #7B2FBE; --purple-light: #9B59FF; --pink: #FF2DF0; |
| --bg: #14121a; --panel: #1e1b26; --text: #f0ecf7; --dim: #9a92ad; |
| } |
| * { box-sizing: border-box; } |
| body { |
| margin: 0; padding: 32px; background: var(--bg); color: var(--text); |
| font-family: -apple-system, 'Segoe UI', Arial, sans-serif; |
| } |
| h1 { margin: 0 0 4px 0; font-size: 22px; } |
| .sub { color: var(--dim); font-size: 13px; margin-bottom: 24px; } |
| .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; } |
| .panel { |
| background: var(--panel); border: 1px solid #302a3d; border-radius: 12px; |
| padding: 20px; |
| } |
| .panel h2 { margin: 0 0 14px 0; font-size: 14px; color: var(--purple-light); |
| text-transform: uppercase; letter-spacing: 0.06em; } |
| table { width: 100%; border-collapse: collapse; font-size: 13px; } |
| th { text-align: left; color: var(--dim); font-weight: 500; padding: 6px 8px; |
| border-bottom: 1px solid #302a3d; } |
| td { padding: 7px 8px; border-bottom: 1px solid #241f2e; } |
| .bar-bg { background: #2a2436; border-radius: 4px; height: 6px; margin-top: 4px; overflow: hidden; } |
| .bar-fill { height: 100%; background: linear-gradient(90deg, var(--purple), var(--pink)); } |
| canvas { display: block; } |
| .full { grid-column: 1 / -1; } |
| .stat-row { display: flex; gap: 24px; margin-bottom: 20px; } |
| .stat { background: var(--panel); border: 1px solid #302a3d; border-radius: 12px; |
| padding: 14px 20px; } |
| .stat .v { font-size: 20px; font-weight: 600; color: var(--purple-light); } |
| .stat .l { font-size: 11px; color: var(--dim); text-transform: uppercase; } |
| .empty { color: var(--dim); font-size: 13px; padding: 20px 0; text-align: center; } |
| .legend { font-size: 12px; color: var(--dim); line-height: 1.7; } |
| .legend b { color: var(--text); } |
| .legend-scale { display: flex; align-items: center; gap: 8px; margin: 10px 0; } |
| .legend-scale .bar { flex: 1; height: 10px; border-radius: 5px; |
| background: linear-gradient(90deg, #0000ff, #00e5ff, #00ff5a, #e8ff00, #ff2d00); } |
| .shot-wrap { position: relative; border-radius: 8px; overflow: hidden; background: #100e16; } |
| .shot-wrap img, .shot-wrap canvas { display: block; width: 100%; height: auto; } |
| .shot-wrap canvas { position: absolute; top: 0; left: 0; } |
| .shot-nav { display: flex; align-items: center; justify-content: space-between; |
| margin-top: 10px; font-size: 12px; color: var(--dim); } |
| .shot-nav button { |
| background: #2a2436; border: 1px solid #3a3348; color: var(--text); |
| padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px; |
| } |
| .shot-nav button:hover { background: #352d44; } |
| .shot-nav button:disabled { opacity: 0.35; cursor: default; } |
| </style> |
| </head> |
| <body> |
| |
| <h1>InsightUX Session Report</h1> |
| <div class="sub">__URL__ · generated from __SAMPLES__ gaze samples</div> |
| |
| <div class="stat-row"> |
| <div class="stat"><div class="v">__DURATION__s</div><div class="l">Session Length</div></div> |
| <div class="stat"><div class="v">__NUM_ELEMENTS__</div><div class="l">Elements Fixated</div></div> |
| <div class="stat"><div class="v">__TOP_LABEL__</div><div class="l">Most Attended</div></div> |
| </div> |
| |
| <div class="grid"> |
| <div class="panel"> |
| <h2>Ranked Attention</h2> |
| <div id="rankingTable"></div> |
| </div> |
| <div class="panel"> |
| <h2>How to read this report</h2> |
| <div class="legend"> |
| <p><b>Ranked Attention</b> — every part of the page you looked at for a |
| meaningful stretch of time, ordered by how long you spent there.</p> |
| <p><b>Heatmap</b> — drawn directly on a screenshot of the page as it |
| appeared during the session. Color shows how much attention that spot |
| received:</p> |
| <div class="legend-scale"> |
| <span>Little</span><div class="bar"></div><span>A lot</span> |
| </div> |
| <p>Each card below is a different scroll position — the report splits |
| the page automatically whenever you scrolled far enough that the view |
| changed meaningfully.</p> |
| <p><b>Timeline</b> — the same attention data laid out across time, |
| so you can see the order things were looked at, not just the totals.</p> |
| </div> |
| </div> |
| <div class="panel full"> |
| <h2>Heatmap by Scroll Position</h2> |
| <div id="heatmapArea"></div> |
| </div> |
| <div class="panel full"> |
| <h2>Attention Timeline</h2> |
| <canvas id="timeline" width="1240" height="320"></canvas> |
| </div> |
| <div class="panel"> |
| <h2>Mouse — Most Interacted Elements</h2> |
| <div id="mouseInterests"></div> |
| </div> |
| <div class="panel"> |
| <h2>Mouse — Click Log</h2> |
| <div id="mouseClicks" style="max-height:260px;overflow-y:auto;"></div> |
| </div> |
| </div> |
| |
| <script> |
| const RANKING = __RANKING_JSON__; |
| const TIMELINE = __TIMELINE_JSON__; |
| const SEGMENTS = __SEGMENTS_JSON__; |
| const DURATION = __DURATION_JSON__; |
| const MOUSE_INTERESTS = __MOUSE_INTERESTS_JSON__; |
| const MOUSE_CLICKS = __MOUSE_CLICKS_JSON__; |
| |
| // ---------- Mouse interests / clicks (from the in-page Mouse Tracker) ---------- |
| (function(){ |
| const el = document.getElementById('mouseInterests'); |
| if (!MOUSE_INTERESTS.length) { |
| el.innerHTML = '<div class="empty">No mouse dwell data recorded for this session.</div>'; |
| } else { |
| let html = '<table><tr><th>#</th><th>Element</th><th>Time</th></tr>'; |
| MOUSE_INTERESTS.forEach((r, i) => { |
| html += `<tr><td>${i+1}</td><td>${r.element}</td><td>${r.seconds}s</td></tr>`; |
| }); |
| html += '</table>'; |
| el.innerHTML = html; |
| } |
| |
| const clickEl = document.getElementById('mouseClicks'); |
| if (!MOUSE_CLICKS.length) { |
| clickEl.innerHTML = '<div class="empty">No clicks recorded for this session.</div>'; |
| } else { |
| let html = '<ul style="padding-left:0;margin:0;list-style:none;">'; |
| MOUSE_CLICKS.slice().reverse().forEach(c => { |
| html += `<li style="margin-bottom:8px;border-bottom:1px solid #302a3d;padding-bottom:6px;"> |
| <span style="color:var(--dim);font-size:11px;">${c.timestamp || ''}</span><br> |
| <b>${c.element || ''}</b><br> |
| <span style="color:var(--text);font-size:12px;">"${(c.text || '').replace(/</g,'<')}"</span> |
| </li>`; |
| }); |
| html += '</ul>'; |
| clickEl.innerHTML = html; |
| } |
| })(); |
| |
| // ---------- Ranked table ---------- |
| (function(){ |
| const el = document.getElementById('rankingTable'); |
| if (!RANKING.length) { el.innerHTML = '<div class="empty">No elements were fixated long enough to register.</div>'; return; } |
| let html = '<table><tr><th>#</th><th>Element</th><th>Dwell</th><th>Share</th><th>Hits</th></tr>'; |
| RANKING.slice(0, 15).forEach((r, i) => { |
| html += `<tr> |
| <td>${i+1}</td> |
| <td>${r.label}<div class="bar-bg"><div class="bar-fill" style="width:${r.pct}%"></div></div></td> |
| <td>${r.seconds}s</td> |
| <td>${r.pct}%</td> |
| <td>${r.hits}</td> |
| </tr>`; |
| }); |
| html += '</table>'; |
| el.innerHTML = html; |
| })(); |
| |
| // ---------- Classic red/green/blue heat colormap ---------- |
| function jetColor(t){ |
| t = Math.max(0, Math.min(1, t)); |
| const stops = [ |
| [0.00, 0, 0, 255], |
| [0.30, 0, 229, 255], |
| [0.55, 0, 255, 90], |
| [0.75, 232, 255, 0], |
| [1.00, 255, 45, 0], |
| ]; |
| for (let i = 0; i < stops.length - 1; i++){ |
| const [t0,r0,g0,b0] = stops[i], [t1,r1,g1,b1] = stops[i+1]; |
| if (t >= t0 && t <= t1){ |
| const k = (t - t0) / (t1 - t0 || 1); |
| return [r0+(r1-r0)*k, g0+(g1-g0)*k, b0+(b1-b0)*k]; |
| } |
| } |
| return [255,45,0]; |
| } |
| |
| // ---------- Screenshot-backed heatmaps, paginated ---------- |
| (function(){ |
| const area = document.getElementById('heatmapArea'); |
| if (!SEGMENTS.length){ |
| area.innerHTML = '<div class="empty">No page screenshots were captured for this session ' + |
| '(older session, or screen-capture failed) — nothing to overlay a heatmap on.</div>'; |
| return; |
| } |
| |
| let cur = 0; |
| |
| function renderSegment(i){ |
| const seg = SEGMENTS[i]; |
| area.innerHTML = ` |
| <div class="shot-wrap" id="shotWrap"> |
| <img id="shotImg" src="${seg.screenshot}"> |
| <canvas id="shotCanvas"></canvas> |
| </div> |
| <div class="shot-nav"> |
| <button id="prevBtn" ${i===0?'disabled':''}>← Earlier</button> |
| <span>Scroll segment ${i+1} of ${SEGMENTS.length} · ~${seg.duration}s of attention here</span> |
| <button id="nextBtn" ${i===SEGMENTS.length-1?'disabled':''}>Later →</button> |
| </div> |
| `; |
| |
| const img = document.getElementById('shotImg'); |
| const cv = document.getElementById('shotCanvas'); |
| const ctx = cv.getContext('2d'); |
| |
| function paint(){ |
| cv.width = img.naturalWidth; |
| cv.height = img.naturalHeight; |
| |
| const off = document.createElement('canvas'); |
| off.width = cv.width; off.height = cv.height; |
| const octx = off.getContext('2d'); |
| |
| seg.points.forEach(p => { |
| const grad = octx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, 55); |
| grad.addColorStop(0, 'rgba(255,255,255,0.09)'); |
| grad.addColorStop(1, 'rgba(255,255,255,0)'); |
| octx.fillStyle = grad; |
| octx.beginPath(); |
| octx.arc(p.sx, p.sy, 55, 0, 2*Math.PI); |
| octx.fill(); |
| }); |
| |
| const idata = octx.getImageData(0, 0, off.width, off.height); |
| const d = idata.data; |
| for (let k = 0; k < d.length; k += 4){ |
| const a = d[k+3] / 255; |
| if (a <= 0) continue; |
| const t = Math.min(1, a * 3.0); |
| const [r,g,b] = jetColor(t); |
| d[k] = r; d[k+1] = g; d[k+2] = b; d[k+3] = Math.min(235, a*520); |
| } |
| octx.putImageData(idata, 0, 0); |
| ctx.clearRect(0,0,cv.width,cv.height); |
| ctx.drawImage(off, 0, 0); |
| } |
| |
| if (img.complete) paint(); else img.onload = paint; |
| |
| document.getElementById('prevBtn').onclick = () => { if (cur>0){ cur--; renderSegment(cur); } }; |
| document.getElementById('nextBtn').onclick = () => { if (cur<SEGMENTS.length-1){ cur++; renderSegment(cur); } }; |
| } |
| |
| renderSegment(cur); |
| })(); |
| |
| // ---------- Timeline ---------- |
| (function(){ |
| const cv = document.getElementById('timeline'); |
| const ctx = cv.getContext('2d'); |
| ctx.fillStyle = '#100e16'; |
| ctx.fillRect(0, 0, cv.width, cv.height); |
| if (!TIMELINE.length || DURATION <= 0) { return; } |
| |
| const labels = [...new Set(TIMELINE.filter(s => s.label).map(s => s.label))]; |
| const rowH = Math.min(28, (cv.height - 40) / Math.max(labels.length, 1)); |
| const leftPad = 220, topPad = 10, plotW = cv.width - leftPad - 20; |
| |
| ctx.font = '11px -apple-system, Arial'; |
| ctx.fillStyle = '#9a92ad'; |
| labels.forEach((lab, i) => { |
| const y = topPad + i * rowH; |
| ctx.fillText(lab.length > 28 ? lab.slice(0,28)+'…' : lab, 4, y + rowH*0.65); |
| }); |
| |
| const colors = ['#7B2FBE', '#9B59FF', '#FF2DF0', '#5EC8D8', '#F0A868', '#7BE0A0']; |
| TIMELINE.forEach(seg => { |
| if (!seg.label) return; |
| const rowIdx = labels.indexOf(seg.label); |
| const x = leftPad + (seg.start / DURATION) * plotW; |
| const w = Math.max(2, ((seg.end - seg.start) / DURATION) * plotW); |
| const y = topPad + rowIdx * rowH + 3; |
| ctx.fillStyle = colors[rowIdx % colors.length]; |
| ctx.fillRect(x, y, w, rowH - 6); |
| }); |
| |
| ctx.strokeStyle = '#302a3d'; |
| ctx.beginPath(); |
| ctx.moveTo(leftPad, cv.height - 18); |
| ctx.lineTo(cv.width - 10, cv.height - 18); |
| ctx.stroke(); |
| ctx.fillStyle = '#9a92ad'; |
| [0, 0.25, 0.5, 0.75, 1].forEach(f => { |
| const x = leftPad + f * plotW; |
| ctx.fillText((f * DURATION).toFixed(1) + 's', x, cv.height - 4); |
| }); |
| })(); |
| </script> |
| </body> |
| </html> |
| """ |
|
|
|
|
| def generate_report(session_dir): |
| gaze, dom = load_session(session_dir) |
| attributed = attribute_gaze(gaze, dom) |
|
|
| ranking = compute_dwell_ranking(attributed) |
| timeline = compute_timeline(attributed) |
| segments = build_screenshot_segments(gaze, dom) |
| summary = session_summary(gaze, dom) |
| mouse = summarize_mouse(session_dir) |
|
|
| top_label = ranking[0]["label"] if ranking else "—" |
|
|
| html = _TEMPLATE |
| html = html.replace("__URL__", summary["url"] or "Unknown page") |
| html = html.replace("__SAMPLES__", str(summary["samples"])) |
| html = html.replace("__DURATION__", str(summary["duration"])) |
| html = html.replace("__NUM_ELEMENTS__", str(len(ranking))) |
| html = html.replace("__TOP_LABEL__", top_label) |
| html = html.replace("__RANKING_JSON__", json.dumps(ranking)) |
| html = html.replace("__TIMELINE_JSON__", json.dumps(timeline)) |
| html = html.replace("__SEGMENTS_JSON__", json.dumps(segments)) |
| html = html.replace("__DURATION_JSON__", json.dumps(summary["duration"])) |
| html = html.replace("__MOUSE_INTERESTS_JSON__", json.dumps(mouse["interests"])) |
| html = html.replace("__MOUSE_CLICKS_JSON__", json.dumps(mouse["clicks"])) |
|
|
| out_path = os.path.join(session_dir, "analysis_report.html") |
| with open(out_path, "w", encoding="utf-8") as f: |
| f.write(html) |
|
|
| return os.path.abspath(out_path) |
|
|
|
|
| if __name__ == "__main__": |
| import sys |
| session_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.join("sessions", "live") |
| path = generate_report(session_dir) |
| print(f"Report written to: {path}") |