Skip to content

Commit e5f52e7

Browse files
jeremymanningclaude
andcommitted
Add Skip button and redesign share images
Skip button lets users skip questions they're unsure about: - Observes skip at 50% knowledge (prior mean) with halved RBF length scale - Yellow fast-forward icon in tooltip for skipped questions - Automatically advances to next question regardless of auto-advance setting - Persists is_skipped field in exports, infers it on import for older data Share image now matches full map rendering: - Bilinear interpolated heatmap at 100 cells with 0.45 alpha - Wikipedia articles as 1px gray dots - Answered questions with black outline + colored fill (green/red/yellow) Fixes flat map bug caused by skip responses being treated as incorrect on import (selected=null was filtered out by validation). Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
1 parent 5c3349d commit e5f52e7

17 files changed

+261
-32
lines changed

src/app.js

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { ParticleSystem } from './viz/particles.js';
2121
import * as controls from './ui/controls.js';
2222
import * as quiz from './ui/quiz.js';
2323
import * as modes from './ui/modes.js';
24+
25+
const SKIP_LENGTH_SCALE_FACTOR = 0.5;
2426
import * as insights from './ui/insights.js';
2527
import * as share from './ui/share.js';
2628
import { showDownload, hideDownload, updateConfidence, initConfidence } from './ui/progress.js';
@@ -125,6 +127,7 @@ async function boot() {
125127

126128
modes.init(quizPanel);
127129
modes.onModeSelect(handleModeSelect);
130+
modes.onSkip(handleSkip);
128131
insights.init();
129132
initConfidence(quizPanel);
130133

@@ -173,7 +176,7 @@ async function boot() {
173176
const responses = $responses.get();
174177
const answeredQuestions = responses
175178
.filter(r => r.x != null && r.y != null)
176-
.map(r => ({ x: r.x, y: r.y, isCorrect: r.is_correct }));
179+
.map(r => ({ x: r.x, y: r.y, isCorrect: r.is_correct, isSkipped: !!r.is_skipped }));
177180
return { estimateGrid: grid, articles, answeredQuestions };
178181
});
179182

@@ -258,13 +261,17 @@ function responsesToAnsweredDots(responses, qIndex) {
258261
for (const [qid, r] of latest) {
259262
const q = qIndex.get(qid);
260263
if (!q) continue;
264+
const isSkipped = !!r.is_skipped;
261265
dots.push({
262266
x: r.x,
263267
y: r.y,
264268
questionId: qid,
265269
title: q.question_text,
266270
isCorrect: r.is_correct,
267-
color: r.is_correct ? [0, 105, 62, 200] : [157, 22, 46, 200],
271+
isSkipped,
272+
color: isSkipped ? [212, 160, 23, 200]
273+
: r.is_correct ? [0, 105, 62, 200]
274+
: [157, 22, 46, 200],
268275
});
269276
}
270277
return dots;
@@ -459,6 +466,46 @@ function handleAnswer(selectedKey, question) {
459466
}
460467
}
461468

469+
function handleSkip() {
470+
const question = quiz.getCurrentQuestion();
471+
if (!question || !currentDomainBundle) return;
472+
473+
const activeDomainId = $activeDomain.get() || 'all';
474+
475+
const response = {
476+
question_id: question.id,
477+
domain_id: activeDomainId,
478+
selected: null,
479+
is_correct: false,
480+
is_skipped: true,
481+
timestamp: Date.now(),
482+
x: question.x,
483+
y: question.y,
484+
};
485+
486+
const current = $responses.get();
487+
const filtered = current.filter(r => r.question_id !== question.id);
488+
const isReanswer = filtered.length < current.length;
489+
$responses.set([...filtered, response]);
490+
491+
const skipLengthScale = UNIFORM_LENGTH_SCALE * SKIP_LENGTH_SCALE_FACTOR;
492+
estimator.observeSkip(question.x, question.y, skipLengthScale);
493+
globalEstimator.observeSkip(question.x, question.y, skipLengthScale);
494+
const estimates = estimator.predict();
495+
$estimates.set(estimates);
496+
497+
if (!isReanswer) {
498+
domainQuestionCount++;
499+
modes.updateAvailability(domainQuestionCount);
500+
updateInsightButtons($responses.get().length);
501+
}
502+
503+
renderer.setAnsweredQuestions(responsesToAnsweredDots($responses.get(), questionIndex));
504+
505+
announce('Skipped. Moving to next question.');
506+
selectAndShowNextQuestion();
507+
}
508+
462509
function handleReset() {
463510
if (!confirm('Are you sure? This will clear all progress.')) return;
464511
resetAll();
@@ -528,8 +575,15 @@ function handleImport(data) {
528575
}
529576

530577
const valid = responses.filter(r =>
531-
r.question_id && r.domain_id && r.selected && typeof r.is_correct === 'boolean'
532-
);
578+
r.question_id && r.domain_id && typeof r.is_correct === 'boolean'
579+
&& (r.selected || r.is_skipped || r.selected === null)
580+
).map(r => {
581+
// Infer is_skipped for older exports that lack the field
582+
if (!r.is_skipped && r.selected === null) {
583+
return { ...r, is_skipped: true };
584+
}
585+
return r;
586+
});
533587

534588
if (valid.length === 0) {
535589
alert('No valid responses found in the imported file.');

src/learning/estimator.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,17 @@ export class Estimator {
8787
this._recompute();
8888
}
8989

90+
/**
91+
* Record a skipped question — labels knowledge at 50% with reduced spatial influence.
92+
* @param {number} x - Normalized x coordinate
93+
* @param {number} y - Normalized y coordinate
94+
* @param {number} lengthScale - Reduced RBF width for skip observations
95+
*/
96+
observeSkip(x, y, lengthScale) {
97+
this._observations.push({ x, y, value: PRIOR_MEAN, lengthScale: lengthScale || this._lengthScale });
98+
this._recompute();
99+
}
100+
90101
/**
91102
* Get estimates for all cells (or viewport subset).
92103
* Returns CellEstimate[] per contracts/active-learner.md.
@@ -240,11 +251,12 @@ export class Estimator {
240251

241252
for (const r of responses) {
242253
if (r.x != null && r.y != null) {
254+
const isSkipped = !!r.is_skipped;
243255
this._observations.push({
244256
x: r.x,
245257
y: r.y,
246-
value: r.is_correct ? 1.0 : 0.0,
247-
lengthScale: ls,
258+
value: isSkipped ? PRIOR_MEAN : (r.is_correct ? 1.0 : 0.0),
259+
lengthScale: isSkipped ? ls * 0.5 : ls,
248260
});
249261
}
250262
}

src/state/persistence.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export function exportResponses() {
2828
domain_id: r.domain_id,
2929
selected: r.selected,
3030
is_correct: r.is_correct,
31+
is_skipped: r.is_skipped || false,
3132
timestamp: r.timestamp,
3233
x: r.x,
3334
y: r.y,

src/ui/modes.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ let buttons = new Map();
1616
let activeMode = 'auto';
1717
let currentAnswerCount = 0;
1818
let onSelectCb = null;
19+
let onSkipCb = null;
1920
let autoAdvance = false;
2021
let autoAdvanceToggleEl = null;
22+
let skipBtnEl = null;
2123

2224
export function init(container) {
2325
if (!container) return;
@@ -131,6 +133,26 @@ export function init(container) {
131133
.auto-advance-track.on .auto-advance-thumb {
132134
transform: translateX(14px);
133135
}
136+
.skip-btn {
137+
display: inline-flex;
138+
align-items: center;
139+
gap: 0.35rem;
140+
padding: 0.35rem 0.6rem;
141+
border: 1px solid #d4a017;
142+
border-radius: 16px;
143+
background: var(--color-surface-raised);
144+
cursor: pointer;
145+
font-size: 0.75rem;
146+
font-family: var(--font-body);
147+
color: #b8860b;
148+
transition: all 0.15s ease;
149+
white-space: nowrap;
150+
}
151+
.skip-btn:hover {
152+
background: #d4a017;
153+
color: #ffffff;
154+
box-shadow: 0 0 8px rgba(212, 160, 23, 0.4);
155+
}
134156
`;
135157
document.head.appendChild(style);
136158
}
@@ -159,6 +181,16 @@ export function init(container) {
159181
wrapper.appendChild(btn);
160182
}
161183

184+
// Skip button
185+
skipBtnEl = document.createElement('button');
186+
skipBtnEl.className = 'skip-btn';
187+
skipBtnEl.innerHTML = '<i class="fa-solid fa-forward"></i> Skip';
188+
skipBtnEl.dataset.tooltip = "Not sure of the answer? Don't guess, just press skip!";
189+
skipBtnEl.addEventListener('click', () => {
190+
if (onSkipCb) onSkipCb();
191+
});
192+
wrapper.appendChild(skipBtnEl);
193+
162194
// Auto-advance toggle
163195
const toggleWrap = document.createElement('div');
164196
toggleWrap.className = 'auto-advance-wrap';
@@ -206,6 +238,10 @@ export function onModeSelect(callback) {
206238
onSelectCb = callback;
207239
}
208240

241+
export function onSkip(callback) {
242+
onSkipCb = callback;
243+
}
244+
209245
export function updateAvailability(responseCount) {
210246
currentAnswerCount = responseCount;
211247
for (const mode of ALL_MODES) {

src/ui/quiz.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,10 @@ export function onNext(callback) {
379379
nextCallback = callback;
380380
}
381381

382+
export function getCurrentQuestion() {
383+
return currentQuestion;
384+
}
385+
382386
/**
383387
* Replaces $...$ with KaTeX rendered HTML.
384388
* Heuristic: if content between $ signs contains only numbers/punctuation, treat as currency.

src/ui/share.js

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -66,54 +66,70 @@ function generateShareImage(data) {
6666
canvas.height = H;
6767
const ctx = canvas.getContext('2d');
6868

69-
ctx.fillStyle = '#f8fafc';
69+
ctx.fillStyle = '#ffffff';
7070
ctx.fillRect(0, 0, W, H);
7171

7272
const { estimateGrid, articles, answeredQuestions } = data;
7373
const N = 50;
7474

75+
// Draw heatmap grid matching the full-map renderer style:
76+
// bilinear interpolation at higher resolution with proper opacity
7577
if (estimateGrid && estimateGrid.length === N * N) {
76-
ctx.globalAlpha = 0.1;
77-
const cellW = W / N;
78-
const cellH = H / N;
79-
for (let gy = 0; gy < N; gy++) {
80-
for (let gx = 0; gx < N; gx++) {
81-
const val = estimateGrid[gy * N + gx];
78+
const CELLS = 100;
79+
const cellW = W / CELLS;
80+
const cellH = H / CELLS;
81+
82+
function sampleGrid(gxf, gyf) {
83+
const gx0 = Math.max(0, Math.min(N - 1, Math.floor(gxf)));
84+
const gy0 = Math.max(0, Math.min(N - 1, Math.floor(gyf)));
85+
const gx1 = Math.min(N - 1, gx0 + 1);
86+
const gy1 = Math.min(N - 1, gy0 + 1);
87+
const fx = gxf - gx0;
88+
const fy = gyf - gy0;
89+
const v00 = estimateGrid[gy0 * N + gx0];
90+
const v10 = estimateGrid[gy0 * N + gx1];
91+
const v01 = estimateGrid[gy1 * N + gx0];
92+
const v11 = estimateGrid[gy1 * N + gx1];
93+
const top = v00 + (v10 - v00) * fx;
94+
const bot = v01 + (v11 - v01) * fx;
95+
return top + (bot - top) * fy;
96+
}
97+
98+
ctx.globalAlpha = 0.45;
99+
for (let sy = 0; sy < CELLS; sy++) {
100+
for (let sx = 0; sx < CELLS; sx++) {
101+
const gxf = ((sx + 0.5) / CELLS) * N - 0.5;
102+
const gyf = ((sy + 0.5) / CELLS) * N - 0.5;
103+
const val = sampleGrid(gxf, gyf);
82104
const [r, g, b] = shareImageColor(val);
83105
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
84-
ctx.fillRect(gx * cellW, gy * cellH, cellW + 0.5, cellH + 0.5);
106+
ctx.fillRect(sx * cellW, sy * cellH, cellW + 0.5, cellH + 0.5);
85107
}
86108
}
87109
ctx.globalAlpha = 1;
88110
}
89111

112+
// Draw Wikipedia articles as 1px gray dots
90113
if (articles && articles.length > 0) {
114+
ctx.fillStyle = 'rgba(148, 163, 184, 0.35)';
91115
for (const a of articles) {
92-
const px = a.x * W;
93-
const py = a.y * H;
94-
const gx = Math.floor(a.x * N);
95-
const gy = Math.floor(a.y * N);
96-
let val = 0.5;
97-
if (estimateGrid && gx >= 0 && gx < N && gy >= 0 && gy < N) {
98-
val = estimateGrid[gy * N + gx];
99-
}
100-
const [r, g, b] = shareImageColor(val);
101-
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
102-
ctx.fillRect(px - 0.5, py - 0.5, 1, 1);
116+
ctx.fillRect(a.x * W - 0.5, a.y * H - 0.5, 1, 1);
103117
}
104118
}
105119

120+
// Draw answered questions as slightly larger dots with outline
106121
if (answeredQuestions && answeredQuestions.length > 0) {
107122
for (const q of answeredQuestions) {
108123
const px = q.x * W;
109124
const py = q.y * H;
110125
ctx.beginPath();
111-
ctx.arc(px, py, 4, 0, Math.PI * 2);
112-
ctx.fillStyle = q.isCorrect ? '#00693e' : '#9d162e';
126+
ctx.arc(px, py, 5, 0, Math.PI * 2);
127+
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
128+
ctx.fill();
129+
ctx.beginPath();
130+
ctx.arc(px, py, 3.5, 0, Math.PI * 2);
131+
ctx.fillStyle = q.isSkipped ? '#d4a017' : q.isCorrect ? '#00693e' : '#9d162e';
113132
ctx.fill();
114-
ctx.strokeStyle = '#000000';
115-
ctx.lineWidth = 1;
116-
ctx.stroke();
117133
}
118134
}
119135

src/viz/renderer.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -995,9 +995,12 @@ export class Renderer {
995995
_buildTooltipHTML(hit) {
996996
if (hit.questionId) {
997997
const q = this._questionMap.get(hit.questionId);
998+
const isSkipped = hit.isSkipped;
998999
const isCorrect = hit.isCorrect;
999-
const borderColor = isCorrect ? '#00693e' : '#9d162e';
1000-
const icon = isCorrect
1000+
const borderColor = isSkipped ? '#d4a017' : isCorrect ? '#00693e' : '#9d162e';
1001+
const icon = isSkipped
1002+
? '<i class="fa-solid fa-forward" style="font-size:0.85em;"></i>'
1003+
: isCorrect
10011004
? '<i class="fa-solid fa-check" style="font-size:0.85em;"></i>'
10021005
: '<i class="fa-solid fa-xmark" style="font-size:0.85em;"></i>';
10031006
const text = hit.title || 'Question';
40.5 KB
Loading
-287 KB
Loading
80.2 KB
Loading

0 commit comments

Comments
 (0)