Aryaman25 Claude Sonnet 5 commited on
Commit
dda0490
·
1 Parent(s): 592bd4d

Fix full-session heatmap viewer not opening; switch chart to line chart

Browse files

Root 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>

Files changed (1) hide show
  1. analysis.py +176 -62
analysis.py CHANGED
@@ -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
- const source = opts.source;
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 new Promise(function(resolve){
940
- const im = new Image();
941
- im.onload = function(){ resolve(im); };
942
- im.onerror = function(){ resolve(null); };
943
- im.src = src;
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
- const a = document.createElement('a');
1040
- a.href = canvas.toDataURL('image/png');
1041
- a.download = 'insightux-' + mode + '-heatmap-segment-' + (cur+1) + '.png';
1042
- a.click();
 
 
 
 
 
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" src="${seg.screenshot}">
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) return;
 
 
 
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) return;
 
 
 
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: vertical bar chart, client-side
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 bars = Object.keys(totals).map(key => ({
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 (!bars.length){
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 = 20, padTop = 20, padBottom = 46;
1187
  const plotW = cv.width - padLeft - padRight;
1188
  const plotH = cv.height - padTop - padBottom;
1189
- const gap = plotW / bars.length;
1190
- const barW = Math.min(70, gap * 0.55);
1191
- const maxVal = bars[0].seconds || 1;
1192
- return { padLeft, padRight, padTop, padBottom, plotW, plotH, gap, barW, maxVal };
 
 
 
 
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
- // bars (rounded top only) + x-axis labels
1220
- bars.forEach((b, i) => {
1221
- const x = g.padLeft + i * g.gap + (g.gap - g.barW) / 2;
1222
- const targetH = (b.seconds / g.maxVal) * g.plotH;
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, y + rr);
1234
- ctx.arcTo(x, y, x + rr, y, rr);
1235
- ctx.arcTo(x + g.barW, y, x + g.barW, y + rr, rr);
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.globalAlpha = 1;
 
 
1241
 
1242
- ctx.fillStyle = i === hoverIdx ? (document.documentElement.getAttribute('data-theme') === 'light' ? '#211D33' : '#F1EEFA') : dim;
1243
- ctx.font = i === hoverIdx ? '600 11px -apple-system, Arial' : '11px -apple-system, Arial';
1244
  ctx.textAlign = 'center';
1245
- const label = b.name.length > 15 ? b.name.slice(0,14) + '…' : b.name;
1246
- ctx.fillText(label, x + g.barW/2, cv.height - g.padBottom + 18);
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
- bars.forEach((b, i) => {
1273
- const x = g.padLeft + i * g.gap;
1274
- if (mx >= x && mx < x + g.gap) idx = i;
 
1275
  });
 
1276
  if (idx !== hoverIdx) { hoverIdx = idx; paint(); }
1277
  if (idx >= 0) {
1278
- const b = bars[idx];
 
1279
  tooltip.style.display = 'block';
1280
- tooltip.style.left = Math.min(cv.clientWidth - 140, Math.max(0, e.clientX - rect.left + 12)) + 'px';
1281
- tooltip.style.top = (e.clientY - rect.top - 12) + 'px';
1282
- tooltip.innerHTML = '<b>' + b.name + '</b><br>' + b.seconds + 's &middot; ' + b.pct + '% of attention';
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 &middot; ' + p.pct + '% of attention';
1397
  } else {
1398
  tooltip.style.display = 'none';
1399
  }