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