Skip to content

Commit d9a0dba

Browse files
jeremymanningclaude
andcommitted
Domain switching now only pans/zooms — all data always visible
Load the "all" domain at boot as the permanent dataset. Domain selection from the dropdown only navigates the viewport to that domain's region instead of replacing articles, questions, and labels. Labels use the global [0,1]×[0,1] grid and persist across all views. Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
1 parent fff04f3 commit d9a0dba

File tree

1 file changed

+95
-86
lines changed

1 file changed

+95
-86
lines changed

src/app.js

Lines changed: 95 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ 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-
// Re-export isAutoAdvance check used in handleAnswer auto-advance logic
2524
import * as insights from './ui/insights.js';
2625
import * as share from './ui/share.js';
2726
import { showDownload, hideDownload, updateConfidence, initConfidence } from './ui/progress.js';
@@ -31,9 +30,6 @@ const GLOBAL_REGION = { x_min: 0, x_max: 1, y_min: 0, y_max: 1 };
3130
const GLOBAL_GRID_SIZE = 50;
3231

3332
// Uniform length scale for all observations — no per-domain variation.
34-
// Previously sub-domains used 0.075 and "all" used 0.30, which created jagged,
35-
// inconsistent smoothness. A single scale of 0.18 produces natural gradients
36-
// while preserving spatial structure in the Matérn 3/2 kernel.
3733
const UNIFORM_LENGTH_SCALE = 0.18;
3834

3935
let renderer = null;
@@ -42,13 +38,15 @@ let particleSystem = null;
4238
let estimator = null;
4339
let globalEstimator = null; // Always covers GLOBAL_REGION for minimap
4440
let sampler = null;
45-
let currentDomainBundle = null;
41+
let allDomainBundle = null; // Permanent "all" domain data — never replaced
42+
let currentDomainBundle = null; // Points to allDomainBundle once loaded
4643
let currentViewport = { x_min: 0, x_max: 1, y_min: 0, y_max: 1 };
4744
let currentDomainRegion = GLOBAL_REGION;
4845
let currentGridSize = GLOBAL_GRID_SIZE;
4946
let domainQuestionCount = 0;
5047
let switchGeneration = 0;
5148
let questionIndex = new Map();
49+
let mapInitialized = false; // True once articles/questions/labels are set on the renderer
5250

5351
async function boot() {
5452
const storageAvailable = isAvailable();
@@ -87,6 +85,21 @@ async function boot() {
8785
globalEstimator = new Estimator();
8886
globalEstimator.init(GLOBAL_GRID_SIZE, GLOBAL_REGION);
8987
sampler = new Sampler();
88+
sampler.configure(GLOBAL_GRID_SIZE, GLOBAL_REGION);
89+
90+
// Eagerly load the "all" domain — this is the permanent, full dataset.
91+
// All articles, questions, and labels come from here; domain selection
92+
// only pans/zooms the viewport rather than replacing data.
93+
try {
94+
allDomainBundle = await loadDomain('all', {});
95+
indexQuestions(allDomainBundle.questions);
96+
questionIndex = new Map(allDomainBundle.questions.map(q => [q.id, q]));
97+
insights.setConcepts(allDomainBundle.questions, allDomainBundle.articles);
98+
} catch (err) {
99+
console.error('[app] Failed to pre-load "all" domain:', err);
100+
showLandingError('Could not load map data. Please try refreshing.');
101+
return;
102+
}
90103

91104
const headerEl = document.getElementById('app-header');
92105
controls.init(headerEl);
@@ -119,7 +132,6 @@ async function boot() {
119132
if (trophyBtn) {
120133
trophyBtn.addEventListener('click', () => {
121134
if (!globalEstimator) return;
122-
// Use global estimator so leaderboard reflects the full map, not just the current domain
123135
const ck = insights.computeConceptKnowledge(
124136
globalEstimator.predict(),
125137
GLOBAL_REGION,
@@ -134,7 +146,6 @@ async function boot() {
134146
if (suggestBtn) {
135147
suggestBtn.addEventListener('click', () => {
136148
if (!globalEstimator) return;
137-
// Use global estimator so suggestions reflect the full map, not just the current domain
138149
const ck = insights.computeConceptKnowledge(
139150
globalEstimator.predict(),
140151
GLOBAL_REGION,
@@ -177,12 +188,10 @@ async function boot() {
177188
else renderer.jumpTo(region);
178189
});
179190

180-
// Load "all" domain articles for a static minimap background
181-
loadDomain('all', {}).then((allBundle) => {
182-
if (minimap && allBundle) {
183-
minimap.setArticles(articlesToPoints(allBundle.articles));
184-
}
185-
}).catch(() => {}); // Non-critical, minimap will just lack article dots
191+
// Use the pre-loaded "all" domain for minimap background
192+
if (allDomainBundle) {
193+
minimap.setArticles(articlesToPoints(allDomainBundle.articles));
194+
}
186195
}
187196

188197
if (import.meta.env.DEV) {
@@ -261,6 +270,11 @@ function responsesToAnsweredDots(responses, qIndex) {
261270
return dots;
262271
}
263272

273+
/**
274+
* Switch to a domain — now only pans/zooms the viewport.
275+
* All articles, questions, and labels remain from the "all" domain loaded at boot.
276+
* The first call also initializes the map display (articles, labels, estimator restore).
277+
*/
264278
async function switchDomain(domainId) {
265279
const generation = ++switchGeneration;
266280
const landing = document.getElementById('landing');
@@ -274,11 +288,13 @@ async function switchDomain(domainId) {
274288
particleSystem = null;
275289
}
276290

277-
const quizPanel = document.getElementById('quiz-panel');
278-
const previousBundle = currentDomainBundle;
279-
280291
renderer.abortTransition();
281292

293+
if (!allDomainBundle) return;
294+
295+
// Look up the target domain's region for viewport navigation.
296+
// Load the domain JSON (cached after first fetch) just for its region metadata.
297+
let targetRegion = GLOBAL_REGION;
282298
try {
283299
const bundle = await loadDomain(domainId, {
284300
onProgress: ({ loaded, total }) => showDownload(loaded, total),
@@ -287,30 +303,23 @@ async function switchDomain(domainId) {
287303
announce(`Failed to load domain. ${err.message}`);
288304
},
289305
});
290-
291306
if (generation !== switchGeneration) return;
307+
if (bundle && bundle.domain && bundle.domain.region) {
308+
targetRegion = bundle.domain.region;
309+
}
310+
} catch (err) {
311+
if (generation === switchGeneration) {
312+
console.error('[app] switchDomain region lookup failed:', err);
313+
}
314+
}
315+
316+
// First-time map initialization: set all articles, questions, labels, and restore GP
317+
if (!mapInitialized) {
318+
currentDomainBundle = allDomainBundle;
319+
renderer.addQuestions(allDomainBundle.questions);
320+
renderer.setLabels(allDomainBundle.labels, GLOBAL_REGION, GLOBAL_GRID_SIZE);
292321

293-
currentDomainBundle = bundle;
294-
indexQuestions(bundle.questions);
295-
questionIndex = new Map(bundle.questions.map(q => [q.id, q]));
296-
renderer.clearQuestions();
297-
renderer.addQuestions(bundle.questions);
298-
insights.setConcepts(bundle.questions, bundle.articles);
299-
domainQuestionCount = $responses.get().filter(r => r.domain_id === domainId).length;
300-
modes.updateAvailability(domainQuestionCount);
301-
updateInsightButtons($responses.get().length);
302-
303-
const domain = bundle.domain;
304-
const domainRegion = domain.region || GLOBAL_REGION;
305-
const domainGrid = domain.grid_size || GLOBAL_GRID_SIZE;
306-
currentDomainRegion = domainRegion;
307-
currentGridSize = domainGrid;
308-
309-
estimator.init(domainGrid, domainRegion);
310-
sampler.configure(domainGrid, domainRegion);
311-
312-
// Enrich any responses missing x/y from the freshly-loaded question index.
313-
// This handles older exports that lacked coordinates.
322+
// Enrich any responses missing x/y from the question index.
314323
let allResponses = $responses.get();
315324
let enriched = 0;
316325
const patched = allResponses.map(r => {
@@ -328,9 +337,7 @@ async function switchDomain(domainId) {
328337
allResponses = patched;
329338
}
330339

331-
const relevantResponses = allResponses.filter(
332-
(r) => r.x != null && r.y != null
333-
);
340+
const relevantResponses = allResponses.filter(r => r.x != null && r.y != null);
334341
if (relevantResponses.length > 0) {
335342
estimator.restore(relevantResponses, UNIFORM_LENGTH_SCALE);
336343
globalEstimator.restore(relevantResponses, UNIFORM_LENGTH_SCALE);
@@ -339,47 +346,37 @@ async function switchDomain(domainId) {
339346
const estimates = estimator.predict();
340347
$estimates.set(estimates);
341348

342-
const targetPoints = articlesToPoints(bundle.articles);
349+
renderer.setPoints(articlesToPoints(allDomainBundle.articles));
350+
renderer.setAnsweredQuestions(responsesToAnsweredDots($responses.get(), questionIndex));
343351

344-
if (previousBundle) {
345-
const sourcePoints = articlesToPoints(previousBundle.articles);
346-
const sourceRegion = previousBundle.domain.region || GLOBAL_REGION;
347-
const targetRegion = domain.region || GLOBAL_REGION;
352+
mapInitialized = true;
353+
}
348354

349-
renderer.setLabels(bundle.labels, domainRegion, domainGrid);
350-
await renderer.transitionPoints(
351-
sourcePoints, targetPoints, sourceRegion, targetRegion
352-
);
353-
} else {
354-
renderer.setPoints(targetPoints);
355-
renderer.setLabels(bundle.labels, domainRegion, domainGrid);
356-
await renderer.transitionTo(domain.region || GLOBAL_REGION);
357-
}
355+
// Update domain-scoped tracking
356+
domainQuestionCount = $responses.get().length;
357+
modes.updateAvailability(domainQuestionCount);
358+
updateInsightButtons($responses.get().length);
358359

359-
if (generation !== switchGeneration) return;
360+
// Pan/zoom to the target domain's region
361+
await renderer.transitionTo(targetRegion);
360362

361-
if (minimap) {
362-
minimap.setActive(domainId);
363-
minimap.setViewport(renderer.getViewport());
364-
}
363+
if (generation !== switchGeneration) return;
365364

366-
toggleQuizPanel(true);
367-
const toggleBtn = document.getElementById('quiz-toggle');
368-
if (toggleBtn) toggleBtn.removeAttribute('hidden');
369-
hideDownload();
370-
controls.showActionButtons();
365+
if (minimap) {
366+
minimap.setActive(domainId);
367+
minimap.setViewport(renderer.getViewport());
368+
}
371369

372-
announce(`Loaded ${domain.name}. ${bundle.questions.length} questions available.`);
370+
toggleQuizPanel(true);
371+
const toggleBtn = document.getElementById('quiz-toggle');
372+
if (toggleBtn) toggleBtn.removeAttribute('hidden');
373+
hideDownload();
374+
controls.showActionButtons();
373375

374-
renderer.setAnsweredQuestions(responsesToAnsweredDots($responses.get(), questionIndex));
376+
const domainName = registry.getDomains().find(d => d.id === domainId)?.name || domainId;
377+
announce(`Navigated to ${domainName}. ${allDomainBundle.questions.length} questions available.`);
375378

376-
selectAndShowNextQuestion();
377-
} catch (err) {
378-
if (generation === switchGeneration) {
379-
hideDownload();
380-
console.error('[app] switchDomain failed:', err);
381-
}
382-
}
379+
selectAndShowNextQuestion();
383380
}
384381

385382
function selectAndShowNextQuestion() {
@@ -389,7 +386,7 @@ function selectAndShowNextQuestion() {
389386
const available = getAvailableQuestions(currentDomainBundle, answeredIds);
390387

391388
if (available.length === 0) {
392-
announce('Domain fully mapped! All questions answered. Try another domain.');
389+
announce('All questions answered! Great work exploring the knowledge map.');
393390
quiz.showQuestion(null);
394391
return;
395392
}
@@ -419,9 +416,13 @@ function handleAnswer(selectedKey, question) {
419416

420417
const isCorrect = selectedKey === question.correct_answer;
421418

419+
// Tag the response with the user's currently selected domain (for tracking),
420+
// not the bundle's domain id (which is always "all" now).
421+
const activeDomainId = $activeDomain.get() || 'all';
422+
422423
const response = {
423424
question_id: question.id,
424-
domain_id: currentDomainBundle.domain.id,
425+
domain_id: activeDomainId,
425426
selected: selectedKey,
426427
is_correct: isCorrect,
427428
timestamp: Date.now(),
@@ -448,9 +449,9 @@ function handleAnswer(selectedKey, question) {
448449
renderer.setAnsweredQuestions(responsesToAnsweredDots($responses.get(), questionIndex));
449450

450451
const feedback = isCorrect ? 'Correct!' : 'Incorrect.';
451-
452+
452453
const coverage = Math.round($coverage.get() * 100);
453-
announce(`${feedback} ${coverage}% of domain mapped. ${50 - domainQuestionCount} questions remaining.`);
454+
announce(`${feedback} ${coverage}% mapped.`);
454455

455456
// Auto-advance after a short delay if the toggle is on
456457
if (modes.isAutoAdvance()) {
@@ -462,6 +463,7 @@ function handleReset() {
462463
if (!confirm('Are you sure? This will clear all progress.')) return;
463464
resetAll();
464465
currentDomainBundle = null;
466+
mapInitialized = false;
465467
domainQuestionCount = 0;
466468
currentDomainRegion = GLOBAL_REGION;
467469
currentGridSize = GLOBAL_GRID_SIZE;
@@ -477,11 +479,19 @@ function handleReset() {
477479
renderer.setAnsweredQuestions([]);
478480
renderer.clearQuestions();
479481
insights.resetGlobalConcepts();
480-
questionIndex = new Map();
482+
// Re-set concepts from the permanent "all" bundle so insights work on next domain select
483+
if (allDomainBundle) {
484+
insights.setConcepts(allDomainBundle.questions, allDomainBundle.articles);
485+
}
486+
questionIndex = allDomainBundle
487+
? new Map(allDomainBundle.questions.map(q => [q.id, q]))
488+
: new Map();
481489
if (minimap) {
482490
minimap.setActive(null);
483491
minimap.setEstimates([]);
484492
}
493+
// Reset viewport to full map
494+
renderer.jumpTo(GLOBAL_REGION);
485495
toggleQuizPanel(false);
486496
const toggleBtn = document.getElementById('quiz-toggle');
487497
if (toggleBtn) toggleBtn.setAttribute('hidden', '');
@@ -557,11 +567,8 @@ function handleImport(data) {
557567
const estimates = estimator.predict();
558568
$estimates.set(estimates);
559569

560-
if (currentDomainBundle) {
561-
const domainId = currentDomainBundle.domain.id;
562-
domainQuestionCount = merged.filter(r => r.domain_id === domainId).length;
563-
modes.updateAvailability(domainQuestionCount);
564-
}
570+
domainQuestionCount = merged.length;
571+
modes.updateAvailability(domainQuestionCount);
565572
renderer.setAnsweredQuestions(responsesToAnsweredDots(merged, questionIndex));
566573
}
567574

@@ -571,7 +578,6 @@ function handleImport(data) {
571578
console.log('[import]', msg);
572579

573580
// If we're still on the welcome screen, switch to map view with "all" domain.
574-
// switchDomain will re-restore the GP from $responses (now including imports).
575581
if (!currentDomainBundle) {
576582
controls.setSelectedDomain('all');
577583
$activeDomain.set('all');
@@ -645,7 +651,10 @@ function _showBanner(message, type = 'warning') {
645651
const dismissBtn = document.createElement('button');
646652
dismissBtn.className = 'notice-banner-dismiss';
647653
dismissBtn.setAttribute('aria-label', 'Dismiss notification');
648-
dismissBtn.innerHTML = '<i class="fa fa-times"></i>';
654+
655+
const iconEl = document.createElement('i');
656+
iconEl.className = 'fa fa-times';
657+
dismissBtn.appendChild(iconEl);
649658

650659
const removeBanner = () => {
651660
banner.classList.add('dismissing');

0 commit comments

Comments
 (0)