| """ |
| 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 |
|
|
| |
| |
| |
| MAX_ATTENTION_ITEMS = 20 |
|
|
|
|
| |
| |
| |
|
|
| 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"] |
| )[: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, |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| <iframe> identically regardless of what's actually inside it or how |
| much of the page it covers — it has no visibility into the iframe's |
| own content. A single page-spanning iframe (a PDF viewer, an embedded |
| system, a video player) then swallows every gaze sample that lands on |
| it under one generic "Embedded content" bucket, drowning out anything |
| more specific and making Ranked Attention/Most Attended report |
| something that isn't a real, distinguishable attention target. Points |
| over an iframe just go unlabeled instead (matching what already |
| happens over any other untracked area) — the raw gaze samples |
| themselves, and the per-segment heatmap that draws them, are |
| unaffected either way.""" |
| best = None |
| best_area = None |
| for a in aois: |
| if a["label"] == "embed": |
| continue |
| 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 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 compute_scroll_depth(dom): |
| """Max % of page height reached, derived from the scrollY/page/viewport |
| fields already present in every dom snapshot (insightuxAOIs() in |
| TRACKING_JS) — no new data collection, just a derived metric.""" |
| best = 0.0 |
| for d in dom: |
| page_h = (d.get("page") or {}).get("h") or 0 |
| viewport_h = (d.get("viewport") or {}).get("h") or 0 |
| scroll_y = d.get("scrollY", 0) |
| if page_h <= 0: |
| continue |
| pct = min(100.0, 100.0 * (scroll_y + viewport_h) / page_h) |
| if pct > best: |
| best = pct |
| return round(best) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def _screenshot_data_uri(session_dir, rel_path): |
| """Reads an already-captured screenshot PNG and returns it as a data: |
| URI. The packaged app's webview treats a file:// path as its own |
| origin, so drawing a plain <img src="screenshots/...png"> onto a |
| canvas silently taints that canvas for readback — canvas.toDataURL() |
| then throws, which is exactly what broke the segment download and the |
| full-page viewer/export in the real app (plain <img> display itself |
| is unaffected, which is why the screenshots still showed up fine). |
| Embedding the bytes directly sidesteps the origin check entirely: no |
| new capture, just how an already-recorded PNG is packaged into the |
| report.""" |
| try: |
| with open(os.path.join(session_dir, rel_path), "rb") as f: |
| data = f.read() |
| except OSError: |
| return None |
| return "data:image/png;base64," + base64.b64encode(data).decode("ascii") |
|
|
|
|
| def build_screenshot_segments(gaze, dom, session_dir): |
| 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 |
| data_uri = _screenshot_data_uri(session_dir, ev["screenshot"]) |
| if not data_uri: |
| continue |
| viewport = ev.get("viewport") or {} |
| segments.append({ |
| "screenshot": ev["screenshot"], |
| "screenshotData": data_uri, |
| "scrollY": ev.get("scrollY", 0), |
| "viewportW": viewport.get("w"), |
| "viewportH": viewport.get("h"), |
| "points": pts, |
| "stickyRects": ev.get("stickyRects") or [], |
| "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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def build_mouse_fullpage_points(session_dir): |
| """Every mouse trail/heatmap/click point recorded this session, in the |
| same page-absolute CSS-pixel space MOUSE_JS already samples them in |
| (clientX/clientY + scrollX/scrollY) — no per-segment bucketing needed, |
| since page-absolute coordinates don't depend on which screenshot was on |
| screen when the point was captured.""" |
| points = [] |
| for b in load_mouse_batches(session_dir): |
| for p in (b.get("trail") or []): |
| points.append({"x": round(p["x"], 1), "y": round(p["y"], 1)}) |
| for p in (b.get("heatmap") or []): |
| points.append({"x": round(p["x"], 1), "y": round(p["y"], 1)}) |
| for c in (b.get("click") or []): |
| points.append({"x": round(c["x"], 1), "y": round(c["y"], 1), "w": 5}) |
| return points |
|
|
|
|
| def full_page_dims(dom): |
| """Largest page width/height seen across every dom snapshot (CSS px) — |
| lets the client size its stitched canvas to the true full page even if |
| the last screenshot captured doesn't reach the page bottom.""" |
| w = max(((d.get("page") or {}).get("w") or 0) for d in dom) if dom else 0 |
| h = max(((d.get("page") or {}).get("h") or 0) for d in dom) if dom else 0 |
| return {"w": w, "h": h} |
|
|
|
|
| |
| |
| |
|
|
| _TEMPLATE = r"""<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="insightux-report" content="true"> |
| <title>InsightUX Session Report</title> |
| <style> |
| __THEME_CSS__ |
| * { box-sizing: border-box; } |
| body { |
| margin: 0; padding: 28px 32px 48px; background: var(--iux-bg); color: var(--iux-text); |
| font-family: var(--iux-font); transition: background .3s var(--iux-ease), color .3s var(--iux-ease); |
| } |
| .topbar { display: flex; align-items: center; gap: 14px; margin-bottom: 22px; } |
| .topbar .brand { display: flex; align-items: center; gap: 10px; } |
| .topbar .mark { |
| width: 34px; height: 34px; border-radius: 10px; background: var(--iux-accent-grad); |
| display: flex; align-items: center; justify-content: center; color: var(--iux-on-accent); flex-shrink: 0; |
| } |
| h1 { margin: 0; font-size: 19px; font-weight: 700; } |
| .sub { color: var(--iux-text-dim); font-size: 12.5px; margin-top: 2px; } |
| .topbar .actions { margin-left: auto; display: flex; gap: 8px; } |
| .topbar .actions .iux-btn { padding: 8px 13px; font-size: 12px; } |
| |
| .stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 22px; } |
| .stat { |
| padding: 16px 18px; position: relative; overflow: hidden; |
| } |
| .stat .icon-badge { |
| width: 30px; height: 30px; border-radius: 9px; display: flex; align-items: center; justify-content: center; |
| background: var(--iux-accent-grad); color: var(--iux-on-accent); margin-bottom: 10px; |
| } |
| .stat .v { font-size: 21px; font-weight: 700; color: var(--iux-text); } |
| .stat .l { font-size: 10.5px; color: var(--iux-text-faint); text-transform: uppercase; letter-spacing: .05em; margin-top: 2px; } |
| .stat::after { |
| content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 3px; |
| background: var(--iux-accent-grad); opacity: .7; |
| } |
| |
| .grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 18px; margin-bottom: 18px; } |
| .panel { padding: 20px; } |
| .panel h2 { |
| margin: 0 0 14px 0; font-size: 12.5px; color: var(--iux-text-dim); font-weight: 700; |
| text-transform: uppercase; letter-spacing: 0.07em; display: flex; align-items: center; gap: 7px; |
| } |
| .panel h2 .n { margin-left: auto; color: var(--iux-text-faint); font-weight: 500; text-transform: none; letter-spacing: 0; } |
| .full { grid-column: 1 / -1; } |
| |
| table { width: 100%; border-collapse: collapse; font-size: 12.5px; } |
| th { text-align: left; color: var(--iux-text-faint); font-weight: 600; padding: 7px 8px; |
| border-bottom: 1px solid var(--iux-border); font-size: 11px; text-transform: uppercase; letter-spacing: .03em; } |
| td { padding: 9px 8px; border-bottom: 1px solid var(--iux-border); vertical-align: middle; } |
| tr.data-row { transition: background .12s ease; cursor: default; } |
| tr.data-row:hover { background: var(--iux-surface-hi); } |
| .row-el { display: flex; align-items: center; gap: 8px; } |
| .row-el .ri { color: var(--iux-primary-light); flex-shrink: 0; } |
| .bar-bg { background: var(--iux-surface-hi); border-radius: 4px; height: 5px; margin-top: 5px; overflow: hidden; } |
| .bar-fill { height: 100%; background: var(--iux-accent-grad); border-radius: 4px; transition: width .4s var(--iux-ease); } |
| |
| #rankingFilter { |
| width: 100%; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--iux-border); |
| background: var(--iux-surface-hi); color: var(--iux-text); font-size: 12px; outline: none; margin-bottom: 10px; |
| } |
| |
| .empty { color: var(--iux-text-faint); font-size: 12.5px; padding: 30px 10px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; } |
| .empty .ei { opacity: .5; } |
| .legend { font-size: 12px; color: var(--iux-text-dim); line-height: 1.7; } |
| .legend b { color: var(--iux-text); } |
| .legend-scale { display: flex; align-items: center; gap: 8px; margin: 10px 0; } |
| .legend-scale .bar { flex: 1; height: 9px; border-radius: 5px; |
| background: linear-gradient(90deg, #0000ff, #00e5ff, #00ff5a, #e8ff00, #ff2d00); } |
| |
| .hm-toolbar { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; flex-wrap: wrap; } |
| .hm-toolbar .seg { display: flex; gap: 4px; background: var(--iux-surface-hi); padding: 3px; border-radius: 999px; } |
| .hm-toolbar .seg button { |
| border: none; background: transparent; color: var(--iux-text-dim); padding: 6px 12px; border-radius: 999px; |
| font-size: 11.5px; cursor: pointer; font-family: var(--iux-font); |
| } |
| .hm-toolbar .seg button.on { background: var(--iux-accent-grad); color: var(--iux-on-accent); } |
| .hm-toolbar .slider-mini { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--iux-text-faint); } |
| .hm-toolbar .slider-mini input { accent-color: var(--iux-primary-light); } |
| .hm-toolbar .iux-btn { padding: 7px 10px; font-size: 11.5px; margin-left: auto; } |
| |
| .shot-wrap { position: relative; border-radius: var(--iux-radius); overflow: hidden; background: var(--iux-bg-alt); border: 1px solid var(--iux-border); cursor: zoom-in; } |
| .shot-wrap:hover { box-shadow: var(--iux-shadow-glow); } |
| .shot-wrap img, .shot-wrap canvas { display: block; width: 100%; height: auto; } |
| .shot-wrap canvas { position: absolute; top: 0; left: 0; } |
| .shot-caption { margin-top: 10px; font-size: 12px; color: var(--iux-text-dim); text-align: center; } |
| |
| .click-timeline { width: 100%; height: 60px; display: block; margin-top: 8px; } |
| |
| .hm-tab { display: flex; align-items: center; gap: 6px; } |
| |
| .elem-chart-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; flex-wrap: wrap; } |
| .elem-chart-heading { display: flex; align-items: center; gap: 12px; } |
| .elem-chart-heading .icon-badge { |
| width: 34px; height: 34px; border-radius: 10px; display: flex; align-items: center; justify-content: center; |
| background: var(--iux-accent-grad); color: var(--iux-on-accent); flex-shrink: 0; |
| } |
| .elem-chart-heading h2 { |
| margin: 0; font-size: 12.5px; color: var(--iux-text-dim); font-weight: 700; |
| text-transform: uppercase; letter-spacing: 0.07em; |
| } |
| .elem-chart-subtitle { font-size: 11.5px; color: var(--iux-text-faint); margin-top: 3px; } |
| .elem-chart-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| .elem-chart-controls .iux-btn { padding: 7px 12px; font-size: 12px; } |
| |
| .elem-view-select { position: relative; } |
| .elem-view-menu { |
| position: absolute; top: calc(100% + 6px); right: 0; z-index: 20; min-width: 168px; |
| background: var(--iux-surface-hi); border: 1px solid var(--iux-border); border-radius: var(--iux-radius-sm); |
| box-shadow: var(--iux-shadow); padding: 5px; display: none; flex-direction: column; gap: 2px; |
| } |
| .elem-view-menu.open { display: flex; } |
| .elem-view-opt { |
| display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 6px; border: none; |
| background: transparent; color: var(--iux-text-dim); font-size: 12px; font-family: var(--iux-font); |
| cursor: pointer; text-align: left; |
| } |
| .elem-view-opt:hover { background: var(--iux-surface); color: var(--iux-text); } |
| .elem-view-opt.on { background: var(--iux-accent-grad); color: var(--iux-on-accent); } |
| |
| .elem-chart-wrap { position: relative; } |
| .elem-chart-scroll { |
| overflow-x: auto; overflow-y: hidden; border-radius: var(--iux-radius-sm); |
| background: var(--iux-bg-alt); border: 1px solid var(--iux-border); |
| } |
| .elem-chart-scroll canvas { display: block; } |
| .elem-chart-hint { font-size: 10.5px; color: var(--iux-text-faint); margin-top: 6px; text-align: right; } |
| .elem-tooltip { |
| position: absolute; pointer-events: none; background: var(--iux-surface-hi); color: var(--iux-text); |
| border: 1px solid var(--iux-border); border-radius: 8px; padding: 9px 13px; font-size: 11.5px; |
| box-shadow: var(--iux-shadow); z-index: 10; line-height: 1.6; max-width: 260px; |
| } |
| .elem-tooltip .dim { color: var(--iux-text-faint); } |
| |
| .elem-summary-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 16px; } |
| .elem-summary-stat { |
| padding: 12px 14px; border-radius: var(--iux-radius-sm); background: var(--iux-bg-alt); |
| border: 1px solid var(--iux-border); display: flex; flex-direction: column; gap: 4px; |
| } |
| .elem-summary-stat .l { font-size: 10.5px; color: var(--iux-text-faint); text-transform: uppercase; letter-spacing: .04em; } |
| .elem-summary-stat .v { font-size: 16px; font-weight: 700; color: var(--iux-text); } |
| |
| .elem-fullscreen { |
| position: fixed; inset: 0; z-index: 2000000; display: none; flex-direction: column; |
| background: rgba(10,8,20,0.96); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); |
| } |
| .elem-fullscreen.open { display: flex; } |
| .elem-fullscreen-bar { |
| display: flex; align-items: center; gap: 14px; padding: 14px 20px; flex-wrap: wrap; |
| background: rgba(27,25,24,0.9); border-bottom: 1px solid rgba(255,255,255,0.1); flex-shrink: 0; |
| } |
| .elem-fullscreen-title { color: #f9f8f3; font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin-right: auto; } |
| .elem-fullscreen-controls-slot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| .elem-fullscreen-bar .iux-btn { color: #f9f8f3; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.14); } |
| .elem-fullscreen-bar .iux-btn:hover { background: rgba(255,255,255,0.18); } |
| .elem-fullscreen-bar .elem-view-menu { background: #332E2A; } |
| .elem-fullscreen-body { flex: 1 1 auto; padding: 24px; overflow: auto; } |
| .elem-fullscreen-body .elem-summary-row { max-width: 900px; } |
| |
| .back-nav { margin-bottom: 16px; padding: 9px 16px; font-size: 12.5px; } |
| |
| .iux-viewer { |
| position: fixed; inset: 0; z-index: 2000000; display: none; flex-direction: column; |
| background: rgba(14,12,10,0.92); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); |
| } |
| .iux-viewer.open { display: flex; } |
| .iv-topbar { |
| display: flex; align-items: center; gap: 10px; padding: 12px 18px; |
| background: rgba(27,25,24,0.9); border-bottom: 1px solid rgba(255,255,255,0.1); flex-shrink: 0; |
| } |
| .iv-topbar .iux-btn { color: #f9f8f3; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.14); } |
| .iv-topbar .iux-btn:hover { background: rgba(255,255,255,0.18); } |
| .iv-title { color: #f9f8f3; font-size: 13px; font-weight: 600; margin-right: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| .iv-zoom-pct { color: #FFE9A8; font-size: 12px; min-width: 44px; text-align: center; flex-shrink: 0; } |
| .iv-stage { flex: 1 1 auto; position: relative; overflow: hidden; cursor: grab; } |
| .iv-stage.grabbing { cursor: grabbing; } |
| #ivImage { position: absolute; top: 50%; left: 50%; max-width: none; user-select: none; -webkit-user-select: none; } |
| .iv-nav { |
| position: absolute; top: 50%; transform: translateY(-50%); z-index: 5; |
| width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; |
| color: #f9f8f3; background: rgba(27,25,24,0.65); border-color: rgba(255,255,255,0.16); |
| } |
| .iv-nav:hover { background: rgba(255,233,168,0.55); } |
| .iv-nav-prev { left: 16px; } |
| .iv-nav-next { right: 16px; } |
| .iv-nav[disabled] { display: none; } |
| .iv-bottombar { |
| display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 18px; |
| background: rgba(27,25,24,0.9); border-top: 1px solid rgba(255,255,255,0.1); flex-shrink: 0; flex-wrap: wrap; |
| } |
| .iv-bottombar .iux-btn { color: #f9f8f3; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.14); padding: 7px 12px; font-size: 12px; } |
| .iv-bottombar .iux-btn:hover { background: rgba(255,255,255,0.18); } |
| .iv-bottombar input[type=range] { accent-color: var(--iux-primary-light); width: 120px; } |
| |
| /* ========================================================================= |
| PRINT / PDF REPORT |
| A self-contained set of fixed-size "pages" built by buildPrintReport() |
| (see the inline <script> at the bottom of this document) from the exact |
| same RANKING/SEGMENTS/MOUSE_* data the on-screen dashboard uses — never |
| a separate data source. Kept visually hidden (off-screen, not |
| display:none, so it can still be measured/laid out for pagination) on |
| screen, and swapped in for everything else only under @media print. |
| Colors are hardcoded to the dark palette here (not var(--iux-*)) so the |
| exported report always matches the intended report design regardless of |
| which theme is active on screen at export time. ========================= */ |
| #printReport { |
| position: absolute; left: -10000px; top: 0; width: 794px; |
| font-family: var(--iux-font); |
| } |
| @page { size: A4; margin: 0; } |
| @media print { |
| html, body { background: #1B1918 !important; } |
| body > .back-nav, body > .topbar, body > .stat-row, body > .grid, |
| #iuxViewer, #elemFullscreen, #iuxReportToast { display: none !important; } |
| #printReport { position: static !important; left: auto !important; width: auto !important; } |
| } |
| |
| .print-page { |
| position: relative; width: 794px; height: 1123px; |
| background: #1B1918; color: #F9F8F3; |
| box-sizing: border-box; overflow: hidden; |
| break-after: page; page-break-after: always; |
| font-family: var(--iux-font); |
| } |
| .print-page:last-child { break-after: auto; page-break-after: auto; } |
| |
| .pp-accent { position: absolute; top: 0; left: 0; right: 0; height: 6px; |
| background: #FFE9A8; } |
| .pp-header { position: absolute; top: 16px; left: 46px; right: 46px; height: 20px; |
| display: flex; align-items: center; justify-content: space-between; } |
| .pp-header .pp-brand { font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; color: #F9F8F3; } |
| .pp-header .pp-tag { font-size: 9px; font-weight: 600; letter-spacing: 0.12em; color: #8E8E92; text-transform: uppercase; } |
| .pp-body { position: absolute; top: 46px; left: 46px; right: 46px; bottom: 52px; overflow: hidden; } |
| .pp-footer { position: absolute; bottom: 0; left: 46px; right: 46px; height: 40px; |
| display: flex; align-items: center; justify-content: space-between; |
| border-top: 1px solid rgba(198,188,178,0.16); font-size: 8.5px; color: #8E8E92; } |
| |
| .pp-cover-title { font-size: 25px; font-weight: 700; color: #F9F8F3; margin: 8px 0 4px; } |
| .pp-cover-meta { font-size: 10.5px; color: #C6BCB2; margin-bottom: 20px; } |
| |
| .pp-metric-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 18px; } |
| .pp-metric { background: #292522; border: 1px solid rgba(198,188,178,0.16); border-radius: 12px; padding: 12px 14px; break-inside: avoid; } |
| .pp-metric .pp-m-label { font-size: 8.5px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: #8E8E92; margin-bottom: 6px; } |
| .pp-metric .pp-m-value { font-size: 19px; font-weight: 700; color: #F9F8F3; } |
| .pp-metric .pp-m-sub { font-size: 8.5px; color: #8E8E92; margin-top: 2px; } |
| |
| .pp-card { background: #292522; border: 1px solid rgba(198,188,178,0.16); border-radius: 12px; padding: 16px 18px; margin-bottom: 14px; break-inside: avoid; } |
| .pp-section-title { font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #F9F8F3; margin: 0 0 10px; } |
| .pp-section-sub { font-size: 10px; color: #C6BCB2; margin: -6px 0 12px; line-height: 1.5; } |
| .pp-body-text { font-size: 10.5px; color: #D6CFC5; line-height: 1.65; } |
| .pp-body-text b { color: #F9F8F3; } |
| .pp-two-col { display: grid; grid-template-columns: 1.35fr 1fr; gap: 14px; } |
| |
| .pp-table { width: 100%; border-collapse: collapse; font-size: 10px; } |
| .pp-table th { text-align: left; color: #8E8E92; font-weight: 700; padding: 6px 8px; font-size: 8.5px; |
| text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid rgba(198,188,178,0.25); } |
| .pp-table td { padding: 7px 8px; border-bottom: 1px solid rgba(198,188,178,0.12); vertical-align: middle; } |
| .pp-table tr:nth-child(even) td { background: rgba(198,188,178,0.05); } |
| .pp-table .pp-num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } |
| .pp-table .pp-rank { color: #8E8E92; width: 22px; } |
| .pp-table .pp-el-name { word-break: break-word; } |
| |
| .pp-snap-item { margin-bottom: 12px; } |
| .pp-snap-item .pp-snap-label { font-size: 8.5px; text-transform: uppercase; letter-spacing: 0.05em; color: #8E8E92; margin-bottom: 3px; } |
| .pp-snap-item .pp-snap-value { font-size: 13px; font-weight: 700; color: #F9F8F3; } |
| |
| .pp-shot-frame { width: 100%; border-radius: 10px; overflow: hidden; border: 1px solid rgba(198,188,178,0.2); background: #221F1D; } |
| .pp-shot-frame img { display: block; width: 100%; height: auto; } |
| .pp-shot-label { font-size: 9.5px; color: #C6BCB2; margin-bottom: 8px; display: flex; justify-content: space-between; } |
| .pp-shot-row { display: grid; gap: 12px; margin-bottom: 14px; break-inside: avoid; } |
| .pp-shot-row.pp-cols-1 { grid-template-columns: 1fr; } |
| .pp-shot-row.pp-cols-2 { grid-template-columns: 1fr 1fr; } |
| |
| .pp-chart-caption { font-size: 9px; color: #8E8E92; margin-top: 8px; text-align: center; } |
| |
| .pp-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } |
| .pp-detail-card { background: #221F1D; border: 1px solid rgba(198,188,178,0.14); border-radius: 10px; padding: 10px 12px; break-inside: avoid; } |
| .pp-detail-card .pp-d-name { font-size: 10.5px; font-weight: 600; color: #F9F8F3; margin-bottom: 6px; } |
| .pp-detail-card .pp-d-row { display: flex; justify-content: space-between; font-size: 9px; color: #C6BCB2; padding: 2px 0; } |
| .pp-detail-card .pp-d-row b { color: #F9F8F3; font-weight: 600; } |
| |
| .pp-click-item { border-bottom: 1px solid rgba(198,188,178,0.12); padding: 7px 0; font-size: 10px; break-inside: avoid; } |
| .pp-click-item .pp-click-time { font-size: 8.5px; color: #8E8E92; } |
| .pp-click-item .pp-click-el { font-weight: 600; color: #F9F8F3; } |
| .pp-click-item .pp-click-text { color: #C6BCB2; font-size: 9.5px; } |
| |
| .pp-insight-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } |
| .pp-insight-card { background: #292522; border: 1px solid rgba(198,188,178,0.16); border-radius: 12px; padding: 16px; break-inside: avoid; } |
| .pp-insight-card .pp-i-label { font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; color: #8E8E92; margin-bottom: 6px; } |
| .pp-insight-card .pp-i-value { font-size: 17px; font-weight: 700; color: #F9F8F3; } |
| |
| .pp-empty { font-size: 10.5px; color: #8E8E92; padding: 10px 0; } |
| </style> |
| </head> |
| <body> |
| |
| <button type="button" class="iux-btn back-nav iux-glass" id="backNav" title="Return to the browser without restarting tracking">__BACK_ICON__ Back to Browser</button> |
| |
| <div class="topbar iux-fade-in"> |
| <div class="brand"> |
| <div class="mark">__EYE_ICON__</div> |
| <div> |
| <h1>InsightUX Session Report</h1> |
| <div class="sub">__SUBJECT_LABEL____URL__ · __SESSION_TIMESTAMP__ · __DURATION__s · __SAMPLES__ gaze samples</div> |
| </div> |
| </div> |
| <div class="actions"> |
| <button class="iux-btn primary" id="btnExport" title="Export / print">__EXPORT_ICON__ Export</button> |
| <button class="iux-btn" id="themeToggle" title="Toggle theme" style="width:36px;"></button> |
| </div> |
| </div> |
| |
| <div class="stat-row iux-fade-in"> |
| <div class="stat iux-card hoverable"><div class="icon-badge">__CLOCK_ICON__</div><div class="v">__DURATION__s</div><div class="l">Session Length</div></div> |
| <div class="stat iux-card hoverable"><div class="icon-badge">__LAYERS_ICON__</div><div class="v">__NUM_ELEMENTS__</div><div class="l">Elements Fixated</div></div> |
| <div class="stat iux-card hoverable"><div class="icon-badge">__TARGET_ICON__</div><div class="v" style="font-size:14px;">__TOP_LABEL__</div><div class="l">Most Attended</div></div> |
| <div class="stat iux-card hoverable"><div class="icon-badge">__CURSOR_ICON__</div><div class="v">__CLICK_COUNT__</div><div class="l">Mouse Events</div></div> |
| </div> |
| |
| <div class="grid"> |
| <div class="panel iux-card iux-fade-in"> |
| <h2>__TYPE_ICON__ Ranked Attention <span class="n" id="rankCount"></span></h2> |
| <input type="text" id="rankingFilter" placeholder="Filter by element name..."> |
| <div id="rankingTable"></div> |
| </div> |
| <div class="panel iux-card iux-fade-in"> |
| <h2>__INFO_ICON__ 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>Attention Heatmaps</b> — drawn directly on a screenshot of the page |
| as it appeared during the session. Switch between the Eye, Mouse, and |
| Combined tabs to see gaze attention, cursor activity, or both together. |
| 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>This is the entire page stitched into one continuous image from |
| every screenshot captured during the session, so you can see attention |
| across the whole layout at a glance instead of one scroll position at |
| a time. Use the maximize/download buttons above to view or save it.</p> |
| </div> |
| </div> |
| <div class="panel iux-card full iux-fade-in"> |
| <h2>__LAYERS_ICON__ Attention Heatmaps</h2> |
| <div class="hm-toolbar" id="hmToolbar" style="display:none;"> |
| <div class="seg" id="hmTabs"> |
| <button class="hm-tab on" data-mode="eye">__EYE_ICON_SM__ Eye Gaze</button> |
| <button class="hm-tab" data-mode="mouse">__CURSOR_ICON_SM__ Mouse</button> |
| <button class="hm-tab" data-mode="combined">__LAYERS_ICON_SM__ Combined</button> |
| </div> |
| <div class="slider-mini">Opacity <input type="range" id="hmOpacity" min="30" max="100" value="90"></div> |
| <div class="slider-mini">Intensity <input type="range" id="hmIntensity" min="1" max="10" value="3"></div> |
| <button class="iux-btn" id="hmFullscreen">__MAXIMIZE_ICON__</button> |
| <button class="iux-btn" id="hmDownload">__DOWNLOAD_ICON__</button> |
| </div> |
| <div id="heatmapArea"></div> |
| </div> |
| <div class="panel iux-card full iux-fade-in" id="elemChartPanel"> |
| <div class="elem-chart-header" id="elemChartHeader"> |
| <div class="elem-chart-heading"> |
| <div class="icon-badge">__BARCHART_ICON__</div> |
| <div> |
| <h2>Element Attention Analysis</h2> |
| <div class="elem-chart-subtitle">Time spent (in seconds) on each element during the session</div> |
| </div> |
| </div> |
| <div class="elem-chart-controls" id="elemChartControls"> |
| <div class="elem-view-select" id="elemViewSelect"> |
| <button type="button" class="iux-btn" id="elemViewBtn">__TRENDING_ICON__ <span id="elemViewLabel">Line Chart</span> __CHEVRON_ICON__</button> |
| <div class="elem-view-menu" id="elemViewMenu"> |
| <button type="button" class="elem-view-opt on" data-view="line">__TRENDING_ICON__ Line Chart</button> |
| <button type="button" class="elem-view-opt" data-view="column">__BARCHART_ICON__ Column Chart</button> |
| </div> |
| </div> |
| <button class="iux-btn" id="elemExportBtn" title="Download chart as image">__DOWNLOAD_ICON__ Export</button> |
| <button class="iux-btn" id="elemFullscreenBtn" title="Fullscreen">__MAXIMIZE_ICON__</button> |
| </div> |
| </div> |
| <div id="elemChartBody"> |
| <div class="elem-chart-wrap" id="elemChartWrap"> |
| <div class="elem-chart-scroll" id="elemChartScroll"> |
| <canvas id="elemChart"></canvas> |
| </div> |
| <div class="elem-tooltip" id="elemTooltip" style="display:none;"></div> |
| </div> |
| <div class="elem-chart-hint" id="elemChartHint" style="display:none;">Scroll to see every element →</div> |
| <div class="elem-summary-row" id="elemSummaryRow"></div> |
| </div> |
| </div> |
| |
| <div id="elemFullscreen" class="elem-fullscreen" aria-hidden="true"> |
| <div class="elem-fullscreen-bar" id="elemFullscreenBar"> |
| <div class="elem-fullscreen-title">__BARCHART_ICON__ Element Attention Analysis</div> |
| </div> |
| <div class="elem-fullscreen-body" id="elemFullscreenBody"></div> |
| </div> |
| <div class="panel iux-card iux-fade-in"> |
| <h2>__CURSOR_ICON__ Mouse — Most Interacted Elements</h2> |
| <div id="mouseInterests"></div> |
| </div> |
| <div class="panel iux-card iux-fade-in"> |
| <h2>__CLICK_ICON__ Mouse — Click Timeline & Log</h2> |
| <canvas class="click-timeline" id="clickTimeline" width="560" height="60"></canvas> |
| <div id="mouseClicks" style="max-height:220px;overflow-y:auto;margin-top:10px;"></div> |
| </div> |
| </div> |
| |
| <div id="iuxViewer" class="iux-viewer" aria-hidden="true"> |
| <div class="iv-topbar"> |
| <button type="button" class="iux-btn" id="ivBack" title="Back">__BACK_ICON__ Back</button> |
| <div class="iv-title" id="ivTitle"></div> |
| <div class="iv-zoom-pct" id="ivZoomPct">100%</div> |
| <button type="button" class="iux-btn" id="ivFullscreen" title="Toggle fullscreen">__MAXIMIZE_ICON__</button> |
| <button type="button" class="iux-btn" id="ivDownload" title="Download">__DOWNLOAD_ICON__</button> |
| <button type="button" class="iux-btn" id="ivClose" title="Close (Esc)">__CLOSE_ICON__</button> |
| </div> |
| <div class="iv-stage" id="ivStage"> |
| <button type="button" class="iux-btn iv-nav iv-nav-prev" id="ivPrev" title="Previous screenshot">__CHEVRON_LEFT_ICON__</button> |
| <img id="ivImage" draggable="false" alt=""> |
| <button type="button" class="iux-btn iv-nav iv-nav-next" id="ivNext" title="Next screenshot">__CHEVRON_RIGHT_ICON__</button> |
| </div> |
| <div class="iv-bottombar"> |
| <button type="button" class="iux-btn" id="ivZoomOut" title="Zoom out">−</button> |
| <input type="range" id="ivZoomSlider" min="10" max="400" value="100"> |
| <button type="button" class="iux-btn" id="ivZoomIn" title="Zoom in">+</button> |
| <button type="button" class="iux-btn" id="ivReset" title="Reset zoom">Reset</button> |
| <button type="button" class="iux-btn" id="ivFitWidth" title="Fit width">Fit Width</button> |
| <button type="button" class="iux-btn" id="ivFitScreen" title="Fit screen">Fit Screen</button> |
| <button type="button" class="iux-btn" id="ivActual" title="Actual size">100%</button> |
| </div> |
| </div> |
| |
| <div id="printReport"></div> |
| |
| <script> |
| __ICONS_JS__ |
| __THEME_JS__ |
| document.getElementById('themeToggle').innerHTML = iuxIcon(window.insightuxGetTheme() === 'light' ? 'moon' : 'sun', 16); |
| window.insightuxRepaintCanvases = []; |
| document.getElementById('themeToggle').addEventListener('click', function(){ |
| const next = window.insightuxToggleTheme(); |
| this.innerHTML = iuxIcon(next === 'light' ? 'moon' : 'sun', 16); |
| window.insightuxRepaintCanvases.forEach(function(fn){ try { fn(); } catch(e){} }); |
| }); |
| |
| const RANKING = __RANKING_JSON__; |
| const SEGMENTS = __SEGMENTS_JSON__; |
| const MOUSE_INTERESTS = __MOUSE_INTERESTS_JSON__; |
| const MOUSE_CLICKS = __MOUSE_CLICKS_JSON__; |
| const MOUSE_FULLPAGE = __MOUSE_FULLPAGE_JSON__; |
| const PAGE_DIMS = __PAGE_DIMS_JSON__; |
| const MAX_ATTENTION_ITEMS = __MAX_ATTENTION_ITEMS__; |
| const SESSION = { |
| url: __URL_JSON__, |
| timestamp: __SESSION_TIMESTAMP_JSON__, |
| duration: __DURATION__, |
| samples: __SAMPLES__, |
| clickCount: __CLICK_COUNT__ |
| }; |
| |
| document.getElementById('btnExport').addEventListener('click', function(){ |
| Promise.resolve(window.__insightuxPrintReady).then(function(){ window.print(); }); |
| }); |
| |
| // ---------- Back to Browser: the report was reached by a REAL navigation |
| // (Python's window.load_url() away from the tracked page, once the session |
| // ended) — that's already a normal entry in this webview's own history, one |
| // step behind this page. history.back() is therefore both correct AND the |
| // only option that doesn't add a new entry: jumping straight to SESSION_URL |
| // via location.href instead would push a *second*, duplicate visit to that |
| // same page, and the very next press of the toolbar's own Back button would |
| // then land on the report again (the exact "Back randomly opens the report" |
| // bug this replaces). history.back() is tried first for that reason; the |
| // recorded tracked-page URL / document.referrer are only a fallback, and |
| // only used if history.back() genuinely didn't navigate anywhere (checked |
| // via a short timeout) — e.g. the report was opened with no prior page in |
| // this window's history at all. Pure client-side navigation either way — |
| // never touches the Api or tracking state. ---------- |
| const SESSION_URL = __URL_JSON__; |
| document.getElementById('backNav').addEventListener('click', function(){ |
| function fallback(){ |
| if (SESSION_URL) window.location.href = SESSION_URL; |
| else if (document.referrer) window.location.href = document.referrer; |
| else showToast('No previous page to return to.', true); |
| } |
| if (window.history.length > 1) { |
| const before = window.location.href; |
| window.history.back(); |
| setTimeout(function(){ |
| if (window.location.href === before) fallback(); // back() had nowhere to go |
| }, 400); |
| } else { |
| fallback(); |
| } |
| }); |
| |
| // ---------- Shared image viewer: fullscreen heatmap segments and the |
| // full-session stitched exports both open through here instead of forcing |
| // an immediate download or a CSS-only "fullscreen" with no way out. ---------- |
| const Viewer = (function(){ |
| const el = document.getElementById('iuxViewer'); |
| const img = document.getElementById('ivImage'); |
| const stage = document.getElementById('ivStage'); |
| const titleEl = document.getElementById('ivTitle'); |
| const zoomPct = document.getElementById('ivZoomPct'); |
| const zoomSlider = document.getElementById('ivZoomSlider'); |
| const MIN_SCALE = 0.1, MAX_SCALE = 4; |
| let scale = 1, panX = 0, panY = 0; |
| let dragging = false, dragStartX = 0, dragStartY = 0, panStartX = 0, panStartY = 0; |
| let naturalW = 0, naturalH = 0; |
| let downloadName = 'insightux-image.png'; |
| const prevBtn = document.getElementById('ivPrev'); |
| const nextBtn = document.getElementById('ivNext'); |
| let onPrev = null, onNext = null; |
| |
| function applyTransform(){ |
| img.style.transform = 'translate(-50%, -50%) translate(' + panX + 'px,' + panY + 'px) scale(' + scale + ')'; |
| zoomPct.textContent = Math.round(scale * 100) + '%'; |
| zoomSlider.value = Math.round(scale * 100); |
| } |
| function setScale(next){ |
| scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, next)); |
| applyTransform(); |
| } |
| function stageSize(){ |
| const r = stage.getBoundingClientRect(); |
| return { w: r.width, h: r.height }; |
| } |
| function fitScreen(){ |
| if (!naturalW || !naturalH) return; |
| const s = stageSize(); |
| const next = Math.min((s.w - 32) / naturalW, (s.h - 32) / naturalH, MAX_SCALE); |
| scale = (isFinite(next) && next > 0) ? next : 1; |
| panX = 0; panY = 0; |
| applyTransform(); |
| } |
| function fitWidth(){ |
| if (!naturalW) return; |
| const s = stageSize(); |
| const next = Math.min((s.w - 32) / naturalW, MAX_SCALE); |
| scale = (isFinite(next) && next > 0) ? next : 1; |
| panX = 0; panY = 0; |
| applyTransform(); |
| } |
| function actualSize(){ scale = 1; panX = 0; panY = 0; applyTransform(); } |
| |
| function onKeydown(e){ |
| if (e.key === 'Escape') close(); |
| else if (e.key === 'ArrowLeft' && onPrev) onPrev(); |
| else if (e.key === 'ArrowRight' && onNext) onNext(); |
| } |
| |
| function open(opts){ |
| const source = opts.source; |
| let dataUrl; |
| try { |
| dataUrl = (typeof source === 'string') ? source : source.toDataURL('image/png'); |
| } catch (err) { |
| console.warn('[insightux-report] viewer could not read source canvas:', err); |
| return false; |
| } |
| titleEl.textContent = opts.title || ''; |
| downloadName = opts.downloadName || 'insightux-image.png'; |
| onPrev = opts.onPrev || null; |
| onNext = opts.onNext || null; |
| prevBtn.disabled = !onPrev; |
| nextBtn.disabled = !onNext; |
| img.src = dataUrl; |
| img.onload = function(){ |
| naturalW = img.naturalWidth; naturalH = img.naturalHeight; |
| fitScreen(); |
| }; |
| el.classList.add('open'); |
| el.setAttribute('aria-hidden', 'false'); |
| document.addEventListener('keydown', onKeydown, true); |
| return true; |
| } |
| function close(){ |
| if (document.fullscreenElement) { document.exitFullscreen().catch(function(){}); } |
| el.classList.remove('open'); |
| el.setAttribute('aria-hidden', 'true'); |
| document.removeEventListener('keydown', onKeydown, true); |
| } |
| |
| el.addEventListener('wheel', function(e){ |
| if (!el.classList.contains('open')) return; |
| e.preventDefault(); |
| setScale(scale + (e.deltaY > 0 ? -1 : 1) * 0.12 * scale); |
| }, { passive: false }); |
| |
| stage.addEventListener('mousedown', function(e){ |
| dragging = true; |
| dragStartX = e.clientX; dragStartY = e.clientY; |
| panStartX = panX; panStartY = panY; |
| stage.classList.add('grabbing'); |
| }); |
| window.addEventListener('mousemove', function(e){ |
| if (!dragging) return; |
| panX = panStartX + (e.clientX - dragStartX); |
| panY = panStartY + (e.clientY - dragStartY); |
| applyTransform(); |
| }); |
| window.addEventListener('mouseup', function(){ |
| dragging = false; |
| stage.classList.remove('grabbing'); |
| }); |
| stage.addEventListener('dblclick', function(){ |
| if (scale > 1.05) fitScreen(); else setScale(2); |
| }); |
| |
| document.getElementById('ivZoomIn').addEventListener('click', function(){ setScale(scale + 0.25); }); |
| document.getElementById('ivZoomOut').addEventListener('click', function(){ setScale(scale - 0.25); }); |
| document.getElementById('ivReset').addEventListener('click', actualSize); |
| document.getElementById('ivFitWidth').addEventListener('click', fitWidth); |
| document.getElementById('ivFitScreen').addEventListener('click', fitScreen); |
| document.getElementById('ivActual').addEventListener('click', actualSize); |
| zoomSlider.addEventListener('input', function(e){ setScale(e.target.value / 100); }); |
| document.getElementById('ivClose').addEventListener('click', close); |
| document.getElementById('ivBack').addEventListener('click', close); |
| prevBtn.addEventListener('click', function(){ if (onPrev) onPrev(); }); |
| nextBtn.addEventListener('click', function(){ if (onNext) onNext(); }); |
| document.getElementById('ivFullscreen').addEventListener('click', function(){ |
| if (!document.fullscreenElement) { el.requestFullscreen().catch(function(){}); } |
| else { document.exitFullscreen().catch(function(){}); } |
| }); |
| document.getElementById('ivDownload').addEventListener('click', function(){ |
| const a = document.createElement('a'); |
| a.href = img.src; |
| a.download = downloadName; |
| a.click(); |
| }); |
| el.addEventListener('click', function(e){ if (e.target === el) close(); }); |
| window.addEventListener('resize', function(){ if (el.classList.contains('open')) fitScreen(); }); |
| |
| return { open: open, close: close }; |
| })(); |
| |
| // ---------- icon inference from friendly_label() output ---------- |
| function iconForLabel(label){ |
| if (!label) return 'click'; |
| if (label.indexOf('Image') === 0) return 'image'; |
| if (label.indexOf('Video') === 0) return 'video'; |
| if (label.indexOf('heading') !== -1 || label.indexOf('Heading') === 0 || label.indexOf('Text') === 0) return 'type'; |
| if (label.indexOf('Navigation') === 0) return 'menu'; |
| if (label.indexOf('Page header') === 0 || label.indexOf('Page footer') === 0) return 'layers'; |
| return 'click'; |
| } |
| |
| // ---------- Mouse interests / clicks (from the in-page Mouse Tracker) ---------- |
| (function(){ |
| const el = document.getElementById('mouseInterests'); |
| if (!MOUSE_INTERESTS.length) { |
| el.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('cursor', 26) + '</span>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 class="data-row"><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"><span class="ei">' + iuxIcon('click', 26) + '</span>No mouse clicks were recorded during 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 var(--iux-border);padding-bottom:8px;"> |
| <span style="color:var(--iux-text-faint);font-size:11px;">${c.timestamp || ''}</span><br> |
| <b>${c.element || ''}</b><br> |
| <span style="color:var(--iux-text-dim);font-size:12px;">"${(c.text || '').replace(/</g,'<')}"</span> |
| </li>`; |
| }); |
| html += '</ul>'; |
| clickEl.innerHTML = html; |
| } |
| |
| // ---- click timeline mini-chart (derived from the same click timestamps) ---- |
| const cv = document.getElementById('clickTimeline'); |
| const ctx = cv.getContext('2d'); |
| function paintClickTimeline(){ |
| // CSS fixes the box at width:100%/height:60px, so clientWidth is stable |
| // post-layout — but re-measure on every call (resize/print/theme) rather |
| // than once, so the canvas never locks in a stale width. |
| cv.width = cv.clientWidth || 560; |
| cv.height = 60; |
| ctx.clearRect(0, 0, cv.width, cv.height); |
| if (MOUSE_CLICKS.length > 1) { |
| const times = MOUSE_CLICKS.map(c => { const p = (c.timestamp||'').split(':').map(Number); return (p[0]||0)*3600+(p[1]||0)*60+(p[2]||0); }); |
| const tmin = Math.min(...times), tmax = Math.max(...times) || tmin + 1; |
| ctx.strokeStyle = 'rgba(255,233,168,0.25)'; |
| ctx.beginPath(); ctx.moveTo(0, cv.height - 10); ctx.lineTo(cv.width, cv.height - 10); ctx.stroke(); |
| times.forEach(function(t){ |
| const x = ((t - tmin) / (tmax - tmin || 1)) * (cv.width - 12) + 6; |
| ctx.fillStyle = '#FFE9A8'; |
| ctx.beginPath(); ctx.arc(x, cv.height - 10, 4, 0, 2*Math.PI); ctx.fill(); |
| }); |
| } |
| } |
| paintClickTimeline(); |
| window.addEventListener('resize', paintClickTimeline, {passive:true}); |
| window.insightuxRepaintCanvases.push(paintClickTimeline); |
| })(); |
| |
| // ---------- Ranked table (with live filter) ---------- |
| (function(){ |
| const el = document.getElementById('rankingTable'); |
| const filterInput = document.getElementById('rankingFilter'); |
| document.getElementById('rankCount').textContent = RANKING.length ? RANKING.length + ' items' : ''; |
| if (!RANKING.length) { |
| filterInput.style.display = 'none'; |
| el.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('target', 26) + '</span>No elements were fixated long enough to register.</div>'; |
| return; |
| } |
| function render(filterText){ |
| const rows = RANKING.filter(r => !filterText || r.label.toLowerCase().indexOf(filterText.toLowerCase()) !== -1).slice(0, 25); |
| if (!rows.length) { el.innerHTML = '<div class="empty">No elements match "' + filterText + '".</div>'; return; } |
| let html = '<table><tr><th>#</th><th>Element</th><th>Dwell</th><th>Share</th><th>Hits</th></tr>'; |
| rows.forEach((r, i) => { |
| html += `<tr class="data-row"> |
| <td>${i+1}</td> |
| <td><div class="row-el"><span class="ri">${iuxIcon(iconForLabel(r.label), 15)}</span><span>${r.label}<div class="bar-bg"><div class="bar-fill" style="width:${r.pct}%"></div></div></span></div></td> |
| <td>${r.seconds}s</td> |
| <td>${r.pct}%</td> |
| <td>${r.hits}</td> |
| </tr>`; |
| }); |
| html += '</table>'; |
| el.innerHTML = html; |
| } |
| render(''); |
| filterInput.addEventListener('input', function(e){ render(e.target.value); }); |
| })(); |
| |
| // ---------- Heat colormaps: eye = classic blue->red, mouse = indigo->cyan->white ---------- |
| function rampColor(t, stops){ |
| t = Math.max(0, Math.min(1, t)); |
| 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]; |
| } |
| } |
| const last = stops[stops.length-1]; |
| return [last[1], last[2], last[3]]; |
| } |
| const EYE_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], |
| ]; |
| const MOUSE_STOPS = [ |
| [0.00, 201, 138, 46], |
| [0.35, 253, 217, 98], |
| [0.70, 255, 233, 168], |
| [1.00, 255, 255, 255], |
| ]; |
| const HEAT_RADIUS = 22; // was 55/60 — smaller, more precise fixation spots |
| |
| // ---------- Generic heat-layer painter: draws `points` ({sx,sy,w?}) onto ctx ---------- |
| function paintHeatLayer(ctx, w, h, points, intensity, stops, opacity, composite){ |
| if (!points || !points.length) return; |
| const off = document.createElement('canvas'); |
| off.width = w; off.height = h; |
| const octx = off.getContext('2d'); |
| points.forEach(function(p){ |
| const weight = p.w || 1; |
| for (let i = 0; i < weight; i++){ |
| const grad = octx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, HEAT_RADIUS); |
| grad.addColorStop(0, 'rgba(255,255,255,' + intensity + ')'); |
| grad.addColorStop(1, 'rgba(255,255,255,0)'); |
| octx.fillStyle = grad; |
| octx.beginPath(); |
| octx.arc(p.sx, p.sy, HEAT_RADIUS, 0, 2*Math.PI); |
| octx.fill(); |
| } |
| }); |
| const idata = octx.getImageData(0, 0, w, h); |
| 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] = rampColor(t, stops); |
| d[k] = r; d[k+1] = g; d[k+2] = b; d[k+3] = Math.min(235, a*520) * opacity; |
| } |
| octx.putImageData(idata, 0, 0); |
| const prevOp = ctx.globalCompositeOperation; |
| ctx.globalCompositeOperation = composite || 'source-over'; |
| ctx.drawImage(off, 0, 0); |
| ctx.globalCompositeOperation = prevOp; |
| } |
| |
| // Minimal toast so a failed export/viewer action is visible instead of |
| // silently doing nothing — reuses the .iux-toast style already shared with |
| // the toolbar/landing page design system. Pass onRetry to add a Retry |
| // action instead of the toast just auto-dismissing. |
| function showToast(message, isError, onRetry){ |
| let toast = document.getElementById('iuxReportToast'); |
| if (!toast) { |
| toast = document.createElement('div'); |
| toast.id = 'iuxReportToast'; |
| toast.className = 'iux-toast'; |
| toast.style.position = 'fixed'; |
| toast.style.bottom = '24px'; |
| toast.style.left = '50%'; |
| toast.style.transform = 'translateX(-50%)'; |
| toast.style.zIndex = '3000000'; |
| toast.style.display = 'none'; |
| toast.style.gap = '12px'; |
| document.body.appendChild(toast); |
| } |
| toast.innerHTML = '<span></span>'; |
| toast.querySelector('span').textContent = message; |
| if (onRetry) { |
| const retryBtn = document.createElement('button'); |
| retryBtn.type = 'button'; |
| retryBtn.className = 'iux-btn'; |
| retryBtn.style.padding = '4px 10px'; |
| retryBtn.style.fontSize = '11.5px'; |
| retryBtn.textContent = 'Retry'; |
| retryBtn.addEventListener('click', function(){ toast.style.display = 'none'; clearTimeout(toast._hideTimer); onRetry(); }); |
| toast.appendChild(retryBtn); |
| } |
| toast.style.borderColor = isError ? 'var(--iux-danger)' : 'var(--iux-border)'; |
| toast.style.color = isError ? 'var(--iux-danger)' : 'var(--iux-text)'; |
| toast.style.display = 'flex'; |
| toast.style.alignItems = 'center'; |
| clearTimeout(toast._hideTimer); |
| toast._hideTimer = setTimeout(function(){ toast.style.display = 'none'; }, onRetry ? 6000 : 3200); |
| } |
| |
| // ---------- Full-page stitch: combines every scroll-position screenshot |
| // into one continuous canvas, and every gaze/mouse point ever recorded into |
| // one flat point list in that same canvas's coordinate space. Shared by the |
| // live Attention Heatmaps panel and the print/export report so both show |
| // the identical whole-page picture — the result is cached (the screenshots |
| // never change after the report is built) so it's only ever stitched once. |
| // |
| // Mouse/DOM coordinates (scrollY, click/trail x-y) are recorded in CSS |
| // pixels by the tracked page itself; gaze sx/sy and the screenshots are |
| // physical screen pixels (pyautogui.screenshot()). Those only match 1:1 at |
| // 100% OS display scale — dprFromViewport() recovers the real ratio from |
| // the screenshot's actual decoded size vs. the CSS viewport width recorded |
| // alongside it, so every point lands on the spot it was actually at instead |
| // of being compressed toward the top-left on scaled displays. |
| // ---------- |
| function dprFromViewport(vw, img){ |
| if (!vw || !img || !img.naturalWidth) return 1; |
| const d = img.naturalWidth / vw; |
| return (isFinite(d) && d > 0) ? d : 1; |
| } |
| |
| let _fullPageStitchPromise = null; |
| function buildFullPageStitch(){ |
| if (_fullPageStitchPromise) return _fullPageStitchPromise; |
| _fullPageStitchPromise = new Promise(function(resolve, reject){ |
| if (!SEGMENTS.length){ reject(new Error('no screenshots to stitch')); return; } |
| const probe = new Image(); |
| probe.onload = function(){ |
| const dpr = dprFromViewport(SEGMENTS[0].viewportW, probe); |
| let canvasH = Math.round((PAGE_DIMS.h || 0) * dpr); |
| SEGMENTS.forEach(function(s){ |
| canvasH = Math.max(canvasH, Math.round(s.scrollY * dpr) + probe.naturalHeight); |
| }); |
| const canvasW = probe.naturalWidth; |
| |
| // Load every segment's screenshot, then paste each at its recorded |
| // scrollY (converted to canvas px) — in capture order, so the most |
| // recently-seen state of any overlapping band wins, same as any |
| // scroll-and-stitch full-page capture tool. |
| Promise.all(SEGMENTS.map(function(s){ |
| return new Promise(function(res){ |
| const im = new Image(); |
| im.onload = function(){ res({ im: im, scrollY: s.scrollY, stickyRects: s.stickyRects || [] }); }; |
| im.onerror = function(){ res(null); }; |
| im.src = s.screenshotData; |
| }); |
| })).then(function(loaded){ |
| const bg = document.createElement('canvas'); |
| bg.width = canvasW; |
| bg.height = canvasH; |
| const bgCtx = bg.getContext('2d'); |
| loaded.filter(Boolean).forEach(function(item, i){ |
| const y = Math.round(item.scrollY * dpr); |
| if (i === 0 || !item.stickyRects.length){ |
| bgCtx.drawImage(item.im, 0, y); |
| return; |
| } |
| // Elements with position:fixed/sticky (a floating nav tab, a |
| // persistent CTA) show up at the same on-screen spot in every |
| // screenshot regardless of scroll depth — segment 0 already drew |
| // it once. Clipping it out of every later, overlapping paste (a |
| // rect-with-a-hole clip path, even-odd fill rule) stops each one |
| // from stamping another copy on top, which is what produced the |
| // "duplicated"/ghosted look a naive scroll-and-stitch gets on |
| // pages with fixed UI. |
| bgCtx.save(); |
| bgCtx.beginPath(); |
| bgCtx.rect(0, y, canvasW, item.im.naturalHeight); |
| item.stickyRects.forEach(function(r){ |
| bgCtx.rect(r.x * dpr, y + r.y * dpr, r.w * dpr, r.h * dpr); |
| }); |
| bgCtx.clip('evenodd'); |
| bgCtx.drawImage(item.im, 0, y); |
| bgCtx.restore(); |
| }); |
| |
| const gazePoints = []; |
| SEGMENTS.forEach(function(s){ |
| const offY = Math.round(s.scrollY * dpr); |
| (s.points || []).forEach(function(p){ gazePoints.push({ sx: p.sx, sy: p.sy + offY }); }); |
| }); |
| const mousePoints = MOUSE_FULLPAGE.map(function(p){ |
| return { sx: p.x * dpr, sy: p.y * dpr, w: p.w }; |
| }); |
| |
| resolve({ canvas: bg, width: canvasW, height: canvasH, dpr: dpr, gazePoints: gazePoints, mousePoints: mousePoints }); |
| }); |
| }; |
| probe.onerror = function(){ reject(new Error('could not load a screenshot to stitch')); }; |
| probe.src = SEGMENTS[0].screenshotData; |
| }); |
| return _fullPageStitchPromise; |
| } |
| |
| // ---------- Attention Heatmaps panel: Eye / Mouse / Combined tabs over one |
| // full-page stitched image (built by buildFullPageStitch() above) ---------- |
| (function(){ |
| const area = document.getElementById('heatmapArea'); |
| const toolbar = document.getElementById('hmToolbar'); |
| if (!SEGMENTS.length){ |
| area.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('layers', 26) + '</span>No page screenshots were captured for this session ' + |
| '(older session, or screen-capture failed) — nothing to overlay a heatmap on.</div>'; |
| return; |
| } |
| toolbar.style.display = 'flex'; |
| |
| let mode = 'eye'; // 'eye' | 'mouse' | 'combined' |
| const heatState = { |
| eye: { opacity: 0.9, intensity: 0.09 }, |
| mouse: { opacity: 0.9, intensity: 0.12 }, |
| combined: { opacity: 0.9, intensity: 0.10 }, |
| }; |
| |
| area.innerHTML = ` |
| <div class="shot-wrap" id="shotWrap" title="Click to open in viewer"> |
| <img id="shotImg" alt=""> |
| <canvas id="shotCanvas"></canvas> |
| </div> |
| <div class="shot-caption" id="shotCaption">Stitching the full page…</div> |
| `; |
| |
| const img = document.getElementById('shotImg'); |
| const cv = document.getElementById('shotCanvas'); |
| const ctx = cv.getContext('2d'); |
| document.getElementById('shotWrap').addEventListener('click', function(){ openFullPageViewer(); }); |
| |
| let stitch = null; |
| |
| function pointsFor(kind){ |
| if (!stitch) return []; |
| return kind === 'eye' ? stitch.gazePoints : stitch.mousePoints; |
| } |
| |
| function paintActive(){ |
| if (!stitch) return; |
| ctx.clearRect(0, 0, cv.width, cv.height); |
| const st = heatState[mode]; |
| if (mode === 'eye'){ |
| paintHeatLayer(ctx, cv.width, cv.height, pointsFor('eye'), st.intensity, EYE_STOPS, st.opacity); |
| } else if (mode === 'mouse'){ |
| paintHeatLayer(ctx, cv.width, cv.height, pointsFor('mouse'), st.intensity, MOUSE_STOPS, st.opacity); |
| } else { |
| paintHeatLayer(ctx, cv.width, cv.height, pointsFor('eye'), st.intensity, EYE_STOPS, st.opacity); |
| paintHeatLayer(ctx, cv.width, cv.height, pointsFor('mouse'), st.intensity, MOUSE_STOPS, st.opacity, 'lighter'); |
| } |
| } |
| |
| document.querySelectorAll('.hm-tab').forEach(function(btn){ |
| btn.addEventListener('click', function(){ |
| mode = btn.dataset.mode; |
| document.querySelectorAll('.hm-tab').forEach(b => b.classList.toggle('on', b === btn)); |
| document.getElementById('hmOpacity').value = Math.round(heatState[mode].opacity * 100); |
| document.getElementById('hmIntensity').value = Math.round(heatState[mode].intensity * 33); |
| paintActive(); |
| }); |
| }); |
| document.getElementById('hmOpacity').addEventListener('input', function(e){ |
| heatState[mode].opacity = e.target.value / 100; |
| paintActive(); |
| }); |
| document.getElementById('hmIntensity').addEventListener('input', function(e){ |
| heatState[mode].intensity = e.target.value / 33; |
| paintActive(); |
| }); |
| |
| // Flattens the visible full-page image + its heat overlay into ONE opaque |
| // canvas. The on-screen view layers a transparent <canvas> on top of an |
| // <img> purely via CSS positioning — exporting the heat canvas alone (as |
| // earlier code did) produces a mostly-transparent PNG that most viewers |
| // render as solid black. This is the single source both the fullscreen |
| // viewer and the direct download button use, so both always show the |
| // real composited picture. |
| function composeFullPageCanvas(){ |
| if (!stitch || !img.complete || !img.naturalWidth) return null; |
| const out = document.createElement('canvas'); |
| out.width = img.naturalWidth; |
| out.height = img.naturalHeight; |
| const octx = out.getContext('2d'); |
| octx.fillStyle = '#ffffff'; |
| octx.fillRect(0, 0, out.width, out.height); |
| octx.drawImage(img, 0, 0); |
| octx.drawImage(cv, 0, 0); |
| return out; |
| } |
| |
| function fullPageTitle(){ |
| return 'Full Page — ' + mode.charAt(0).toUpperCase() + mode.slice(1) + ' heatmap'; |
| } |
| function fullPageDownloadName(){ |
| return 'insightux-' + mode + '-heatmap-fullpage.png'; |
| } |
| |
| function openFullPageViewer(){ |
| const canvas = composeFullPageCanvas(); |
| if (!canvas) { |
| showToast('Could not prepare the full-page heatmap for viewing.', true, openFullPageViewer); |
| return; |
| } |
| Viewer.open({ |
| source: canvas, |
| title: fullPageTitle(), |
| downloadName: fullPageDownloadName(), |
| onPrev: null, |
| onNext: null, |
| }); |
| } |
| |
| document.getElementById('hmFullscreen').addEventListener('click', openFullPageViewer); |
| document.getElementById('hmDownload').addEventListener('click', function(){ |
| const canvas = composeFullPageCanvas(); |
| if (!canvas) { |
| showToast('Could not prepare the full-page heatmap for export.', true); |
| return; |
| } |
| try { |
| const a = document.createElement('a'); |
| a.href = canvas.toDataURL('image/png'); |
| a.download = fullPageDownloadName(); |
| a.click(); |
| } catch (err) { |
| console.warn('[insightux-report] full-page download failed:', err); |
| showToast('Could not export this image.', true); |
| } |
| }); |
| |
| function renderFail(){ |
| const shotWrap = document.getElementById('shotWrap'); |
| if (shotWrap) { |
| shotWrap.innerHTML = |
| '<div class="empty"><span class="ei">' + iuxIcon('info', 26) + '</span>' + |
| 'Could not stitch the full-page heatmap for this session.<br>' + |
| '<button type="button" class="iux-btn" id="retryShotBtn" style="margin-top:10px;">' + |
| iuxIcon('refresh', 13) + ' Retry</button></div>'; |
| shotWrap.removeAttribute('title'); |
| const retryBtn = document.getElementById('retryShotBtn'); |
| if (retryBtn) retryBtn.addEventListener('click', function(e){ e.stopPropagation(); load(); }); |
| } |
| } |
| |
| function load(){ |
| buildFullPageStitch().then(function(result){ |
| stitch = result; |
| img.onload = function(){ |
| cv.width = stitch.width; |
| cv.height = stitch.height; |
| paintActive(); |
| }; |
| img.src = stitch.canvas.toDataURL('image/png'); |
| const caption = document.getElementById('shotCaption'); |
| if (caption) { |
| caption.textContent = SESSION.duration + 's session · ' + SESSION.samples + ' gaze samples · ' + MOUSE_FULLPAGE.length + ' mouse points'; |
| } |
| }).catch(renderFail); |
| } |
| |
| load(); |
| })(); |
| |
| // ---------- Element Attention Analysis: plots EVERY row of the already- |
| // computed RANKING array (compute_dwell_ranking() output, in its existing |
| // order) — one point per ranked element, no aggregation, no new metric, |
| // nothing dropped, never reordered. Line and Column are two renderings of |
| // the exact same `points` array; switching "View as" never touches |
| // RANKING or recomputes anything. ---------- |
| (function(){ |
| const chartPanel = document.getElementById('elemChartPanel'); |
| const chartHeader = document.getElementById('elemChartHeader'); |
| const chartBody = document.getElementById('elemChartBody'); |
| const chartControls = document.getElementById('elemChartControls'); |
| const scrollBox = document.getElementById('elemChartScroll'); |
| const cv = document.getElementById('elemChart'); |
| const tooltip = document.getElementById('elemTooltip'); |
| const hint = document.getElementById('elemChartHint'); |
| const summaryRow = document.getElementById('elemSummaryRow'); |
| const viewBtn = document.getElementById('elemViewBtn'); |
| const viewLabel = document.getElementById('elemViewLabel'); |
| const viewMenu = document.getElementById('elemViewMenu'); |
| const viewSelect = document.getElementById('elemViewSelect'); |
| const exportBtn = document.getElementById('elemExportBtn'); |
| const fsBtn = document.getElementById('elemFullscreenBtn'); |
| const fsOverlay = document.getElementById('elemFullscreen'); |
| const fsBar = document.getElementById('elemFullscreenBar'); |
| const fsBody = document.getElementById('elemFullscreenBody'); |
| const ctx = cv.getContext('2d'); |
| |
| if (!RANKING.length){ |
| chartControls.style.display = 'none'; |
| chartBody.innerHTML = '<div class="empty"><span class="ei">' + iuxIcon('bar-chart', 26) + '</span>No gaze attention data available for this session.</div>'; |
| return; |
| } |
| |
| // One point per Ranked Attention row, same order, same values — RANKING |
| // is already sorted by seconds descending by compute_dwell_ranking(). |
| const points = RANKING.map(r => ({ name: r.label, seconds: r.seconds, pct: r.pct })); |
| |
| let mode = 'line'; // 'line' | 'column' — presentation only, same `points` either way |
| let hoverIdx = -1; |
| let animProgress = 0; |
| let raf = null; |
| // Re-read from --iux-indigo/--iux-primary-light on every paint() so the |
| // chart follows the live theme toggle instead of locking in one value — |
| // primary-light in particular differs between dark (bright yellow) and |
| // light (deep goldenrod) so it stays legible against bgAlt either way. |
| let accent = '#FFE9A8'; |
| let accentLight = '#FFE9A8'; |
| |
| const MIN_SLOT = 60; // generous px per element — chart scrolls instead of compressing |
| |
| // "Nice" step (1/2/5 x 10^n) so ticks read like 0.5s/1.0s/1.5s instead of |
| // whatever maxVal/N happens to divide into. |
| function niceTicks(maxVal, targetCount){ |
| if (!(maxVal > 0)) return [0, 1]; |
| const rawStep = maxVal / targetCount; |
| const mag = Math.pow(10, Math.floor(Math.log10(rawStep))); |
| const norm = rawStep / mag; |
| const niceNorm = norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10; |
| const step = niceNorm * mag; |
| const niceMax = Math.ceil(maxVal / step) * step; |
| const ticks = []; |
| for (let v = 0; v <= niceMax + step / 1e6; v += step) ticks.push(Math.round(v * 1000) / 1000); |
| return ticks; |
| } |
| |
| function inFullscreen(){ return fsOverlay.classList.contains('open'); } |
| |
| function layout(){ |
| const containerW = scrollBox.clientWidth || 320; |
| const needed = 148 + points.length * MIN_SLOT; |
| cv.width = Math.max(containerW, needed); |
| cv.height = inFullscreen() ? Math.max(460, Math.round(window.innerHeight * 0.6)) : 400; |
| hint.style.display = needed > containerW ? 'block' : 'none'; |
| } |
| |
| // Padding is dedicated margin *around* the plot, not carved out of it — |
| // cv.height/width already grew by the same amount (see layout()) so the |
| // plotted area itself doesn't shrink. padLeft needs enough room for the |
| // rotated Y-axis title + tick labels before the first point; padBottom |
| // needs enough room for the diagonally-rotated X-axis labels *and* a |
| // clear gap before the X-axis title below them — otherwise the title and |
| // the longest labels' diagonal tails collide, and a wide first column |
| // butts right up against the Y-axis title. |
| function geometry(){ |
| const padLeft = 112, padRight = 34, padTop = 30, padBottom = 108; |
| const plotW = cv.width - padLeft - padRight; |
| const plotH = cv.height - padTop - padBottom; |
| const dataMax = Math.max.apply(null, points.map(p => p.seconds)) || 1; |
| const ticks = niceTicks(dataMax, 5); |
| const axisMax = ticks[ticks.length - 1] || 1; |
| return { padLeft, padRight, padTop, padBottom, plotW, plotH, axisMax, ticks }; |
| } |
| |
| function pointX(i, g){ |
| return points.length === 1 ? g.padLeft + g.plotW / 2 : g.padLeft + (i / (points.length - 1)) * g.plotW; |
| } |
| function valueY(v, g){ |
| return g.padTop + g.plotH - (v / g.axisMax) * g.plotH * animProgress; |
| } |
| |
| function drawLine(g, pts){ |
| if (pts.length){ |
| ctx.beginPath(); |
| ctx.moveTo(pts[0].x, g.padTop + g.plotH); |
| pts.forEach(pt => ctx.lineTo(pt.x, pt.y)); |
| ctx.lineTo(pts[pts.length - 1].x, g.padTop + g.plotH); |
| ctx.closePath(); |
| const areaGrad = ctx.createLinearGradient(0, g.padTop, 0, g.padTop + g.plotH); |
| areaGrad.addColorStop(0, 'rgba(255,233,168,0.30)'); |
| areaGrad.addColorStop(1, 'rgba(255,233,168,0)'); |
| ctx.fillStyle = areaGrad; |
| ctx.fill(); |
| } |
| if (pts.length > 1){ |
| ctx.beginPath(); |
| ctx.moveTo(pts[0].x, pts[0].y); |
| for (let i = 0; i < pts.length - 1; i++){ |
| const cur = pts[i], next = pts[i + 1]; |
| const midX = (cur.x + next.x) / 2, midY = (cur.y + next.y) / 2; |
| ctx.quadraticCurveTo(cur.x, cur.y, midX, midY); |
| } |
| ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y); |
| ctx.strokeStyle = accent; |
| ctx.lineWidth = 2.5; |
| ctx.lineJoin = 'round'; |
| ctx.lineCap = 'round'; |
| ctx.stroke(); |
| } |
| } |
| |
| function drawColumns(g, pts){ |
| const slot = pts.length > 1 ? g.plotW / pts.length : g.plotW; |
| const barW = Math.max(6, Math.min(38, slot * 0.5)); |
| pts.forEach(function(pt){ |
| const top = pt.y, bottom = g.padTop + g.plotH, x = pt.x - barW / 2; |
| const h = bottom - top; |
| if (h <= 0) return; |
| const grad = ctx.createLinearGradient(0, top, 0, bottom); |
| grad.addColorStop(0, accentLight); |
| grad.addColorStop(1, accent); |
| ctx.fillStyle = grad; |
| const r = Math.min(6, barW / 2, h); |
| ctx.beginPath(); |
| ctx.moveTo(x, bottom); |
| ctx.lineTo(x, top + r); |
| ctx.arcTo(x, top, x + r, top, r); |
| ctx.lineTo(x + barW - r, top); |
| ctx.arcTo(x + barW, top, x + barW, top + r, r); |
| ctx.lineTo(x + barW, bottom); |
| ctx.closePath(); |
| ctx.fill(); |
| }); |
| } |
| |
| function paint(){ |
| const dim = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-dim').trim() || '#c6bcb2'; |
| const faint = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-faint').trim() || '#8e8e92'; |
| const border = getComputedStyle(document.documentElement).getPropertyValue('--iux-border').trim() || 'rgba(198,188,178,0.16)'; |
| const bgAlt = getComputedStyle(document.documentElement).getPropertyValue('--iux-bg-alt').trim() || '#221F1D'; |
| const strong = document.documentElement.getAttribute('data-theme') === 'light' ? '#312F2E' : '#F9F8F3'; |
| accent = getComputedStyle(document.documentElement).getPropertyValue('--iux-indigo').trim() || '#FFE9A8'; |
| accentLight = getComputedStyle(document.documentElement).getPropertyValue('--iux-primary-light').trim() || '#FFE9A8'; |
| const g = geometry(); |
| |
| // Opaque fill first: this canvas is also what Export reads directly, |
| // and a transparent background exports as solid black in most viewers. |
| ctx.fillStyle = bgAlt; |
| ctx.fillRect(0, 0, cv.width, cv.height); |
| |
| // gridlines + Y-axis tick labels (nice round steps, not maxVal/N) |
| ctx.lineWidth = 1; |
| ctx.strokeStyle = border; |
| g.ticks.forEach(function(v){ |
| const y = g.padTop + g.plotH - (v / g.axisMax) * g.plotH; |
| ctx.beginPath(); |
| ctx.moveTo(g.padLeft, y); |
| ctx.lineTo(cv.width - g.padRight, y); |
| ctx.stroke(); |
| ctx.fillStyle = faint; |
| ctx.font = '10.5px -apple-system, Arial'; |
| ctx.textAlign = 'right'; |
| // Gap has to clear the widest possible column bar (up to 38px wide, |
| // so up to 19px to either side of its center at the first x position) |
| // or the tick text and the first bar overlap — a plain 10px gap here |
| // was still narrower than half that bar width. |
| ctx.fillText(v.toFixed(v < 10 ? 1 : 0) + 's', g.padLeft - 42, y + 3); |
| }); |
| |
| const pts = points.map((p, i) => ({ x: pointX(i, g), y: valueY(p.seconds, g), p: p })); |
| |
| if (mode === 'column') drawColumns(g, pts); else drawLine(g, pts); |
| |
| // markers on top of either mode |
| pts.forEach(function(pt, i){ |
| const isHover = i === hoverIdx; |
| ctx.beginPath(); |
| ctx.arc(pt.x, pt.y, isHover ? 6.5 : 4, 0, 2 * Math.PI); |
| ctx.fillStyle = isHover ? accentLight : accent; |
| ctx.fill(); |
| ctx.lineWidth = 2; |
| ctx.strokeStyle = bgAlt; |
| ctx.stroke(); |
| }); |
| |
| // rotated X-axis labels — shortened here, full name always in the tooltip |
| pts.forEach(function(pt, i){ |
| const isHover = i === hoverIdx; |
| ctx.save(); |
| ctx.translate(pt.x, g.padTop + g.plotH + 12); |
| ctx.rotate(-Math.PI / 4); |
| ctx.fillStyle = isHover ? strong : dim; |
| ctx.font = isHover ? '600 10.5px -apple-system, Arial' : '10.5px -apple-system, Arial'; |
| ctx.textAlign = 'right'; |
| const label = pt.p.name.length > 20 ? pt.p.name.slice(0, 19) + '…' : pt.p.name; |
| ctx.fillText(label, 0, 0); |
| ctx.restore(); |
| }); |
| |
| // axis baseline |
| ctx.strokeStyle = border; |
| ctx.beginPath(); |
| ctx.moveTo(g.padLeft, g.padTop + g.plotH); |
| ctx.lineTo(cv.width - g.padRight, g.padTop + g.plotH); |
| ctx.stroke(); |
| |
| // axis titles — drawn last, in their own dedicated margin below/left of |
| // everything else (see the padLeft/padBottom comment on geometry()) |
| ctx.fillStyle = faint; |
| ctx.font = '600 10px -apple-system, Arial'; |
| ctx.textAlign = 'center'; |
| ctx.fillText('WEBPAGE ELEMENTS (' + pts.length + ')', g.padLeft + g.plotW / 2, cv.height - 14); |
| ctx.save(); |
| ctx.translate(20, g.padTop + g.plotH / 2); |
| ctx.rotate(-Math.PI / 2); |
| ctx.fillText('GAZE ATTENTION (SEC)', 0, 0); |
| ctx.restore(); |
| } |
| |
| function animate(){ |
| animProgress = Math.min(1, animProgress + 0.06); |
| paint(); |
| if (animProgress < 1) { raf = requestAnimationFrame(animate); } |
| } |
| function redraw(){ layout(); animProgress = 1; paint(); } |
| window.insightuxRepaintCanvases.push(redraw); |
| |
| // ---- tooltip: nearest point by x, positioned relative to the visible |
| // scrolled viewport (not the full, possibly-scrolled-off canvas), and |
| // flipped left/right or above/below so it never gets clipped. ---- |
| function showTooltip(idx, g){ |
| const p = points[idx]; |
| const px = pointX(idx, g), py = valueY(p.seconds, g); |
| tooltip.innerHTML = |
| '<b>' + p.name + '</b><br>' + |
| p.seconds + 's <span class="dim">·</span> ' + p.pct + '% of attention'; |
| tooltip.style.display = 'block'; |
| const viewportW = scrollBox.clientWidth; |
| const localX = px - scrollBox.scrollLeft; |
| const tw = tooltip.offsetWidth || 180; |
| const th = tooltip.offsetHeight || 44; |
| let left = localX + 14; |
| if (left + tw > viewportW - 8) left = localX - tw - 14; |
| left = Math.max(8, Math.min(viewportW - tw - 8, left)); |
| let top = py - 12 - th; |
| if (top < 4) top = py + 16; |
| tooltip.style.left = left + 'px'; |
| tooltip.style.top = top + 'px'; |
| } |
| |
| function handleMove(clientX){ |
| const rect = cv.getBoundingClientRect(); |
| const scaleX = cv.width / rect.width; |
| const mx = (clientX - rect.left) * scaleX; |
| const g = geometry(); |
| let idx = -1, best = Infinity; |
| points.forEach(function(p, i){ |
| const d = Math.abs(mx - pointX(i, g)); |
| if (d < best) { best = d; idx = i; } |
| }); |
| if (best > g.plotW / Math.max(points.length, 1)) idx = -1; |
| if (idx !== hoverIdx) { hoverIdx = idx; paint(); } |
| if (idx >= 0) showTooltip(idx, geometry()); else tooltip.style.display = 'none'; |
| } |
| cv.addEventListener('mousemove', function(e){ handleMove(e.clientX); }); |
| cv.addEventListener('mouseleave', function(){ hoverIdx = -1; tooltip.style.display = 'none'; paint(); }); |
| |
| // ---- View as: Line / Column, same underlying `points` either way ---- |
| function setMode(next){ |
| viewMenu.classList.remove('open'); |
| if (next === mode) return; |
| mode = next; |
| viewLabel.textContent = mode === 'line' ? 'Line Chart' : 'Column Chart'; |
| document.querySelectorAll('.elem-view-opt').forEach(function(b){ b.classList.toggle('on', b.dataset.view === mode); }); |
| animProgress = 1; |
| paint(); |
| } |
| viewBtn.addEventListener('click', function(e){ e.stopPropagation(); viewMenu.classList.toggle('open'); }); |
| document.querySelectorAll('.elem-view-opt').forEach(function(b){ |
| b.addEventListener('click', function(){ setMode(b.dataset.view); }); |
| }); |
| document.addEventListener('click', function(e){ |
| if (!viewSelect.contains(e.target)) viewMenu.classList.remove('open'); |
| }); |
| |
| // ---- Export: the canvas already holds the COMPLETE chart at full |
| // internal width regardless of scroll position or current mode — no |
| // separate offscreen render needed, it's exactly what's on screen. ---- |
| exportBtn.addEventListener('click', function(){ |
| try { |
| const a = document.createElement('a'); |
| a.href = cv.toDataURL('image/png'); |
| a.download = 'insightux-element-attention-' + mode + '.png'; |
| a.click(); |
| } catch (err) { |
| console.warn('[insightux-report] element chart export failed:', err); |
| showToast('Could not export this chart.', true); |
| } |
| }); |
| |
| // ---- Fullscreen: reparents the SAME controls/body DOM (not a copy) so |
| // there's exactly one chart instance, one set of listeners, one state — |
| // just a bigger container to draw into. ---- |
| function onFsKeydown(e){ if (e.key === 'Escape') closeFullscreen(); } |
| function openFullscreen(){ |
| fsBar.appendChild(chartControls); |
| fsBody.appendChild(chartBody); |
| fsOverlay.classList.add('open'); |
| fsOverlay.setAttribute('aria-hidden', 'false'); |
| fsBtn.innerHTML = iuxIcon('x', 13); |
| fsBtn.title = 'Close (Esc)'; |
| document.addEventListener('keydown', onFsKeydown, true); |
| layout(); animProgress = 1; paint(); |
| } |
| function closeFullscreen(){ |
| chartHeader.appendChild(chartControls); |
| chartPanel.appendChild(chartBody); |
| fsOverlay.classList.remove('open'); |
| fsOverlay.setAttribute('aria-hidden', 'true'); |
| fsBtn.innerHTML = iuxIcon('maximize', 13); |
| fsBtn.title = 'Fullscreen'; |
| document.removeEventListener('keydown', onFsKeydown, true); |
| layout(); animProgress = 1; paint(); |
| } |
| fsBtn.addEventListener('click', function(){ inFullscreen() ? closeFullscreen() : openFullscreen(); }); |
| fsOverlay.addEventListener('click', function(e){ if (e.target === fsOverlay) closeFullscreen(); }); |
| |
| // ---- summary row: derived from the same `points`, nothing recalculated |
| // upstream or independently of Ranked Attention ---- |
| (function renderSummary(){ |
| const total = points.reduce(function(s, p){ return s + p.seconds; }, 0); |
| const topShare = points[0] ? points[0].pct : 0; |
| const avg = points.length ? total / points.length : 0; |
| summaryRow.innerHTML = |
| '<div class="elem-summary-stat"><span class="l">Total Gaze Time</span><span class="v">' + total.toFixed(2) + 's</span></div>' + |
| '<div class="elem-summary-stat"><span class="l">Top Element Share</span><span class="v">' + topShare + '%</span></div>' + |
| '<div class="elem-summary-stat"><span class="l">Total Elements</span><span class="v">' + points.length + '</span></div>' + |
| '<div class="elem-summary-stat"><span class="l">Avg. Time / Element</span><span class="v">' + avg.toFixed(2) + 's</span></div>'; |
| })(); |
| |
| layout(); |
| if (raf) cancelAnimationFrame(raf); |
| requestAnimationFrame(animate); |
| window.addEventListener('resize', function(){ layout(); paint(); }); |
| })(); |
| |
| // ============================================================================= |
| // PRINT / PDF REPORT — builds a fixed set of paginated .print-page elements |
| // inside #printReport from the exact same RANKING/SEGMENTS/MOUSE_*/SESSION |
| // data every on-screen panel above already uses (nothing recomputed, nothing |
| // fabricated). Pagination is done here in JS rather than left to the |
| // browser's default print reflow: each atomic block (a table row, a card, an |
| // image) is appended and measured, and moved whole to a fresh page the |
| // instant it would overflow, so a card/row/image is never split across a |
| // page boundary and a heading never ends up alone at the bottom of one. |
| // window.__insightuxPrintReady is a Promise the Export button (and anyone |
| // calling window.print() directly) can await first, since the heatmap |
| // images are composited (screenshot + eye heat layer, flattened to a single |
| // PNG) asynchronously before layout runs. |
| // ============================================================================= |
| (function(){ |
| const root = document.getElementById('printReport'); |
| if (!root) { window.__insightuxPrintReady = Promise.resolve(); return; } |
| |
| function esc(s){ |
| return (s == null ? '' : String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); |
| } |
| function fmtS(n){ return (Math.round((n||0)*100)/100).toFixed(2) + 's'; } |
| function fmtPct(n){ return (n||0).toFixed(1) + '%'; } |
| |
| // Composites the whole-session full-page stitch (buildFullPageStitch(), |
| // shared with the live Attention Heatmaps panel) with its eye-attention |
| // heat layer (the exact same paintHeatLayer()/EYE_STOPS) into one |
| // flattened PNG, so print doesn't depend on a live canvas repainting |
| // correctly inside the print engine. |
| function compositeFullPageImage(){ |
| return buildFullPageStitch().then(function(stitch){ |
| const cv = document.createElement('canvas'); |
| cv.width = stitch.width || 1; |
| cv.height = stitch.height || 1; |
| const cx = cv.getContext('2d'); |
| cx.drawImage(stitch.canvas, 0, 0); |
| try { paintHeatLayer(cx, cv.width, cv.height, stitch.gazePoints || [], 0.09, EYE_STOPS, 0.9); } catch(e){} |
| let dataUrl = null; |
| try { dataUrl = cv.toDataURL('image/png'); } catch(e){} |
| return { dataUrl: dataUrl, w: cv.width, h: cv.height }; |
| }).catch(function(){ return null; }); |
| } |
| |
| window.__insightuxPrintReady = compositeFullPageImage() |
| .then(buildPrintPages) |
| .catch(function(){ /* best-effort — export still works with whatever got built */ }); |
| |
| function buildPrintPages(fullPage){ |
| const BODY_BUDGET = 1025; // must match .pp-body's CSS height (1123 - 46 top - 52 bottom) |
| const PAGES = []; |
| let curBody = null; |
| |
| function newPage(){ |
| const page = document.createElement('div'); |
| page.className = 'print-page'; |
| page.innerHTML = |
| '<div class="pp-accent"></div>' + |
| '<div class="pp-header"><span class="pp-brand">INSIGHTUX</span><span class="pp-tag">Session Report</span></div>' + |
| '<div class="pp-body"></div>' + |
| '<div class="pp-footer"><span class="pp-f-meta"></span><span class="pp-f-page"></span></div>'; |
| root.appendChild(page); |
| curBody = page.querySelector('.pp-body'); |
| PAGES.push(page); |
| return page; |
| } |
| |
| // Appends one atomic block; if it overflows AND the page already holds |
| // other content, moves it whole to a fresh page instead (break-inside: |
| // avoid on .pp-card/.pp-metric/etc. is a defensive backstop — this is |
| // the actual mechanism preventing split cards/images/charts). |
| function fitAppend(el){ |
| curBody.appendChild(el); |
| if (curBody.scrollHeight > BODY_BUDGET && curBody.children.length > 1) { |
| curBody.removeChild(el); |
| newPage(); |
| curBody.appendChild(el); |
| } |
| } |
| |
| // Generic "table that may need to continue onto further pages" flow: |
| // tries the full row set first (the common case — fits on one page), |
| // and only if that overflows does it trim back to however many rows |
| // actually fit, repeat the header + heading with " — Continued" on a |
| // fresh page, and carry on with what's left. A heading is never left |
| // without at least its header row + one data row, and a solitary |
| // last row is folded back a page rather than stranded alone. |
| function flowTable(title, headHtml, rows, rowHtmlFn){ |
| function build(rowsSlice, continued){ |
| const card = document.createElement('div'); |
| card.className = 'pp-card'; |
| const t = document.createElement('div'); |
| t.className = 'pp-section-title'; |
| t.textContent = title + (continued ? ' — Continued' : ''); |
| card.appendChild(t); |
| const table = document.createElement('table'); |
| table.className = 'pp-table'; |
| table.innerHTML = '<thead><tr>' + headHtml + '</tr></thead>'; |
| const tbody = document.createElement('tbody'); |
| rowsSlice.forEach(function(r, i){ |
| const tr = document.createElement('tr'); |
| tr.innerHTML = rowHtmlFn(r, i); |
| tbody.appendChild(tr); |
| }); |
| table.appendChild(tbody); |
| card.appendChild(table); |
| return { card: card, tbody: tbody }; |
| } |
| let remaining = rows.slice(); |
| let continued = false; |
| while (remaining.length) { |
| const built = build(remaining, continued); |
| curBody.appendChild(built.card); |
| if (curBody.scrollHeight <= BODY_BUDGET) { remaining = []; break; } |
| let fitCount = remaining.length; |
| while (fitCount > 0) { |
| while (built.tbody.children.length > fitCount) built.tbody.removeChild(built.tbody.lastChild); |
| if (curBody.scrollHeight <= BODY_BUDGET) break; |
| fitCount--; |
| } |
| if (fitCount === 0) { |
| curBody.removeChild(built.card); |
| newPage(); |
| continue; |
| } |
| if (remaining.length - fitCount === 1 && fitCount > 1) { |
| fitCount--; |
| while (built.tbody.children.length > fitCount) built.tbody.removeChild(built.tbody.lastChild); |
| } |
| remaining = remaining.slice(fitCount); |
| continued = true; |
| if (remaining.length) newPage(); |
| } |
| } |
| |
| // Same continuation-safe flow as flowTable, for a heading + a list of |
| // non-tabular item cards (the click log) — the heading is bundled with |
| // at least its first item so it's never left alone at a page's bottom. |
| function flowList(title, items, renderFn){ |
| function build(itemsSlice, continued){ |
| const wrap = document.createElement('div'); |
| const h = document.createElement('div'); |
| h.className = 'pp-section-title'; |
| h.style.fontSize = '15px'; |
| h.style.marginBottom = '10px'; |
| h.textContent = title + (continued ? ' — Continued' : ''); |
| wrap.appendChild(h); |
| const list = document.createElement('div'); |
| itemsSlice.forEach(function(it){ list.appendChild(renderFn(it)); }); |
| wrap.appendChild(list); |
| return { wrap: wrap, list: list }; |
| } |
| let remaining = items.slice(); |
| let continued = false; |
| while (remaining.length) { |
| const built = build(remaining, continued); |
| curBody.appendChild(built.wrap); |
| if (curBody.scrollHeight <= BODY_BUDGET) { remaining = []; break; } |
| let fitCount = remaining.length; |
| while (fitCount > 0) { |
| while (built.list.children.length > fitCount) built.list.removeChild(built.list.lastChild); |
| if (curBody.scrollHeight <= BODY_BUDGET) break; |
| fitCount--; |
| } |
| if (fitCount === 0) { |
| curBody.removeChild(built.wrap); |
| newPage(); |
| continue; |
| } |
| remaining = remaining.slice(fitCount); |
| continued = true; |
| if (remaining.length) newPage(); |
| } |
| } |
| |
| function sectionHeading(text, sub){ |
| const wrap = document.createElement('div'); |
| wrap.innerHTML = '<div class="pp-section-title" style="font-size:15px;margin-bottom:' + (sub ? '4px' : '10px') + ';">' + esc(text) + '</div>' + |
| (sub ? '<div class="pp-section-sub">' + esc(sub) + '</div>' : ''); |
| return wrap; |
| } |
| |
| const items = RANKING.slice(0, MAX_ATTENTION_ITEMS); |
| items.forEach(function(r, i){ r._rank = i + 1; }); |
| const totalGaze = RANKING.reduce(function(s, r){ return s + r.seconds; }, 0); |
| const avgPerElement = RANKING.length ? totalGaze / RANKING.length : 0; |
| const topLabel = RANKING.length ? RANKING[0].label : '—'; |
| const topPct = RANKING.length ? RANKING[0].pct : 0; |
| |
| // ====================================================================== |
| // PAGE 1 — cover, metric cards, attention summary, ranked attention |
| // ====================================================================== |
| newPage(); |
| |
| const cover = document.createElement('div'); |
| cover.innerHTML = |
| '<div class="pp-cover-title">InsightUX Session Report</div>' + |
| '<div class="pp-cover-meta">' + esc(SESSION.url || 'Unknown page') + ' · ' + esc(SESSION.timestamp) + |
| ' · ' + SESSION.duration + 's session · ' + SESSION.samples + ' gaze samples</div>'; |
| fitAppend(cover); |
| |
| const metrics = document.createElement('div'); |
| metrics.className = 'pp-metric-row'; |
| metrics.innerHTML = [ |
| ['Session Length', SESSION.duration + 's', 'recorded session'], |
| ['Gaze Samples', String(SESSION.samples), 'eye-tracking samples'], |
| ['Elements Tracked', String(RANKING.length), 'ranked attention targets'], |
| ['Mouse Events', String(SESSION.clickCount), 'recorded clicks'] |
| ].map(function(m){ |
| return '<div class="pp-metric"><div class="pp-m-label">' + m[0] + '</div><div class="pp-m-value">' + m[1] + '</div><div class="pp-m-sub">' + m[2] + '</div></div>'; |
| }).join(''); |
| fitAppend(metrics); |
| |
| const summaryCard = document.createElement('div'); |
| summaryCard.className = 'pp-card pp-two-col'; |
| let summaryText; |
| if (RANKING.length) { |
| summaryText = 'During this session, InsightUX recorded <b>' + fmtS(totalGaze) + '</b> of element-level gaze attention across ' + |
| '<b>' + RANKING.length + '</b> tracked webpage element' + (RANKING.length === 1 ? '' : 's') + '. ' + |
| 'The highest-attention element was <b>' + esc(topLabel) + '</b>, receiving <b>' + fmtPct(topPct) + '</b> of recorded attention.'; |
| if (SESSION.clickCount > 0) { |
| summaryText += ' The session also recorded <b>' + SESSION.clickCount + '</b> mouse click' + (SESSION.clickCount === 1 ? '' : 's') + '.'; |
| } |
| } else { |
| summaryText = 'No elements were fixated long enough to register during this session.'; |
| } |
| summaryCard.innerHTML = |
| '<div>' + |
| '<div class="pp-section-title">Attention Summary</div>' + |
| '<div class="pp-body-text">' + summaryText + '</div>' + |
| '</div>' + |
| '<div>' + |
| '<div class="pp-section-title">Session Snapshot</div>' + |
| '<div class="pp-snap-item"><div class="pp-snap-label">Top Element</div><div class="pp-snap-value">' + esc(topLabel) + '</div></div>' + |
| '<div class="pp-snap-item"><div class="pp-snap-label">Top Share</div><div class="pp-snap-value">' + fmtPct(topPct) + '</div></div>' + |
| '<div class="pp-snap-item"><div class="pp-snap-label">Average / Element</div><div class="pp-snap-value">' + fmtS(avgPerElement) + '</div></div>' + |
| '</div>'; |
| fitAppend(summaryCard); |
| |
| if (items.length) { |
| flowTable('Ranked Attention', |
| '<th style="width:22px;">#</th><th>Element</th><th class="pp-num">Gaze Attention</th><th class="pp-num">Share</th><th class="pp-num">Hits</th>', |
| items, |
| function(r){ |
| return '<td class="pp-rank">' + r._rank + '</td>' + |
| '<td class="pp-el-name">' + esc(r.label) + '</td>' + |
| '<td class="pp-num">' + fmtS(r.seconds) + '</td>' + |
| '<td class="pp-num">' + fmtPct(r.pct) + '</td>' + |
| '<td class="pp-num">' + r.hits + '</td>'; |
| }); |
| } else { |
| const empty = document.createElement('div'); |
| empty.className = 'pp-card'; |
| empty.innerHTML = '<div class="pp-section-title">Ranked Attention</div><div class="pp-empty">No elements were fixated long enough to register.</div>'; |
| fitAppend(empty); |
| } |
| |
| // ====================================================================== |
| // PAGE — Attention Visualization: session view + element attention chart |
| // ====================================================================== |
| newPage(); |
| fitAppend(sectionHeading('Attention Visualization', 'Visual representation of the recorded gaze attention across webpage elements.')); |
| |
| const chartCard = document.createElement('div'); |
| chartCard.className = 'pp-card'; |
| const chartTitle = document.createElement('div'); |
| chartTitle.className = 'pp-section-title'; |
| chartTitle.style.fontSize = '10.5px'; |
| chartTitle.textContent = 'Element Attention Analysis'; |
| chartCard.appendChild(chartTitle); |
| if (items.length) { |
| const chartWrap = document.createElement('div'); |
| chartWrap.style.cssText = 'display:flex;justify-content:center;'; |
| const chartCanvas = document.createElement('canvas'); |
| chartWrap.appendChild(chartCanvas); |
| chartCard.appendChild(chartWrap); |
| renderPrintChart(chartCanvas, items); |
| if (items.length > 8) { |
| const caption = document.createElement('div'); |
| caption.className = 'pp-chart-caption'; |
| caption.textContent = 'Reference numbers correspond to rank in the Ranked Attention table.'; |
| chartCard.appendChild(caption); |
| } |
| } else { |
| const emptyChart = document.createElement('div'); |
| emptyChart.className = 'pp-empty'; |
| emptyChart.textContent = 'No elements were fixated long enough to chart.'; |
| chartCard.appendChild(emptyChart); |
| } |
| fitAppend(chartCard); |
| |
| const vizStats = document.createElement('div'); |
| vizStats.className = 'pp-metric-row'; |
| vizStats.innerHTML = [ |
| ['Total Gaze Time', fmtS(totalGaze)], |
| ['Top Element Share', fmtPct(topPct)], |
| ['Total Elements', String(RANKING.length)], |
| ['Avg. / Element', fmtS(avgPerElement)] |
| ].map(function(m){ |
| return '<div class="pp-metric"><div class="pp-m-label">' + m[0] + '</div><div class="pp-m-value" style="font-size:15px;">' + m[1] + '</div></div>'; |
| }).join(''); |
| fitAppend(vizStats); |
| |
| // ====================================================================== |
| // PAGE(S) — Attention Heatmaps |
| // ====================================================================== |
| newPage(); |
| fitAppend(sectionHeading('Attention Heatmaps', 'Heatmaps show where visual attention was concentrated within the recorded webpage viewport.')); |
| |
| if (fullPage && fullPage.dataUrl && fullPage.w && fullPage.h) { |
| // Shrunk to fit within whatever body space is left on this page — |
| // the stitched full-page image is usually far taller than one A4 |
| // page, so it's scaled down as a whole (poster-style) rather than |
| // sliced across several pages. |
| const usedH = curBody.scrollHeight; |
| const availW = 702; // .pp-body content width: 794 - 46*2 |
| const availH = Math.max(160, BODY_BUDGET - usedH - 60); |
| const scale = Math.min(availW / fullPage.w, availH / fullPage.h, 1); |
| const dispW = Math.max(1, Math.round(fullPage.w * scale)); |
| const dispH = Math.max(1, Math.round(fullPage.h * scale)); |
| const shotCard = document.createElement('div'); |
| shotCard.className = 'pp-card'; |
| shotCard.innerHTML = |
| '<div class="pp-shot-label"><span>Whole page, every screenshot stitched together</span></div>' + |
| '<div class="pp-shot-frame" style="display:flex;justify-content:center;">' + |
| '<img width="' + dispW + '" height="' + dispH + '" style="width:' + dispW + 'px;height:' + dispH + 'px;" src="' + fullPage.dataUrl + '" alt=""></div>'; |
| curBody.appendChild(shotCard); |
| } else { |
| const empty = document.createElement('div'); |
| empty.className = 'pp-card'; |
| empty.innerHTML = '<div class="pp-empty">No page screenshots were captured for this session (older session, or screen-capture failed).</div>'; |
| fitAppend(empty); |
| } |
| |
| // ====================================================================== |
| // PAGE — Element Attention Details (a compact reference sheet; only |
| // worth its own page once there are more ranked elements than the |
| // Ranked Attention table on page 1 can be quickly scanned at a glance) |
| // ====================================================================== |
| if (items.length > 6) { |
| newPage(); |
| fitAppend(sectionHeading('Element Attention Details')); |
| let grid = document.createElement('div'); |
| grid.className = 'pp-detail-grid'; |
| curBody.appendChild(grid); |
| items.forEach(function(r){ |
| const card = document.createElement('div'); |
| card.className = 'pp-detail-card'; |
| card.innerHTML = |
| '<div class="pp-d-name">#' + r._rank + ' · ' + esc(r.label) + '</div>' + |
| '<div class="pp-d-row"><span>Gaze Time</span><b>' + fmtS(r.seconds) + '</b></div>' + |
| '<div class="pp-d-row"><span>Share of Attention</span><b>' + fmtPct(r.pct) + '</b></div>' + |
| '<div class="pp-d-row"><span>Hits</span><b>' + r.hits + '</b></div>'; |
| grid.appendChild(card); |
| if (curBody.scrollHeight > BODY_BUDGET && grid.children.length > 1) { |
| grid.removeChild(card); |
| newPage(); |
| grid = document.createElement('div'); |
| grid.className = 'pp-detail-grid'; |
| curBody.appendChild(grid); |
| grid.appendChild(card); |
| } |
| }); |
| } |
| |
| // ====================================================================== |
| // PAGE(S) — Mouse Interaction Analysis |
| // ====================================================================== |
| if (MOUSE_INTERESTS.length || MOUSE_CLICKS.length) { |
| newPage(); |
| fitAppend(sectionHeading('Mouse Interaction Analysis')); |
| |
| if (MOUSE_INTERESTS.length) { |
| flowTable('Most Interacted Elements', |
| '<th style="width:22px;">#</th><th>Element</th><th class="pp-num">Interaction Time</th>', |
| MOUSE_INTERESTS, |
| function(r, i){ return '<td class="pp-rank">' + (i + 1) + '</td><td class="pp-el-name">' + esc(r.element) + '</td><td class="pp-num">' + fmtS(r.seconds) + '</td>'; }); |
| } else { |
| const empty = document.createElement('div'); |
| empty.className = 'pp-card'; |
| empty.innerHTML = '<div class="pp-section-title">Most Interacted Elements</div><div class="pp-empty">No mouse dwell data recorded for this session.</div>'; |
| fitAppend(empty); |
| } |
| |
| if (MOUSE_CLICKS.length) { |
| flowList('Mouse — Click Timeline & Log', MOUSE_CLICKS, function(c){ |
| const item = document.createElement('div'); |
| item.className = 'pp-click-item'; |
| item.innerHTML = |
| '<div class="pp-click-time">' + esc(c.timestamp || '') + '</div>' + |
| '<div class="pp-click-el">' + esc(c.element || '') + '</div>' + |
| '<div class="pp-click-text">"' + esc(c.text || '') + '"</div>'; |
| return item; |
| }); |
| } else { |
| fitAppend(sectionHeading('Mouse — Click Timeline & Log')); |
| const empty = document.createElement('div'); |
| empty.className = 'pp-card'; |
| empty.innerHTML = '<div class="pp-empty">No mouse clicks were recorded during this session.</div>'; |
| fitAppend(empty); |
| } |
| } |
| |
| // ====================================================================== |
| // FINAL PAGE — Session Insights (objective, derived-only wording) |
| // ====================================================================== |
| if (RANKING.length) { |
| newPage(); |
| fitAppend(sectionHeading('Session Insights')); |
| const grid = document.createElement('div'); |
| grid.className = 'pp-insight-grid'; |
| grid.innerHTML = [ |
| ['Highest Attention', esc(topLabel) + ' — ' + fmtS(RANKING[0].seconds)], |
| ['Largest Attention Share', fmtPct(topPct)], |
| ['Tracked Elements', String(RANKING.length)], |
| ['Total Gaze Attention', fmtS(totalGaze)] |
| ].map(function(x){ |
| return '<div class="pp-insight-card"><div class="pp-i-label">' + x[0] + '</div><div class="pp-i-value">' + x[1] + '</div></div>'; |
| }).join(''); |
| fitAppend(grid); |
| } |
| |
| // ---- footers, now that the final page count is known ---- |
| PAGES.forEach(function(page, i){ |
| page.querySelector('.pp-f-meta').textContent = |
| 'InsightUX · Session Report · ' + (SESSION.url || '') + ' · ' + (SESSION.timestamp || ''); |
| page.querySelector('.pp-f-page').textContent = 'Page ' + (i + 1) + ' of ' + PAGES.length; |
| }); |
| } |
| |
| // Clean, static line chart for the Element Attention Analysis print page: |
| // every ranked element gets one point, numbered #1.. to match the Ranked |
| // Attention table's rank column (full names are already on that table — |
| // repeating all of them here gets unreadable past ~8 points). |
| function renderPrintChart(canvas, items){ |
| const cssW = 690, cssH = 300, dpr = 2; |
| canvas.width = cssW * dpr; |
| canvas.height = cssH * dpr; |
| canvas.style.width = cssW + 'px'; |
| canvas.style.height = cssH + 'px'; |
| const ctx = canvas.getContext('2d'); |
| ctx.scale(dpr, dpr); |
| const font = getComputedStyle(document.body).fontFamily || 'sans-serif'; |
| |
| const padL = 54, padR = 16, padT = 16, padB = 40; |
| const plotW = cssW - padL - padR, plotH = cssH - padT - padB; |
| |
| const maxVal = items.reduce(function(m, r){ return Math.max(m, r.seconds); }, 0) || 1; |
| function niceCeil(v){ |
| if (v <= 0) return 1; |
| const mag = Math.pow(10, Math.floor(Math.log10(v))); |
| const norm = v / mag; |
| const step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10; |
| return step * mag; |
| } |
| const top = niceCeil(maxVal * 1.15); |
| const TICKS = 5; |
| |
| ctx.clearRect(0, 0, cssW, cssH); |
| |
| ctx.strokeStyle = 'rgba(198,188,178,0.16)'; |
| ctx.fillStyle = '#8E8E92'; |
| ctx.font = '9px ' + font; |
| ctx.textAlign = 'right'; |
| ctx.textBaseline = 'middle'; |
| for (let i = 0; i <= TICKS; i++){ |
| const v = top * i / TICKS; |
| const y = padT + plotH - (v / top) * plotH; |
| ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + plotW, y); ctx.stroke(); |
| ctx.fillText(v.toFixed(1) + 's', padL - 8, y); |
| } |
| |
| const n = items.length; |
| const stepX = n > 1 ? plotW / (n - 1) : 0; |
| const xFor = function(i){ return n > 1 ? padL + i * stepX : padL + plotW / 2; }; |
| const yFor = function(v){ return padT + plotH - (v / top) * plotH; }; |
| |
| ctx.strokeStyle = '#FFE9A8'; |
| ctx.lineWidth = 2; |
| ctx.beginPath(); |
| items.forEach(function(r, i){ |
| const x = xFor(i), y = yFor(r.seconds); |
| if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); |
| }); |
| ctx.stroke(); |
| |
| const showValues = n <= 10; |
| items.forEach(function(r, i){ |
| const x = xFor(i), y = yFor(r.seconds); |
| ctx.fillStyle = '#FFE9A8'; |
| ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill(); |
| if (showValues) { |
| ctx.fillStyle = '#F9F8F3'; |
| ctx.font = '600 9px ' + font; |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'alphabetic'; |
| ctx.fillText(r.seconds.toFixed(2) + 's', x, Math.max(10, y - 10)); |
| } |
| }); |
| |
| ctx.fillStyle = '#C6BCB2'; |
| ctx.font = '8.5px ' + font; |
| ctx.textAlign = 'center'; |
| ctx.textBaseline = 'top'; |
| items.forEach(function(r, i){ ctx.fillText('#' + (i + 1), xFor(i), padT + plotH + 8); }); |
| |
| ctx.fillStyle = '#8E8E92'; |
| ctx.font = '700 8px ' + font; |
| ctx.textAlign = 'center'; |
| ctx.fillText('WEBPAGE ELEMENTS', padL + plotW / 2, cssH - 8); |
| ctx.save(); |
| ctx.translate(12, padT + plotH / 2); |
| ctx.rotate(-Math.PI / 2); |
| ctx.fillText('GAZE ATTENTION (SEC)', 0, 0); |
| ctx.restore(); |
| } |
| })(); |
| |
| </script> |
| </body> |
| </html> |
| """ |
|
|
|
|
| def generate_report(session_dir): |
| gaze, dom = load_session(session_dir) |
| attributed = attribute_gaze(gaze, dom) |
|
|
| ranking = compute_dwell_ranking(attributed) |
| segments = build_screenshot_segments(gaze, dom, session_dir) |
| summary = session_summary(gaze, dom) |
| mouse = summarize_mouse(session_dir) |
| mouse_fullpage = build_mouse_fullpage_points(session_dir) |
| page_dims = full_page_dims(dom) |
|
|
| top_label = ranking[0]["label"] if ranking else "—" |
|
|
| |
| |
| |
| |
| try: |
| session_ts = datetime.strptime(os.path.basename(session_dir.rstrip("/\\")), "%Y%m%d_%H%M%S") |
| session_timestamp = session_ts.strftime("%b %d, %Y — %I:%M %p").replace(" 0", " ") |
| except ValueError: |
| session_timestamp = "Unknown time" |
|
|
| |
| |
| |
| |
| subject_label = "" |
| try: |
| with open(os.path.join(session_dir, "session_meta.json"), "r", encoding="utf-8") as f: |
| meta = json.load(f) |
| if meta.get("tracking_mode") == "participant" and meta.get("participant_name"): |
| subject_label = f"Tracking: {html_escape.escape(meta['participant_name'])} · " |
| except (OSError, json.JSONDecodeError): |
| pass |
|
|
| html = _TEMPLATE |
| html = html.replace("__SUBJECT_LABEL__", subject_label) |
| html = html.replace("__URL__", summary["url"] or "Unknown page") |
| html = html.replace("__URL_JSON__", json.dumps(summary["url"] or "")) |
| html = html.replace("__SESSION_TIMESTAMP__", session_timestamp) |
| html = html.replace("__SESSION_TIMESTAMP_JSON__", json.dumps(session_timestamp)) |
| 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("__CLICK_COUNT__", str(mouse["click_count"])) |
| html = html.replace("__MAX_ATTENTION_ITEMS__", str(MAX_ATTENTION_ITEMS)) |
| html = html.replace("__RANKING_JSON__", json.dumps(ranking)) |
| html = html.replace("__SEGMENTS_JSON__", json.dumps(segments)) |
| html = html.replace("__MOUSE_INTERESTS_JSON__", json.dumps(mouse["interests"])) |
| html = html.replace("__MOUSE_CLICKS_JSON__", json.dumps(mouse["clicks"])) |
| html = html.replace("__MOUSE_FULLPAGE_JSON__", json.dumps(mouse_fullpage)) |
| html = html.replace("__PAGE_DIMS_JSON__", json.dumps(page_dims)) |
|
|
| html = html.replace("__THEME_CSS__", theme.THEME_CSS) |
| html = html.replace("__THEME_JS__", theme.THEME_TOGGLE_JS) |
| html = html.replace("__ICONS_JS__", theme.ICONS_JS) |
| html = html.replace("__EYE_ICON__", theme.icon("eye", 18)) |
| html = html.replace("__EXPORT_ICON__", theme.icon("download", 13)) |
| html = html.replace("__CLOCK_ICON__", theme.icon("clock", 15)) |
| html = html.replace("__LAYERS_ICON__", theme.icon("layers", 15)) |
| html = html.replace("__TARGET_ICON__", theme.icon("target", 15)) |
| html = html.replace("__CURSOR_ICON__", theme.icon("cursor", 15)) |
| html = html.replace("__CHEVRON_ICON__", theme.icon("chevron-down", 15)) |
| html = html.replace("__TYPE_ICON__", theme.icon("type", 13)) |
| html = html.replace("__INFO_ICON__", theme.icon("info", 13)) |
| html = html.replace("__CLICK_ICON__", theme.icon("click", 13)) |
| html = html.replace("__MAXIMIZE_ICON__", theme.icon("maximize", 13)) |
| html = html.replace("__DOWNLOAD_ICON__", theme.icon("download", 13)) |
| html = html.replace("__EYE_ICON_SM__", theme.icon("eye", 13)) |
| html = html.replace("__CURSOR_ICON_SM__", theme.icon("cursor", 13)) |
| html = html.replace("__LAYERS_ICON_SM__", theme.icon("layers", 13)) |
| html = html.replace("__BARCHART_ICON__", theme.icon("bar-chart", 15)) |
| html = html.replace("__TRENDING_ICON__", theme.icon("trending-up", 13)) |
| html = html.replace("__BACK_ICON__", theme.icon("arrow-left", 14)) |
| html = html.replace("__CLOSE_ICON__", theme.icon("x", 14)) |
| html = html.replace("__CHEVRON_LEFT_ICON__", theme.icon("chevron-left", 20)) |
| html = html.replace("__CHEVRON_RIGHT_ICON__", theme.icon("chevron-right", 20)) |
|
|
| 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}") |
|
|