Fix full-session heatmap viewer not opening; switch chart to line chart
Browse filesRoot cause of the "buttons do nothing" report: embedded webviews can
treat a file:// screenshot drawn onto a canvas as tainted, so the later
canvas.toDataURL()/getImageData() throws a SecurityError that silently
kills the click handler before the viewer ever opens. Every screenshot
used for compositing (per-segment and full-session) now loads through
fetch() + a blob: URL instead of pointing <img> straight at the file://
path -- same bytes, non-tainting origin. Also added a lightweight toast
and try/catch around every export/compose path so a genuine failure is
visible instead of looking like nothing happened.
Replaced the Element Attention bar chart with a line chart: connected
points with smooth curve interpolation, circular markers, gradient fill,
hover tooltips, grid lines, and axis titles -- same category totals as
before, no new metric.
Still entirely confined to the report's own HTML/CSS/JS template; no
tracking, calibration, analytics, or data-collection code touched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- analysis.py +176 -62
|
@@ -711,10 +711,17 @@ const Viewer = (function(){
|
|
| 711 |
}
|
| 712 |
|
| 713 |
function open(opts){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
titleEl.textContent = opts.title || '';
|
| 715 |
downloadName = opts.downloadName || 'insightux-image.png';
|
| 716 |
-
|
| 717 |
-
img.src = (typeof source === 'string') ? source : source.toDataURL('image/png');
|
| 718 |
img.onload = function(){
|
| 719 |
naturalW = img.naturalWidth; naturalH = img.naturalHeight;
|
| 720 |
fitScreen();
|
|
@@ -722,6 +729,7 @@ const Viewer = (function(){
|
|
| 722 |
el.classList.add('open');
|
| 723 |
el.setAttribute('aria-hidden', 'false');
|
| 724 |
document.addEventListener('keydown', onKeydown, true);
|
|
|
|
| 725 |
}
|
| 726 |
function close(){
|
| 727 |
if (document.fullscreenElement) { document.exitFullscreen().catch(function(){}); }
|
|
@@ -935,13 +943,58 @@ function paintHeatLayer(ctx, w, h, points, intensity, stops, opacity, composite)
|
|
| 935 |
ctx.globalCompositeOperation = prevOp;
|
| 936 |
}
|
| 937 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 938 |
function loadImage(src){
|
| 939 |
-
return
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 945 |
}
|
| 946 |
|
| 947 |
// ---------- Screenshot-backed heatmaps: Eye / Mouse / Combined tabs, paginated ----------
|
|
@@ -1026,7 +1079,7 @@ function loadImage(src){
|
|
| 1026 |
|
| 1027 |
document.getElementById('hmFullscreen').addEventListener('click', function(){
|
| 1028 |
const canvas = composeSegmentCanvas();
|
| 1029 |
-
if (!canvas) return;
|
| 1030 |
Viewer.open({
|
| 1031 |
source: canvas,
|
| 1032 |
title: 'Scroll segment ' + (cur+1) + ' of ' + SEGMENTS.length + ' — ' + mode.charAt(0).toUpperCase() + mode.slice(1) + ' heatmap',
|
|
@@ -1035,18 +1088,23 @@ function loadImage(src){
|
|
| 1035 |
});
|
| 1036 |
document.getElementById('hmDownload').addEventListener('click', function(){
|
| 1037 |
const canvas = composeSegmentCanvas();
|
| 1038 |
-
if (!canvas) return;
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1043 |
});
|
| 1044 |
|
| 1045 |
-
function renderSegment(i){
|
| 1046 |
const seg = SEGMENTS[i];
|
| 1047 |
area.innerHTML = `
|
| 1048 |
<div class="shot-wrap" id="shotWrap">
|
| 1049 |
-
<img id="shotImg"
|
| 1050 |
<canvas id="shotCanvas"></canvas>
|
| 1051 |
</div>
|
| 1052 |
<div class="shot-nav">
|
|
@@ -1056,6 +1114,9 @@ function loadImage(src){
|
|
| 1056 |
</div>
|
| 1057 |
`;
|
| 1058 |
|
|
|
|
|
|
|
|
|
|
| 1059 |
const img = document.getElementById('shotImg');
|
| 1060 |
const cv = document.getElementById('shotCanvas');
|
| 1061 |
const ctx = cv.getContext('2d');
|
|
@@ -1066,10 +1127,13 @@ function loadImage(src){
|
|
| 1066 |
paintActive(ctx, cv.width, cv.height);
|
| 1067 |
}
|
| 1068 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
if (img.complete) paint(); else img.onload = paint;
|
| 1070 |
-
|
| 1071 |
-
document.getElementById('prevBtn').onclick = () => { if (cur>0){ cur--; renderSegment(cur); } };
|
| 1072 |
-
document.getElementById('nextBtn').onclick = () => { if (cur<SEGMENTS.length-1){ cur++; renderSegment(cur); } };
|
| 1073 |
}
|
| 1074 |
|
| 1075 |
renderSegment(cur);
|
|
@@ -1082,11 +1146,17 @@ function loadImage(src){
|
|
| 1082 |
try {
|
| 1083 |
const imgs = await Promise.all(SEGMENTS.map(s => loadImage(s.screenshot)));
|
| 1084 |
const valid = imgs.filter(Boolean);
|
| 1085 |
-
if (!valid.length)
|
|
|
|
|
|
|
|
|
|
| 1086 |
const width = valid[0].naturalWidth;
|
| 1087 |
let height = 0;
|
| 1088 |
SEGMENTS.forEach((s, i) => { if (imgs[i]) height = Math.max(height, s.scrollY + imgs[i].naturalHeight); });
|
| 1089 |
-
if (!height)
|
|
|
|
|
|
|
|
|
|
| 1090 |
|
| 1091 |
const full = document.createElement('canvas');
|
| 1092 |
full.width = width; full.height = height;
|
|
@@ -1115,11 +1185,15 @@ function loadImage(src){
|
|
| 1115 |
// immediate download — the viewer's own Download button saves it
|
| 1116 |
// once the user has actually looked at it, same as a PDF/image
|
| 1117 |
// preview rather than a silent file-save.
|
| 1118 |
-
Viewer.open({
|
| 1119 |
source: full,
|
| 1120 |
title: 'Full Session — ' + exportMode.charAt(0).toUpperCase() + exportMode.slice(1) + ' Heatmap',
|
| 1121 |
downloadName: 'insightux-full-session-' + exportMode + '-heatmap.png',
|
| 1122 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1123 |
} finally {
|
| 1124 |
if (btn) { btn.innerHTML = original; btn.disabled = false; }
|
| 1125 |
}
|
|
@@ -1130,7 +1204,7 @@ function loadImage(src){
|
|
| 1130 |
document.getElementById('exportCombined').addEventListener('click', function(){ exportFullSession('combined'); });
|
| 1131 |
})();
|
| 1132 |
|
| 1133 |
-
// ---------- Element Attention Analysis:
|
| 1134 |
// re-aggregation of the already-computed RANKING array (compute_dwell_ranking()
|
| 1135 |
// output) — no new metric, just grouped by element type and charted. ----------
|
| 1136 |
(function(){
|
|
@@ -1160,18 +1234,17 @@ function loadImage(src){
|
|
| 1160 |
const totals = {};
|
| 1161 |
RANKING.forEach(r => { const c = categoryFor(r.label); totals[c] = (totals[c] || 0) + r.seconds; });
|
| 1162 |
const grand = Object.values(totals).reduce((a,b) => a+b, 0) || 1;
|
| 1163 |
-
const
|
| 1164 |
key, name: CATS[key].name,
|
| 1165 |
seconds: Math.round(totals[key] * 10) / 10,
|
| 1166 |
pct: Math.round(100 * totals[key] / grand),
|
| 1167 |
})).filter(r => r.seconds > 0).sort((a,b) => b.seconds - a.seconds);
|
| 1168 |
|
| 1169 |
-
if (!
|
| 1170 |
wrap.innerHTML = '<div class="empty">No categorized attention data yet.</div>';
|
| 1171 |
return;
|
| 1172 |
}
|
| 1173 |
|
| 1174 |
-
const COLORS = ['#6366F1', '#8B5CF6', '#C084FC', '#67E8F9', '#FBBF24', '#34D399'];
|
| 1175 |
let animProgress = 0;
|
| 1176 |
let hoverIdx = -1;
|
| 1177 |
let raf = null;
|
|
@@ -1183,19 +1256,25 @@ function loadImage(src){
|
|
| 1183 |
}
|
| 1184 |
|
| 1185 |
function geometry(){
|
| 1186 |
-
const padLeft = 54, padRight =
|
| 1187 |
const plotW = cv.width - padLeft - padRight;
|
| 1188 |
const plotH = cv.height - padTop - padBottom;
|
| 1189 |
-
const
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1193 |
}
|
| 1194 |
|
| 1195 |
function paint(){
|
| 1196 |
const dim = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-dim').trim() || '#9a95b3';
|
| 1197 |
const faint = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-faint').trim() || '#6e6885';
|
| 1198 |
const border = getComputedStyle(document.documentElement).getPropertyValue('--iux-border').trim() || 'rgba(148,138,179,0.16)';
|
|
|
|
|
|
|
| 1199 |
const g = geometry();
|
| 1200 |
ctx.clearRect(0, 0, cv.width, cv.height);
|
| 1201 |
|
|
@@ -1216,34 +1295,55 @@ function loadImage(src){
|
|
| 1216 |
ctx.fillText(v.toFixed(1) + 's', g.padLeft - 8, y + 3);
|
| 1217 |
}
|
| 1218 |
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
|
| 1223 |
-
const h = Math.max(0, targetH * animProgress);
|
| 1224 |
-
const y = g.padTop + g.plotH - h;
|
| 1225 |
-
const grad = ctx.createLinearGradient(0, y, 0, g.padTop + g.plotH);
|
| 1226 |
-
const base = COLORS[i % COLORS.length];
|
| 1227 |
-
grad.addColorStop(0, base);
|
| 1228 |
-
grad.addColorStop(1, 'rgba(99,102,241,0.32)');
|
| 1229 |
-
ctx.fillStyle = grad;
|
| 1230 |
-
ctx.globalAlpha = (hoverIdx === -1 || hoverIdx === i) ? 1 : 0.55;
|
| 1231 |
-
const rr = Math.min(8, g.barW / 2, Math.max(0, h));
|
| 1232 |
ctx.beginPath();
|
| 1233 |
-
ctx.moveTo(x,
|
| 1234 |
-
|
| 1235 |
-
ctx.
|
| 1236 |
-
ctx.lineTo(x + g.barW, g.padTop + g.plotH);
|
| 1237 |
-
ctx.lineTo(x, g.padTop + g.plotH);
|
| 1238 |
ctx.closePath();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1239 |
ctx.fill();
|
| 1240 |
-
ctx.
|
|
|
|
|
|
|
| 1241 |
|
| 1242 |
-
ctx.fillStyle =
|
| 1243 |
-
ctx.font =
|
| 1244 |
ctx.textAlign = 'center';
|
| 1245 |
-
const label =
|
| 1246 |
-
ctx.fillText(label,
|
| 1247 |
});
|
| 1248 |
|
| 1249 |
// axis baseline
|
|
@@ -1252,6 +1352,17 @@ function loadImage(src){
|
|
| 1252 |
ctx.moveTo(g.padLeft, g.padTop + g.plotH);
|
| 1253 |
ctx.lineTo(cv.width - g.padRight, g.padTop + g.plotH);
|
| 1254 |
ctx.stroke();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1255 |
}
|
| 1256 |
|
| 1257 |
function animate(){
|
|
@@ -1268,18 +1379,21 @@ function loadImage(src){
|
|
| 1268 |
const scaleX = cv.width / rect.width;
|
| 1269 |
const mx = (e.clientX - rect.left) * scaleX;
|
| 1270 |
const g = geometry();
|
| 1271 |
-
let idx = -1;
|
| 1272 |
-
|
| 1273 |
-
const x =
|
| 1274 |
-
|
|
|
|
| 1275 |
});
|
|
|
|
| 1276 |
if (idx !== hoverIdx) { hoverIdx = idx; paint(); }
|
| 1277 |
if (idx >= 0) {
|
| 1278 |
-
const
|
|
|
|
| 1279 |
tooltip.style.display = 'block';
|
| 1280 |
-
tooltip.style.left = Math.min(cv.clientWidth -
|
| 1281 |
-
tooltip.style.top =
|
| 1282 |
-
tooltip.innerHTML = '<b>' +
|
| 1283 |
} else {
|
| 1284 |
tooltip.style.display = 'none';
|
| 1285 |
}
|
|
|
|
| 711 |
}
|
| 712 |
|
| 713 |
function open(opts){
|
| 714 |
+
const source = opts.source;
|
| 715 |
+
let dataUrl;
|
| 716 |
+
try {
|
| 717 |
+
dataUrl = (typeof source === 'string') ? source : source.toDataURL('image/png');
|
| 718 |
+
} catch (err) {
|
| 719 |
+
console.warn('[insightux-report] viewer could not read source canvas:', err);
|
| 720 |
+
return false;
|
| 721 |
+
}
|
| 722 |
titleEl.textContent = opts.title || '';
|
| 723 |
downloadName = opts.downloadName || 'insightux-image.png';
|
| 724 |
+
img.src = dataUrl;
|
|
|
|
| 725 |
img.onload = function(){
|
| 726 |
naturalW = img.naturalWidth; naturalH = img.naturalHeight;
|
| 727 |
fitScreen();
|
|
|
|
| 729 |
el.classList.add('open');
|
| 730 |
el.setAttribute('aria-hidden', 'false');
|
| 731 |
document.addEventListener('keydown', onKeydown, true);
|
| 732 |
+
return true;
|
| 733 |
}
|
| 734 |
function close(){
|
| 735 |
if (document.fullscreenElement) { document.exitFullscreen().catch(function(){}); }
|
|
|
|
| 943 |
ctx.globalCompositeOperation = prevOp;
|
| 944 |
}
|
| 945 |
|
| 946 |
+
// Loads a screenshot as a blob: URL rather than pointing <img> straight at
|
| 947 |
+
// the file:// path. Some embedded webviews (this app's included) treat a
|
| 948 |
+
// file:// image drawn onto a canvas as tainted, so a later toDataURL()/
|
| 949 |
+
// getImageData() throws a SecurityError and the calling click handler dies
|
| 950 |
+
// silently — the exact "button does nothing" symptom. Fetching the same
|
| 951 |
+
// file and handing the canvas a blob: URL instead sidesteps that entirely;
|
| 952 |
+
// the bytes on disk are identical, only how the browser tags their origin
|
| 953 |
+
// changes. Every screenshot used for compositing/export goes through this.
|
| 954 |
function loadImage(src){
|
| 955 |
+
return fetch(src)
|
| 956 |
+
.then(function(resp){
|
| 957 |
+
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
| 958 |
+
return resp.blob();
|
| 959 |
+
})
|
| 960 |
+
.then(function(blob){
|
| 961 |
+
return new Promise(function(resolve, reject){
|
| 962 |
+
const url = URL.createObjectURL(blob);
|
| 963 |
+
const im = new Image();
|
| 964 |
+
im.onload = function(){ resolve(im); };
|
| 965 |
+
im.onerror = function(){ reject(new Error('image decode failed')); };
|
| 966 |
+
im.src = url;
|
| 967 |
+
});
|
| 968 |
+
})
|
| 969 |
+
.catch(function(err){
|
| 970 |
+
console.warn('[insightux-report] could not load screenshot:', src, err);
|
| 971 |
+
return null;
|
| 972 |
+
});
|
| 973 |
+
}
|
| 974 |
+
|
| 975 |
+
// Minimal toast so a failed export/viewer action is visible instead of
|
| 976 |
+
// silently doing nothing — reuses the .iux-toast style already shared with
|
| 977 |
+
// the toolbar/landing page design system.
|
| 978 |
+
function showToast(message, isError){
|
| 979 |
+
let toast = document.getElementById('iuxReportToast');
|
| 980 |
+
if (!toast) {
|
| 981 |
+
toast = document.createElement('div');
|
| 982 |
+
toast.id = 'iuxReportToast';
|
| 983 |
+
toast.className = 'iux-toast';
|
| 984 |
+
toast.style.position = 'fixed';
|
| 985 |
+
toast.style.bottom = '24px';
|
| 986 |
+
toast.style.left = '50%';
|
| 987 |
+
toast.style.transform = 'translateX(-50%)';
|
| 988 |
+
toast.style.zIndex = '3000000';
|
| 989 |
+
toast.style.display = 'none';
|
| 990 |
+
document.body.appendChild(toast);
|
| 991 |
+
}
|
| 992 |
+
toast.textContent = message;
|
| 993 |
+
toast.style.borderColor = isError ? 'var(--iux-danger)' : 'var(--iux-border)';
|
| 994 |
+
toast.style.color = isError ? 'var(--iux-danger)' : 'var(--iux-text)';
|
| 995 |
+
toast.style.display = 'flex';
|
| 996 |
+
clearTimeout(toast._hideTimer);
|
| 997 |
+
toast._hideTimer = setTimeout(function(){ toast.style.display = 'none'; }, 3200);
|
| 998 |
}
|
| 999 |
|
| 1000 |
// ---------- Screenshot-backed heatmaps: Eye / Mouse / Combined tabs, paginated ----------
|
|
|
|
| 1079 |
|
| 1080 |
document.getElementById('hmFullscreen').addEventListener('click', function(){
|
| 1081 |
const canvas = composeSegmentCanvas();
|
| 1082 |
+
if (!canvas) { showToast('Still loading this screenshot — try again in a moment.', true); return; }
|
| 1083 |
Viewer.open({
|
| 1084 |
source: canvas,
|
| 1085 |
title: 'Scroll segment ' + (cur+1) + ' of ' + SEGMENTS.length + ' — ' + mode.charAt(0).toUpperCase() + mode.slice(1) + ' heatmap',
|
|
|
|
| 1088 |
});
|
| 1089 |
document.getElementById('hmDownload').addEventListener('click', function(){
|
| 1090 |
const canvas = composeSegmentCanvas();
|
| 1091 |
+
if (!canvas) { showToast('Still loading this screenshot — try again in a moment.', true); return; }
|
| 1092 |
+
try {
|
| 1093 |
+
const a = document.createElement('a');
|
| 1094 |
+
a.href = canvas.toDataURL('image/png');
|
| 1095 |
+
a.download = 'insightux-' + mode + '-heatmap-segment-' + (cur+1) + '.png';
|
| 1096 |
+
a.click();
|
| 1097 |
+
} catch (err) {
|
| 1098 |
+
console.warn('[insightux-report] segment download failed:', err);
|
| 1099 |
+
showToast('Could not export this image.', true);
|
| 1100 |
+
}
|
| 1101 |
});
|
| 1102 |
|
| 1103 |
+
async function renderSegment(i){
|
| 1104 |
const seg = SEGMENTS[i];
|
| 1105 |
area.innerHTML = `
|
| 1106 |
<div class="shot-wrap" id="shotWrap">
|
| 1107 |
+
<img id="shotImg" alt="">
|
| 1108 |
<canvas id="shotCanvas"></canvas>
|
| 1109 |
</div>
|
| 1110 |
<div class="shot-nav">
|
|
|
|
| 1114 |
</div>
|
| 1115 |
`;
|
| 1116 |
|
| 1117 |
+
document.getElementById('prevBtn').onclick = () => { if (cur>0){ cur--; renderSegment(cur); } };
|
| 1118 |
+
document.getElementById('nextBtn').onclick = () => { if (cur<SEGMENTS.length-1){ cur++; renderSegment(cur); } };
|
| 1119 |
+
|
| 1120 |
const img = document.getElementById('shotImg');
|
| 1121 |
const cv = document.getElementById('shotCanvas');
|
| 1122 |
const ctx = cv.getContext('2d');
|
|
|
|
| 1127 |
paintActive(ctx, cv.width, cv.height);
|
| 1128 |
}
|
| 1129 |
|
| 1130 |
+
// Loaded as a blob: URL (see loadImage()) so the <img> that ends up on
|
| 1131 |
+
// screen is already non-tainting for the compose/download/fullscreen
|
| 1132 |
+
// canvas operations above — same fix as the full-session export below.
|
| 1133 |
+
const loaded = await loadImage(seg.screenshot);
|
| 1134 |
+
if (!loaded || document.getElementById('shotImg') !== img) return; // segment may have changed while awaiting
|
| 1135 |
+
img.src = loaded.src;
|
| 1136 |
if (img.complete) paint(); else img.onload = paint;
|
|
|
|
|
|
|
|
|
|
| 1137 |
}
|
| 1138 |
|
| 1139 |
renderSegment(cur);
|
|
|
|
| 1146 |
try {
|
| 1147 |
const imgs = await Promise.all(SEGMENTS.map(s => loadImage(s.screenshot)));
|
| 1148 |
const valid = imgs.filter(Boolean);
|
| 1149 |
+
if (!valid.length) {
|
| 1150 |
+
showToast('Could not load this session’s screenshots for a full-page export.', true);
|
| 1151 |
+
return;
|
| 1152 |
+
}
|
| 1153 |
const width = valid[0].naturalWidth;
|
| 1154 |
let height = 0;
|
| 1155 |
SEGMENTS.forEach((s, i) => { if (imgs[i]) height = Math.max(height, s.scrollY + imgs[i].naturalHeight); });
|
| 1156 |
+
if (!height) {
|
| 1157 |
+
showToast('Could not determine the page size for this export.', true);
|
| 1158 |
+
return;
|
| 1159 |
+
}
|
| 1160 |
|
| 1161 |
const full = document.createElement('canvas');
|
| 1162 |
full.width = width; full.height = height;
|
|
|
|
| 1185 |
// immediate download — the viewer's own Download button saves it
|
| 1186 |
// once the user has actually looked at it, same as a PDF/image
|
| 1187 |
// preview rather than a silent file-save.
|
| 1188 |
+
const opened = Viewer.open({
|
| 1189 |
source: full,
|
| 1190 |
title: 'Full Session — ' + exportMode.charAt(0).toUpperCase() + exportMode.slice(1) + ' Heatmap',
|
| 1191 |
downloadName: 'insightux-full-session-' + exportMode + '-heatmap.png',
|
| 1192 |
});
|
| 1193 |
+
if (!opened) showToast('Could not open the ' + exportMode + ' heatmap viewer.', true);
|
| 1194 |
+
} catch (err) {
|
| 1195 |
+
console.warn('[insightux-report] full-session export failed:', exportMode, err);
|
| 1196 |
+
showToast('Could not build the full-session ' + exportMode + ' heatmap.', true);
|
| 1197 |
} finally {
|
| 1198 |
if (btn) { btn.innerHTML = original; btn.disabled = false; }
|
| 1199 |
}
|
|
|
|
| 1204 |
document.getElementById('exportCombined').addEventListener('click', function(){ exportFullSession('combined'); });
|
| 1205 |
})();
|
| 1206 |
|
| 1207 |
+
// ---------- Element Attention Analysis: line chart, client-side
|
| 1208 |
// re-aggregation of the already-computed RANKING array (compute_dwell_ranking()
|
| 1209 |
// output) — no new metric, just grouped by element type and charted. ----------
|
| 1210 |
(function(){
|
|
|
|
| 1234 |
const totals = {};
|
| 1235 |
RANKING.forEach(r => { const c = categoryFor(r.label); totals[c] = (totals[c] || 0) + r.seconds; });
|
| 1236 |
const grand = Object.values(totals).reduce((a,b) => a+b, 0) || 1;
|
| 1237 |
+
const points = Object.keys(totals).map(key => ({
|
| 1238 |
key, name: CATS[key].name,
|
| 1239 |
seconds: Math.round(totals[key] * 10) / 10,
|
| 1240 |
pct: Math.round(100 * totals[key] / grand),
|
| 1241 |
})).filter(r => r.seconds > 0).sort((a,b) => b.seconds - a.seconds);
|
| 1242 |
|
| 1243 |
+
if (!points.length){
|
| 1244 |
wrap.innerHTML = '<div class="empty">No categorized attention data yet.</div>';
|
| 1245 |
return;
|
| 1246 |
}
|
| 1247 |
|
|
|
|
| 1248 |
let animProgress = 0;
|
| 1249 |
let hoverIdx = -1;
|
| 1250 |
let raf = null;
|
|
|
|
| 1256 |
}
|
| 1257 |
|
| 1258 |
function geometry(){
|
| 1259 |
+
const padLeft = 54, padRight = 30, padTop = 26, padBottom = 46;
|
| 1260 |
const plotW = cv.width - padLeft - padRight;
|
| 1261 |
const plotH = cv.height - padTop - padBottom;
|
| 1262 |
+
const maxVal = Math.max.apply(null, points.map(p => p.seconds)) || 1;
|
| 1263 |
+
return { padLeft, padRight, padTop, padBottom, plotW, plotH, maxVal };
|
| 1264 |
+
}
|
| 1265 |
+
|
| 1266 |
+
function pointXY(i, g){
|
| 1267 |
+
const x = g.padLeft + (points.length === 1 ? g.plotW / 2 : (i / (points.length - 1)) * g.plotW);
|
| 1268 |
+
const y = g.padTop + g.plotH - (points[i].seconds / g.maxVal) * g.plotH * animProgress;
|
| 1269 |
+
return { x, y };
|
| 1270 |
}
|
| 1271 |
|
| 1272 |
function paint(){
|
| 1273 |
const dim = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-dim').trim() || '#9a95b3';
|
| 1274 |
const faint = getComputedStyle(document.documentElement).getPropertyValue('--iux-text-faint').trim() || '#6e6885';
|
| 1275 |
const border = getComputedStyle(document.documentElement).getPropertyValue('--iux-border').trim() || 'rgba(148,138,179,0.16)';
|
| 1276 |
+
const surface = getComputedStyle(document.documentElement).getPropertyValue('--iux-surface').trim() || '#1E2030';
|
| 1277 |
+
const strong = document.documentElement.getAttribute('data-theme') === 'light' ? '#211D33' : '#F1EEFA';
|
| 1278 |
const g = geometry();
|
| 1279 |
ctx.clearRect(0, 0, cv.width, cv.height);
|
| 1280 |
|
|
|
|
| 1295 |
ctx.fillText(v.toFixed(1) + 's', g.padLeft - 8, y + 3);
|
| 1296 |
}
|
| 1297 |
|
| 1298 |
+
const pts = points.map((p, i) => Object.assign({}, pointXY(i, g), { p }));
|
| 1299 |
+
|
| 1300 |
+
// gradient fill under the line
|
| 1301 |
+
if (pts.length){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1302 |
ctx.beginPath();
|
| 1303 |
+
ctx.moveTo(pts[0].x, g.padTop + g.plotH);
|
| 1304 |
+
pts.forEach(pt => ctx.lineTo(pt.x, pt.y));
|
| 1305 |
+
ctx.lineTo(pts[pts.length - 1].x, g.padTop + g.plotH);
|
|
|
|
|
|
|
| 1306 |
ctx.closePath();
|
| 1307 |
+
const areaGrad = ctx.createLinearGradient(0, g.padTop, 0, g.padTop + g.plotH);
|
| 1308 |
+
areaGrad.addColorStop(0, 'rgba(139,92,246,0.30)');
|
| 1309 |
+
areaGrad.addColorStop(1, 'rgba(139,92,246,0)');
|
| 1310 |
+
ctx.fillStyle = areaGrad;
|
| 1311 |
+
ctx.fill();
|
| 1312 |
+
}
|
| 1313 |
+
|
| 1314 |
+
// smooth connected line (quadratic curve through segment midpoints)
|
| 1315 |
+
if (pts.length > 1){
|
| 1316 |
+
ctx.beginPath();
|
| 1317 |
+
ctx.moveTo(pts[0].x, pts[0].y);
|
| 1318 |
+
for (let i = 0; i < pts.length - 1; i++){
|
| 1319 |
+
const cur = pts[i], next = pts[i+1];
|
| 1320 |
+
const midX = (cur.x + next.x) / 2, midY = (cur.y + next.y) / 2;
|
| 1321 |
+
ctx.quadraticCurveTo(cur.x, cur.y, midX, midY);
|
| 1322 |
+
}
|
| 1323 |
+
ctx.lineTo(pts[pts.length-1].x, pts[pts.length-1].y);
|
| 1324 |
+
ctx.strokeStyle = '#8B5CF6';
|
| 1325 |
+
ctx.lineWidth = 2.5;
|
| 1326 |
+
ctx.lineJoin = 'round';
|
| 1327 |
+
ctx.lineCap = 'round';
|
| 1328 |
+
ctx.stroke();
|
| 1329 |
+
}
|
| 1330 |
+
|
| 1331 |
+
// markers + x-axis labels
|
| 1332 |
+
pts.forEach((pt, i) => {
|
| 1333 |
+
const isHover = i === hoverIdx;
|
| 1334 |
+
ctx.beginPath();
|
| 1335 |
+
ctx.arc(pt.x, pt.y, isHover ? 6.5 : 4.5, 0, 2 * Math.PI);
|
| 1336 |
+
ctx.fillStyle = isHover ? '#C084FC' : '#8B5CF6';
|
| 1337 |
ctx.fill();
|
| 1338 |
+
ctx.lineWidth = 2;
|
| 1339 |
+
ctx.strokeStyle = surface;
|
| 1340 |
+
ctx.stroke();
|
| 1341 |
|
| 1342 |
+
ctx.fillStyle = isHover ? strong : dim;
|
| 1343 |
+
ctx.font = isHover ? '600 11px -apple-system, Arial' : '11px -apple-system, Arial';
|
| 1344 |
ctx.textAlign = 'center';
|
| 1345 |
+
const label = pt.p.name.length > 15 ? pt.p.name.slice(0,14) + '…' : pt.p.name;
|
| 1346 |
+
ctx.fillText(label, pt.x, cv.height - g.padBottom + 18);
|
| 1347 |
});
|
| 1348 |
|
| 1349 |
// axis baseline
|
|
|
|
| 1352 |
ctx.moveTo(g.padLeft, g.padTop + g.plotH);
|
| 1353 |
ctx.lineTo(cv.width - g.padRight, g.padTop + g.plotH);
|
| 1354 |
ctx.stroke();
|
| 1355 |
+
|
| 1356 |
+
// axis titles
|
| 1357 |
+
ctx.fillStyle = faint;
|
| 1358 |
+
ctx.font = '600 10px -apple-system, Arial';
|
| 1359 |
+
ctx.textAlign = 'center';
|
| 1360 |
+
ctx.fillText('WEBPAGE ELEMENT TYPE', g.padLeft + g.plotW / 2, cv.height - 6);
|
| 1361 |
+
ctx.save();
|
| 1362 |
+
ctx.translate(14, g.padTop + g.plotH / 2);
|
| 1363 |
+
ctx.rotate(-Math.PI / 2);
|
| 1364 |
+
ctx.fillText('GAZE ATTENTION (SECONDS)', 0, 0);
|
| 1365 |
+
ctx.restore();
|
| 1366 |
}
|
| 1367 |
|
| 1368 |
function animate(){
|
|
|
|
| 1379 |
const scaleX = cv.width / rect.width;
|
| 1380 |
const mx = (e.clientX - rect.left) * scaleX;
|
| 1381 |
const g = geometry();
|
| 1382 |
+
let idx = -1, best = Infinity;
|
| 1383 |
+
points.forEach((p, i) => {
|
| 1384 |
+
const { x } = pointXY(i, g);
|
| 1385 |
+
const d = Math.abs(mx - x);
|
| 1386 |
+
if (d < best) { best = d; idx = i; }
|
| 1387 |
});
|
| 1388 |
+
if (best > g.plotW / Math.max(points.length, 1)) idx = -1;
|
| 1389 |
if (idx !== hoverIdx) { hoverIdx = idx; paint(); }
|
| 1390 |
if (idx >= 0) {
|
| 1391 |
+
const p = points[idx];
|
| 1392 |
+
const { x, y } = pointXY(idx, g);
|
| 1393 |
tooltip.style.display = 'block';
|
| 1394 |
+
tooltip.style.left = Math.min(cv.clientWidth - 150, Math.max(0, x + 12)) + 'px';
|
| 1395 |
+
tooltip.style.top = Math.max(0, y - 46) + 'px';
|
| 1396 |
+
tooltip.innerHTML = '<b>' + p.name + '</b><br>' + p.seconds + 's · ' + p.pct + '% of attention';
|
| 1397 |
} else {
|
| 1398 |
tooltip.style.display = 'none';
|
| 1399 |
}
|