Capstone project. I'm ngl it's vibe-coded and it's only here so I can mess around with it
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #4 from chriskalos/feature/analytical-engine

Implement new analytical engine & results page

authored by

Chris and committed by
GitHub
15bb3169 8781293d

+3012 -40
+3
.gitignore
··· 1 1 .DS_Store 2 2 .uxet-server.log 3 + TODO.md 4 + /test-results 5 + /tests
+46 -3
README.md
··· 7 7 1. **Load** a testable web app into a sandboxed iframe. 8 8 2. **Calibrate** eye-tracking via webcam (powered by [WebGazer](https://webgazer.cs.brown.edu/)). 9 9 3. **Record** a session — gaze data, mouse movement, clicks, keypresses, and scroll events are all captured while the user completes a task. 10 - 4. **Debrief** — when the task is done (or a win condition fires automatically), UXET renders per-screen gaze heatmaps and a full stats summary. 11 - 5. **Export** the raw session data as JSON for further analysis. 10 + 4. **Debrief** — when the task is done (or a win condition fires automatically), UXET renders per-screen gaze heatmaps, v3 ranked findings, element-level issues, and interaction stats. 11 + 5. **Export / Import** session data as JSON so UXET can re-analyze prior sessions offline, including temporary cohort comparison from multiple imported files. 12 12 13 13 ## Getting started 14 14 ··· 50 50 4. When calibration passes, click **Start Test** to begin recording. 51 51 5. Complete the task. The session ends automatically when the win condition is met, or press **Shift+Escape** to end it manually. 52 52 6. Review the debrief screen — heatmaps, timing, click counts, fixation stats, etc. 53 - 7. Click **Export Data** to download the session as JSON. 53 + 7. Click **Test Again** to rerun the same app from the debrief screen, or **Export Data** to download the session as JSON. 54 + 55 + When you use **Test Again** on the same app, UXET keeps the completed live runs in memory and adds comparison insights after the second run. The comparison highlights repeated findings, repeated element-level patterns, and outlier sessions for that app/task until you reset or reload. 56 + 57 + ### Import a prior session 58 + 59 + 1. Click **Import Data**. 60 + 2. Select a UXET JSON export. 61 + 3. UXET validates the file and renders the same debrief pipeline used for live sessions. 62 + 63 + To compare sessions, select multiple UXET JSON exports in the import dialog. UXET analyzes the files in memory, reports repeated findings, element patterns, outlier sessions, and data-quality warnings, then discards the cohort when you reset or reload. 64 + 65 + Legacy exports are still supported, but they may lack screenshots, dense mouse traces, or element snapshots. In those cases UXET shows fidelity warnings and limits the analysis accordingly. 54 66 55 67 ### Debug mode 56 68 ··· 59 71 - **Skip Calibration** — bypass eye-tracking calibration entirely. 60 72 - **Use mouse as gaze** — substitute mouse position for eye-tracking (useful for development and demos without a webcam). 61 73 - **End Test** — force-stop a running session. 74 + 75 + ## Export schema 76 + 77 + New exports use `schemaVersion: "3"` and include: 78 + 79 + - full `screenRecords` with screenshots, gaze points, interaction events, and element snapshots 80 + - `mouseTrace` sampled during recording 81 + - `fixations` 82 + - `calibration` 83 + - `analysisContext` 84 + - recomputed deterministic `analysis` with ranked findings, confidence, screen metrics, and element metrics 85 + 86 + Older UXET JSON files can still be imported, but they only support a subset of the richer analysis. UXET recomputes analysis on import rather than trusting stale embedded reports. 87 + 88 + ## Analytics engine v3 89 + 90 + The v3 engine is deterministic and offline. It computes attention friction, interaction friction, data coverage, screen metrics, element metrics, and task-agnostic pre-action exploration metrics, then ranks evidence-bound findings by severity, confidence, recurrence, affected time, and data quality. 91 + 92 + UXET does not infer the correct task target from task text. It focuses on observable behavior: time to first action, how many regions or elements were inspected before action, scroll depth before action, repeated clicks, and post-action feedback patterns. Fast first actions suppress speculative friction claims unless there is clear repeated interaction failure. 93 + 94 + Element-level findings are generated when exports include element snapshots or click fingerprints. Future recordings capture more clickable/card-like elements as areas of interest, including `[data-id]`, `[onclick]`, `[tabindex]`, and pointer-cursor elements. If element snapshots are missing, UXET falls back to spatial zone metrics and shows a confidence warning. 95 + 96 + ### Analysis tests 97 + 98 + Run the lightweight module tests with: 99 + 100 + ```bash 101 + node tests/analysis.test.js 102 + ``` 103 + 104 + Or open `tests/run-analysis-tests.html` from the local server. 62 105 63 106 ## Adding your own apps 64 107
+161 -2
index.css
··· 527 527 528 528 .debrief-header { 529 529 display: flex; 530 - justify-content: space-between; 530 + flex-direction: column; 531 + justify-content: flex-start; 531 532 gap: var(--space-4); 532 - align-items: flex-end; 533 + align-items: flex-start; 533 534 flex-wrap: wrap; 534 535 padding-bottom: var(--space-4); 535 536 border-bottom: 2px solid var(--border); 537 + } 538 + 539 + .debrief-actions { 540 + display: flex; 541 + align-items: center; 542 + justify-content: flex-start; 543 + gap: var(--space-3); 544 + flex-wrap: wrap; 545 + width: 100%; 536 546 } 537 547 538 548 #stats-container { ··· 570 580 gap: var(--space-5); 571 581 } 572 582 583 + .analysis-banner { 584 + display: flex; 585 + justify-content: space-between; 586 + gap: var(--space-4); 587 + align-items: flex-start; 588 + flex-wrap: wrap; 589 + padding: var(--space-4) var(--space-5); 590 + border-top: 3px solid var(--secondary); 591 + } 592 + 593 + .analysis-banner-copy h3, 594 + .analysis-panel h3, 595 + .analysis-finding h4 { 596 + margin: 0; 597 + } 598 + 599 + .analysis-banner-copy p { 600 + margin: var(--space-2) 0 0; 601 + color: var(--muted); 602 + } 603 + 604 + .analysis-banner-meta { 605 + display: flex; 606 + gap: var(--space-2); 607 + flex-wrap: wrap; 608 + justify-content: flex-end; 609 + } 610 + 611 + .analysis-grid { 612 + display: grid; 613 + grid-template-columns: repeat(2, minmax(0, 1fr)); 614 + gap: var(--space-4); 615 + } 616 + 617 + .analysis-panel { 618 + padding: var(--space-4); 619 + } 620 + 621 + .analysis-panel:last-child { 622 + grid-column: 1 / -1; 623 + } 624 + 625 + .analysis-panel header { 626 + margin-bottom: var(--space-3); 627 + padding-bottom: var(--space-2); 628 + border-bottom: 1px dashed var(--border); 629 + } 630 + 631 + .analysis-finding { 632 + padding: var(--space-3) 0; 633 + border-top: 1px solid var(--border); 634 + } 635 + 636 + .analysis-finding:first-child { 637 + border-top: none; 638 + padding-top: 0; 639 + } 640 + 641 + .analysis-finding-header { 642 + margin-bottom: var(--space-2); 643 + } 644 + 645 + .analysis-finding p, 646 + .analysis-finding ul, 647 + .analysis-empty { 648 + margin: var(--space-2) 0 0; 649 + } 650 + 651 + .analysis-finding ul { 652 + padding-left: 18px; 653 + color: var(--muted); 654 + } 655 + 656 + .analysis-target { 657 + font-family: var(--font-mono); 658 + font-size: 0.72rem; 659 + color: var(--muted); 660 + overflow-wrap: anywhere; 661 + } 662 + 663 + .analysis-metric-rail { 664 + display: grid; 665 + grid-template-columns: repeat(4, minmax(0, 1fr)); 666 + gap: var(--space-2); 667 + margin-bottom: var(--space-3); 668 + } 669 + 670 + .analysis-mini-stat { 671 + min-width: 0; 672 + padding: var(--space-2); 673 + border: 1px solid var(--border); 674 + background: var(--surface-2); 675 + } 676 + 677 + .analysis-mini-stat span, 678 + .analysis-mini-stat strong { 679 + display: block; 680 + } 681 + 682 + .analysis-mini-stat span { 683 + color: var(--muted); 684 + font-size: 0.68rem; 685 + text-transform: uppercase; 686 + font-family: var(--font-mono); 687 + } 688 + 689 + .analysis-mini-stat strong { 690 + margin-top: 2px; 691 + font-size: 1rem; 692 + overflow-wrap: anywhere; 693 + } 694 + 695 + .analysis-badge { 696 + display: inline-flex; 697 + align-items: center; 698 + padding: 2px 8px; 699 + border: 1px solid var(--border); 700 + font-size: 0.72rem; 701 + text-transform: uppercase; 702 + letter-spacing: 0.04em; 703 + font-family: var(--font-mono); 704 + margin-right: var(--space-2); 705 + color: var(--text); 706 + background: var(--surface-2); 707 + } 708 + 709 + .analysis-badge-high, 710 + .analysis-severity-high { 711 + border-color: var(--danger); 712 + } 713 + 714 + .analysis-badge-moderate, 715 + .analysis-severity-moderate { 716 + border-color: var(--primary); 717 + } 718 + 719 + .analysis-badge-info, 720 + .analysis-severity-info { 721 + border-color: var(--secondary); 722 + } 723 + 573 724 .screen-card { 574 725 padding: var(--space-5); 575 726 } ··· 637 788 @media (max-width: 720px) { 638 789 #status-bar { 639 790 grid-template-columns: 1fr; 791 + } 792 + 793 + .analysis-grid { 794 + grid-template-columns: 1fr; 795 + } 796 + 797 + .analysis-metric-rail { 798 + grid-template-columns: repeat(2, minmax(0, 1fr)); 640 799 } 641 800 642 801 #setup-shell {
+32
index.html
··· 29 29 <h2>Session Debrief</h2> 30 30 <p id="screen-gallery-label">Heatmaps will appear here after a test completes.</p> 31 31 </div> 32 + <div class="debrief-actions"> 33 + <button id="debrief-test-again-btn" type="button">Test Again</button> 34 + <button id="debrief-export-btn" type="button">Export Data</button> 35 + <button id="debrief-reset-btn" type="button">Reset</button> 36 + </div> 32 37 </header> 38 + 39 + <section id="imported-session-banner" class="overlay-card analysis-banner"></section> 33 40 34 41 <section class="overlay-card debrief-panel"> 35 42 <div id="stats-container"> ··· 45 52 </div> 46 53 </section> 47 54 55 + <section class="analysis-grid"> 56 + <section class="overlay-card analysis-panel"> 57 + <header><h3>Executive Summary</h3></header> 58 + <div id="analysis-summary"></div> 59 + </section> 60 + <section class="overlay-card analysis-panel"> 61 + <header><h3>Top Findings</h3></header> 62 + <div id="analysis-attention"></div> 63 + </section> 64 + <section class="overlay-card analysis-panel"> 65 + <header><h3>Element-Level Issues</h3></header> 66 + <div id="analysis-mouse"></div> 67 + </section> 68 + <section class="overlay-card analysis-panel"> 69 + <header><h3>Screen &amp; Journey Findings</h3></header> 70 + <div id="analysis-timeline"></div> 71 + </section> 72 + <section class="overlay-card analysis-panel"> 73 + <header><h3>Data Quality &amp; Confidence</h3></header> 74 + <div id="analysis-quality"></div> 75 + </section> 76 + </section> 77 + 48 78 <div id="screen-gallery"></div> 49 79 </section> 50 80 </section> ··· 77 107 78 108 <div class="primary-actions"> 79 109 <button id="load-app-btn" type="button">Load App</button> 110 + <button id="import-btn" type="button">Import Data</button> 111 + <input id="import-input" type="file" accept=".json,application/json" multiple class="hidden"> 80 112 <button id="export-btn" type="button" disabled>Export Data</button> 81 113 <button id="reset-btn" type="button">Reset</button> 82 114 </div>
+134
js/analysisElements.js
··· 1 + import { distanceToRect, groupBy, pointInRect } from './analysisMetrics.js'; 2 + 3 + const NEAR_DISTANCE = 48; 4 + const ACTIONABLE_TAGS = new Set(['a', 'button', 'input', 'select', 'textarea', 'label']); 5 + 6 + function rectArea(rect) { 7 + return Math.max(0, rect?.width || 0) * Math.max(0, rect?.height || 0); 8 + } 9 + 10 + function isActionable(element) { 11 + return ACTIONABLE_TAGS.has(element.tag) || Boolean(element.role) || Boolean(element.fingerprint); 12 + } 13 + 14 + function labelFor(element) { 15 + return element.label || element.text || element.fingerprint || `${element.tag || 'element'}`; 16 + } 17 + 18 + function findElementForClick(click, elements) { 19 + if (click.clickTargetFingerprint) { 20 + const exact = elements.find((element) => element.fingerprint === click.clickTargetFingerprint); 21 + if (exact) { 22 + return exact; 23 + } 24 + } 25 + const containing = elements.find((element) => pointInRect(click, element.rect)); 26 + if (containing) { 27 + return containing; 28 + } 29 + return elements 30 + .map((element) => ({ element, distance: distanceToRect(click, element.rect) })) 31 + .filter((item) => item.distance <= NEAR_DISTANCE) 32 + .sort((a, b) => a.distance - b.distance)[0]?.element || null; 33 + } 34 + 35 + function repeatedClickCount(clicks) { 36 + let count = 0; 37 + for (let index = 1; index < clicks.length; index += 1) { 38 + const current = clicks[index]; 39 + const previous = clicks[index - 1]; 40 + if (current.timestamp - previous.timestamp <= 1200 && 41 + Math.hypot((current.docX || 0) - (previous.docX || 0), (current.docY || 0) - (previous.docY || 0)) <= 36) { 42 + count += 1; 43 + } 44 + } 45 + return count; 46 + } 47 + 48 + function hasLookBeforeClick(click, fixations) { 49 + return fixations.some((fixation) => 50 + fixation.endTime <= click.timestamp && 51 + click.timestamp - fixation.endTime <= 1600 && 52 + Math.hypot(fixation.x - (click.docX || 0), fixation.y - (click.docY || 0)) <= 120 53 + ); 54 + } 55 + 56 + function hasPostClickConfirmation(click, fixations) { 57 + return fixations.some((fixation) => 58 + fixation.startTime >= click.timestamp && 59 + fixation.startTime - click.timestamp <= 1500 && 60 + Math.hypot(fixation.x - (click.docX || 0), fixation.y - (click.docY || 0)) <= 120 61 + ); 62 + } 63 + 64 + export function buildElementMetrics({ screens, events, fixations }) { 65 + const eventsByScreen = groupBy(events, (event) => event.screenKey); 66 + const fixationsByScreen = groupBy(fixations, (fixation) => fixation.screenKey); 67 + const metrics = []; 68 + 69 + screens.forEach((screen) => { 70 + const elements = screen.elementSnapshots 71 + .filter((element) => element.visible !== false && rectArea(element.rect) > 0 && isActionable(element)) 72 + .slice(0, 120); 73 + if (!elements.length) { 74 + return; 75 + } 76 + 77 + const screenEvents = eventsByScreen.get(screen.key) || []; 78 + const screenClicks = screenEvents.filter((event) => event.type === 'click' && Number.isFinite(event.docX) && Number.isFinite(event.docY)); 79 + const screenFixations = fixationsByScreen.get(screen.key) || []; 80 + const clicksByFingerprint = new Map(elements.map((element) => [element.fingerprint, []])); 81 + const missedByFingerprint = new Map(elements.map((element) => [element.fingerprint, 0])); 82 + 83 + screenClicks.forEach((click) => { 84 + const element = findElementForClick(click, elements); 85 + if (!element) { 86 + return; 87 + } 88 + clicksByFingerprint.get(element.fingerprint).push(click); 89 + if (!pointInRect(click, element.rect)) { 90 + missedByFingerprint.set(element.fingerprint, (missedByFingerprint.get(element.fingerprint) || 0) + 1); 91 + } 92 + }); 93 + 94 + elements.forEach((element) => { 95 + const nearbyFixations = screenFixations.filter((fixation) => { 96 + const point = { docX: fixation.x, docY: fixation.y }; 97 + return pointInRect(point, element.rect) || distanceToRect(point, element.rect) <= NEAR_DISTANCE; 98 + }); 99 + const clicks = clicksByFingerprint.get(element.fingerprint) || []; 100 + const lookBeforeClicks = clicks.filter((click) => hasLookBeforeClick(click, screenFixations)).length; 101 + const postClickConfirmations = clicks.filter((click) => hasPostClickConfirmation(click, screenFixations)).length; 102 + const totalFixationDuration = nearbyFixations.reduce((sum, fixation) => sum + fixation.duration, 0); 103 + const clickCount = clicks.length; 104 + const nearbyFixationCount = nearbyFixations.length; 105 + 106 + metrics.push({ 107 + screenKey: screen.key, 108 + screenTitle: screen.title, 109 + fingerprint: element.fingerprint, 110 + tag: element.tag, 111 + role: element.role, 112 + label: labelFor(element), 113 + text: element.text, 114 + rect: { ...element.rect }, 115 + area: rectArea(element.rect), 116 + disabled: Boolean(element.disabled), 117 + clickCount, 118 + nearbyFixationCount, 119 + totalFixationDuration, 120 + firstFixationTime: nearbyFixations[0]?.startTime || null, 121 + firstClickTime: clicks[0]?.timestamp || null, 122 + lookBeforeClickRate: clickCount ? Math.round((lookBeforeClicks / clickCount) * 100) : 0, 123 + postClickConfirmationRate: clickCount ? Math.round((postClickConfirmations / clickCount) * 100) : 0, 124 + repeatedClickCount: repeatedClickCount(clicks), 125 + missedClickProximityCount: missedByFingerprint.get(element.fingerprint) || 0, 126 + deadOrDisabledClicked: Boolean(element.disabled && clickCount > 0), 127 + attentionWithoutActionScore: clickCount === 0 ? Math.min(100, Math.round(totalFixationDuration / 35)) : 0, 128 + actionWithoutAttentionScore: clickCount > 0 ? Math.max(0, 100 - (nearbyFixationCount * 20) - lookBeforeClicks * 20) : 0 129 + }); 130 + }); 131 + }); 132 + 133 + return metrics; 134 + }
+447
js/analysisFindings.js
··· 1 + import { clamp } from './analysisMetrics.js'; 2 + 3 + const SEVERITY_WEIGHT = { high: 35, moderate: 22, info: 10 }; 4 + const CONFIDENCE_WEIGHT = { high: 20, medium: 10, low: 0 }; 5 + 6 + function evidence(label, value, unit = '') { 7 + return { label, value, unit }; 8 + } 9 + 10 + function priority({ severity, confidence, scope, recurrence = 0, affectedTime = 0, dataQualityPenalty = 0 }) { 11 + return clamp(Math.round( 12 + (SEVERITY_WEIGHT[severity] || 0) + 13 + (CONFIDENCE_WEIGHT[confidence] || 0) + 14 + Math.min(recurrence, 20) + 15 + Math.min(affectedTime, 12) + 16 + (scope === 'element' ? 8 : 0) - 17 + dataQualityPenalty 18 + ), 0, 100); 19 + } 20 + 21 + function finding(source) { 22 + return { 23 + limitations: [], 24 + ...source, 25 + priority: priority(source) 26 + }; 27 + } 28 + 29 + function qualityPenalty(confidence) { 30 + if (confidence.score < 45) { 31 + return 25; 32 + } 33 + if (confidence.score < 70) { 34 + return 10; 35 + } 36 + return 0; 37 + } 38 + 39 + function hasFastAction(globalMetrics) { 40 + return globalMetrics.behaviorOutcome === 'fast-action' || globalMetrics.timeToFirstAction <= 1500; 41 + } 42 + 43 + function hasEnoughPostActionObservation(globalMetrics) { 44 + return Math.max(0, globalMetrics.duration - globalMetrics.timeToFirstAction) >= 2500; 45 + } 46 + 47 + export function buildFindings({ artifact, screenMetrics, elementMetrics, globalMetrics, warnings, confidence }) { 48 + const findings = []; 49 + const dataPenalty = qualityPenalty(confidence); 50 + const fastAction = hasFastAction(globalMetrics); 51 + 52 + if (fastAction) { 53 + findings.push(finding({ 54 + id: 'fast-first-action', 55 + category: 'attention', 56 + scope: 'session', 57 + severity: 'info', 58 + confidence: confidence.score >= 70 ? 'high' : 'medium', 59 + title: 'First action happened quickly', 60 + evidence: [ 61 + evidence('Time to first action', globalMetrics.timeToFirstAction, 'ms'), 62 + evidence('Pre-action zones viewed', globalMetrics.preActionUniqueZones), 63 + evidence('Pre-action elements viewed', globalMetrics.preActionUniqueElements), 64 + evidence('Session duration', globalMetrics.duration, 'ms') 65 + ], 66 + implication: 'The user acted almost immediately, so there is no strong evidence of early discovery friction.', 67 + recommendation: 'Treat any gaze-only concern in this short path as supporting context, not a primary issue.' 68 + })); 69 + } 70 + 71 + if (globalMetrics.behaviorOutcome === 'smooth-action-path') { 72 + findings.push(finding({ 73 + id: 'smooth-action-path', 74 + category: 'interaction', 75 + scope: 'session', 76 + severity: 'info', 77 + confidence: 'medium', 78 + title: 'Action path progressed without repeated interaction friction', 79 + evidence: [ 80 + evidence('Clicks', globalMetrics.totalClicks), 81 + evidence('Repeated click bursts', globalMetrics.repeatedClickBursts), 82 + evidence('Time to first action', globalMetrics.timeToFirstAction, 'ms') 83 + ], 84 + implication: 'The interaction sequence did not show repeated failed attempts or obvious motor friction.', 85 + recommendation: 'Use this session as a directional baseline unless more sessions show repeated hesitation.' 86 + })); 87 + } 88 + 89 + if (globalMetrics.timeToFirstAction >= 5000) { 90 + findings.push(finding({ 91 + id: 'delayed-first-action', 92 + category: 'attention', 93 + scope: 'session', 94 + severity: globalMetrics.timeToFirstAction > 10000 ? 'high' : 'moderate', 95 + confidence: confidence.score >= 70 ? 'high' : 'medium', 96 + affectedTime: Math.min(12, globalMetrics.timeToFirstAction / 1000), 97 + title: 'Delayed first action after observation', 98 + evidence: [ 99 + evidence('Time to first action', globalMetrics.timeToFirstAction, 'ms'), 100 + evidence('Pre-action fixations', globalMetrics.preActionFixationCount), 101 + evidence('Pre-action zones viewed', globalMetrics.preActionUniqueZones), 102 + evidence('Pre-action scroll depth', globalMetrics.preActionScrollDepth, '%') 103 + ], 104 + implication: 'The user spent a meaningful amount of time observing the interface before taking the first action.', 105 + recommendation: 'Review the first screen for whether the initial action choices are easy to distinguish and visually prioritize.' 106 + })); 107 + } 108 + 109 + if (globalMetrics.timeToFirstAction >= 3000 && 110 + (globalMetrics.preActionUniqueZones >= 6 || 111 + globalMetrics.preActionUniqueElements >= 5 || 112 + globalMetrics.preActionGazeEntropy >= 2.8)) { 113 + findings.push(finding({ 114 + id: 'broad-pre-action-search', 115 + category: 'attention', 116 + scope: 'session', 117 + severity: globalMetrics.timeToFirstAction >= 5000 ? 'high' : 'moderate', 118 + confidence: confidence.score >= 70 ? 'high' : 'medium', 119 + affectedTime: Math.min(12, globalMetrics.preActionFixationDuration / 1000), 120 + title: 'Broad pre-action exploration', 121 + evidence: [ 122 + evidence('Pre-action zones viewed', globalMetrics.preActionUniqueZones), 123 + evidence('Pre-action elements viewed', globalMetrics.preActionUniqueElements), 124 + evidence('Pre-action gaze entropy', globalMetrics.preActionGazeEntropy), 125 + evidence('Pre-action fixation duration', globalMetrics.preActionFixationDuration, 'ms') 126 + ], 127 + implication: 'Before acting, attention spread across several regions rather than settling quickly.', 128 + recommendation: 'Check whether the first screen offers too many similarly weighted options before the user can commit.' 129 + })); 130 + } 131 + 132 + elementMetrics.forEach((element) => { 133 + if (element.repeatedClickCount >= 1) { 134 + findings.push(finding({ 135 + id: 'element-repeated-clicks', 136 + category: 'interaction', 137 + scope: 'element', 138 + severity: element.repeatedClickCount >= 2 ? 'high' : 'moderate', 139 + confidence: 'high', 140 + recurrence: element.repeatedClickCount * 8, 141 + title: `Repeated clicks on ${element.label}`, 142 + evidence: [ 143 + evidence('Repeated click bursts', element.repeatedClickCount), 144 + evidence('Total clicks', element.clickCount), 145 + evidence('Screen', element.screenTitle) 146 + ], 147 + implication: 'The control may not be giving users enough immediate feedback, or it may feel unreliable.', 148 + recommendation: 'Make the control state change visibly on click and confirm that the resulting action is immediate and noticeable.', 149 + screenKey: element.screenKey, 150 + elementFingerprint: element.fingerprint 151 + })); 152 + } 153 + 154 + if (element.deadOrDisabledClicked) { 155 + findings.push(finding({ 156 + id: 'disabled-element-clicked', 157 + category: 'interaction', 158 + scope: 'element', 159 + severity: 'high', 160 + confidence: 'high', 161 + recurrence: element.clickCount * 6, 162 + title: `Disabled control attracted clicks: ${element.label}`, 163 + evidence: [ 164 + evidence('Clicks', element.clickCount), 165 + evidence('Element state', 'disabled'), 166 + evidence('Screen', element.screenTitle) 167 + ], 168 + implication: 'Users tried to act on a control that was not available, which usually indicates unclear requirements or state feedback.', 169 + recommendation: 'Explain why the control is unavailable near the control itself, or keep it hidden until it can be used.', 170 + screenKey: element.screenKey, 171 + elementFingerprint: element.fingerprint 172 + })); 173 + } 174 + 175 + if (!fastAction && element.clickCount > 0 && element.lookBeforeClickRate < 45) { 176 + findings.push(finding({ 177 + id: 'element-action-without-attention', 178 + category: 'attention', 179 + scope: 'element', 180 + severity: 'moderate', 181 + confidence: artifact.fidelity.hasRawGazePoints ? 'medium' : 'low', 182 + recurrence: element.clickCount * 2, 183 + dataQualityPenalty: dataPenalty, 184 + title: `Clicks on ${element.label} were weakly preceded by gaze`, 185 + evidence: [ 186 + evidence('Look-before-click rate', element.lookBeforeClickRate, '%'), 187 + evidence('Clicks', element.clickCount), 188 + evidence('Screen', element.screenTitle) 189 + ], 190 + implication: 'The action may have happened with limited visual confirmation near the clicked control.', 191 + recommendation: 'Use this as supporting evidence only; prioritize it when it repeats across sessions or appears with delayed action.', 192 + screenKey: element.screenKey, 193 + elementFingerprint: element.fingerprint, 194 + limitations: artifact.fidelity.hasRawGazePoints ? [] : ['No raw gaze points were available.'] 195 + })); 196 + } 197 + 198 + if (!fastAction && element.attentionWithoutActionScore >= 60) { 199 + findings.push(finding({ 200 + id: 'element-attention-without-action', 201 + category: 'attention', 202 + scope: 'element', 203 + severity: element.attentionWithoutActionScore >= 85 ? 'high' : 'moderate', 204 + confidence: 'medium', 205 + affectedTime: Math.min(12, element.totalFixationDuration / 1000), 206 + dataQualityPenalty: dataPenalty, 207 + title: `Long attention without action on ${element.label}`, 208 + evidence: [ 209 + evidence('Fixation duration', element.totalFixationDuration, 'ms'), 210 + evidence('Nearby fixations', element.nearbyFixationCount), 211 + evidence('Clicks', element.clickCount) 212 + ], 213 + implication: 'This element drew attention but did not resolve into action, which can signal ambiguity or a competing visual target.', 214 + recommendation: 'Clarify whether this element is actionable, informational, or secondary; reduce its visual weight if it is not part of the immediate action path.', 215 + screenKey: element.screenKey, 216 + elementFingerprint: element.fingerprint 217 + })); 218 + } 219 + 220 + if (!fastAction && 221 + hasEnoughPostActionObservation(globalMetrics) && 222 + element.clickCount > 1 && 223 + element.postClickConfirmationRate < 35) { 224 + findings.push(finding({ 225 + id: 'post-action-confirmation-gap', 226 + category: 'interaction', 227 + scope: 'element', 228 + severity: element.clickCount >= 3 ? 'moderate' : 'info', 229 + confidence: 'medium', 230 + recurrence: element.clickCount * 2, 231 + dataQualityPenalty: dataPenalty, 232 + title: `Weak post-action confirmation near ${element.label}`, 233 + evidence: [ 234 + evidence('Post-click confirmation', element.postClickConfirmationRate, '%'), 235 + evidence('Clicks', element.clickCount), 236 + evidence('Screen', element.screenTitle) 237 + ], 238 + implication: 'After acting, attention did not return near this control often enough to suggest clear visible confirmation.', 239 + recommendation: 'Only treat this as a problem if users continue acting repeatedly or verbal feedback confirms uncertainty.', 240 + screenKey: element.screenKey, 241 + elementFingerprint: element.fingerprint 242 + })); 243 + } 244 + }); 245 + 246 + if (globalMetrics.backtrackCount >= 2) { 247 + findings.push(finding({ 248 + id: 'screen-backtracking', 249 + category: 'navigation', 250 + scope: 'session', 251 + severity: globalMetrics.backtrackCount >= 4 ? 'high' : 'moderate', 252 + confidence: 'medium', 253 + recurrence: globalMetrics.backtrackCount * 5, 254 + title: 'Repeated screen backtracking', 255 + evidence: [ 256 + evidence('Backtracks', globalMetrics.backtrackCount), 257 + evidence('Screen transitions', globalMetrics.transitionCount) 258 + ], 259 + implication: 'The user returned to prior screens enough times to suggest weak information scent or uncertain next steps.', 260 + recommendation: 'Improve progress cues and make the relationship between screens and action steps easier to predict.' 261 + })); 262 + } 263 + 264 + if (!fastAction && 265 + globalMetrics.scrollDepthBeforeFirstAction > 65 && 266 + globalMetrics.timeToFirstAction > 2500) { 267 + findings.push(finding({ 268 + id: 'deep-scroll-before-action', 269 + category: 'scroll', 270 + scope: 'session', 271 + severity: 'moderate', 272 + confidence: 'medium', 273 + title: 'Deep scrolling happened before the first action', 274 + evidence: [ 275 + evidence('Scroll depth before first action', globalMetrics.scrollDepthBeforeFirstAction, '%'), 276 + evidence('First action latency', globalMetrics.timeToFirstAction, 'ms') 277 + ], 278 + implication: 'The user moved through substantial page depth before committing to the first action.', 279 + recommendation: 'Review whether above-the-fold choices and section cues support quick action.' 280 + })); 281 + } 282 + 283 + if (!fastAction && 284 + globalMetrics.attentionFrictionScore >= 70 && 285 + !findings.some((item) => item.id === 'delayed-first-action' || item.id === 'broad-pre-action-search')) { 286 + findings.push(finding({ 287 + id: 'broad-search-pattern', 288 + category: 'attention', 289 + scope: 'session', 290 + severity: 'moderate', 291 + confidence: confidence.score >= 70 ? 'medium' : 'low', 292 + dataQualityPenalty: dataPenalty, 293 + title: 'Broad search pattern', 294 + evidence: [ 295 + evidence('Attention friction score', globalMetrics.attentionFrictionScore, '/100'), 296 + evidence('Pre-action zones viewed', globalMetrics.preActionUniqueZones), 297 + evidence('Scanpath length', globalMetrics.scanpathLength, 'px') 298 + ], 299 + implication: 'The gaze pattern was broad, but the evidence is not specific enough to call it high friction by itself.', 300 + recommendation: 'Compare against additional sessions before treating this broad scanning pattern as a design issue.' 301 + })); 302 + } 303 + 304 + if (!fastAction && artifact.fidelity.hasMouseTrace && artifact.session.duration >= 5000 && globalMetrics.pathEfficiency > 1.8) { 305 + findings.push(finding({ 306 + id: 'circuitous-mouse', 307 + category: 'interaction', 308 + scope: 'session', 309 + severity: 'moderate', 310 + confidence: 'medium', 311 + title: 'Mouse movement was circuitous before action', 312 + evidence: [ 313 + evidence('Path efficiency ratio', globalMetrics.pathEfficiency), 314 + evidence('Hesitation windows', globalMetrics.hesitationCount) 315 + ], 316 + implication: 'Cursor movement suggests some difficulty locating or committing to intended controls.', 317 + recommendation: 'Treat this as supporting evidence alongside delayed action or repeated interaction signals.' 318 + })); 319 + } 320 + 321 + if (artifact.interactions.stats.keyboard.backspaces >= 4) { 322 + findings.push(finding({ 323 + id: 'form-corrections', 324 + category: 'form', 325 + scope: 'session', 326 + severity: 'moderate', 327 + confidence: 'medium', 328 + recurrence: artifact.interactions.stats.keyboard.backspaces * 2, 329 + title: 'Form correction activity was elevated', 330 + evidence: [ 331 + evidence('Backspaces', artifact.interactions.stats.keyboard.backspaces), 332 + evidence('Total keys', artifact.interactions.stats.keyboard.totalKeys) 333 + ], 334 + implication: 'The user corrected input often enough to warrant checking field expectations, validation, and labels.', 335 + recommendation: 'Review field labels, examples, validation timing, and accepted formats for this screen.' 336 + })); 337 + } 338 + 339 + screenMetrics.forEach((screen) => { 340 + if (!fastAction && screen.gazeEntropy >= 3.2 && screen.clickCount <= 1 && screen.fixationCount >= 5) { 341 + findings.push(finding({ 342 + id: 'diffuse-screen-attention', 343 + category: 'attention', 344 + scope: 'screen', 345 + severity: 'moderate', 346 + confidence: 'medium', 347 + title: `Attention was diffuse on ${screen.title}`, 348 + evidence: [ 349 + evidence('Gaze entropy', screen.gazeEntropy), 350 + evidence('Clicks', screen.clickCount), 351 + evidence('Fixations', screen.fixationCount) 352 + ], 353 + implication: 'The user scanned broadly without many actions, which can indicate unclear hierarchy or weak information scent.', 354 + recommendation: 'Clarify headings, grouping, and the dominant action path for this screen.', 355 + screenKey: screen.key 356 + })); 357 + } 358 + 359 + if (!fastAction && screen.scrollToFixationLatency > 1200) { 360 + findings.push(finding({ 361 + id: 'scroll-latency', 362 + category: 'scroll', 363 + scope: 'screen', 364 + severity: 'info', 365 + confidence: 'medium', 366 + title: `Attention took time to stabilize after scrolling on ${screen.title}`, 367 + evidence: [ 368 + evidence('Scroll-to-fixation latency', screen.scrollToFixationLatency, 'ms'), 369 + evidence('Scroll events', screen.scrollCount) 370 + ], 371 + implication: 'Information scent below the fold may be weak, forcing extra search after scroll transitions.', 372 + recommendation: 'Use stronger section signposting and preserve contextual cues across scroll depth.', 373 + screenKey: screen.key 374 + })); 375 + } 376 + }); 377 + 378 + warnings.forEach((warning) => { 379 + findings.push(finding({ 380 + id: `quality-${warning.code}`, 381 + category: 'quality', 382 + scope: 'session', 383 + severity: warning.severity, 384 + confidence: 'high', 385 + title: warning.code.replace(/-/g, ' '), 386 + evidence: [evidence('Warning', warning.message)], 387 + implication: warning.message, 388 + recommendation: 'Use this limitation when interpreting the rest of the report.' 389 + })); 390 + }); 391 + 392 + if (!findings.some((item) => item.category !== 'quality')) { 393 + findings.push(finding({ 394 + id: 'balanced-session', 395 + category: 'attention', 396 + scope: 'session', 397 + severity: 'info', 398 + confidence: confidence.score >= 70 ? 'medium' : 'low', 399 + title: 'No dominant friction signal exceeded the current threshold', 400 + evidence: [ 401 + evidence('Data quality warnings', warnings.length), 402 + evidence('Clicks', globalMetrics.totalClicks), 403 + evidence('Fixations', globalMetrics.totalFixations) 404 + ], 405 + implication: 'The session did not produce a dominant friction signature under the current conservative rules.', 406 + recommendation: 'Use this as a baseline and compare across additional sessions before drawing stronger conclusions.' 407 + })); 408 + } 409 + 410 + return findings.sort((a, b) => b.priority - a.priority); 411 + } 412 + 413 + export function buildSummary({ findings, confidence, globalMetrics }) { 414 + const actionable = findings.filter((item) => item.category !== 'quality'); 415 + const top = actionable.slice(0, 3); 416 + let verdict = 'low-friction'; 417 + 418 + if (confidence.score < 45 || globalMetrics.behaviorOutcome === 'inconclusive') { 419 + verdict = 'low-confidence'; 420 + } else if (globalMetrics.behaviorOutcome === 'delayed-first-action' && globalMetrics.timeToFirstAction > 10000) { 421 + verdict = 'high-friction'; 422 + } else if (['delayed-first-action', 'high-pre-action-exploration', 'interaction-friction'].includes(globalMetrics.behaviorOutcome)) { 423 + verdict = 'mixed'; 424 + } 425 + 426 + const headline = verdict === 'low-confidence' 427 + ? 'Analysis confidence is limited by data quality.' 428 + : globalMetrics.behaviorOutcome === 'fast-action' 429 + ? 'The first action happened quickly with no strong friction signal.' 430 + : globalMetrics.behaviorOutcome === 'delayed-first-action' 431 + ? 'The main signal was delayed action after pre-action exploration.' 432 + : globalMetrics.behaviorOutcome === 'high-pre-action-exploration' 433 + ? 'The main signal was broad exploration before action.' 434 + : globalMetrics.behaviorOutcome === 'interaction-friction' 435 + ? 'The main signal was interaction friction after action began.' 436 + : verdict === 'mixed' 437 + ? 'The session shows some focused friction signals.' 438 + : 'No dominant friction signal exceeded the current thresholds.'; 439 + 440 + return { 441 + headline, 442 + verdict, 443 + topFindings: top.map((item) => item.title), 444 + confidenceScore: confidence.score 445 + }; 446 + } 447 +
+398
js/analysisMetrics.js
··· 1 + export function median(values) { 2 + const numeric = values.filter(Number.isFinite); 3 + if (!numeric.length) { 4 + return 0; 5 + } 6 + const sorted = [...numeric].sort((a, b) => a - b); 7 + const middle = Math.floor(sorted.length / 2); 8 + return sorted.length % 2 === 0 9 + ? Math.round((sorted[middle - 1] + sorted[middle]) / 2) 10 + : Math.round(sorted[middle]); 11 + } 12 + 13 + export function average(values) { 14 + const numeric = values.filter(Number.isFinite); 15 + return numeric.length 16 + ? numeric.reduce((sum, value) => sum + value, 0) / numeric.length 17 + : 0; 18 + } 19 + 20 + export function clamp(value, min, max) { 21 + return Math.min(max, Math.max(min, value)); 22 + } 23 + 24 + export function distance(a, b) { 25 + return Math.hypot((a?.docX || a?.x || 0) - (b?.docX || b?.x || 0), (a?.docY || a?.y || 0) - (b?.docY || b?.y || 0)); 26 + } 27 + 28 + export function groupBy(items, keyFn) { 29 + const map = new Map(); 30 + items.forEach((item) => { 31 + const key = keyFn(item); 32 + if (!map.has(key)) { 33 + map.set(key, []); 34 + } 35 + map.get(key).push(item); 36 + }); 37 + return map; 38 + } 39 + 40 + export function detectFixations(points, fixationThreshold = 50, fixationMinDuration = 150) { 41 + const fixations = []; 42 + let current = null; 43 + 44 + const finalize = (endTime) => { 45 + if (!current) { 46 + return; 47 + } 48 + const duration = endTime - current.startTime; 49 + if (duration >= fixationMinDuration) { 50 + fixations.push({ 51 + screenKey: current.screenKey, 52 + startTime: current.startTime, 53 + endTime, 54 + duration, 55 + x: Math.round(current.x), 56 + y: Math.round(current.y), 57 + pointCount: current.pointCount 58 + }); 59 + } 60 + current = null; 61 + }; 62 + 63 + points.forEach((point) => { 64 + if (!current) { 65 + current = { 66 + x: point.docX, 67 + y: point.docY, 68 + screenKey: point.screenKey, 69 + startTime: point.timestamp, 70 + pointCount: 1 71 + }; 72 + return; 73 + } 74 + 75 + const sameScreen = point.screenKey === current.screenKey; 76 + const movement = Math.hypot(point.docX - current.x, point.docY - current.y); 77 + if (sameScreen && movement <= fixationThreshold) { 78 + current.pointCount += 1; 79 + current.x = ((current.x * (current.pointCount - 1)) + point.docX) / current.pointCount; 80 + current.y = ((current.y * (current.pointCount - 1)) + point.docY) / current.pointCount; 81 + return; 82 + } 83 + 84 + finalize(point.timestamp); 85 + current = { 86 + x: point.docX, 87 + y: point.docY, 88 + screenKey: point.screenKey, 89 + startTime: point.timestamp, 90 + pointCount: 1 91 + }; 92 + }); 93 + 94 + if (current) { 95 + finalize(points[points.length - 1]?.timestamp || Date.now()); 96 + } 97 + 98 + return fixations; 99 + } 100 + 101 + export function computeScanpathLength(points) { 102 + let total = 0; 103 + for (let index = 1; index < points.length; index += 1) { 104 + if (points[index].screenKey !== points[index - 1].screenKey) { 105 + continue; 106 + } 107 + total += distance(points[index], points[index - 1]); 108 + } 109 + return Math.round(total); 110 + } 111 + 112 + export function computeEntropy(points, width, height) { 113 + if (!points.length || width <= 0 || height <= 0) { 114 + return 0; 115 + } 116 + const buckets = new Map(); 117 + const cols = 4; 118 + const rows = 4; 119 + points.forEach((point) => { 120 + const col = clamp(Math.floor((point.docX / width) * cols), 0, cols - 1); 121 + const row = clamp(Math.floor((point.docY / height) * rows), 0, rows - 1); 122 + const key = `${row}:${col}`; 123 + buckets.set(key, (buckets.get(key) || 0) + 1); 124 + }); 125 + 126 + let entropy = 0; 127 + buckets.forEach((count) => { 128 + const p = count / points.length; 129 + entropy -= p * Math.log2(p); 130 + }); 131 + return Number(entropy.toFixed(2)); 132 + } 133 + 134 + export function computeUniqueZones(points, width, height, cols = 4, rows = 4) { 135 + if (!points.length || width <= 0 || height <= 0) { 136 + return 0; 137 + } 138 + const zones = new Set(); 139 + points.forEach((point) => { 140 + const col = clamp(Math.floor((point.docX / width) * cols), 0, cols - 1); 141 + const row = clamp(Math.floor((point.docY / height) * rows), 0, rows - 1); 142 + zones.add(`${row}:${col}`); 143 + }); 144 + return zones.size; 145 + } 146 + 147 + export function computeDominantZones(points, width, height) { 148 + if (!points.length || width <= 0 || height <= 0) { 149 + return []; 150 + } 151 + const cols = 3; 152 + const rows = 3; 153 + const buckets = new Map(); 154 + points.forEach((point) => { 155 + const col = clamp(Math.floor((point.docX / width) * cols), 0, cols - 1); 156 + const row = clamp(Math.floor((point.docY / height) * rows), 0, rows - 1); 157 + const key = `${row}:${col}`; 158 + buckets.set(key, (buckets.get(key) || 0) + 1); 159 + }); 160 + return Array.from(buckets.entries()) 161 + .sort((a, b) => b[1] - a[1]) 162 + .slice(0, 3) 163 + .map(([key, count]) => { 164 + const [row, col] = key.split(':').map(Number); 165 + return { row, col, count, share: Math.round((count / points.length) * 100) }; 166 + }); 167 + } 168 + 169 + export function computeRevisitCount(fixations) { 170 + let revisits = 0; 171 + const visited = new Set(); 172 + fixations.forEach((fixation) => { 173 + const key = `${Math.floor(fixation.x / 160)}:${Math.floor(fixation.y / 160)}`; 174 + if (visited.has(key)) { 175 + revisits += 1; 176 + } else { 177 + visited.add(key); 178 + } 179 + }); 180 + return revisits; 181 + } 182 + 183 + export function computeClickDispersion(clicks) { 184 + if (clicks.length < 2) { 185 + return 0; 186 + } 187 + const center = { 188 + x: average(clicks.map((click) => click.docX || 0)), 189 + y: average(clicks.map((click) => click.docY || 0)) 190 + }; 191 + return Math.round(average(clicks.map((click) => Math.hypot((click.docX || 0) - center.x, (click.docY || 0) - center.y)))); 192 + } 193 + 194 + export function computeRepeatedClickBursts(clicks) { 195 + let bursts = 0; 196 + for (let index = 1; index < clicks.length; index += 1) { 197 + const current = clicks[index]; 198 + const previous = clicks[index - 1]; 199 + if (current.screenKey !== previous.screenKey) { 200 + continue; 201 + } 202 + const withinTime = current.timestamp - previous.timestamp <= 1200; 203 + const withinDistance = Math.hypot((current.docX || 0) - (previous.docX || 0), (current.docY || 0) - (previous.docY || 0)) <= 36; 204 + if (withinTime && withinDistance) { 205 + bursts += 1; 206 + } 207 + } 208 + return bursts; 209 + } 210 + 211 + export function computePreClickLatency(clicks, fixations) { 212 + const byScreen = groupBy(fixations, (fixation) => fixation.screenKey); 213 + const latencies = clicks.map((click) => { 214 + const candidates = byScreen.get(click.screenKey) || []; 215 + for (let index = candidates.length - 1; index >= 0; index -= 1) { 216 + const fixation = candidates[index]; 217 + if (fixation.endTime > click.timestamp) { 218 + continue; 219 + } 220 + if (Math.hypot(fixation.x - (click.docX || 0), fixation.y - (click.docY || 0)) <= 120) { 221 + return click.timestamp - fixation.endTime; 222 + } 223 + } 224 + return null; 225 + }).filter(Number.isFinite); 226 + return latencies.length ? Math.round(average(latencies)) : 0; 227 + } 228 + 229 + export function computeLookThenActRate(clicks, fixations) { 230 + if (!clicks.length) { 231 + return 0; 232 + } 233 + const byScreen = groupBy(fixations, (fixation) => fixation.screenKey); 234 + const looked = clicks.filter((click) => { 235 + const candidates = byScreen.get(click.screenKey) || []; 236 + return candidates.some((fixation) => 237 + fixation.endTime <= click.timestamp && 238 + click.timestamp - fixation.endTime <= 1600 && 239 + Math.hypot(fixation.x - (click.docX || 0), fixation.y - (click.docY || 0)) <= 120 240 + ); 241 + }); 242 + return Math.round((looked.length / clicks.length) * 100); 243 + } 244 + 245 + export function computePostClickConfirmation(clicks, fixations) { 246 + if (!clicks.length) { 247 + return 0; 248 + } 249 + const byScreen = groupBy(fixations, (fixation) => fixation.screenKey); 250 + const confirmations = clicks.filter((click) => { 251 + const candidates = byScreen.get(click.screenKey) || []; 252 + return candidates.some((fixation) => 253 + fixation.startTime >= click.timestamp && 254 + fixation.startTime - click.timestamp <= 1500 && 255 + Math.hypot(fixation.x - (click.docX || 0), fixation.y - (click.docY || 0)) <= 120 256 + ); 257 + }); 258 + return Math.round((confirmations.length / clicks.length) * 100); 259 + } 260 + 261 + export function computeScrollToFixationLatency(scrolls, fixations) { 262 + const byScreen = groupBy(fixations, (fixation) => fixation.screenKey); 263 + const latencies = scrolls.map((scroll) => { 264 + const candidates = byScreen.get(scroll.screenKey) || []; 265 + const fixation = candidates.find((candidate) => candidate.startTime >= scroll.timestamp); 266 + return fixation ? fixation.startTime - scroll.timestamp : null; 267 + }).filter(Number.isFinite); 268 + return latencies.length ? Math.round(average(latencies)) : 0; 269 + } 270 + 271 + export function computeMouseTraceMetrics(mouseTrace, clicks) { 272 + if (!mouseTrace.length) { 273 + return { 274 + hesitationCount: 0, 275 + deadMovement: 0, 276 + motionBurstiness: 0, 277 + pathEfficiency: 0, 278 + idleHesitationWindows: 0 279 + }; 280 + } 281 + 282 + let hesitationCount = 0; 283 + let deadMovement = 0; 284 + let idleHesitationWindows = 0; 285 + const velocities = []; 286 + for (let index = 1; index < mouseTrace.length; index += 1) { 287 + const current = mouseTrace[index]; 288 + const previous = mouseTrace[index - 1]; 289 + const dt = Math.max(1, current.timestamp - previous.timestamp); 290 + const segmentDistance = Math.hypot(current.docX - previous.docX, current.docY - previous.docY); 291 + const velocity = segmentDistance / (dt / 1000); 292 + velocities.push(velocity); 293 + if (segmentDistance > 80 && dt > 900) { 294 + hesitationCount += 1; 295 + } 296 + if (segmentDistance < 12 && dt > 1200) { 297 + idleHesitationWindows += 1; 298 + } 299 + const nearAction = clicks.some((click) => Math.abs(click.timestamp - current.timestamp) <= 1200); 300 + if (!nearAction) { 301 + deadMovement += segmentDistance; 302 + } 303 + } 304 + 305 + let burstTransitions = 0; 306 + for (let index = 1; index < velocities.length; index += 1) { 307 + if (velocities[index] > 900 && velocities[index - 1] < 120) { 308 + burstTransitions += 1; 309 + } 310 + } 311 + 312 + const clickEfficiencies = clicks.map((click) => { 313 + const leading = mouseTrace.filter((point) => point.screenKey === click.screenKey && 314 + point.timestamp <= click.timestamp && 315 + point.timestamp >= click.timestamp - 2500); 316 + if (leading.length < 2) { 317 + return null; 318 + } 319 + const path = computeScanpathLength(leading); 320 + const direct = Math.hypot(leading[0].docX - (click.docX || 0), leading[0].docY - (click.docY || 0)); 321 + return direct > 0 ? path / direct : 1; 322 + }).filter(Number.isFinite); 323 + 324 + return { 325 + hesitationCount, 326 + deadMovement: Math.round(deadMovement), 327 + motionBurstiness: burstTransitions, 328 + pathEfficiency: clickEfficiencies.length ? Number(average(clickEfficiencies).toFixed(2)) : 0, 329 + idleHesitationWindows 330 + }; 331 + } 332 + 333 + export function computeGazeMouseCoupling(points, mouseTrace) { 334 + if (!points.length || !mouseTrace.length) { 335 + return 0; 336 + } 337 + const tracesByScreen = groupBy(mouseTrace, (trace) => trace.screenKey); 338 + let comparisons = 0; 339 + let total = 0; 340 + points.forEach((point) => { 341 + const candidates = tracesByScreen.get(point.screenKey) || []; 342 + const nearest = candidates.find((trace) => Math.abs(trace.timestamp - point.timestamp) <= 120); 343 + if (!nearest) { 344 + return; 345 + } 346 + comparisons += 1; 347 + total += Math.hypot(point.docX - nearest.docX, point.docY - nearest.docY); 348 + }); 349 + return comparisons ? Math.round(total / comparisons) : 0; 350 + } 351 + 352 + export function computeScrollDepth(events, screens) { 353 + const maxDocumentHeight = Math.max(...screens.map((screen) => screen.document.height || screen.viewport.height || 0), 0); 354 + const maxViewportHeight = Math.max(...screens.map((screen) => screen.viewport.height || 0), 0); 355 + const scrollable = Math.max(maxDocumentHeight - maxViewportHeight, 1); 356 + const maxScroll = Math.max(...events.map((event) => event.scrollY || 0), 0); 357 + return clamp(Math.round((maxScroll / scrollable) * 100), 0, 100); 358 + } 359 + 360 + export function computeScrollDepthBeforeFirstAction(events, screens) { 361 + const firstClick = events.find((event) => event.type === 'click'); 362 + const before = firstClick ? events.filter((event) => event.timestamp <= firstClick.timestamp) : events; 363 + return computeScrollDepth(before, screens); 364 + } 365 + 366 + export function dedupeEvents(events) { 367 + const seen = new Set(); 368 + return events.filter((event) => { 369 + if (event.type !== 'scroll') { 370 + return true; 371 + } 372 + const key = [ 373 + event.type, 374 + event.timestamp, 375 + event.screenKey, 376 + event.scrollX || 0, 377 + event.scrollY || 0 378 + ].join('|'); 379 + if (seen.has(key)) { 380 + return false; 381 + } 382 + seen.add(key); 383 + return true; 384 + }); 385 + } 386 + 387 + export function pointInRect(point, rect) { 388 + return point.docX >= rect.x && 389 + point.docX <= rect.x + rect.width && 390 + point.docY >= rect.y && 391 + point.docY <= rect.y + rect.height; 392 + } 393 + 394 + export function distanceToRect(point, rect) { 395 + const dx = Math.max(rect.x - point.docX, 0, point.docX - (rect.x + rect.width)); 396 + const dy = Math.max(rect.y - point.docY, 0, point.docY - (rect.y + rect.height)); 397 + return Math.hypot(dx, dy); 398 + }
+305
js/analysisRenderer.js
··· 1 + function escapeHtml(value) { 2 + return String(value ?? '') 3 + .replaceAll('&', '&amp;') 4 + .replaceAll('<', '&lt;') 5 + .replaceAll('>', '&gt;') 6 + .replaceAll('"', '&quot;') 7 + .replaceAll("'", '&#39;'); 8 + } 9 + 10 + function formatDateTime(value) { 11 + if (!value) { 12 + return 'Unknown date'; 13 + } 14 + const date = new Date(value); 15 + return Number.isNaN(date.getTime()) ? 'Unknown date' : date.toLocaleString(); 16 + } 17 + 18 + function formatDuration(ms) { 19 + const seconds = Math.round((ms || 0) / 1000); 20 + const minutes = Math.floor(seconds / 60); 21 + const remaining = seconds % 60; 22 + return minutes > 0 ? `${minutes}m ${remaining}s` : `${remaining}s`; 23 + } 24 + 25 + function buildBadge(text, tone = 'neutral') { 26 + return `<span class="analysis-badge analysis-badge-${escapeHtml(tone)}">${escapeHtml(text)}</span>`; 27 + } 28 + 29 + function formatEvidence(item) { 30 + if (typeof item === 'string') { 31 + return escapeHtml(item); 32 + } 33 + return `${escapeHtml(item.label)}: ${escapeHtml(item.value)}${escapeHtml(item.unit || '')}`; 34 + } 35 + 36 + function renderFindingList(findings) { 37 + if (!findings.length) { 38 + return '<p class="analysis-empty">No findings were generated for this section.</p>'; 39 + } 40 + 41 + return findings.map((finding) => { 42 + const evidence = (finding.evidence || []).map((item) => `<li>${formatEvidence(item)}</li>`).join(''); 43 + const target = finding.elementFingerprint 44 + ? `<p class="analysis-target">Element: ${escapeHtml(finding.elementFingerprint)}</p>` 45 + : finding.screenKey 46 + ? `<p class="analysis-target">Screen: ${escapeHtml(finding.screenKey)}</p>` 47 + : ''; 48 + return ` 49 + <article class="analysis-finding analysis-severity-${escapeHtml(finding.severity)}"> 50 + <header class="analysis-finding-header"> 51 + <div> 52 + <h4>${escapeHtml(finding.title)}</h4> 53 + <p> 54 + ${buildBadge(finding.severity, finding.severity)} 55 + ${buildBadge(`confidence: ${finding.confidence}`)} 56 + ${buildBadge(`priority ${finding.priority ?? 0}`)} 57 + </p> 58 + </div> 59 + </header> 60 + ${target} 61 + <ul>${evidence}</ul> 62 + <p><strong>Implication:</strong> ${escapeHtml(finding.implication)}</p> 63 + <p><strong>Design direction:</strong> ${escapeHtml(finding.recommendation)}</p> 64 + </article> 65 + `; 66 + }).join(''); 67 + } 68 + 69 + function renderMetricRail(metrics) { 70 + return ` 71 + <div class="analysis-metric-rail"> 72 + ${metrics.map((metric) => ` 73 + <div class="analysis-mini-stat"> 74 + <span>${escapeHtml(metric.label)}</span> 75 + <strong>${escapeHtml(metric.value)}</strong> 76 + </div> 77 + `).join('')} 78 + </div> 79 + `; 80 + } 81 + 82 + function renderWarningList(warnings) { 83 + if (!warnings.length) { 84 + return '<p class="analysis-empty">No data quality warnings were generated.</p>'; 85 + } 86 + return renderFindingList(warnings.map((warning) => ({ 87 + title: warning.code.replace(/-/g, ' '), 88 + severity: warning.severity, 89 + confidence: 'high', 90 + priority: warning.severity === 'high' ? 80 : warning.severity === 'moderate' ? 55 : 35, 91 + evidence: [{ label: 'Warning', value: warning.message }], 92 + implication: warning.message, 93 + recommendation: 'Use this limitation when interpreting the rest of the report.' 94 + }))); 95 + } 96 + 97 + export class AnalysisRenderer { 98 + constructor({ bannerElement, sections }) { 99 + this.bannerElement = bannerElement; 100 + this.sections = sections; 101 + } 102 + 103 + render({ artifact, analysis }) { 104 + const fidelityBadges = [ 105 + buildBadge(`schema v${artifact.schemaVersion}`), 106 + buildBadge(artifact.source === 'import' ? 'imported session' : 'live session'), 107 + buildBadge(artifact.session.inputMode === 'mouse' ? 'mouse-derived gaze' : 'webgazer') 108 + ]; 109 + 110 + if (!artifact.fidelity.hasScreenshots) { 111 + fidelityBadges.push(buildBadge('limited heatmaps', 'moderate')); 112 + } 113 + if (!artifact.fidelity.hasMouseTrace) { 114 + fidelityBadges.push(buildBadge('limited mouse diagnostics', 'info')); 115 + } 116 + if (!artifact.fidelity.hasElementSnapshots) { 117 + fidelityBadges.push(buildBadge('screen-level analysis', 'info')); 118 + } 119 + 120 + this.bannerElement.innerHTML = ` 121 + <div class="analysis-banner-copy"> 122 + <h3>${escapeHtml(artifact.session.appName)}</h3> 123 + <p>${escapeHtml(analysis.summary.headline)}</p> 124 + <p>${escapeHtml(artifact.session.task || 'No task description available.')}</p> 125 + <p>Recorded: ${escapeHtml(formatDateTime(artifact.session.startTime))}</p> 126 + </div> 127 + <div class="analysis-banner-meta"> 128 + ${fidelityBadges.join('')} 129 + ${buildBadge(`confidence ${analysis.confidence.score}/100`, analysis.confidence.score >= 70 ? 'info' : 'moderate')} 130 + ${buildBadge(analysis.summary.verdict, analysis.summary.verdict === 'high-friction' ? 'high' : 'info')} 131 + </div> 132 + `; 133 + 134 + const topFindings = analysis.findings.filter((finding) => finding.category !== 'quality').slice(0, 5); 135 + const elementFindings = analysis.findings.filter((finding) => finding.scope === 'element').slice(0, 6); 136 + const screenFindings = analysis.findings.filter((finding) => finding.scope === 'screen' || ['navigation', 'scroll'].includes(finding.category)).slice(0, 6); 137 + const summaryFindings = [{ 138 + title: analysis.summary.headline, 139 + severity: analysis.summary.verdict === 'high-friction' ? 'high' : analysis.summary.verdict === 'low-confidence' ? 'moderate' : 'info', 140 + confidence: analysis.confidence.level, 141 + priority: analysis.confidence.score, 142 + evidence: [ 143 + { label: 'Top findings', value: analysis.summary.topFindings.length ? analysis.summary.topFindings.join(' | ') : 'No dominant finding' }, 144 + { label: 'Attention friction', value: analysis.globalMetrics.attentionFrictionScore, unit: '/100' }, 145 + { label: 'Interaction friction', value: analysis.globalMetrics.interactionFrictionScore, unit: '/100' }, 146 + { label: 'Data coverage', value: analysis.globalMetrics.dataCoverageScore, unit: '/100' } 147 + ], 148 + implication: analysis.summary.headline, 149 + recommendation: 'Review the ranked findings below before deciding whether a design change is warranted.' 150 + }]; 151 + 152 + const sectionMap = { 153 + summary: `${renderMetricRail([ 154 + { label: 'Attention', value: `${analysis.globalMetrics.attentionFrictionScore}/100` }, 155 + { label: 'Interaction', value: `${analysis.globalMetrics.interactionFrictionScore}/100` }, 156 + { label: 'First action', value: formatDuration(analysis.globalMetrics.timeToFirstMeaningfulAction) }, 157 + { label: 'Coverage', value: `${analysis.globalMetrics.dataCoverageScore}/100` } 158 + ])}${renderFindingList(summaryFindings)}`, 159 + attention: renderFindingList(topFindings), 160 + mouse: renderFindingList(elementFindings), 161 + timeline: renderFindingList(screenFindings), 162 + quality: renderWarningList(analysis.warnings) 163 + }; 164 + 165 + Object.entries(this.sections).forEach(([key, element]) => { 166 + element.innerHTML = sectionMap[key] || ''; 167 + }); 168 + } 169 + 170 + renderCohort({ cohortAnalysis, importErrors = [] }) { 171 + const warnings = [...cohortAnalysis.warnings]; 172 + importErrors.forEach((error) => { 173 + warnings.push({ 174 + code: `import-failed-${error.fileName}`, 175 + severity: 'moderate', 176 + message: `${error.fileName}: ${error.message}` 177 + }); 178 + }); 179 + 180 + this.bannerElement.innerHTML = ` 181 + <div class="analysis-banner-copy"> 182 + <h3>Cohort analysis</h3> 183 + <p>${cohortAnalysis.cohort.sessionCount} imported sessions analyzed in-browser.</p> 184 + <p>${escapeHtml(cohortAnalysis.cohort.appNames.join(', ') || 'Unknown app')}</p> 185 + </div> 186 + <div class="analysis-banner-meta"> 187 + ${buildBadge('schema v3')} 188 + ${buildBadge('temporary cohort')} 189 + ${buildBadge(cohortAnalysis.cohort.mixedAppOrTask ? 'mixed app/task' : 'matched task', cohortAnalysis.cohort.mixedAppOrTask ? 'high' : 'info')} 190 + </div> 191 + `; 192 + 193 + this.sections.summary.innerHTML = ` 194 + ${renderMetricRail([ 195 + { label: 'Sessions', value: cohortAnalysis.cohort.sessionCount }, 196 + { label: 'Median duration', value: formatDuration(cohortAnalysis.aggregateMetrics.medianDuration) }, 197 + { label: 'First action', value: formatDuration(cohortAnalysis.aggregateMetrics.medianTimeToFirstAction) }, 198 + { label: 'Completion', value: cohortAnalysis.aggregateMetrics.completionRate === null ? 'n/a' : `${cohortAnalysis.aggregateMetrics.completionRate}%` } 199 + ])} 200 + ${renderFindingList([{ 201 + title: 'Cohort summary', 202 + severity: cohortAnalysis.cohort.mixedAppOrTask ? 'moderate' : 'info', 203 + confidence: cohortAnalysis.cohort.sessionCount >= 3 ? 'medium' : 'low', 204 + priority: 50, 205 + evidence: [ 206 + { label: 'Median pre-action zones', value: cohortAnalysis.aggregateMetrics.medianPreActionUniqueZones }, 207 + { label: 'Median pre-action elements', value: cohortAnalysis.aggregateMetrics.medianPreActionUniqueElements }, 208 + { label: 'Fast-action sessions', value: cohortAnalysis.aggregateMetrics.fastActionRate, unit: '%' }, 209 + { label: 'Delayed-first-action sessions', value: cohortAnalysis.aggregateMetrics.delayedFirstActionRate, unit: '%' }, 210 + { label: 'Tasks', value: cohortAnalysis.cohort.tasks.join(' | ') || 'Unknown' } 211 + ], 212 + implication: 'The cohort report highlights variation in observable action timing and pre-action exploration.', 213 + recommendation: 'Prioritize sessions where delayed first action and broad pre-action exploration repeat.' 214 + }])} 215 + `; 216 + 217 + this.sections.attention.innerHTML = renderFindingList(cohortAnalysis.repeatedFindings.map((item) => ({ 218 + title: item.title, 219 + severity: item.severity, 220 + confidence: item.sessionCount >= 3 ? 'high' : 'medium', 221 + priority: item.medianPriority, 222 + evidence: [ 223 + { label: 'Sessions', value: item.sessionCount }, 224 + { label: 'Share', value: item.share, unit: '%' }, 225 + { label: 'Category', value: item.category } 226 + ], 227 + implication: 'This pattern appeared in multiple sessions, making it more useful than a single-session signal.', 228 + recommendation: item.recommendation 229 + }))); 230 + 231 + this.sections.mouse.innerHTML = renderFindingList(cohortAnalysis.elementPatterns.map((item) => ({ 232 + title: `Repeated element pattern: ${item.label}`, 233 + severity: item.totalRepeatedClicks > 0 || item.medianLookBeforeClickRate < 40 ? 'moderate' : 'info', 234 + confidence: item.sessionCount >= 3 ? 'high' : 'medium', 235 + priority: 60 + Math.min(item.totalRepeatedClicks * 4, 20), 236 + evidence: [ 237 + { label: 'Sessions', value: item.sessionCount }, 238 + { label: 'Total clicks', value: item.totalClicks }, 239 + { label: 'Median look-before-click', value: item.medianLookBeforeClickRate, unit: '%' }, 240 + { label: 'Repeated clicks', value: item.totalRepeatedClicks } 241 + ], 242 + implication: 'The same element attracted friction-like behavior across sessions.', 243 + recommendation: 'Review this control for label clarity, feedback, and placement in the task path.', 244 + elementFingerprint: item.fingerprint 245 + }))); 246 + 247 + this.sections.timeline.innerHTML = renderFindingList(cohortAnalysis.outlierSessions.map((item) => ({ 248 + title: `Outlier session ${item.sessionIndex + 1}`, 249 + severity: 'moderate', 250 + confidence: 'medium', 251 + priority: Math.max(item.attentionFrictionScore, item.interactionFrictionScore), 252 + evidence: [ 253 + { label: 'Duration', value: formatDuration(item.duration) }, 254 + { label: 'Time to first action', value: item.timeToFirstAction, unit: 'ms' }, 255 + { label: 'Pre-action zones', value: item.preActionUniqueZones }, 256 + { label: 'Pre-action elements', value: item.preActionUniqueElements } 257 + ], 258 + implication: item.reason, 259 + recommendation: 'Inspect this session separately before treating it as representative of the cohort.' 260 + }))); 261 + 262 + this.sections.quality.innerHTML = renderWarningList(warnings); 263 + } 264 + 265 + renderSessionComparison({ cohortAnalysis }) { 266 + const repeated = cohortAnalysis.repeatedFindings.slice(0, 3).map((item) => ` 267 + <li>${escapeHtml(item.title)} appeared in ${escapeHtml(item.sessionCount)} sessions (${escapeHtml(item.share)}%).</li> 268 + `).join(''); 269 + const elements = cohortAnalysis.elementPatterns.slice(0, 3).map((item) => ` 270 + <li>${escapeHtml(item.label)}: ${escapeHtml(item.totalClicks)} clicks, ${escapeHtml(item.totalRepeatedClicks)} repeated clicks, ${escapeHtml(item.medianLookBeforeClickRate)}% median look-before-click.</li> 271 + `).join(''); 272 + const outliers = cohortAnalysis.outlierSessions.slice(0, 2).map((item) => ` 273 + <li>Session ${escapeHtml(item.sessionIndex + 1)}: ${escapeHtml(item.reason)}</li> 274 + `).join(''); 275 + 276 + this.sections.summary.insertAdjacentHTML('beforeend', ` 277 + <article class="analysis-finding analysis-comparison analysis-severity-info"> 278 + <header class="analysis-finding-header"> 279 + <div> 280 + <h4>Comparison with prior same-app sessions</h4> 281 + <p> 282 + ${buildBadge(`${cohortAnalysis.cohort.sessionCount} sessions`, 'info')} 283 + ${buildBadge(`median first action ${formatDuration(cohortAnalysis.aggregateMetrics.medianTimeToFirstAction)}`)} 284 + ${buildBadge(`fast action ${cohortAnalysis.aggregateMetrics.fastActionRate}%`)} 285 + ${buildBadge(`delayed action ${cohortAnalysis.aggregateMetrics.delayedFirstActionRate}%`)} 286 + </p> 287 + </div> 288 + </header> 289 + <p><strong>Repeated patterns:</strong></p> 290 + <ul>${repeated || '<li>No repeated finding crossed the current comparison threshold.</li>'}</ul> 291 + <p><strong>Element patterns:</strong></p> 292 + <ul>${elements || '<li>No repeated element-level issue crossed the current comparison threshold.</li>'}</ul> 293 + <p><strong>Outliers:</strong></p> 294 + <ul>${outliers || '<li>No major outlier session was detected.</li>'}</ul> 295 + </article> 296 + `); 297 + } 298 + 299 + reset() { 300 + this.bannerElement.innerHTML = ''; 301 + Object.values(this.sections).forEach((element) => { 302 + element.innerHTML = ''; 303 + }); 304 + } 305 + }
+190
js/cohortAnalyzer.js
··· 1 + import { analyzeSessionArtifact } from './sessionAnalyzer.js'; 2 + import { median } from './analysisMetrics.js'; 3 + 4 + function unique(values) { 5 + return Array.from(new Set(values.filter(Boolean))); 6 + } 7 + 8 + function normalizeText(value) { 9 + return String(value || '').trim().toLowerCase().replace(/\s+/g, ' '); 10 + } 11 + 12 + function findingGroupKey(finding, artifact) { 13 + const screen = finding.screenKey 14 + ? artifact.screens.find((item) => item.key === finding.screenKey) 15 + : null; 16 + return [ 17 + finding.id, 18 + finding.elementFingerprint || '', 19 + screen?.title || finding.screenKey || '' 20 + ].join('|'); 21 + } 22 + 23 + function pushMap(map, key, value) { 24 + if (!map.has(key)) { 25 + map.set(key, []); 26 + } 27 + map.get(key).push(value); 28 + } 29 + 30 + function completionRate(artifacts) { 31 + const known = artifacts.filter((artifact) => artifact.session.status); 32 + if (!known.length) { 33 + return null; 34 + } 35 + const complete = known.filter((artifact) => ['complete', 'finishing'].includes(artifact.session.status)).length; 36 + return Math.round((complete / known.length) * 100); 37 + } 38 + 39 + function rate(analyses, predicate) { 40 + if (!analyses.length) { 41 + return 0; 42 + } 43 + return Math.round((analyses.filter(predicate).length / analyses.length) * 100); 44 + } 45 + 46 + export function analyzeSessionCohort(artifacts, options = {}) { 47 + const analyses = options.analyses || artifacts.map((artifact) => analyzeSessionArtifact(artifact)); 48 + const appNames = unique(artifacts.map((artifact) => artifact.session.appName)); 49 + const tasks = unique(artifacts.map((artifact) => artifact.session.task)); 50 + const mixedAppOrTask = appNames.length > 1 || unique(tasks.map(normalizeText)).length > 1; 51 + const threshold = Math.min(2, Math.max(1, Math.ceil(artifacts.length * 0.4))); 52 + const findingGroups = new Map(); 53 + const elementGroups = new Map(); 54 + 55 + analyses.forEach((analysis, index) => { 56 + const artifact = artifacts[index]; 57 + analysis.findings 58 + .filter((finding) => finding.category !== 'quality') 59 + .forEach((finding) => { 60 + pushMap(findingGroups, findingGroupKey(finding, artifact), { 61 + finding, 62 + analysis, 63 + artifact, 64 + sessionIndex: index 65 + }); 66 + }); 67 + 68 + analysis.elementMetrics 69 + .filter((element) => element.clickCount > 0 || element.nearbyFixationCount > 0) 70 + .forEach((element) => { 71 + pushMap(elementGroups, element.fingerprint, { 72 + element, 73 + analysis, 74 + artifact, 75 + sessionIndex: index 76 + }); 77 + }); 78 + }); 79 + 80 + const repeatedFindings = Array.from(findingGroups.values()) 81 + .filter((items) => new Set(items.map((item) => item.sessionIndex)).size >= threshold) 82 + .map((items) => { 83 + const representative = items.sort((a, b) => b.finding.priority - a.finding.priority)[0].finding; 84 + return { 85 + id: representative.id, 86 + title: representative.title, 87 + severity: representative.severity, 88 + category: representative.category, 89 + sessionCount: new Set(items.map((item) => item.sessionIndex)).size, 90 + share: Math.round((new Set(items.map((item) => item.sessionIndex)).size / artifacts.length) * 100), 91 + medianPriority: median(items.map((item) => item.finding.priority)), 92 + recommendation: representative.recommendation 93 + }; 94 + }) 95 + .sort((a, b) => b.medianPriority - a.medianPriority); 96 + 97 + const elementPatterns = Array.from(elementGroups.values()) 98 + .filter((items) => new Set(items.map((item) => item.sessionIndex)).size >= threshold) 99 + .map((items) => { 100 + const representative = items[0].element; 101 + return { 102 + fingerprint: representative.fingerprint, 103 + label: representative.label, 104 + screenTitle: representative.screenTitle, 105 + sessionCount: new Set(items.map((item) => item.sessionIndex)).size, 106 + totalClicks: items.reduce((sum, item) => sum + item.element.clickCount, 0), 107 + medianLookBeforeClickRate: median(items.map((item) => item.element.lookBeforeClickRate)), 108 + medianPostClickConfirmationRate: median(items.map((item) => item.element.postClickConfirmationRate)), 109 + totalRepeatedClicks: items.reduce((sum, item) => sum + item.element.repeatedClickCount, 0) 110 + }; 111 + }) 112 + .filter((pattern) => pattern.totalRepeatedClicks > 0 || pattern.medianLookBeforeClickRate < 60 || pattern.medianPostClickConfirmationRate < 60) 113 + .sort((a, b) => b.totalRepeatedClicks - a.totalRepeatedClicks || a.medianLookBeforeClickRate - b.medianLookBeforeClickRate); 114 + 115 + const durations = analyses.map((analysis) => analysis.globalMetrics.duration); 116 + const medianDuration = median(durations); 117 + const outlierSessions = analyses 118 + .map((analysis, index) => ({ 119 + sessionIndex: index, 120 + appName: artifacts[index].session.appName, 121 + task: artifacts[index].session.task, 122 + duration: analysis.globalMetrics.duration, 123 + timeToFirstAction: analysis.globalMetrics.timeToFirstAction, 124 + preActionUniqueZones: analysis.globalMetrics.preActionUniqueZones, 125 + preActionUniqueElements: analysis.globalMetrics.preActionUniqueElements, 126 + preActionExplorationScore: analysis.globalMetrics.preActionExplorationScore, 127 + attentionFrictionScore: analysis.globalMetrics.attentionFrictionScore, 128 + interactionFrictionScore: analysis.globalMetrics.interactionFrictionScore, 129 + confidenceScore: analysis.confidence.score, 130 + reason: analysis.globalMetrics.timeToFirstAction >= 5000 131 + ? 'First action was delayed after pre-action exploration.' 132 + : analysis.globalMetrics.duration > medianDuration * 1.8 133 + ? 'Duration was much longer than the cohort median.' 134 + : analysis.globalMetrics.preActionExplorationScore >= 70 135 + ? 'Pre-action exploration was materially higher than the cohort.' 136 + : analysis.globalMetrics.interactionFrictionScore >= 80 137 + ? 'Interaction friction was materially higher than the cohort.' 138 + : analysis.globalMetrics.behaviorOutcome === 'fast-action' 139 + ? 'First action happened almost immediately.' 140 + : '' 141 + })) 142 + .filter((session) => session.reason) 143 + .sort((a, b) => (b.attentionFrictionScore + b.interactionFrictionScore) - (a.attentionFrictionScore + a.interactionFrictionScore)); 144 + 145 + const warnings = []; 146 + if (mixedAppOrTask) { 147 + warnings.push({ 148 + code: 'mixed-app-or-task', 149 + severity: 'high', 150 + message: 'This cohort includes different apps or task descriptions, so repeated patterns may reflect different flows rather than the same UX issue.' 151 + }); 152 + } 153 + if (artifacts.length < 3) { 154 + warnings.push({ 155 + code: 'small-cohort', 156 + severity: 'info', 157 + message: 'The cohort is small; treat repeated patterns as directional until more sessions are available.' 158 + }); 159 + } 160 + 161 + return { 162 + analysisVersion: '3', 163 + generatedAt: options.generatedAt || Date.now(), 164 + cohort: { 165 + sessionCount: artifacts.length, 166 + appNames, 167 + tasks, 168 + mixedAppOrTask 169 + }, 170 + aggregateMetrics: { 171 + medianDuration, 172 + medianClicks: median(analyses.map((analysis) => analysis.globalMetrics.totalClicks)), 173 + medianFixations: median(analyses.map((analysis) => analysis.globalMetrics.totalFixations)), 174 + medianAttentionFrictionScore: median(analyses.map((analysis) => analysis.globalMetrics.attentionFrictionScore)), 175 + medianInteractionFrictionScore: median(analyses.map((analysis) => analysis.globalMetrics.interactionFrictionScore)), 176 + medianTimeToFirstAction: median(analyses.map((analysis) => analysis.globalMetrics.timeToFirstAction)), 177 + medianPreActionUniqueZones: median(analyses.map((analysis) => analysis.globalMetrics.preActionUniqueZones)), 178 + medianPreActionUniqueElements: median(analyses.map((analysis) => analysis.globalMetrics.preActionUniqueElements)), 179 + medianPreActionExplorationScore: median(analyses.map((analysis) => analysis.globalMetrics.preActionExplorationScore)), 180 + fastActionRate: rate(analyses, (analysis) => analysis.globalMetrics.behaviorOutcome === 'fast-action'), 181 + delayedFirstActionRate: rate(analyses, (analysis) => analysis.globalMetrics.timeToFirstAction >= 5000), 182 + completionRate: completionRate(artifacts) 183 + }, 184 + repeatedFindings, 185 + outlierSessions, 186 + elementPatterns, 187 + warnings, 188 + sessionAnalyses: analyses 189 + }; 190 + }
+34 -2
js/gazeTracker.js
··· 51 51 }, 52 52 gazePoints: [], 53 53 interactionEvents: [], 54 - fixationCount: 0 54 + fixationCount: 0, 55 + elementSnapshots: [] 55 56 }; 56 57 } 57 58 ··· 70 71 this.fixationThreshold = 50; 71 72 this.fixationMinDuration = 150; 72 73 this.currentFixation = null; 74 + this.fixations = []; 73 75 this.lastAcceptedAt = 0; 74 76 this.logInterval = 50; 75 77 } ··· 174 176 175 177 record.screenshot.status = 'failed'; 176 178 record.screenshot.dataUrl = null; 179 + } 180 + 181 + setElementSnapshots(screenKey, elementSnapshots = []) { 182 + const record = this.screenRecords.get(screenKey); 183 + if (!record) { 184 + return; 185 + } 186 + record.elementSnapshots = Array.isArray(elementSnapshots) 187 + ? elementSnapshots.map((snapshot) => ({ ...snapshot, rect: { ...snapshot.rect } })) 188 + : []; 177 189 } 178 190 179 191 recordInteractionEvent(event) { ··· 324 336 325 337 const duration = endTime - this.currentFixation.startTime; 326 338 if (duration >= this.fixationMinDuration) { 339 + const fixation = { 340 + screenKey: this.currentFixation.screenKey, 341 + startTime: this.currentFixation.startTime, 342 + endTime, 343 + duration, 344 + x: Math.round(this.currentFixation.x), 345 + y: Math.round(this.currentFixation.y), 346 + pointCount: this.currentFixation.pointCount 347 + }; 327 348 this.stats.fixations += 1; 328 349 this.stats.totalFixationDuration += duration; 329 350 this.stats.avgFixationDuration = Math.round(this.stats.totalFixationDuration / this.stats.fixations); 351 + this.fixations.push(fixation); 330 352 const record = this.screenRecords.get(this.currentFixation.screenKey); 331 353 if (record) { 332 354 record.fixationCount += 1; ··· 348 370 return [...this.gazePoints]; 349 371 } 350 372 373 + getFixations() { 374 + return this.fixations.map((fixation) => ({ ...fixation })); 375 + } 376 + 351 377 getScreenRecords() { 352 378 return Array.from(this.screenRecords.values()).map((screen) => ({ 353 379 ...screen, ··· 355 381 document: { ...screen.document }, 356 382 screenshot: { ...screen.screenshot }, 357 383 gazePoints: [...screen.gazePoints], 358 - interactionEvents: [...screen.interactionEvents] 384 + interactionEvents: [...screen.interactionEvents], 385 + elementSnapshots: screen.elementSnapshots.map((snapshot) => ({ 386 + ...snapshot, 387 + rect: { ...snapshot.rect } 388 + })) 359 389 })); 360 390 } 361 391 ··· 383 413 return { 384 414 stats: this.getStats(), 385 415 points: this.getGazeData(), 416 + fixations: this.getFixations(), 386 417 screens: screenLookup, 387 418 inputMode: this.getInputMode() 388 419 }; ··· 398 429 this.currentScreenKey = null; 399 430 this.currentMetrics = null; 400 431 this.currentFixation = null; 432 + this.fixations = []; 401 433 this.lastAcceptedAt = 0; 402 434 } 403 435
+59
js/iframeBridge.js
··· 245 245 } 246 246 } 247 247 248 + captureElementSnapshots(limit = 80) { 249 + if (!this.isAttached) { 250 + return []; 251 + } 252 + 253 + const doc = this.iframeDocument; 254 + const selector = 'a, button, input, select, textarea, label, [role], [data-uxet], [data-id], [onclick], [tabindex]'; 255 + const candidateSet = new Set(Array.from(doc.querySelectorAll(selector))); 256 + Array.from(doc.body?.querySelectorAll('*') || []).forEach((element) => { 257 + const style = this.iframeWindow.getComputedStyle(element); 258 + if (style.cursor === 'pointer') { 259 + candidateSet.add(element); 260 + } 261 + }); 262 + 263 + const candidates = Array.from(candidateSet); 264 + return candidates.slice(0, limit).map((element) => { 265 + const rect = element.getBoundingClientRect(); 266 + const style = this.iframeWindow.getComputedStyle(element); 267 + const role = element.getAttribute('role'); 268 + const label = element.getAttribute('aria-label') || element.innerText?.trim()?.slice(0, 80) || element.value || ''; 269 + const clickable = element.matches(selector) || style.cursor === 'pointer'; 270 + const ancestor = element.closest('[data-id], [role], a, button, [onclick], [tabindex]'); 271 + return { 272 + fingerprint: [ 273 + element.tagName.toLowerCase(), 274 + element.id || '', 275 + role || '', 276 + element.getAttribute('name') || '', 277 + label 278 + ].filter(Boolean).join('|'), 279 + tag: element.tagName.toLowerCase(), 280 + role: role || null, 281 + label: label || null, 282 + className: typeof element.className === 'string' ? element.className : '', 283 + dataset: { ...element.dataset }, 284 + clickable, 285 + ancestorText: ancestor && ancestor !== element 286 + ? ancestor.textContent?.trim()?.slice(0, 160) || null 287 + : null, 288 + text: element.textContent?.trim()?.slice(0, 120) || null, 289 + href: element.getAttribute('href'), 290 + inputType: element.getAttribute('type'), 291 + visible: style.display !== 'none' && 292 + style.visibility !== 'hidden' && 293 + parseFloat(style.opacity || '1') > 0 && 294 + rect.width > 0 && 295 + rect.height > 0, 296 + disabled: Boolean(element.disabled || element.getAttribute('aria-disabled') === 'true'), 297 + rect: { 298 + x: Math.round(rect.left + this.iframeWindow.scrollX), 299 + y: Math.round(rect.top + this.iframeWindow.scrollY), 300 + width: Math.round(rect.width), 301 + height: Math.round(rect.height) 302 + } 303 + }; 304 + }); 305 + } 306 + 248 307 detach() { 249 308 this.detachFns.forEach((fn) => fn()); 250 309 this.detachFns = [];
+44
js/importer.js
··· 1 + import { isLikelyUxetSession, normalizeImportedSession } from './sessionArtifact.js'; 2 + 3 + export class SessionImporter { 4 + async importFile(file) { 5 + if (!file) { 6 + throw new Error('No file was provided.'); 7 + } 8 + 9 + const text = await file.text(); 10 + let parsed; 11 + try { 12 + parsed = JSON.parse(text); 13 + } catch (error) { 14 + throw new Error(`The selected file is not valid JSON: ${error.message}`); 15 + } 16 + 17 + if (!isLikelyUxetSession(parsed)) { 18 + throw new Error('This JSON file does not look like a UXET session export.'); 19 + } 20 + 21 + return normalizeImportedSession(parsed); 22 + } 23 + 24 + async importFiles(files) { 25 + const results = []; 26 + const errors = []; 27 + 28 + for (const file of Array.from(files || [])) { 29 + try { 30 + results.push({ 31 + fileName: file.name, 32 + artifact: await this.importFile(file) 33 + }); 34 + } catch (error) { 35 + errors.push({ 36 + fileName: file.name, 37 + message: error.message || 'Import failed.' 38 + }); 39 + } 40 + } 41 + 42 + return { results, errors }; 43 + } 44 + }
+285 -31
js/main.js
··· 5 5 import { CalibrationController } from './calibration.js'; 6 6 import { WinConditionRegistry } from './winConditions.js'; 7 7 import { DebriefRenderer } from './debriefRenderer.js'; 8 + import { AnalysisRenderer } from './analysisRenderer.js'; 9 + import { SessionImporter } from './importer.js'; 10 + import { createSessionArtifactFromLive } from './sessionArtifact.js'; 11 + import { analyzeSessionArtifact } from './sessionAnalyzer.js'; 12 + import { analyzeSessionCohort } from './cohortAnalyzer.js'; 8 13 9 14 class UXETApp { 10 15 constructor() { ··· 15 20 this.gazeTracker = new GazeTracker(); 16 21 this.bridge = new IframeBridge(); 17 22 this.winConditions = new WinConditionRegistry(); 23 + this.importer = new SessionImporter(); 18 24 this.selectedApp = null; 19 25 this.captureJobs = new Map(); 20 26 this.calibrationPassed = false; 21 27 this.calibrationSkippedByDebug = false; 22 28 this.gazeInitialized = false; 23 29 this.lastCalibrationResult = null; 30 + this.latestArtifact = null; 31 + this.latestAnalysis = null; 32 + this.latestCohort = null; 33 + this.lastTestApp = null; 34 + this.liveSessionHistory = []; 24 35 this.debugState = { 25 36 mouseGazeMode: false, 26 37 setupPanelOpen: false, ··· 34 45 35 46 appSelect: document.getElementById('app-select'), 36 47 loadAppBtn: document.getElementById('load-app-btn'), 48 + importBtn: document.getElementById('import-btn'), 49 + importInput: document.getElementById('import-input'), 37 50 resetBtn: document.getElementById('reset-btn'), 38 51 exportBtn: document.getElementById('export-btn'), 52 + debriefTestAgainBtn: document.getElementById('debrief-test-again-btn'), 53 + debriefExportBtn: document.getElementById('debrief-export-btn'), 54 + debriefResetBtn: document.getElementById('debrief-reset-btn'), 39 55 startTestBtn: document.getElementById('start-test-btn'), 40 56 startCurtain: document.getElementById('start-curtain'), 41 57 themeDarkBtn: document.getElementById('theme-dark-btn'), ··· 85 101 debriefGazePoints: document.getElementById('debrief-gaze-points'), 86 102 debriefFixations: document.getElementById('debrief-fixations'), 87 103 debriefFixationDuration: document.getElementById('debrief-fixation-duration'), 104 + importedSessionBanner: document.getElementById('imported-session-banner'), 105 + executiveSummary: document.getElementById('analysis-summary'), 106 + attentionFindings: document.getElementById('analysis-attention'), 107 + mouseFindings: document.getElementById('analysis-mouse'), 108 + timelineFindings: document.getElementById('analysis-timeline'), 109 + qualityFindings: document.getElementById('analysis-quality'), 88 110 galleryLabel: document.getElementById('screen-gallery-label'), 89 111 gallery: document.getElementById('screen-gallery') 90 112 }; ··· 93 115 galleryElement: this.elements.gallery, 94 116 labelElement: this.elements.galleryLabel 95 117 }); 118 + this.analysisRenderer = new AnalysisRenderer({ 119 + bannerElement: this.elements.importedSessionBanner, 120 + sections: { 121 + summary: this.elements.executiveSummary, 122 + attention: this.elements.attentionFindings, 123 + mouse: this.elements.mouseFindings, 124 + timeline: this.elements.timelineFindings, 125 + quality: this.elements.qualityFindings 126 + } 127 + }); 96 128 this.calibration = new CalibrationController({ 97 129 gazeTracker: this.gazeTracker, 98 130 elements: { ··· 138 170 bindEvents() { 139 171 this.elements.appSelect.addEventListener('change', (event) => this.selectApp(event.target)); 140 172 this.elements.loadAppBtn.addEventListener('click', () => this.loadSelectedApp()); 173 + this.elements.importBtn.addEventListener('click', () => this.elements.importInput.click()); 174 + this.elements.importInput.addEventListener('change', (event) => this.handleImport(event)); 141 175 this.elements.resetBtn.addEventListener('click', () => this.resetSession()); 142 176 this.elements.exportBtn.addEventListener('click', () => this.exportData()); 177 + this.elements.debriefTestAgainBtn.addEventListener('click', () => this.testAgain()); 178 + this.elements.debriefExportBtn.addEventListener('click', () => this.exportData()); 179 + this.elements.debriefResetBtn.addEventListener('click', () => this.resetSession()); 143 180 this.elements.startTestBtn.addEventListener('click', () => this.beginTesting()); 144 181 this.elements.retryCalibrationBtn.addEventListener('click', () => this.retryCalibration()); 145 182 this.elements.themeDarkBtn.addEventListener('click', () => this.setTheme('dark')); ··· 321 358 return; 322 359 } 323 360 361 + this.lastTestApp = { ...this.selectedApp }; 324 362 await this.resetRuntimeOnly(); 325 363 this.session.reset(); 326 364 this.calibrationPassed = false; ··· 470 508 471 509 const job = (async () => { 472 510 try { 511 + this.gazeTracker.setElementSnapshots(screenKey, this.bridge.captureElementSnapshots()); 473 512 const screenshot = await this.bridge.captureScreenshot(); 474 513 this.gazeTracker.setScreenScreenshot(screenKey, screenshot); 475 514 return screenshot; ··· 486 525 return job; 487 526 } 488 527 528 + buildLiveArtifact(details = {}) { 529 + return createSessionArtifactFromLive({ 530 + session: this.session.getMetadata(), 531 + gaze: this.gazeTracker.exportData(), 532 + interactions: this.tracker.exportData(), 533 + screenRecords: this.gazeTracker.getScreenRecords(), 534 + calibration: this.lastCalibrationResult 535 + ? { ...this.lastCalibrationResult } 536 + : { 537 + passed: !this.calibrationSkippedByDebug && this.gazeTracker.getInputMode() !== 'mouse', 538 + averageError: null, 539 + points: [] 540 + }, 541 + debug: { 542 + mouseGazeMode: this.debugState.mouseGazeMode, 543 + calibrationSkipped: this.calibrationSkippedByDebug, 544 + inputMode: this.gazeTracker.getInputMode() 545 + }, 546 + analysisContext: { 547 + completionStrategy: details?.strategy || 'manual', 548 + analysisVersion: '3' 549 + } 550 + }); 551 + } 552 + 489 553 async renderDebrief(details) { 490 - const stats = this.tracker.getStats(); 491 - const gazeStats = this.gazeTracker.getStats(); 492 - const screens = this.gazeTracker.getScreenRecords(); 493 - const inputMode = this.gazeTracker.getInputMode(); 554 + const artifact = this.buildLiveArtifact(details); 555 + const analysis = analyzeSessionArtifact(artifact); 556 + this.latestArtifact = artifact; 557 + this.latestAnalysis = analysis; 558 + this.latestCohort = null; 559 + if (artifact.source === 'live' && this.lastTestApp) { 560 + this.liveSessionHistory.push({ 561 + app: { ...this.lastTestApp }, 562 + artifact, 563 + analysis 564 + }); 565 + } 566 + await this.renderArtifactDebrief(artifact, analysis, details); 567 + } 494 568 495 - this.elements.debriefTime.textContent = this.session.formatTime(this.session.elapsed); 569 + async renderArtifactDebrief(artifact, analysis, details = {}) { 570 + const stats = artifact.interactions.stats; 571 + 572 + this.elements.debriefTime.textContent = this.session.formatTime(artifact.session.duration); 496 573 this.elements.debriefClicks.textContent = stats.mouse.clicks.toLocaleString(); 497 574 this.elements.debriefKeys.textContent = stats.keyboard.totalKeys.toLocaleString(); 498 575 this.elements.debriefDistance.textContent = Math.round(stats.mouse.distance).toLocaleString(); 499 576 this.elements.debriefScrolls.textContent = stats.mouse.scrollEvents.toLocaleString(); 500 577 this.elements.debriefVelocity.textContent = stats.mouse.avgVelocity.toLocaleString(); 501 - this.elements.debriefGazePoints.textContent = gazeStats.gazePoints.toLocaleString(); 502 - this.elements.debriefFixations.textContent = gazeStats.fixations.toLocaleString(); 503 - this.elements.debriefFixationDuration.textContent = gazeStats.avgFixationDuration.toLocaleString(); 504 - this.elements.sessionMessage.textContent = `Test finished via ${details?.strategy || 'manual'} completion.`; 505 - this.elements.galleryLabel.textContent = inputMode === 'mouse' 506 - ? 'Mouse-derived gaze debug session' 507 - : 'Heatmaps will appear here after a test completes.'; 578 + this.elements.debriefGazePoints.textContent = artifact.gaze.points.length.toLocaleString(); 579 + this.elements.debriefFixations.textContent = analysis.globalMetrics.totalFixations.toLocaleString(); 580 + this.elements.debriefFixationDuration.textContent = analysis.globalMetrics.avgFixationDuration.toLocaleString(); 581 + this.elements.sessionMessage.textContent = artifact.source === 'import' 582 + ? 'Imported session analyzed successfully.' 583 + : `Test finished via ${details?.strategy || 'manual'} completion.`; 584 + this.elements.galleryLabel.textContent = artifact.session.inputMode === 'mouse' 585 + ? 'Mouse-derived gaze session' 586 + : 'Heatmaps and analysis derived from the recorded session.'; 508 587 509 - await this.debriefRenderer.render(screens); 588 + this.analysisRenderer.render({ artifact, analysis }); 589 + const sameAppHistory = this.getSameAppHistory(); 590 + if (artifact.source === 'live' && sameAppHistory.length >= 2) { 591 + const cohortAnalysis = analyzeSessionCohort( 592 + sameAppHistory.map((entry) => entry.artifact), 593 + { analyses: sameAppHistory.map((entry) => entry.analysis) } 594 + ); 595 + this.analysisRenderer.renderSessionComparison({ cohortAnalysis }); 596 + this.latestCohort = cohortAnalysis; 597 + } 598 + await this.debriefRenderer.render(artifact.screens); 510 599 this.elements.exportBtn.disabled = false; 600 + this.elements.debriefExportBtn.disabled = false; 601 + this.elements.debriefTestAgainBtn.disabled = !this.lastTestApp || artifact.source !== 'live'; 602 + } 603 + 604 + downloadData(data, filename = `uxet-session-${Date.now()}.json`) { 605 + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); 606 + const url = URL.createObjectURL(blob); 607 + const link = document.createElement('a'); 608 + link.href = url; 609 + link.download = filename; 610 + document.body.appendChild(link); 611 + link.click(); 612 + document.body.removeChild(link); 613 + URL.revokeObjectURL(url); 511 614 } 512 615 513 616 exportData() { 514 - const screenRecords = this.gazeTracker.getScreenRecords().map((screen) => ({ 515 - key: screen.key, 516 - title: screen.title, 517 - firstSeenAt: screen.firstSeenAt, 518 - lastSeenAt: screen.lastSeenAt, 519 - documentWidth: screen.document.width, 520 - documentHeight: screen.document.height, 521 - viewportWidth: screen.viewport.width, 522 - viewportHeight: screen.viewport.height, 523 - screenshotCaptured: screen.screenshot.status === 'ready', 524 - screenshotWidth: screen.screenshot.width, 525 - screenshotHeight: screen.screenshot.height, 526 - gazePointCount: screen.gazePoints.length, 527 - fixationCount: screen.fixationCount 528 - })); 617 + if (this.latestArtifact) { 618 + this.downloadData({ 619 + schemaVersion: '3', 620 + session: this.latestArtifact.session, 621 + gaze: { 622 + stats: this.latestArtifact.gaze.stats, 623 + points: this.latestArtifact.gaze.points, 624 + fixations: this.latestArtifact.gaze.fixations 625 + }, 626 + interactions: { 627 + stats: this.latestArtifact.interactions.stats, 628 + events: this.latestArtifact.interactions.events, 629 + mouseTrace: this.latestArtifact.interactions.mouseTrace 630 + }, 631 + screenRecords: this.latestArtifact.screens, 632 + screens: this.latestArtifact.screens.map((screen) => ({ 633 + key: screen.key, 634 + title: screen.title, 635 + firstSeenAt: screen.firstSeenAt, 636 + lastSeenAt: screen.lastSeenAt, 637 + documentWidth: screen.document.width, 638 + documentHeight: screen.document.height, 639 + viewportWidth: screen.viewport.width, 640 + viewportHeight: screen.viewport.height, 641 + screenshotCaptured: screen.screenshot.status === 'ready', 642 + screenshotWidth: screen.screenshot.width, 643 + screenshotHeight: screen.screenshot.height, 644 + gazePointCount: screen.gazePoints.length, 645 + fixationCount: screen.fixationCount 646 + })), 647 + mouseTrace: this.latestArtifact.interactions.mouseTrace, 648 + fixations: this.latestArtifact.gaze.fixations, 649 + calibration: this.latestArtifact.calibration, 650 + debug: this.latestArtifact.debug, 651 + analysisContext: this.latestArtifact.analysisContext, 652 + analysis: this.latestAnalysis 653 + }); 654 + return; 655 + } 656 + 657 + if (this.latestCohort) { 658 + this.downloadData({ 659 + schemaVersion: '3', 660 + cohortAnalysis: this.latestCohort 661 + }, `uxet-cohort-${Date.now()}.json`); 662 + return; 663 + } 529 664 530 665 this.session.exportSession({ 531 666 gaze: this.gazeTracker.exportData(), 532 667 interactions: this.tracker.exportData(), 533 - screens: screenRecords, 668 + screenRecords: this.gazeTracker.getScreenRecords(), 669 + screens: this.gazeTracker.getScreenRecords().map((screen) => ({ 670 + key: screen.key, 671 + title: screen.title, 672 + firstSeenAt: screen.firstSeenAt, 673 + lastSeenAt: screen.lastSeenAt, 674 + documentWidth: screen.document.width, 675 + documentHeight: screen.document.height, 676 + viewportWidth: screen.viewport.width, 677 + viewportHeight: screen.viewport.height, 678 + screenshotCaptured: screen.screenshot.status === 'ready', 679 + screenshotWidth: screen.screenshot.width, 680 + screenshotHeight: screen.screenshot.height, 681 + gazePointCount: screen.gazePoints.length, 682 + fixationCount: screen.fixationCount 683 + })), 684 + mouseTrace: this.tracker.getMouseTrace(), 685 + fixations: this.gazeTracker.getFixations(), 686 + calibration: this.lastCalibrationResult, 534 687 debug: { 535 688 mouseGazeMode: this.debugState.mouseGazeMode, 536 689 calibrationSkipped: this.calibrationSkippedByDebug, 537 690 inputMode: this.gazeTracker.getInputMode() 538 - } 691 + }, 692 + analysisContext: { 693 + source: 'live-export', 694 + analysisVersion: '3' 695 + }, 696 + analysis: this.latestAnalysis 539 697 }); 540 698 } 541 699 700 + async handleImport(event) { 701 + const files = Array.from(event.target?.files || []); 702 + if (!files.length) { 703 + return; 704 + } 705 + 706 + try { 707 + await this.resetRuntimeOnly(); 708 + this.session.reset(); 709 + const { results, errors } = await this.importer.importFiles(files); 710 + if (!results.length) { 711 + throw new Error(errors.map((error) => `${error.fileName}: ${error.message}`).join('\n') || 'No valid UXET session files were selected.'); 712 + } 713 + 714 + if (results.length > 1) { 715 + const artifacts = results.map((result) => result.artifact); 716 + const analyses = artifacts.map((artifact) => analyzeSessionArtifact(artifact)); 717 + const cohortAnalysis = analyzeSessionCohort(artifacts, { analyses }); 718 + this.latestArtifact = null; 719 + this.latestAnalysis = null; 720 + this.latestCohort = cohortAnalysis; 721 + this.lastTestApp = null; 722 + this.session.setAppName('Cohort analysis'); 723 + this.session.setTask(`${artifacts.length} imported sessions`); 724 + this.elements.currentTaskText.textContent = `${artifacts.length} imported sessions`; 725 + this.selectedApp = null; 726 + this.elements.debriefTime.textContent = this.session.formatTime(cohortAnalysis.aggregateMetrics.medianDuration); 727 + this.elements.debriefClicks.textContent = cohortAnalysis.aggregateMetrics.medianClicks.toLocaleString(); 728 + this.elements.debriefKeys.textContent = '0'; 729 + this.elements.debriefDistance.textContent = '0'; 730 + this.elements.debriefScrolls.textContent = '0'; 731 + this.elements.debriefVelocity.textContent = '0'; 732 + this.elements.debriefGazePoints.textContent = analyses.reduce((sum, analysis) => sum + analysis.globalMetrics.totalFixations, 0).toLocaleString(); 733 + this.elements.debriefFixations.textContent = cohortAnalysis.aggregateMetrics.medianFixations.toLocaleString(); 734 + this.elements.debriefFixationDuration.textContent = '0'; 735 + this.elements.sessionMessage.textContent = `Imported ${artifacts.length} sessions for cohort analysis.`; 736 + this.elements.galleryLabel.textContent = 'Cohort mode renders textual comparison only; import one session to view heatmaps.'; 737 + this.elements.gallery.innerHTML = ''; 738 + this.analysisRenderer.renderCohort({ cohortAnalysis, importErrors: errors }); 739 + this.elements.exportBtn.disabled = false; 740 + this.session.setState('complete'); 741 + return; 742 + } 743 + 744 + const artifact = results[0].artifact; 745 + const analysis = analyzeSessionArtifact(artifact); 746 + this.latestArtifact = artifact; 747 + this.latestAnalysis = analysis; 748 + this.latestCohort = null; 749 + this.lastTestApp = null; 750 + this.session.setAppName(artifact.session.appName); 751 + this.session.setTask(artifact.session.task); 752 + this.elements.currentTaskText.textContent = artifact.session.task || 'Imported session'; 753 + this.selectedApp = null; 754 + await this.renderArtifactDebrief(artifact, analysis, { strategy: 'import' }); 755 + if (errors.length) { 756 + this.elements.sessionMessage.textContent = `Imported one session. ${errors.length} file(s) were skipped.`; 757 + } 758 + this.session.setState('complete'); 759 + } catch (error) { 760 + this.session.setState('idle'); 761 + this.failSession(error.message || 'Failed to import the selected file.'); 762 + } finally { 763 + this.elements.importInput.value = ''; 764 + } 765 + } 766 + 542 767 getStoredTheme() { 543 768 const storedTheme = window.localStorage.getItem(this.themeStorageKey); 544 769 return storedTheme === 'light' ? 'light' : 'dark'; ··· 575 800 this.bridge.detach(); 576 801 this.captureJobs.clear(); 577 802 this.gazeTracker.reset(); 803 + this.latestArtifact = null; 804 + this.latestAnalysis = null; 805 + this.latestCohort = null; 578 806 this.elements.exportBtn.disabled = true; 807 + this.elements.debriefExportBtn.disabled = true; 808 + this.elements.debriefTestAgainBtn.disabled = true; 579 809 this.elements.gallery.innerHTML = ''; 580 810 this.elements.galleryLabel.textContent = 'Heatmaps will appear here after a test completes.'; 811 + this.analysisRenderer.reset(); 581 812 this.elements.calibrationScreen.classList.add('hidden'); 582 813 this.elements.startCurtain.classList.add('hidden'); 583 814 this.elements.startOverlay.classList.add('hidden'); 584 815 this.elements.calibrationFailureOverlay.classList.add('hidden'); 585 816 this.elements.debriefShell.classList.add('hidden'); 586 817 this.elements.sessionStage.classList.add('hidden'); 818 + this.elements.importInput.value = ''; 587 819 this.lastCalibrationResult = null; 588 820 this.calibrationPassed = false; 589 821 this.calibrationSkippedByDebug = false; ··· 599 831 await this.resetRuntimeOnly(); 600 832 this.session.reset(); 601 833 this.selectedApp = null; 834 + this.lastTestApp = null; 835 + this.liveSessionHistory = []; 602 836 this.debugState.mouseGazeMode = false; 603 837 this.debugState.setupPanelOpen = false; 604 838 this.elements.appSelect.value = ''; ··· 610 844 this.syncDebugUi(); 611 845 } 612 846 847 + getSameAppHistory() { 848 + const app = this.lastTestApp; 849 + if (!app) { 850 + return []; 851 + } 852 + return this.liveSessionHistory.filter((entry) => entry.app.value === app.value && entry.app.task === app.task); 853 + } 854 + 855 + async testAgain() { 856 + if (!this.lastTestApp) { 857 + return; 858 + } 859 + this.selectedApp = { ...this.lastTestApp }; 860 + this.elements.appSelect.value = this.selectedApp.value; 861 + this.syncSelectedAppUi(); 862 + await this.loadSelectedApp(); 863 + } 864 + 613 865 failSession(message) { 614 866 this.session.setState('error', { errorMessage: message }); 615 867 this.elements.sessionMessage.textContent = message; ··· 642 894 643 895 this.elements.sessionStatus.textContent = state.replace(/_/g, ' '); 644 896 this.elements.exportBtn.disabled = state !== 'complete'; 645 - this.elements.setupShell.classList.toggle('hidden', state === 'calibrating' || state === 'calibration_failed' || state === 'ready_to_start' || state === 'recording' || state === 'finishing'); 897 + this.elements.debriefExportBtn.disabled = state !== 'complete'; 898 + this.elements.debriefTestAgainBtn.disabled = state !== 'complete' || !this.lastTestApp || Boolean(this.latestCohort && !this.latestArtifact); 899 + this.elements.setupShell.classList.toggle('hidden', state === 'calibrating' || state === 'calibration_failed' || state === 'ready_to_start' || state === 'recording' || state === 'finishing' || state === 'complete'); 646 900 this.elements.sessionStage.classList.toggle('hidden', !sessionActive); 647 901 this.elements.debriefShell.classList.toggle('hidden', state !== 'complete'); 648 902 this.elements.calibrationScreen.classList.toggle('hidden', state !== 'calibrating');
+1
js/session.js
··· 134 134 135 135 exportSession(payload) { 136 136 const data = { 137 + schemaVersion: '2', 137 138 session: this.getMetadata(), 138 139 ...payload 139 140 };
+451
js/sessionAnalyzer.js
··· 1 + import { 2 + average, 3 + clamp, 4 + computeClickDispersion, 5 + computeDominantZones, 6 + computeEntropy, 7 + computeGazeMouseCoupling, 8 + computeLookThenActRate, 9 + computeMouseTraceMetrics, 10 + computePostClickConfirmation, 11 + computePreClickLatency, 12 + computeRepeatedClickBursts, 13 + computeRevisitCount, 14 + computeScanpathLength, 15 + computeScrollDepth, 16 + computeScrollDepthBeforeFirstAction, 17 + computeScrollToFixationLatency, 18 + computeUniqueZones, 19 + dedupeEvents, 20 + detectFixations, 21 + distanceToRect, 22 + groupBy, 23 + median, 24 + pointInRect 25 + } from './analysisMetrics.js'; 26 + import { buildElementMetrics } from './analysisElements.js'; 27 + import { buildFindings, buildSummary } from './analysisFindings.js'; 28 + 29 + function buildWarnings(artifact) { 30 + const warnings = []; 31 + if (artifact.source === 'import' && !['2', '3'].includes(String(artifact.schemaVersion))) { 32 + warnings.push({ 33 + code: 'legacy-import', 34 + severity: 'moderate', 35 + message: 'Imported file uses a legacy UXET export shape, so some analysis and heatmap features are unavailable.' 36 + }); 37 + } 38 + if (artifact.debug?.inputMode === 'mouse' || artifact.debug?.mouseGazeMode || artifact.fidelity.usesMouseDerivedGaze) { 39 + warnings.push({ 40 + code: 'mouse-derived-gaze', 41 + severity: 'high', 42 + message: 'This session used mouse-derived gaze, so eye-tracking findings should be treated as directional only.' 43 + }); 44 + } 45 + if (!artifact.fidelity.hasScreenshots) { 46 + warnings.push({ 47 + code: 'missing-screenshots', 48 + severity: 'moderate', 49 + message: 'Screenshots are missing, so heatmaps may be unavailable or partial.' 50 + }); 51 + } 52 + if (!artifact.fidelity.hasMouseTrace) { 53 + warnings.push({ 54 + code: 'missing-mouse-trace', 55 + severity: 'info', 56 + message: 'Dense mouse trace data is unavailable, so motor-friction metrics are limited.' 57 + }); 58 + } 59 + if (!artifact.fidelity.hasElementSnapshots) { 60 + warnings.push({ 61 + code: 'screen-level-only', 62 + severity: 'info', 63 + message: 'Element snapshots are unavailable, so findings remain screen-level rather than control-level.' 64 + }); 65 + } 66 + if (!artifact.fidelity.hasRawGazePoints) { 67 + warnings.push({ 68 + code: 'missing-gaze', 69 + severity: 'high', 70 + message: 'No raw gaze points were available, so attention analysis is heavily constrained.' 71 + }); 72 + } 73 + if (artifact.debug?.calibrationSkipped) { 74 + warnings.push({ 75 + code: 'calibration-skipped', 76 + severity: 'high', 77 + message: 'Calibration was skipped, which lowers confidence in gaze-derived findings.' 78 + }); 79 + } 80 + if (artifact.calibration?.averageError && artifact.calibration.averageError > 80) { 81 + warnings.push({ 82 + code: 'poor-calibration', 83 + severity: 'moderate', 84 + message: `Calibration quality was weak (${artifact.calibration.averageError}px average error), so precise gaze location should be interpreted cautiously.` 85 + }); 86 + } 87 + if (artifact.analysisContext?.embeddedAnalysisOnly) { 88 + warnings.push({ 89 + code: 'embedded-analysis-only', 90 + severity: 'high', 91 + message: 'This import had little or no raw session data, so UXET could only render limited recomputed analysis.' 92 + }); 93 + } 94 + return warnings; 95 + } 96 + 97 + function buildConfidence(artifact, warnings) { 98 + let score = 100; 99 + const reasons = []; 100 + warnings.forEach((warning) => { 101 + if (warning.severity === 'high') { 102 + score -= 20; 103 + } else if (warning.severity === 'moderate') { 104 + score -= 12; 105 + } else { 106 + score -= 5; 107 + } 108 + reasons.push(warning.message); 109 + }); 110 + 111 + const durationSeconds = Math.max((artifact.session.duration || 0) / 1000, 1); 112 + const gazeDensity = artifact.gaze.points.length / durationSeconds; 113 + if (gazeDensity < 2 && artifact.fidelity.hasRawGazePoints) { 114 + score -= 12; 115 + reasons.push('Gaze point density is low relative to the session duration.'); 116 + } 117 + 118 + const clamped = clamp(score, 10, 100); 119 + return { 120 + score: clamped, 121 + level: clamped >= 75 ? 'high' : clamped >= 45 ? 'medium' : 'low', 122 + reasons 123 + }; 124 + } 125 + 126 + function computeBacktracks(events) { 127 + const screenSequence = []; 128 + events.forEach((event) => { 129 + if (!event.screenKey || event.screenKey === 'unknown') { 130 + return; 131 + } 132 + if (screenSequence[screenSequence.length - 1] !== event.screenKey) { 133 + screenSequence.push(event.screenKey); 134 + } 135 + }); 136 + 137 + let backtracks = 0; 138 + const seen = new Set(); 139 + screenSequence.forEach((key) => { 140 + if (seen.has(key)) { 141 + backtracks += 1; 142 + } 143 + seen.add(key); 144 + }); 145 + return backtracks; 146 + } 147 + 148 + function computeRageClickCandidates(clicks) { 149 + const candidates = []; 150 + for (let index = 2; index < clicks.length; index += 1) { 151 + const group = [clicks[index - 2], clicks[index - 1], clicks[index]]; 152 + const sameScreen = group.every((click) => click.screenKey === group[0].screenKey); 153 + const withinTime = group[2].timestamp - group[0].timestamp <= 1800; 154 + const close = group.every((click) => Math.hypot((click.docX || 0) - (group[0].docX || 0), (click.docY || 0) - (group[0].docY || 0)) <= 48); 155 + if (sameScreen && withinTime && close) { 156 + candidates.push({ 157 + screenKey: group[0].screenKey, 158 + timestamp: group[0].timestamp, 159 + clickCount: 3 160 + }); 161 + } 162 + } 163 + return candidates; 164 + } 165 + 166 + function firstMeaningfulAction(events) { 167 + return events.find((event) => event.type === 'click' || event.type === 'key') || null; 168 + } 169 + 170 + function rectArea(rect) { 171 + return Math.max(0, rect?.width || 0) * Math.max(0, rect?.height || 0); 172 + } 173 + 174 + function isVisibleAoi(element) { 175 + return element.visible !== false && rectArea(element.rect) > 0; 176 + } 177 + 178 + function computeAnalysisStartTime({ points, events, fixations }) { 179 + const candidates = [ 180 + points[0]?.timestamp, 181 + events[0]?.timestamp, 182 + fixations[0]?.startTime 183 + ].filter(Number.isFinite); 184 + return candidates.length ? Math.min(...candidates) : 0; 185 + } 186 + 187 + function countPreActionElements({ screens, fixations }) { 188 + const elementsByScreen = new Map(screens.map((screen) => [ 189 + screen.key, 190 + screen.elementSnapshots.filter(isVisibleAoi).slice(0, 160) 191 + ])); 192 + const seen = new Set(); 193 + 194 + fixations.forEach((fixation) => { 195 + const elements = elementsByScreen.get(fixation.screenKey) || []; 196 + const point = { docX: fixation.x, docY: fixation.y }; 197 + elements.forEach((element) => { 198 + if (pointInRect(point, element.rect) || distanceToRect(point, element.rect) <= 48) { 199 + seen.add(`${fixation.screenKey}|${element.fingerprint}`); 200 + } 201 + }); 202 + }); 203 + 204 + return seen.size; 205 + } 206 + 207 + function computePreActionMetrics({ artifact, points, fixations, events, analysisStartTime }) { 208 + const firstAction = firstMeaningfulAction(events); 209 + const actionTime = firstAction?.timestamp ?? null; 210 + const preActionPoints = actionTime 211 + ? points.filter((point) => point.timestamp <= actionTime) 212 + : points; 213 + const preActionFixations = actionTime 214 + ? fixations.filter((fixation) => fixation.startTime <= actionTime) 215 + : fixations; 216 + const preActionEvents = actionTime 217 + ? events.filter((event) => event.timestamp <= actionTime) 218 + : events; 219 + const maxWidth = Math.max(...artifact.screens.map((screen) => screen.document.width || screen.viewport.width || 0), 0); 220 + const maxHeight = Math.max(...artifact.screens.map((screen) => screen.document.height || screen.viewport.height || 0), 0); 221 + const preActionFixationDuration = preActionFixations.reduce((sum, fixation) => { 222 + const endTime = actionTime ? Math.min(fixation.endTime, actionTime) : fixation.endTime; 223 + return sum + Math.max(0, endTime - fixation.startTime); 224 + }, 0); 225 + const preActionUniqueZones = computeUniqueZones(preActionPoints, maxWidth, maxHeight, 4, 4); 226 + const preActionUniqueElements = countPreActionElements({ screens: artifact.screens, fixations: preActionFixations }); 227 + const preActionGazeEntropy = computeEntropy(preActionPoints, maxWidth, maxHeight); 228 + const preActionScrollEvents = preActionEvents.filter((event) => event.type === 'scroll').length; 229 + const preActionScrollDepth = computeScrollDepth(preActionEvents, artifact.screens); 230 + const preActionScanpathLength = computeScanpathLength(preActionPoints); 231 + const timeToFirstAction = actionTime ? Math.max(0, actionTime - analysisStartTime) : 0; 232 + const preActionExplorationScore = clamp(Math.round( 233 + (preActionUniqueZones * 7) + 234 + (preActionUniqueElements * 6) + 235 + (preActionGazeEntropy * 10) + 236 + Math.min(preActionFixationDuration / 350, 22) + 237 + Math.min(preActionScrollEvents * 2, 16) 238 + ), 0, 100); 239 + 240 + return { 241 + firstAction, 242 + timeToFirstAction, 243 + preActionFixationCount: preActionFixations.length, 244 + preActionFixationDuration: Math.round(preActionFixationDuration), 245 + preActionUniqueZones, 246 + preActionUniqueElements, 247 + preActionGazeEntropy, 248 + preActionScrollEvents, 249 + preActionScrollDepth, 250 + preActionScanpathLength, 251 + preActionExplorationScore 252 + }; 253 + } 254 + 255 + function classifyBehavior({ globalMetrics, clicks, confidence }) { 256 + if (confidence.score < 35 || (!globalMetrics.totalFixations && !globalMetrics.totalClicks)) { 257 + return 'inconclusive'; 258 + } 259 + if (!globalMetrics.timeToFirstAction && !globalMetrics.totalClicks && !globalMetrics.totalKeys) { 260 + return 'inconclusive'; 261 + } 262 + if (globalMetrics.repeatedClickBursts >= 2 || globalMetrics.rageClickCandidates >= 1) { 263 + return 'interaction-friction'; 264 + } 265 + if (globalMetrics.timeToFirstAction <= 1500 && 266 + globalMetrics.repeatedClickBursts === 0 && 267 + (globalMetrics.duration <= 5000 || clicks.length <= 4)) { 268 + return 'fast-action'; 269 + } 270 + if (globalMetrics.timeToFirstAction >= 5000) { 271 + return 'delayed-first-action'; 272 + } 273 + if (globalMetrics.timeToFirstAction >= 3000 && 274 + (globalMetrics.preActionUniqueZones >= 6 || 275 + globalMetrics.preActionUniqueElements >= 5 || 276 + globalMetrics.preActionGazeEntropy >= 2.8)) { 277 + return 'high-pre-action-exploration'; 278 + } 279 + return 'smooth-action-path'; 280 + } 281 + 282 + function buildScreenMetrics({ artifact, pointsByScreen, fixationsByScreen, eventsByScreen, analysisStartTime }) { 283 + return artifact.screens.map((screen) => { 284 + const screenPoints = pointsByScreen.get(screen.key) || []; 285 + const screenFixations = fixationsByScreen.get(screen.key) || []; 286 + const screenEvents = eventsByScreen.get(screen.key) || []; 287 + const screenClicks = screenEvents.filter((event) => event.type === 'click'); 288 + const screenScrolls = screenEvents.filter((event) => event.type === 'scroll'); 289 + const firstFixation = screenFixations[0]; 290 + const firstClick = screenClicks[0]; 291 + const firstAction = firstMeaningfulAction(screenEvents); 292 + const dwellTime = screenFixations.reduce((sum, fixation) => sum + fixation.duration, 0); 293 + const width = screen.document.width || screen.viewport.width; 294 + const height = screen.document.height || screen.viewport.height; 295 + const scrollValues = screenEvents.map((event) => event.scrollY || 0); 296 + const scrollMin = scrollValues.length ? Math.min(...scrollValues) : 0; 297 + const scrollMax = scrollValues.length ? Math.max(...scrollValues) : 0; 298 + const revisitCount = computeRevisitCount(screenFixations); 299 + const gazeEntropy = computeEntropy(screenPoints, width, height); 300 + const timeToFirstFixation = firstFixation 301 + ? Math.max(0, firstFixation.startTime - analysisStartTime) 302 + : 0; 303 + const frictionScore = clamp(Math.round( 304 + (gazeEntropy * 14) + 305 + (revisitCount * 8) + 306 + Math.min(timeToFirstFixation / 80, 30) + 307 + (screenClicks.length === 0 && screenFixations.length > 4 ? 15 : 0) 308 + ), 0, 100); 309 + 310 + return { 311 + key: screen.key, 312 + title: screen.title, 313 + pointCount: screenPoints.length, 314 + fixationCount: screenFixations.length, 315 + medianFixationDuration: median(screenFixations.map((fixation) => fixation.duration)), 316 + dwellTime, 317 + timeToFirstFixation, 318 + timeToFirstClick: firstClick ? Math.max(0, firstClick.timestamp - analysisStartTime) : 0, 319 + revisitCount, 320 + gazeEntropy, 321 + clickCount: screenClicks.length, 322 + scrollCount: screenScrolls.length, 323 + actionDensity: Number(((screenClicks.length + screenScrolls.length) / Math.max(dwellTime || 1, 1) * 1000).toFixed(2)), 324 + preClickGazeLatency: computePreClickLatency(screenClicks, screenFixations), 325 + postClickConfirmationRate: computePostClickConfirmation(screenClicks, screenFixations), 326 + scrollToFixationLatency: computeScrollToFixationLatency(screenScrolls, screenFixations), 327 + scrollDepthRange: { 328 + min: scrollMin, 329 + max: scrollMax 330 + }, 331 + firstMeaningfulAction: firstAction ? { 332 + type: firstAction.type, 333 + timestamp: firstAction.timestamp, 334 + label: firstAction.targetLabel || firstAction.message || firstAction.type 335 + } : null, 336 + dominantAttentionZones: computeDominantZones(screenPoints, width, height), 337 + frictionScore 338 + }; 339 + }); 340 + } 341 + 342 + export function analyzeSessionArtifact(artifact, options = {}) { 343 + const points = [...artifact.gaze.points].sort((a, b) => a.timestamp - b.timestamp); 344 + const events = dedupeEvents([...artifact.interactions.events].sort((a, b) => a.timestamp - b.timestamp)); 345 + const mouseTrace = [...artifact.interactions.mouseTrace].sort((a, b) => a.timestamp - b.timestamp); 346 + const clicks = events.filter((event) => event.type === 'click'); 347 + const scrolls = events.filter((event) => event.type === 'scroll'); 348 + const fixations = artifact.gaze.fixations?.length 349 + ? [...artifact.gaze.fixations].sort((a, b) => a.startTime - b.startTime) 350 + : detectFixations(points); 351 + const analysisStartTime = computeAnalysisStartTime({ points, events, fixations }); 352 + const fixationsByScreen = groupBy(fixations, (fixation) => fixation.screenKey); 353 + const pointsByScreen = groupBy(points, (point) => point.screenKey); 354 + const eventsByScreen = groupBy(events, (event) => event.screenKey); 355 + const screenMetrics = buildScreenMetrics({ artifact, pointsByScreen, fixationsByScreen, eventsByScreen, analysisStartTime }); 356 + const elementMetrics = buildElementMetrics({ screens: artifact.screens, events, fixations }); 357 + const mouseMetrics = computeMouseTraceMetrics(mouseTrace, clicks); 358 + const totalFixationDuration = fixations.reduce((sum, fixation) => sum + fixation.duration, 0); 359 + const scanpathLength = computeScanpathLength(points); 360 + const lookThenActRate = computeLookThenActRate(clicks, fixations); 361 + const preActionMetrics = computePreActionMetrics({ artifact, points, fixations, events, analysisStartTime }); 362 + const timeToFirstMeaningfulAction = preActionMetrics.timeToFirstAction; 363 + const attentionFrictionScore = clamp(Math.round( 364 + Math.min(preActionMetrics.timeToFirstAction / 160, 55) + 365 + (preActionMetrics.preActionUniqueZones * 4) + 366 + (preActionMetrics.preActionUniqueElements * 3) + 367 + (preActionMetrics.preActionGazeEntropy * 8) + 368 + Math.min(preActionMetrics.preActionScrollEvents * 2, 12) 369 + ), 0, 100); 370 + const interactionFrictionScore = clamp(Math.round( 371 + (artifact.session.duration >= 5000 ? mouseMetrics.hesitationCount * 12 : 0) + 372 + (computeRepeatedClickBursts(clicks) * 10) + 373 + (artifact.session.duration >= 5000 ? (100 - lookThenActRate) / 3 : 0) + 374 + (artifact.session.duration >= 5000 && mouseMetrics.pathEfficiency > 1 ? mouseMetrics.pathEfficiency * 6 : 0) 375 + ), 0, 100); 376 + const warnings = buildWarnings(artifact); 377 + const confidence = buildConfidence(artifact, warnings); 378 + const dataCoverageScore = clamp(Math.round( 379 + (artifact.fidelity.hasRawGazePoints ? 30 : 0) + 380 + (artifact.fidelity.hasMouseTrace ? 20 : 0) + 381 + (artifact.fidelity.hasScreenshots ? 20 : 0) + 382 + (artifact.fidelity.hasElementSnapshots ? 20 : 0) + 383 + (artifact.calibration?.passed ? 10 : 0) 384 + ), 0, 100); 385 + 386 + const rageClickCandidates = computeRageClickCandidates(clicks).length; 387 + const globalMetrics = { 388 + analysisStartTime, 389 + duration: artifact.session.duration, 390 + totalClicks: clicks.length, 391 + totalScrolls: scrolls.length, 392 + totalKeys: artifact.interactions.stats.keyboard.totalKeys, 393 + totalFixations: fixations.length, 394 + avgFixationDuration: fixations.length ? Math.round(totalFixationDuration / fixations.length) : 0, 395 + medianFixationDuration: median(fixations.map((fixation) => fixation.duration)), 396 + scanpathLength, 397 + scanpathLengthNormalized: artifact.session.duration 398 + ? Number((scanpathLength / Math.max(artifact.session.duration / 1000, 1)).toFixed(2)) 399 + : 0, 400 + transitionCount: artifact.screens.reduce((count, screen, index, screens) => index > 0 && screens[index - 1].key !== screen.key ? count + 1 : count, 0), 401 + revisitCount: screenMetrics.reduce((sum, screen) => sum + screen.revisitCount, 0), 402 + preClickGazeLatency: computePreClickLatency(clicks, fixations), 403 + postClickConfirmationRate: computePostClickConfirmation(clicks, fixations), 404 + scrollToFixationLatency: computeScrollToFixationLatency(scrolls, fixations), 405 + clickDispersion: computeClickDispersion(clicks), 406 + repeatedClickBursts: computeRepeatedClickBursts(clicks), 407 + gazeMouseCoupling: computeGazeMouseCoupling(points, mouseTrace), 408 + lookThenActRate, 409 + actWithoutLookingRate: clicks.length ? 100 - lookThenActRate : 0, 410 + searchFrictionScore: attentionFrictionScore, 411 + uncertaintyScore: interactionFrictionScore, 412 + dataCoverageScore, 413 + interactionFrictionScore, 414 + attentionFrictionScore, 415 + completionConfidenceScore: confidence.score, 416 + timeToFirstMeaningfulAction, 417 + timeToFirstAction: preActionMetrics.timeToFirstAction, 418 + preActionFixationCount: preActionMetrics.preActionFixationCount, 419 + preActionFixationDuration: preActionMetrics.preActionFixationDuration, 420 + preActionUniqueZones: preActionMetrics.preActionUniqueZones, 421 + preActionUniqueElements: preActionMetrics.preActionUniqueElements, 422 + preActionGazeEntropy: preActionMetrics.preActionGazeEntropy, 423 + preActionScrollEvents: preActionMetrics.preActionScrollEvents, 424 + preActionScrollDepth: preActionMetrics.preActionScrollDepth, 425 + preActionScanpathLength: preActionMetrics.preActionScanpathLength, 426 + preActionExplorationScore: preActionMetrics.preActionExplorationScore, 427 + rageClickCandidates, 428 + backtrackCount: computeBacktracks(events), 429 + idleHesitationWindows: mouseMetrics.idleHesitationWindows, 430 + scrollDepthReached: computeScrollDepth(events, artifact.screens), 431 + scrollDepthBeforeFirstAction: computeScrollDepthBeforeFirstAction(events, artifact.screens), 432 + ...mouseMetrics 433 + }; 434 + globalMetrics.behaviorOutcome = classifyBehavior({ globalMetrics, clicks, confidence }); 435 + 436 + const findings = buildFindings({ artifact, screenMetrics, elementMetrics, globalMetrics, warnings, confidence }); 437 + const summary = buildSummary({ findings, confidence, globalMetrics }); 438 + 439 + return { 440 + analysisVersion: '3', 441 + generatedAt: options.generatedAt || Date.now(), 442 + summary, 443 + confidence, 444 + warnings, 445 + globalMetrics, 446 + screenMetrics, 447 + elementMetrics, 448 + findings, 449 + fixations 450 + }; 451 + }
+350
js/sessionArtifact.js
··· 1 + function asArray(value) { 2 + return Array.isArray(value) ? value : []; 3 + } 4 + 5 + function asObject(value) { 6 + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; 7 + } 8 + 9 + function asNumber(value, fallback = 0) { 10 + return Number.isFinite(value) ? value : fallback; 11 + } 12 + 13 + function asString(value, fallback = '') { 14 + return typeof value === 'string' ? value : fallback; 15 + } 16 + 17 + function sortByTimestamp(items, field = 'timestamp') { 18 + return [...items].sort((a, b) => asNumber(a?.[field]) - asNumber(b?.[field])); 19 + } 20 + 21 + function normalizeInteractionStats(stats) { 22 + const source = asObject(stats); 23 + const mouse = asObject(source.mouse); 24 + const keyboard = asObject(source.keyboard); 25 + return { 26 + mouse: { 27 + x: asNumber(mouse.x), 28 + y: asNumber(mouse.y), 29 + movements: asNumber(mouse.movements), 30 + clicks: asNumber(mouse.clicks), 31 + distance: asNumber(mouse.distance), 32 + avgVelocity: asNumber(mouse.avgVelocity), 33 + scrollEvents: asNumber(mouse.scrollEvents) 34 + }, 35 + keyboard: { 36 + totalKeys: asNumber(keyboard.totalKeys), 37 + keysPerMinute: asNumber(keyboard.keysPerMinute), 38 + lastKey: asString(keyboard.lastKey), 39 + backspaces: asNumber(keyboard.backspaces) 40 + }, 41 + totalEvents: asNumber(source.totalEvents) 42 + }; 43 + } 44 + 45 + function normalizeGazePoint(point) { 46 + const source = asObject(point); 47 + return { 48 + timestamp: asNumber(source.timestamp), 49 + screenKey: asString(source.screenKey, 'unknown'), 50 + viewportX: asNumber(source.viewportX), 51 + viewportY: asNumber(source.viewportY), 52 + iframeX: asNumber(source.iframeX), 53 + iframeY: asNumber(source.iframeY), 54 + inIframe: source.inIframe !== false, 55 + scrollX: asNumber(source.scrollX), 56 + scrollY: asNumber(source.scrollY), 57 + docX: asNumber(source.docX), 58 + docY: asNumber(source.docY), 59 + viewportWidth: asNumber(source.viewportWidth), 60 + viewportHeight: asNumber(source.viewportHeight), 61 + documentWidth: asNumber(source.documentWidth), 62 + documentHeight: asNumber(source.documentHeight) 63 + }; 64 + } 65 + 66 + function normalizeInteractionEvent(event) { 67 + const source = asObject(event); 68 + return { 69 + timestamp: asNumber(source.timestamp), 70 + type: asString(source.type, 'unknown'), 71 + message: asString(source.message), 72 + screenKey: asString(source.screenKey, 'unknown'), 73 + scrollX: asNumber(source.scrollX), 74 + scrollY: asNumber(source.scrollY), 75 + viewportWidth: asNumber(source.viewportWidth), 76 + viewportHeight: asNumber(source.viewportHeight), 77 + documentWidth: asNumber(source.documentWidth), 78 + documentHeight: asNumber(source.documentHeight), 79 + targetTag: source.targetTag ?? null, 80 + targetRole: source.targetRole ?? null, 81 + targetLabel: source.targetLabel ?? null, 82 + targetId: source.targetId ?? null, 83 + targetClass: source.targetClass ?? null, 84 + clickTargetFingerprint: source.clickTargetFingerprint ?? null, 85 + targetPath: asArray(source.targetPath).map((item) => asObject(item)), 86 + docX: Number.isFinite(source.docX) ? source.docX : null, 87 + docY: Number.isFinite(source.docY) ? source.docY : null 88 + }; 89 + } 90 + 91 + function normalizeMouseTracePoint(point) { 92 + const source = asObject(point); 93 + return { 94 + timestamp: asNumber(source.timestamp), 95 + screenKey: asString(source.screenKey, 'unknown'), 96 + clientX: asNumber(source.clientX), 97 + clientY: asNumber(source.clientY), 98 + docX: asNumber(source.docX), 99 + docY: asNumber(source.docY), 100 + velocity: asNumber(source.velocity), 101 + scrollX: asNumber(source.scrollX), 102 + scrollY: asNumber(source.scrollY) 103 + }; 104 + } 105 + 106 + function normalizeFixation(fixation) { 107 + const source = asObject(fixation); 108 + return { 109 + screenKey: asString(source.screenKey, 'unknown'), 110 + startTime: asNumber(source.startTime), 111 + endTime: asNumber(source.endTime), 112 + duration: asNumber(source.duration), 113 + x: asNumber(source.x), 114 + y: asNumber(source.y), 115 + pointCount: asNumber(source.pointCount) 116 + }; 117 + } 118 + 119 + function normalizeElementSnapshot(snapshot) { 120 + const source = asObject(snapshot); 121 + const rect = asObject(source.rect); 122 + return { 123 + fingerprint: asString(source.fingerprint), 124 + tag: asString(source.tag), 125 + role: source.role ?? null, 126 + label: source.label ?? null, 127 + className: asString(source.className), 128 + dataset: asObject(source.dataset), 129 + clickable: Boolean(source.clickable), 130 + ancestorText: source.ancestorText ?? null, 131 + text: source.text ?? null, 132 + href: source.href ?? null, 133 + inputType: source.inputType ?? null, 134 + visible: source.visible !== false, 135 + disabled: Boolean(source.disabled), 136 + rect: { 137 + x: asNumber(rect.x), 138 + y: asNumber(rect.y), 139 + width: asNumber(rect.width), 140 + height: asNumber(rect.height) 141 + } 142 + }; 143 + } 144 + 145 + function normalizeScreenRecord(screen) { 146 + const source = asObject(screen); 147 + const viewport = asObject(source.viewport); 148 + const documentInfo = asObject(source.document); 149 + const screenshot = asObject(source.screenshot); 150 + return { 151 + key: asString(source.key, 'unknown'), 152 + title: asString(source.title, asString(source.key, 'Untitled screen')), 153 + firstSeenAt: asNumber(source.firstSeenAt), 154 + lastSeenAt: asNumber(source.lastSeenAt), 155 + viewport: { 156 + width: asNumber(viewport.width, asNumber(source.viewportWidth)), 157 + height: asNumber(viewport.height, asNumber(source.viewportHeight)) 158 + }, 159 + document: { 160 + width: asNumber(documentInfo.width, asNumber(source.documentWidth)), 161 + height: asNumber(documentInfo.height, asNumber(source.documentHeight)) 162 + }, 163 + screenshot: { 164 + dataUrl: typeof screenshot.dataUrl === 'string' ? screenshot.dataUrl : null, 165 + width: asNumber(screenshot.width, asNumber(source.screenshotWidth)), 166 + height: asNumber(screenshot.height, asNumber(source.screenshotHeight)), 167 + capturedAt: screenshot.capturedAt ?? null, 168 + status: asString(screenshot.status, source.screenshotCaptured ? 'ready' : 'missing') 169 + }, 170 + gazePoints: sortByTimestamp(asArray(source.gazePoints).map(normalizeGazePoint)), 171 + interactionEvents: sortByTimestamp(asArray(source.interactionEvents).map(normalizeInteractionEvent)), 172 + elementSnapshots: asArray(source.elementSnapshots).map(normalizeElementSnapshot), 173 + fixationCount: asNumber(source.fixationCount) 174 + }; 175 + } 176 + 177 + function normalizeScreenSummary(summary) { 178 + const source = asObject(summary); 179 + return normalizeScreenRecord({ 180 + key: source.key, 181 + title: source.title, 182 + firstSeenAt: source.firstSeenAt, 183 + lastSeenAt: source.lastSeenAt, 184 + viewportWidth: source.viewportWidth, 185 + viewportHeight: source.viewportHeight, 186 + documentWidth: source.documentWidth, 187 + documentHeight: source.documentHeight, 188 + screenshotCaptured: source.screenshotCaptured, 189 + screenshotWidth: source.screenshotWidth, 190 + screenshotHeight: source.screenshotHeight, 191 + fixationCount: source.fixationCount 192 + }); 193 + } 194 + 195 + function enrichScreensWithPoints(screens, points, events) { 196 + const map = new Map(screens.map((screen) => [screen.key, screen])); 197 + 198 + const ensureScreen = (screenKey) => { 199 + if (map.has(screenKey)) { 200 + return map.get(screenKey); 201 + } 202 + const inferred = normalizeScreenRecord({ key: screenKey, title: screenKey }); 203 + map.set(screenKey, inferred); 204 + return inferred; 205 + }; 206 + 207 + points.forEach((point) => { 208 + ensureScreen(point.screenKey).gazePoints.push(point); 209 + }); 210 + 211 + events.forEach((event) => { 212 + ensureScreen(event.screenKey).interactionEvents.push(event); 213 + }); 214 + 215 + return Array.from(map.values()).sort((a, b) => a.firstSeenAt - b.firstSeenAt); 216 + } 217 + 218 + function buildFidelity({ screens, mouseTrace, points, debug }) { 219 + return { 220 + hasScreenshots: screens.some((screen) => screen.screenshot.status === 'ready' && screen.screenshot.dataUrl), 221 + hasMouseTrace: mouseTrace.length > 0, 222 + hasElementSnapshots: screens.some((screen) => screen.elementSnapshots.length > 0), 223 + hasRawGazePoints: points.length > 0, 224 + usesMouseDerivedGaze: debug.mouseGazeMode || debug.inputMode === 'mouse' 225 + }; 226 + } 227 + 228 + export function createSessionArtifactFromLive({ 229 + session, 230 + gaze, 231 + interactions, 232 + screenRecords, 233 + calibration, 234 + debug, 235 + analysisContext 236 + }) { 237 + const gazePoints = sortByTimestamp(asArray(gaze?.points).map(normalizeGazePoint)); 238 + const interactionEvents = sortByTimestamp(asArray(interactions?.events).map(normalizeInteractionEvent)); 239 + const mouseTrace = sortByTimestamp(asArray(interactions?.mouseTrace).map(normalizeMouseTracePoint)); 240 + const fixations = sortByTimestamp(asArray(gaze?.fixations).map(normalizeFixation), 'startTime'); 241 + const screens = enrichScreensWithPoints( 242 + asArray(screenRecords).map(normalizeScreenRecord), 243 + gazePoints, 244 + interactionEvents 245 + ); 246 + const debugInfo = { 247 + mouseGazeMode: Boolean(debug?.mouseGazeMode), 248 + calibrationSkipped: Boolean(debug?.calibrationSkipped), 249 + inputMode: debug?.inputMode || gaze?.inputMode || 'webgazer' 250 + }; 251 + 252 + return { 253 + schemaVersion: '3', 254 + source: 'live', 255 + fidelity: buildFidelity({ screens, mouseTrace, points: gazePoints, debug: debugInfo }), 256 + session: { 257 + appName: asString(session?.appName, 'Imported UXET Session'), 258 + task: asString(session?.task, 'Unknown task'), 259 + status: asString(session?.status, 'complete'), 260 + startTime: session?.startTime || null, 261 + duration: asNumber(session?.duration), 262 + inputMode: debugInfo.inputMode 263 + }, 264 + gaze: { 265 + stats: asObject(gaze?.stats), 266 + points: gazePoints, 267 + fixations 268 + }, 269 + interactions: { 270 + stats: normalizeInteractionStats(interactions?.stats), 271 + events: interactionEvents, 272 + mouseTrace 273 + }, 274 + screens, 275 + calibration: calibration || null, 276 + debug: debugInfo, 277 + analysisContext: asObject(analysisContext) 278 + }; 279 + } 280 + 281 + export function normalizeImportedSession(raw) { 282 + const source = asObject(raw); 283 + const schemaVersion = String(source.schemaVersion || '1'); 284 + const session = asObject(source.session); 285 + const debug = asObject(source.debug); 286 + const gaze = asObject(source.gaze); 287 + const interactions = asObject(source.interactions); 288 + const analysis = asObject(source.analysis); 289 + const analysisContext = { 290 + ...asObject(source.analysisContext), 291 + embeddedAnalysisOnly: Boolean(schemaVersion === '3' && source.analysis && (!source.gaze || !source.interactions)) 292 + }; 293 + const importedScreenRecords = source.screenRecords; 294 + const importedScreens = source.screens; 295 + 296 + const gazePoints = sortByTimestamp(asArray(gaze.points).map(normalizeGazePoint)); 297 + const interactionEvents = sortByTimestamp(asArray(interactions.events).map(normalizeInteractionEvent)); 298 + const mouseTrace = sortByTimestamp(asArray(source.mouseTrace || interactions.mouseTrace).map(normalizeMouseTracePoint)); 299 + const fixations = sortByTimestamp(asArray(source.fixations || gaze.fixations).map(normalizeFixation), 'startTime'); 300 + 301 + let screens = []; 302 + if (Array.isArray(importedScreenRecords)) { 303 + screens = importedScreenRecords.map(normalizeScreenRecord); 304 + } else if (Array.isArray(importedScreens)) { 305 + screens = importedScreens.map(normalizeScreenSummary); 306 + } else if (importedScreens && typeof importedScreens === 'object') { 307 + screens = Object.values(importedScreens).map(normalizeScreenSummary); 308 + } 309 + 310 + screens = enrichScreensWithPoints(screens, gazePoints, interactionEvents); 311 + 312 + const debugInfo = { 313 + mouseGazeMode: Boolean(debug.mouseGazeMode), 314 + calibrationSkipped: Boolean(debug.calibrationSkipped), 315 + inputMode: debug.inputMode || gaze.inputMode || 'webgazer' 316 + }; 317 + 318 + return { 319 + schemaVersion, 320 + source: 'import', 321 + fidelity: buildFidelity({ screens, mouseTrace, points: gazePoints, debug: debugInfo }), 322 + session: { 323 + appName: asString(session.appName, 'Imported UXET Session'), 324 + task: asString(session.task, 'Unknown task'), 325 + status: asString(session.status, 'complete'), 326 + startTime: session.startTime || null, 327 + duration: asNumber(session.duration, asNumber(analysis.globalMetrics?.duration)), 328 + inputMode: debugInfo.inputMode 329 + }, 330 + gaze: { 331 + stats: asObject(gaze.stats), 332 + points: gazePoints, 333 + fixations 334 + }, 335 + interactions: { 336 + stats: normalizeInteractionStats(interactions.stats), 337 + events: interactionEvents, 338 + mouseTrace 339 + }, 340 + screens, 341 + calibration: source.calibration || null, 342 + debug: debugInfo, 343 + analysisContext 344 + }; 345 + } 346 + 347 + export function isLikelyUxetSession(data) { 348 + const source = asObject(data); 349 + return Boolean(source.session && ((source.gaze && source.interactions) || source.analysis)); 350 + }
+72 -2
js/tracker.js
··· 27 27 constructor() { 28 28 this.isRecording = false; 29 29 this.events = []; 30 + this.mouseTrace = []; 30 31 this.stats = createInitialStats(); 31 32 this.bridge = null; 32 33 this.detachFns = []; 33 34 this.lastMousePos = null; 34 35 this.lastMouseTime = null; 35 36 this.velocities = []; 37 + this.lastTraceAt = 0; 38 + this.mouseTraceInterval = 50; 36 39 this.onEvent = null; 37 40 this.onMousePosition = null; 38 41 } ··· 77 80 reset() { 78 81 this.stop(); 79 82 this.events = []; 83 + this.mouseTrace = []; 80 84 this.stats = createInitialStats(); 81 85 this.lastMousePos = null; 82 86 this.lastMouseTime = null; 83 87 this.velocities = []; 88 + this.lastTraceAt = 0; 84 89 } 85 90 86 91 handleMouseMove(event) { ··· 130 135 }); 131 136 } 132 137 138 + if (now - this.lastTraceAt >= this.mouseTraceInterval) { 139 + const velocity = this.velocities.length ? this.velocities[this.velocities.length - 1] : 0; 140 + this.mouseTrace.push({ 141 + timestamp: now, 142 + screenKey: metrics.key, 143 + clientX: iframeX, 144 + clientY: iframeY, 145 + docX, 146 + docY, 147 + velocity: Math.round(velocity), 148 + scrollX: metrics.scrollX, 149 + scrollY: metrics.scrollY 150 + }); 151 + this.lastTraceAt = now; 152 + } 153 + 133 154 if (this.stats.mouse.movements % 12 === 0) { 134 155 this.logEvent('mouse', 'mousemove', event, metrics, { docX, docY }); 135 156 } ··· 151 172 ? `.${target.className.split(' ').filter(Boolean)[0] || ''}` 152 173 : '' 153 174 ].join(''); 175 + const targetLabel = target?.getAttribute?.('aria-label') || 176 + target?.innerText?.trim?.()?.slice(0, 80) || 177 + target?.value || 178 + ''; 179 + const clickTargetFingerprint = [ 180 + tagName, 181 + target?.id || '', 182 + target?.getAttribute?.('role') || '', 183 + target?.getAttribute?.('name') || '', 184 + targetLabel 185 + ].filter(Boolean).join('|'); 186 + const targetPath = []; 187 + let cursor = target; 188 + while (cursor && targetPath.length < 4 && cursor !== this.bridge.iframeDocument.body) { 189 + const rect = cursor.getBoundingClientRect?.(); 190 + const label = cursor.getAttribute?.('aria-label') || 191 + cursor.innerText?.trim?.()?.slice(0, 80) || 192 + cursor.value || 193 + ''; 194 + targetPath.push({ 195 + tag: cursor.tagName?.toLowerCase?.() || 'unknown', 196 + id: cursor.id || '', 197 + className: typeof cursor.className === 'string' ? cursor.className : '', 198 + role: cursor.getAttribute?.('role') || null, 199 + label, 200 + text: cursor.textContent?.trim?.()?.slice(0, 120) || '', 201 + dataset: { ...(cursor.dataset || {}) }, 202 + rect: rect ? { 203 + x: Math.round(rect.left + metrics.scrollX), 204 + y: Math.round(rect.top + metrics.scrollY), 205 + width: Math.round(rect.width), 206 + height: Math.round(rect.height) 207 + } : null 208 + }); 209 + cursor = cursor.parentElement; 210 + } 154 211 155 212 this.logEvent('click', `click:${descriptor}`, event, metrics, { 156 213 docX: Math.round(event.clientX + metrics.scrollX), 157 - docY: Math.round(event.clientY + metrics.scrollY) 214 + docY: Math.round(event.clientY + metrics.scrollY), 215 + targetId: target?.id || null, 216 + targetClass: typeof target?.className === 'string' 217 + ? target.className.split(' ').filter(Boolean)[0] || null 218 + : null, 219 + targetRole: target?.getAttribute?.('role') || null, 220 + targetLabel: targetLabel || null, 221 + clickTargetFingerprint: clickTargetFingerprint || null, 222 + targetPath 158 223 }); 159 224 } 160 225 ··· 220 285 return [...this.events]; 221 286 } 222 287 288 + getMouseTrace() { 289 + return [...this.mouseTrace]; 290 + } 291 + 223 292 exportData() { 224 293 return { 225 294 stats: this.getStats(), 226 - events: this.getEvents() 295 + events: this.getEvents(), 296 + mouseTrace: this.getMouseTrace() 227 297 }; 228 298 } 229 299 }