Insight_UX_1.0 / analysis.py
Aryaman25's picture
Update Insight_UX_1.0 with latest changes: add auth, participants, tests, and dev requirements
f9609df
Raw
History Blame Contribute Delete
114 kB
"""
analysis.py
Turns a raw InsightUX session (gaze_log.jsonl + dom_log.jsonl + screenshots/)
into a self-contained HTML report: ranked attention (in plain language)
and per-scroll-position heatmaps drawn directly on top of what the page
actually looked like.
Usage:
from analysis import generate_report
report_path = generate_report(session_dir) # returns abs path to html
PAD_PX must match the value used in browser_session.py's injected
TRACKING_JS, or the post-hoc AOI attribution here will disagree with what
the user visually saw highlighted during the session.
"""
import os
import re
import json
import bisect
import base64
import html as html_escape
from datetime import datetime
import theme
PAD_PX = 90 # keep identical to TRACKING_JS's PAD_PX in browser_session.py
# Report/print item caps — presentation limits only, never applied to the
# underlying computed data (compute_dwell_ranking/summarize_mouse still
# return every item; this only bounds how many rows get *displayed*).
MAX_ATTENTION_ITEMS = 20
# =============================================================================
# LOADING
# =============================================================================
def _load_jsonl(path):
records = []
if not os.path.exists(path):
return records
with open(path, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue
return records
def load_session(session_dir):
gaze = [r for r in _load_jsonl(os.path.join(session_dir, "gaze_log.jsonl"))
if r.get("type") == "gaze"]
dom = [r for r in _load_jsonl(os.path.join(session_dir, "dom_log.jsonl"))
if r.get("type") == "dom"]
gaze.sort(key=lambda r: r["t"])
dom.sort(key=lambda r: r["t"])
return gaze, dom
# =============================================================================
# MOUSE ACTIVITY (from mouse_log.jsonl — batches pushed by the in-page Mouse
# Tracker overlay, same trail/heatmap/click/dwell shape as the standalone
# "Mouse Tracker & Heatmap" Chrome extension this was ported from)
# =============================================================================
def load_mouse_batches(session_dir):
return [r for r in _load_jsonl(os.path.join(session_dir, "mouse_log.jsonl"))
if r.get("type") == "mouse_batch"]
def summarize_mouse(session_dir):
batches = load_mouse_batches(session_dir)
dwell_totals = {}
clicks = []
trail_points = 0
heatmap_points = 0
for b in batches:
for item in (b.get("dwell") or []):
element = item.get("element")
duration = item.get("duration", 0)
if not element:
continue
dwell_totals[element] = dwell_totals.get(element, 0) + duration
for c in (b.get("click") or []):
clicks.append(c)
trail_points += len(b.get("trail") or [])
heatmap_points += len(b.get("heatmap") or [])
interests = sorted(
({"element": k, "seconds": round(v / 1000.0, 1)} for k, v in dwell_totals.items()),
key=lambda r: -r["seconds"]
)[:MAX_ATTENTION_ITEMS]
clicks.sort(key=lambda c: c.get("timestamp", ""))
return {
"interests": interests,
"clicks": clicks[-50:],
"click_count": len(clicks),
"trail_points": trail_points,
"heatmap_points": heatmap_points,
}
# =============================================================================
# HUMAN-READABLE LABELS
# Raw AOI labels come straight out of the DOM (tag names, CSS classes,
# truncated text). Fine for matching, unreadable for a report. This maps
# them to plain language without losing which element they refer to.
# =============================================================================
def friendly_label(raw):
if not raw:
return "Unlabeled area"
if raw == "navbar":
return "Navigation bar"
if raw == "header":
return "Page header"
if raw == "footer":
return "Page footer"
if raw == "video":
return "Video"
if raw.startswith("img: "):
return "Image — " + raw[5:]
m = re.match(r"^(h[123]):\s*(.*)$", raw)
if m:
level = {"h1": "Main heading", "h2": "Heading", "h3": "Sub-heading"}[m.group(1)]
return f"{level} — “{m.group(2)}”"
m = re.match(r"^p \((.*)\)$", raw)
if m:
return f"Text — “{m.group(1)}…”"
if raw.startswith("#"):
return "Section: " + raw[1:]
if raw.startswith("."):
return "Block: " + raw[1:]
return raw[0].upper() + raw[1:] if raw else raw
# =============================================================================
# GAZE -> AOI ATTRIBUTION
# =============================================================================
def _find_aoi(px, py, aois):
"""Smallest padded AOI containing (px, py), or None. Mirrors the JS hit-test.
Skips raw label "embed": TRACKING_JS's insightuxAOIs() tags every
<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
# =============================================================================
# METRICS
# =============================================================================
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)
# =============================================================================
# SCREENSHOT-BACKED HEATMAP SEGMENTS
# Groups gaze points against whichever screenshot was on screen at the time,
# so the heatmap draws on top of the real page instead of a blank canvas.
#
# viewportW/viewportH are carried through unchanged from the dom snapshot
# (already recorded, not new data) so the report's JS can derive the
# CSS-px -> physical-px ratio for placing mouse points on the Mouse/
# Combined tabs (scrollY and mouse coordinates are DOM/CSS pixels;
# screenshots and gaze sx/sy are physical screen pixels — they only match
# 1:1 when the OS display scale is 100%).
# =============================================================================
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"], # relative path — kept for traceability/debugging only, not consumed by the report
"screenshotData": data_uri, # what the report's <img>/canvas actually load
"scrollY": ev.get("scrollY", 0),
"viewportW": viewport.get("w"),
"viewportH": viewport.get("h"),
"points": pts,
"stickyRects": ev.get("stickyRects") or [], # viewport-relative CSS rects of position:fixed/sticky elements at capture time — lets the stitcher paste each one once instead of once per screenshot
"duration": 0.0, # filled below
})
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
# =============================================================================
# FULL-PAGE HEATMAP DATA (whole-session, not per scroll position). Every
# point below was already written to mouse_log.jsonl by the existing
# MOUSE_JS sampling loop and Api.log_mouse_data(); summarize_mouse() above
# is unchanged and still powers the interests table / click log / click-
# timeline exactly as before. This just surfaces the raw coordinates so the
# report's client-side stitcher can plot every mouse point ever recorded
# onto one continuous full-page image.
# =============================================================================
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}
# =============================================================================
# HTML REPORT
# =============================================================================
_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__ &nbsp;&middot;&nbsp; __SESSION_TIMESTAMP__ &nbsp;&middot;&nbsp; __DURATION__s &nbsp;&middot;&nbsp; __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 &rarr;</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 &amp; 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">&minus;</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,'&lt;')}"</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&hellip;</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">&middot;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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') + ' &middot; ' + esc(SESSION.timestamp) +
' &middot; ' + SESSION.duration + 's session &middot; ' + 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 "—"
# session_dir's own folder name is already the session's timestamp
# (browser_session.py names it that way) — reused here only to print a
# human-readable date in the header, same convention _sessions_json()
# already parses for the session-history modal.
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"
# session_meta.json (browser_session.py's Api._write_session_meta())
# records who was being tracked — absent on sessions recorded before
# participants existed, which is fine, they're just "self" sessions
# with nothing to label.
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'])} &nbsp;&middot;&nbsp; "
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}")