Skip to content

Commit 7c57a7b

Browse files
jeremymanningclaude
andcommitted
Smooth heatmap, welcome-screen import, hide header dropdown on landing
- Replace per-domain RBF length scales (0.075/0.15/0.30) with uniform 0.18 to eliminate jagged patches from inconsistent kernel smoothness - Add bilinear interpolation to heatmap renderer (100×100 screen cells sampling a 50×50 GP grid) for smooth color gradients instead of blocky cells - Remove grid lines since interpolated cells are too small for visible lines - Import on welcome screen now auto-switches to map view with "all" domain - Hide domain dropdown on welcome screen; show only import button in header - Add setSelectedDomain() to sync header dropdown on programmatic domain switch - Enrich imported responses missing x/y coords from question index during switchDomain (handles older exports that lacked coordinates) - Pass uniform length scale to estimator.restore() to override stale per-obs values Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
1 parent dbf2b47 commit 7c57a7b

File tree

4 files changed

+116
-69
lines changed

4 files changed

+116
-69
lines changed

src/app.js

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import * as registry from './domain/registry.js';
1313
import { load as loadDomain } from './domain/loader.js';
1414
import { indexQuestions, getAvailableQuestions } from './domain/questions.js';
15-
import { Estimator, DEFAULT_LENGTH_SCALE } from './learning/estimator.js';
15+
import { Estimator } from './learning/estimator.js';
1616
import { Sampler } from './learning/sampler.js';
1717
import { getCentrality } from './learning/curriculum.js';
1818
import { Renderer } from './viz/renderer.js';
@@ -29,24 +29,11 @@ import { announce, setupKeyboardNav } from './utils/accessibility.js';
2929
const GLOBAL_REGION = { x_min: 0, x_max: 1, y_min: 0, y_max: 1 };
3030
const GLOBAL_GRID_SIZE = 50;
3131

32-
function lengthScaleForDomain(domain) {
33-
if (!domain) return DEFAULT_LENGTH_SCALE;
34-
if (domain.level === 'all') return DEFAULT_LENGTH_SCALE * 2;
35-
if (domain.level === 'sub') return DEFAULT_LENGTH_SCALE * 0.5;
36-
return DEFAULT_LENGTH_SCALE; // general
37-
}
38-
39-
function lengthScaleForDomainId(domainId) {
40-
const domain = registry.getDomain(domainId);
41-
return lengthScaleForDomain(domain);
42-
}
43-
44-
function enrichResponsesWithLengthScale(responses) {
45-
return responses.map(r => {
46-
if (r.lengthScale != null) return r;
47-
return { ...r, lengthScale: lengthScaleForDomainId(r.domain_id) };
48-
});
49-
}
32+
// Uniform length scale for all observations — no per-domain variation.
33+
// Previously sub-domains used 0.075 and "all" used 0.30, which created jagged,
34+
// inconsistent smoothness. A single scale of 0.18 produces natural gradients
35+
// while preserving spatial structure in the Matérn 3/2 kernel.
36+
const UNIFORM_LENGTH_SCALE = 0.18;
5037

5138
let renderer = null;
5239
let minimap = null;
@@ -321,13 +308,31 @@ async function switchDomain(domainId) {
321308
estimator.init(domainGrid, domainRegion);
322309
sampler.configure(domainGrid, domainRegion);
323310

324-
const allResponses = enrichResponsesWithLengthScale($responses.get());
311+
// Enrich any responses missing x/y from the freshly-loaded question index.
312+
// This handles older exports that lacked coordinates.
313+
let allResponses = $responses.get();
314+
let enriched = 0;
315+
const patched = allResponses.map(r => {
316+
if (r.x != null && r.y != null) return r;
317+
const q = questionIndex.get(r.question_id);
318+
if (q && q.x != null && q.y != null) {
319+
enriched++;
320+
return { ...r, x: q.x, y: q.y };
321+
}
322+
return r;
323+
});
324+
if (enriched > 0) {
325+
console.log(`[app] Enriched ${enriched} responses with x/y from question index`);
326+
$responses.set(patched);
327+
allResponses = patched;
328+
}
329+
325330
const relevantResponses = allResponses.filter(
326331
(r) => r.x != null && r.y != null
327332
);
328333
if (relevantResponses.length > 0) {
329-
estimator.restore(relevantResponses);
330-
globalEstimator.restore(relevantResponses);
334+
estimator.restore(relevantResponses, UNIFORM_LENGTH_SCALE);
335+
globalEstimator.restore(relevantResponses, UNIFORM_LENGTH_SCALE);
331336
}
332337

333338
const estimates = estimator.predict();
@@ -413,9 +418,6 @@ function handleAnswer(selectedKey, question) {
413418

414419
const isCorrect = selectedKey === question.correct_answer;
415420

416-
const questionDomainId = (question.domain_ids || []).find(id => id !== 'all') || currentDomainBundle.domain.id;
417-
const ls = lengthScaleForDomainId(questionDomainId);
418-
419421
const response = {
420422
question_id: question.id,
421423
domain_id: currentDomainBundle.domain.id,
@@ -424,16 +426,15 @@ function handleAnswer(selectedKey, question) {
424426
timestamp: Date.now(),
425427
x: question.x,
426428
y: question.y,
427-
lengthScale: ls,
428429
};
429430

430431
const current = $responses.get();
431432
const filtered = current.filter(r => r.question_id !== question.id);
432433
const isReanswer = filtered.length < current.length;
433434
$responses.set([...filtered, response]);
434435

435-
estimator.observe(question.x, question.y, isCorrect, ls);
436-
globalEstimator.observe(question.x, question.y, isCorrect, ls);
436+
estimator.observe(question.x, question.y, isCorrect, UNIFORM_LENGTH_SCALE);
437+
globalEstimator.observe(question.x, question.y, isCorrect, UNIFORM_LENGTH_SCALE);
437438
const estimates = estimator.predict();
438439
$estimates.set(estimates);
439440

@@ -547,10 +548,9 @@ function handleImport(data) {
547548
$responses.set(merged);
548549

549550
if (estimator) {
550-
const enriched = enrichResponsesWithLengthScale(merged);
551-
const relevant = enriched.filter(r => r.x != null && r.y != null);
552-
estimator.restore(relevant);
553-
if (globalEstimator) globalEstimator.restore(relevant);
551+
const relevant = merged.filter(r => r.x != null && r.y != null);
552+
estimator.restore(relevant, UNIFORM_LENGTH_SCALE);
553+
if (globalEstimator) globalEstimator.restore(relevant, UNIFORM_LENGTH_SCALE);
554554
const estimates = estimator.predict();
555555
$estimates.set(estimates);
556556

@@ -566,6 +566,13 @@ function handleImport(data) {
566566
announce(msg);
567567
_showBanner(msg, 'success');
568568
console.log('[import]', msg);
569+
570+
// If we're still on the welcome screen, switch to map view with "all" domain.
571+
// switchDomain will re-restore the GP from $responses (now including imports).
572+
if (!currentDomainBundle) {
573+
controls.setSelectedDomain('all');
574+
$activeDomain.set('all');
575+
}
569576
}
570577

571578
function handleViewportChange(viewport) {

src/learning/estimator.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,18 +228,23 @@ export class Estimator {
228228
* Restore from persisted UserResponse[].
229229
* Each response may include a `lengthScale` for per-observation RBF width.
230230
*/
231-
restore(responses) {
231+
restore(responses, uniformLengthScale) {
232232
this.reset();
233233

234234
if (!this._region || !responses || responses.length === 0) return;
235235

236+
// Use a uniform length scale for all restored observations to ensure
237+
// consistent smoothness. Any per-observation lengths from older exports
238+
// are intentionally ignored.
239+
const ls = uniformLengthScale || this._lengthScale;
240+
236241
for (const r of responses) {
237242
if (r.x != null && r.y != null) {
238243
this._observations.push({
239244
x: r.x,
240245
y: r.y,
241246
value: r.is_correct ? 1.0 : 0.0,
242-
lengthScale: r.lengthScale || this._lengthScale,
247+
lengthScale: ls,
243248
});
244249
}
245250
}

src/ui/controls.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ let onExportCb = null;
88
let onImportCb = null;
99

1010
let container = null;
11+
let dropdownEl = null;
1112
let resetButton = null;
1213
let exportButton = null;
1314
let importButton = null;
@@ -137,6 +138,7 @@ export function init(headerElement) {
137138
}
138139
container = domainSelector;
139140
container.innerHTML = '';
141+
container.hidden = false; // Ensure visible even on the welcome screen (import button lives here)
140142

141143
container.style.display = 'flex';
142144
container.style.alignItems = 'center';
@@ -173,10 +175,11 @@ export function init(headerElement) {
173175
document.head.appendChild(style);
174176
}
175177

176-
const dropdown = createDropdown('Choose a domain\u2026', buildOptions(), (value) => {
178+
dropdownEl = createDropdown('Choose a domain\u2026', buildOptions(), (value) => {
177179
if (onDomainSelectCb) onDomainSelectCb(value);
178180
});
179-
container.appendChild(dropdown);
181+
dropdownEl.hidden = true; // Hidden on welcome screen; shown by showActionButtons()
182+
container.appendChild(dropdownEl);
180183

181184
resetButton = document.createElement('button');
182185
resetButton.className = 'control-btn';
@@ -205,7 +208,7 @@ export function init(headerElement) {
205208
importButton.ariaLabel = 'Import saved progress';
206209
importButton.dataset.tooltip = 'Import progress';
207210
importButton.innerHTML = '<i class="fa-solid fa-upload"></i>';
208-
importButton.hidden = true;
211+
// Import is always visible — users may want to restore saved progress from the welcome screen
209212

210213
importButton.addEventListener('click', () => {
211214
// Create a file input, attach to DOM (required by some browsers for
@@ -270,11 +273,26 @@ export function onImport(callback) {
270273

271274
export function showActionButtons() {
272275
if (container) container.hidden = false;
276+
if (dropdownEl) dropdownEl.hidden = false;
273277
if (resetButton) resetButton.hidden = false;
274278
if (exportButton) exportButton.hidden = false;
275279
if (importButton) importButton.hidden = false;
276280
}
277281

282+
/**
283+
* Programmatically update the header dropdown to show a given domain as selected.
284+
* Used when the domain changes via code (e.g. import on welcome screen) rather
285+
* than a user click.
286+
*/
287+
export function setSelectedDomain(domainId) {
288+
if (!dropdownEl) return;
289+
const option = dropdownEl.querySelector(`.custom-select-option[data-value="${domainId}"]`);
290+
if (!option) return;
291+
const valueSpan = dropdownEl.querySelector('.custom-select-value');
292+
if (valueSpan) valueSpan.textContent = option.textContent.trim();
293+
dropdownEl.dataset.value = domainId;
294+
}
295+
278296
export function createLandingSelector(container, callback) {
279297
const dropdown = createDropdown('Choose a region to explore\u2026', buildOptions(), callback);
280298
dropdown.classList.add('custom-select--landing');

src/viz/renderer.js

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -464,13 +464,9 @@ export class Renderer {
464464
const region = this._heatmapRegion;
465465
if (!region) return;
466466

467-
// The heatmap grid covers the domain region in world space.
468-
// We draw fixed-size screen cells and look up which grid cell each maps to.
469-
// Screen pixel (sx, sy) → world coord → grid cell (gx, gy).
470-
//
471-
// World coords: point.x * w = panX + normX * zoom * w
472-
// normX = (screenX - panX) / (zoom * w)
473-
const SCREEN_CELLS = 50; // fixed number of screen cells
467+
// Use more screen cells for a smoother appearance. The GP grid is N×N but
468+
// we sample at higher resolution and bilinearly interpolate between cells.
469+
const SCREEN_CELLS = 100;
474470
const cellW = w / SCREEN_CELLS;
475471
const cellH = h / SCREEN_CELLS;
476472

@@ -479,6 +475,45 @@ export class Renderer {
479475
const rXSpan = region.x_max - region.x_min;
480476
const rYSpan = region.y_max - region.y_min;
481477

478+
// Helper: bilinear interpolation of grid values
479+
function sampleGrid(gxf, gyf) {
480+
const gx0 = Math.max(0, Math.min(N - 1, Math.floor(gxf)));
481+
const gy0 = Math.max(0, Math.min(N - 1, Math.floor(gyf)));
482+
const gx1 = Math.min(N - 1, gx0 + 1);
483+
const gy1 = Math.min(N - 1, gy0 + 1);
484+
const fx = gxf - gx0; // fractional x [0,1)
485+
const fy = gyf - gy0; // fractional y [0,1)
486+
487+
const v00 = grid[gy0 * N + gx0];
488+
const v10 = grid[gy0 * N + gx1];
489+
const v01 = grid[gy1 * N + gx0];
490+
const v11 = grid[gy1 * N + gx1];
491+
492+
// Bilinear blend
493+
const top = v00 + (v10 - v00) * fx;
494+
const bot = v01 + (v11 - v01) * fx;
495+
return top + (bot - top) * fy;
496+
}
497+
498+
// Helper: bilinear interpolation of evidence counts (for opacity)
499+
function sampleEvidence(gxf, gyf) {
500+
const gx0 = Math.max(0, Math.min(N - 1, Math.floor(gxf)));
501+
const gy0 = Math.max(0, Math.min(N - 1, Math.floor(gyf)));
502+
const gx1 = Math.min(N - 1, gx0 + 1);
503+
const gy1 = Math.min(N - 1, gy0 + 1);
504+
const fx = gxf - gx0;
505+
const fy = gyf - gy0;
506+
507+
const e00 = evidence[gy0 * N + gx0];
508+
const e10 = evidence[gy0 * N + gx1];
509+
const e01 = evidence[gy1 * N + gx0];
510+
const e11 = evidence[gy1 * N + gx1];
511+
512+
const top = e00 + (e10 - e00) * fx;
513+
const bot = e01 + (e11 - e01) * fx;
514+
return top + (bot - top) * fy;
515+
}
516+
482517
ctx.globalAlpha = 0.45;
483518

484519
for (let sy = 0; sy < SCREEN_CELLS; sy++) {
@@ -489,44 +524,26 @@ export class Renderer {
489524
const wx = (centerSX - this._panX) / (this._zoom * w);
490525
const wy = (centerSY - this._panY) / (this._zoom * h);
491526

492-
// Map world coord to grid cell within domain region
493-
const gx = Math.floor(((wx - rXMin) / rXSpan) * N);
494-
const gy = Math.floor(((wy - rYMin) / rYSpan) * N);
527+
// Map world coord to fractional grid position within domain region
528+
const gxf = ((wx - rXMin) / rXSpan) * N - 0.5;
529+
const gyf = ((wy - rYMin) / rYSpan) * N - 0.5;
495530

496-
if (gx < 0 || gx >= N || gy < 0 || gy >= N) {
531+
if (gxf < -1 || gxf >= N || gyf < -1 || gyf >= N) {
497532
// Outside domain region — draw neutral prior
498533
ctx.fillStyle = 'rgba(245, 220, 105, 0.25)';
499534
ctx.fillRect(sx * cellW, sy * cellH, cellW + 0.5, cellH + 0.5);
500535
continue;
501536
}
502537

503-
const idx = gy * N + gx;
504-
const val = grid[idx];
505-
const ev = evidence[idx];
538+
const val = sampleGrid(gxf, gyf);
539+
const ev = sampleEvidence(gxf, gyf);
506540
const [r, g, b] = valueToColor(val);
507-
const a = ev === 0 ? 0.5 : 0.75;
541+
const a = ev < 0.5 ? 0.5 : 0.75;
508542
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
509543
ctx.fillRect(sx * cellW, sy * cellH, cellW + 0.5, cellH + 0.5);
510544
}
511545
}
512546

513-
ctx.globalAlpha = 1;
514-
// Only draw grid lines if cells are large enough to be visible
515-
if (cellW > 4 && cellH > 4) {
516-
ctx.strokeStyle = 'rgba(0, 0, 0, 0.06)';
517-
ctx.lineWidth = 0.5;
518-
ctx.beginPath();
519-
for (let i = 0; i <= SCREEN_CELLS; i++) {
520-
const x = i * cellW;
521-
ctx.moveTo(x, 0);
522-
ctx.lineTo(x, h);
523-
const y = i * cellH;
524-
ctx.moveTo(0, y);
525-
ctx.lineTo(w, y);
526-
}
527-
ctx.stroke();
528-
}
529-
530547
ctx.globalAlpha = 1;
531548
}
532549

0 commit comments

Comments
 (0)