Skip to content

Commit b29af7b

Browse files
committed
Optimize atlas startup rendering
1 parent d7db9fe commit b29af7b

2 files changed

Lines changed: 93 additions & 20 deletions

File tree

atlas/js/app.js

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const THEME_META = {
2828

2929
const state = {
3030
stops: [],
31+
stopsByTheme: new Map(),
3132
index: 0,
3233
activeTheme: "Thermal",
3334
locale: localeFromUrl(),
@@ -251,6 +252,14 @@ let progressFrame = 0;
251252
let progressTrackWidth = 0;
252253
const imageLoadCache = new Map();
253254

255+
function scheduleIdleTask(callback, timeout = 1000) {
256+
if ("requestIdleCallback" in window) {
257+
window.requestIdleCallback(callback, { timeout });
258+
} else {
259+
window.setTimeout(callback, 0);
260+
}
261+
}
262+
254263
function syncProgressTrackWidth() {
255264
progressTrackWidth = ui.track.clientWidth || progressTrackWidth;
256265
}
@@ -322,6 +331,7 @@ function showKeyboardHints() {
322331
function buildWaveform(seed) {
323332
clearNode(ui.waveform);
324333
const count = 48;
334+
const fragment = document.createDocumentFragment();
325335

326336
for (let i = 0; i < count; i++) {
327337
const bar = document.createElement("span");
@@ -330,8 +340,10 @@ function buildWaveform(seed) {
330340
bar.style.setProperty("--h", `${height}%`);
331341
bar.style.setProperty("--d", `${i * 35}ms`);
332342
bar.dataset.index = i;
333-
ui.waveform.appendChild(bar);
343+
fragment.appendChild(bar);
334344
}
345+
346+
ui.waveform.appendChild(fragment);
335347
}
336348

337349
function updateWaveform(percent) {
@@ -469,6 +481,16 @@ function derivePaletteFromImage(src, fallbackPalette, fallbackSrc = "") {
469481
});
470482
}
471483

484+
function scheduleImagePalette(imageSrc, fallbackPalette, fallbackSrc, expectedId) {
485+
scheduleIdleTask(() => {
486+
derivePaletteFromImage(imageSrc, fallbackPalette, fallbackSrc).then((palette) => {
487+
const selected = state.stops[state.index];
488+
if (!selected || selected.id !== expectedId) return;
489+
applyDerivedPalette(palette);
490+
});
491+
}, 1200);
492+
}
493+
472494
function applyDerivedPalette(palette) {
473495
[ui.body, ui.playerCard, ui.strip].forEach((node) => {
474496
node.style.setProperty("--theme-color", palette.accent);
@@ -671,11 +693,7 @@ function updateAtmosphere(stop) {
671693
setBackdropImage(loaded, localized.title);
672694
});
673695

674-
derivePaletteFromImage(imageSrc, fallbackPalette, fallbackSrc).then((palette) => {
675-
const selected = state.stops[state.index];
676-
if (!selected || selected.id !== expectedId) return;
677-
applyDerivedPalette(palette);
678-
});
696+
scheduleImagePalette(imageSrc, fallbackPalette, fallbackSrc, expectedId);
679697
}
680698

681699
function getStopNumber(index) {
@@ -697,9 +715,7 @@ function applyTheme(theme) {
697715
}
698716

699717
function themeStops(theme) {
700-
return state.stops
701-
.map((stop, index) => ({ ...stop, index }))
702-
.filter((stop) => stop.theme === theme);
718+
return state.stopsByTheme.get(theme) || [];
703719
}
704720

705721
function activePlaybackTheme() {
@@ -740,9 +756,12 @@ function themeCount(theme) {
740756

741757
function buildThemeTabs() {
742758
clearNode(ui.themeTabs);
759+
const fragment = document.createDocumentFragment();
760+
const text = copy();
743761

744762
THEME_ORDER.forEach((theme) => {
745763
const meta = getThemeMeta(theme);
764+
const countValue = themeCount(theme);
746765
const themeLabel = localizeThemeName(theme, state.locale);
747766
const button = document.createElement("button");
748767
button.type = "button";
@@ -754,7 +773,7 @@ function buildThemeTabs() {
754773
button.setAttribute("role", "tab");
755774
button.setAttribute("aria-controls", "chip-carousel");
756775
button.setAttribute("aria-selected", String(theme === state.activeTheme));
757-
button.setAttribute("aria-label", copy().selectTheme(themeLabel, themeCount(theme)));
776+
button.setAttribute("aria-label", text.selectTheme(themeLabel, countValue));
758777
button.addEventListener("click", () => setActiveTheme(theme, true));
759778

760779
const swatch = document.createElement("span");
@@ -767,15 +786,19 @@ function buildThemeTabs() {
767786

768787
const count = document.createElement("span");
769788
count.className = "theme-count";
770-
count.textContent = themeCount(theme);
789+
count.textContent = countValue;
771790

772791
button.append(swatch, name, count);
773-
ui.themeTabs.appendChild(button);
792+
fragment.appendChild(button);
774793
});
794+
795+
ui.themeTabs.appendChild(fragment);
775796
}
776797

777-
function buildChipCarousel() {
798+
function buildChipCarousel({ deferImages = false } = {}) {
778799
clearNode(ui.chipCarousel);
800+
const fragment = document.createDocumentFragment();
801+
const text = copy();
779802

780803
themeStops(state.activeTheme).forEach((stop) => {
781804
const meta = getThemeMeta(stop.theme);
@@ -785,7 +808,7 @@ function buildChipCarousel() {
785808
chip.className = "specimen-chip stamp-chip";
786809
chip.dataset.index = String(stop.index);
787810
chip.style.setProperty("--theme-color", meta.color);
788-
chip.setAttribute("aria-label", copy().selectStop(localized.title));
811+
chip.setAttribute("aria-label", text.selectStop(localized.title));
789812
chip.addEventListener("click", () => {
790813
selectStop(stop.index, !ui.audio.paused, { keepActiveTheme: true });
791814
});
@@ -798,7 +821,11 @@ function buildChipCarousel() {
798821
photo.decoding = "async";
799822
photo.setAttribute("fetchpriority", "low");
800823
photo.dataset.fallbackSrc = originalImageSrc(stop.imagePath);
801-
photo.src = thumbnailPhotoSrc(stop);
824+
if (deferImages) {
825+
photo.dataset.src = thumbnailPhotoSrc(stop);
826+
} else {
827+
photo.src = thumbnailPhotoSrc(stop);
828+
}
802829
photo.addEventListener("error", onChipPhotoError);
803830
chip.appendChild(photo);
804831
}
@@ -813,7 +840,16 @@ function buildChipCarousel() {
813840
theme.textContent = localized.theme;
814841

815842
chip.append(label, title, theme);
816-
ui.chipCarousel.appendChild(chip);
843+
fragment.appendChild(chip);
844+
});
845+
846+
ui.chipCarousel.appendChild(fragment);
847+
}
848+
849+
function hydrateDeferredChipImages() {
850+
ui.chipCarousel.querySelectorAll(".chip-photo[data-src]").forEach((photo) => {
851+
photo.src = photo.dataset.src;
852+
photo.removeAttribute("data-src");
817853
});
818854
}
819855

@@ -1001,6 +1037,7 @@ function onChipPhotoError(e) {
10011037

10021038
if (fallbackSrc) {
10031039
img.removeAttribute("data-fallback-src");
1040+
img.removeAttribute("data-src");
10041041
img.src = fallbackSrc;
10051042
} else {
10061043
img.remove();
@@ -1435,6 +1472,18 @@ function validateStops(stops) {
14351472
});
14361473
}
14371474

1475+
function indexStops(stops) {
1476+
return stops.map((stop, index) => ({ ...stop, index }));
1477+
}
1478+
1479+
function groupStopsByTheme(stops) {
1480+
const buckets = new Map(THEME_ORDER.map((theme) => [theme, []]));
1481+
stops.forEach((stop) => {
1482+
buckets.get(stop.theme)?.push(stop);
1483+
});
1484+
return buckets;
1485+
}
1486+
14381487
async function loadRoute() {
14391488
try {
14401489
const res = await fetch(ROUTE_URL);
@@ -1443,14 +1492,21 @@ async function loadRoute() {
14431492
const stops = await res.json();
14441493
validateStops(stops);
14451494

1446-
state.stops = stops;
1447-
const startingIndex = initialStopIndex(stops);
1448-
state.activeTheme = stops[startingIndex].theme;
1495+
state.stops = indexStops(stops);
1496+
state.stopsByTheme = groupStopsByTheme(state.stops);
1497+
const startingIndex = initialStopIndex(state.stops);
1498+
state.activeTheme = state.stops[startingIndex].theme;
14491499
applyTheme(state.activeTheme);
14501500
buildThemeTabs();
1451-
buildChipCarousel();
14521501
setMinimized(false);
14531502
selectStop(startingIndex);
1503+
scheduleIdleTask(() => {
1504+
if (!ui.chipCarousel.children.length) {
1505+
buildChipCarousel({ deferImages: true });
1506+
updateThemeNav();
1507+
}
1508+
hydrateDeferredChipImages();
1509+
}, 900);
14541510
showStatus(copy().statusLoaded(stops.length));
14551511
} catch (err) {
14561512
showStatus(err.message, true);

tests/test_atlas_static.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,8 @@ def test_stamp_layout_contract_avoids_expanded_view_scrolling():
355355
def test_app_derives_item_palette_and_backdrop_from_selected_specimen():
356356
script = JS_PATH.read_text(encoding="utf-8")
357357

358+
assert "requestIdleCallback" in script
359+
assert "function scheduleImagePalette" in script
358360
assert "derivePaletteFromImage" in script
359361
assert "semanticPaletteForStop" in script
360362
assert "applyDerivedPalette" in script
@@ -390,7 +392,10 @@ def test_app_loads_chip_thumbnails_instead_of_full_size_strip_images():
390392
assert "thumbnailPhotoSrc(stop)" in script
391393
assert 'photo.setAttribute("fetchpriority", "low");' in script
392394
assert "photo.dataset.fallbackSrc = originalImageSrc(stop.imagePath);" in script
395+
assert "photo.dataset.src = thumbnailPhotoSrc(stop);" in script
393396
assert "photo.src = thumbnailPhotoSrc(stop);" in script
397+
assert "function hydrateDeferredChipImages()" in script
398+
assert "buildChipCarousel({ deferImages: true });" in script
394399
assert "onChipPhotoError" in script
395400

396401

@@ -402,9 +407,21 @@ def test_chip_thumbnail_error_handler_stays_active_for_fallback_failure():
402407
assert 'photo.addEventListener("error", onChipPhotoError);' in build_chip_carousel
403408
assert 'photo.addEventListener("error", onChipPhotoError, { once: true });' not in build_chip_carousel
404409
assert 'img.removeAttribute("data-fallback-src");' in script
410+
assert 'img.removeAttribute("data-src");' in script
405411
assert "img.remove();" in script
406412

407413

414+
def test_app_indexes_theme_stops_once_before_navigation_renders():
415+
script = JS_PATH.read_text(encoding="utf-8")
416+
417+
assert "stopsByTheme: new Map()" in script
418+
assert "function indexStops(stops)" in script
419+
assert "function groupStopsByTheme(stops)" in script
420+
assert "state.stops = indexStops(stops);" in script
421+
assert "state.stopsByTheme = groupStopsByTheme(state.stops);" in script
422+
assert "return state.stopsByTheme.get(theme) || [];" in script
423+
424+
408425
def test_player_expand_and_minimize_use_bounded_crossfade_blur():
409426
css = CSS_PATH.read_text(encoding="utf-8")
410427
script = JS_PATH.read_text(encoding="utf-8")

0 commit comments

Comments
 (0)