Skip to content

Commit 849b356

Browse files
authored
Merge pull request #26 from rosuH/codex/optimize-atlas-performance
perf: optimize atlas image loading
2 parents 0f8dace + 836922d commit 849b356

78 files changed

Lines changed: 208 additions & 35 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

atlas/index.html

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@
2323
<meta name="twitter:image" content="https://ysl.rosuh.me/docs/assets/banner.png">
2424
<link rel="icon" href="data:,">
2525
<link rel="manifest" href="manifest.json">
26-
<link rel="preload" href="css/main.css?v=16" as="style">
27-
<link rel="modulepreload" href="js/app.js?v=16">
26+
<link rel="preload" href="css/main.css?v=18" as="style">
27+
<link rel="modulepreload" href="js/app.js?v=18">
28+
<link rel="modulepreload" href="js/i18n.js?v=18">
2829
<link rel="preload" href="dawn-to-night.json" as="fetch" crossorigin="anonymous">
29-
<link rel="stylesheet" href="css/main.css?v=16">
30+
<link rel="preload" href="media/photos/american-coots.webp" as="image" type="image/webp" fetchpriority="high">
31+
<link rel="stylesheet" href="css/main.css?v=18">
3032
</head>
3133
<body>
3234
<div class="item-backdrop" id="item-backdrop"></div>
@@ -100,7 +102,7 @@ <h2 id="backdrop-note-title">Loading...</h2>
100102
</header>
101103

102104
<figure class="stamp-photo-frame" id="scene-photo-frame">
103-
<img id="scene-photo" class="scene-photo" alt="" decoding="async">
105+
<img id="scene-photo" class="scene-photo" alt="" decoding="async" fetchpriority="high">
104106
</figure>
105107

106108
<section class="stamp-copy">
@@ -285,6 +287,6 @@ <h2 id="archive-slip-title">Yellowstone Sound Atlas</h2>
285287
</div>
286288
</noscript>
287289

288-
<script src="js/app.js?v=16" type="module"></script>
290+
<script data-cfasync="false" src="js/app.js?v=18" type="module"></script>
289291
</body>
290292
</html>

atlas/js/app.js

Lines changed: 107 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import {
99
shareHashtags,
1010
shareText,
1111
uiText,
12-
} from "./i18n.js";
12+
} from "./i18n.js?v=18";
1313

1414
const ROUTE_URL = "./dawn-to-night.json";
15+
const OPTIMIZED_PHOTO_DIR = "media/photos";
16+
const THUMBNAIL_DIR = "media/thumbs";
1517
const THEME_ORDER = ["Thermal", "Birds", "Wildlife", "Human", "Weather", "Ambient", "Water"];
1618

1719
const THEME_META = {
@@ -244,17 +246,70 @@ function clearNode(node) {
244246
}
245247
}
246248

247-
function preloadImage(src) {
248-
return new Promise((resolve) => {
249+
let keyboardHintTimer;
250+
let progressFrame = 0;
251+
let progressTrackWidth = 0;
252+
const imageLoadCache = new Map();
253+
254+
function syncProgressTrackWidth() {
255+
progressTrackWidth = ui.track.clientWidth || progressTrackWidth;
256+
}
257+
258+
if ("ResizeObserver" in window) {
259+
const progressTrackResizeObserver = new ResizeObserver(syncProgressTrackWidth);
260+
progressTrackResizeObserver.observe(ui.track);
261+
} else {
262+
window.addEventListener("resize", syncProgressTrackWidth, { passive: true });
263+
}
264+
265+
requestAnimationFrame(syncProgressTrackWidth);
266+
267+
function preloadImage(src, fallbackSrc = "") {
268+
if (!src) return Promise.resolve(null);
269+
270+
const cacheKey = fallbackSrc ? `${src}::${fallbackSrc}` : src;
271+
if (imageLoadCache.has(cacheKey)) return imageLoadCache.get(cacheKey);
272+
273+
const promise = new Promise((resolve) => {
249274
const img = new Image();
250275
img.onload = () => resolve(src);
251-
img.onerror = () => resolve(null);
276+
img.onerror = () => {
277+
if (fallbackSrc && fallbackSrc !== src) {
278+
preloadImage(fallbackSrc).then(resolve);
279+
} else {
280+
resolve(null);
281+
}
282+
};
252283
img.src = src;
284+
}).then((loaded) => {
285+
if (!loaded) imageLoadCache.delete(cacheKey);
286+
return loaded;
253287
});
288+
289+
imageLoadCache.set(cacheKey, promise);
290+
return promise;
254291
}
255292

256-
let keyboardHintTimer;
257-
let progressFrame = 0;
293+
function originalImageSrc(imagePath) {
294+
return imagePath ? encodeURI(`../${imagePath}`) : "";
295+
}
296+
297+
function optimizedPhotoSrc(stop) {
298+
return stop.imagePath ? `${OPTIMIZED_PHOTO_DIR}/${encodeURIComponent(stop.id)}.webp` : "";
299+
}
300+
301+
function thumbnailPhotoSrc(stop) {
302+
return stop.imagePath ? `${THUMBNAIL_DIR}/${encodeURIComponent(stop.id)}.webp` : "";
303+
}
304+
305+
function imageSourcesForStop(stop) {
306+
if (!stop || !stop.imagePath) return { primary: "", fallback: "" };
307+
308+
return {
309+
primary: optimizedPhotoSrc(stop),
310+
fallback: originalImageSrc(stop.imagePath),
311+
};
312+
}
258313

259314
function showKeyboardHints() {
260315
ui.keyboardHints.classList.add("visible");
@@ -352,7 +407,7 @@ function hslPalette(hue, saturation, lightness) {
352407
};
353408
}
354409

355-
function derivePaletteFromImage(src, fallbackPalette) {
410+
function derivePaletteFromImage(src, fallbackPalette, fallbackSrc = "") {
356411
return new Promise((resolve) => {
357412
if (!src) {
358413
resolve(fallbackPalette);
@@ -403,7 +458,13 @@ function derivePaletteFromImage(src, fallbackPalette) {
403458
resolve(fallbackPalette);
404459
}
405460
};
406-
image.onerror = () => resolve(fallbackPalette);
461+
image.onerror = () => {
462+
if (fallbackSrc && fallbackSrc !== src) {
463+
derivePaletteFromImage(fallbackSrc, fallbackPalette).then(resolve);
464+
} else {
465+
resolve(fallbackPalette);
466+
}
467+
};
407468
image.src = src;
408469
});
409470
}
@@ -441,7 +502,7 @@ function setBackdropNote(stop) {
441502
ui.backdropNoteBody.textContent = localized.fieldNote || localized.description;
442503
}
443504

444-
function setBackdropPrint(stop, src) {
505+
function setBackdropPrint(stop, src, fallbackSrc = "") {
445506
const expectedId = stop.id;
446507
const localized = displayStop(stop);
447508
const caption = copy().printCaption(localized.title, localized.theme, localized.timeOfDay);
@@ -455,7 +516,7 @@ function setBackdropPrint(stop, src) {
455516
return;
456517
}
457518

458-
preloadImage(src).then((loaded) => {
519+
preloadImage(src, fallbackSrc).then((loaded) => {
459520
const selected = state.stops[state.index];
460521
if (!selected || selected.id !== expectedId) return;
461522

@@ -595,16 +656,22 @@ function initialStopIndex(stops) {
595656

596657
function updateAtmosphere(stop) {
597658
const fallbackPalette = semanticPaletteForStop(stop);
598-
const imageSrc = stop.imagePath ? encodeURI(`../${stop.imagePath}`) : "";
659+
const { primary: imageSrc, fallback: fallbackSrc } = imageSourcesForStop(stop);
599660
const expectedId = stop.id;
600661
const localized = displayStop(stop);
601662

602663
applyDerivedPalette(fallbackPalette);
603-
setBackdropImage(imageSrc, localized.title);
664+
setBackdropImage("", localized.title);
604665
setBackdropNote(stop);
605-
setBackdropPrint(stop, imageSrc);
666+
setBackdropPrint(stop, imageSrc, fallbackSrc);
606667

607-
derivePaletteFromImage(imageSrc, fallbackPalette).then((palette) => {
668+
preloadImage(imageSrc, fallbackSrc).then((loaded) => {
669+
const selected = state.stops[state.index];
670+
if (!selected || selected.id !== expectedId) return;
671+
setBackdropImage(loaded, localized.title);
672+
});
673+
674+
derivePaletteFromImage(imageSrc, fallbackPalette, fallbackSrc).then((palette) => {
608675
const selected = state.stops[state.index];
609676
if (!selected || selected.id !== expectedId) return;
610677
applyDerivedPalette(palette);
@@ -729,7 +796,10 @@ function buildChipCarousel() {
729796
photo.alt = "";
730797
photo.loading = "lazy";
731798
photo.decoding = "async";
732-
photo.src = encodeURI(`../${stop.imagePath}`);
799+
photo.setAttribute("fetchpriority", "low");
800+
photo.dataset.fallbackSrc = originalImageSrc(stop.imagePath);
801+
photo.src = thumbnailPhotoSrc(stop);
802+
photo.addEventListener("error", onChipPhotoError);
733803
chip.appendChild(photo);
734804
}
735805

@@ -785,7 +855,6 @@ function updateLocalizedSurface() {
785855
buildChipCarousel();
786856
const stop = currentStop();
787857
if (stop) {
788-
const imageSrc = stop.imagePath ? encodeURI(`../${stop.imagePath}`) : "";
789858
const localized = displayStop(stop);
790859
const stopNumber = getStopNumber(state.index);
791860
ui.eyebrow.textContent = copy().eyebrow(stopNumber);
@@ -796,9 +865,7 @@ function updateLocalizedSurface() {
796865
ui.miniMeta.textContent = `${localized.theme} - ${localized.timeOfDay}`;
797866
ui.desc.textContent = localized.fieldNote || localized.description;
798867
ui.credit.textContent = creditForStop(stop);
799-
setBackdropImage(imageSrc, localized.title);
800-
setBackdropNote(stop);
801-
setBackdropPrint(stop, imageSrc);
868+
updateAtmosphere(stop);
802869
updateScenePhoto(stop);
803870
updateShareTargets(stop);
804871
}
@@ -928,18 +995,30 @@ function setActiveTheme(theme, selectFirst = false) {
928995
});
929996
}
930997

931-
function setPhoto(img, frame, imagePath, sourceTitle, displayTitle = sourceTitle) {
932-
const expectedPath = imagePath || "";
998+
function onChipPhotoError(e) {
999+
const img = e.currentTarget;
1000+
const fallbackSrc = img.dataset.fallbackSrc;
1001+
1002+
if (fallbackSrc) {
1003+
img.removeAttribute("data-fallback-src");
1004+
img.src = fallbackSrc;
1005+
} else {
1006+
img.remove();
1007+
}
1008+
}
1009+
1010+
function setPhoto(img, frame, stop, displayTitle = stop.title) {
1011+
const expectedId = stop.id;
9331012
const matchesSelectedStop = () => {
9341013
const selected = state.stops[state.index];
935-
return selected && selected.title === sourceTitle && (selected.imagePath || "") === expectedPath;
1014+
return selected && selected.id === expectedId;
9361015
};
9371016

9381017
img.alt = displayTitle || "";
9391018
frame.dataset.fallbackLabel = displayTitle || copy().routeSpecimen;
9401019
img.classList.remove("is-loaded");
9411020

942-
if (!imagePath) {
1021+
if (!stop.imagePath) {
9431022
if (matchesSelectedStop()) {
9441023
img.removeAttribute("src");
9451024
img.alt = "";
@@ -951,8 +1030,8 @@ function setPhoto(img, frame, imagePath, sourceTitle, displayTitle = sourceTitle
9511030

9521031
img.classList.remove("is-fallback");
9531032
frame.classList.remove("has-fallback");
954-
const src = encodeURI(`../${imagePath}`);
955-
preloadImage(src).then((loaded) => {
1033+
const { primary: src, fallback: fallbackSrc } = imageSourcesForStop(stop);
1034+
preloadImage(src, fallbackSrc).then((loaded) => {
9561035
if (!matchesSelectedStop()) return;
9571036

9581037
if (loaded) {
@@ -972,8 +1051,8 @@ function setPhoto(img, frame, imagePath, sourceTitle, displayTitle = sourceTitle
9721051

9731052
function updateScenePhoto(stop) {
9741053
const localized = displayStop(stop);
975-
setPhoto(ui.scenePhoto, ui.sceneFrame, stop.imagePath, stop.title, localized.title);
976-
setPhoto(ui.miniScenePhoto, ui.miniFrame, stop.imagePath, stop.title, localized.title);
1054+
setPhoto(ui.scenePhoto, ui.sceneFrame, stop, localized.title);
1055+
setPhoto(ui.miniScenePhoto, ui.miniFrame, stop, localized.title);
9771056
}
9781057

9791058
function creditForStop(stop) {
@@ -1117,9 +1196,8 @@ function updatePlayIcon() {
11171196

11181197
function setProgressVisual(ratio) {
11191198
const normalized = Math.max(0, Math.min(1, ratio || 0));
1120-
const trackWidth = ui.track.getBoundingClientRect().width || 0;
11211199
ui.track.style.setProperty("--progress-ratio", normalized.toFixed(4));
1122-
ui.track.style.setProperty("--progress-x", `${normalized * trackWidth}px`);
1200+
ui.track.style.setProperty("--progress-x", `${(normalized * progressTrackWidth).toFixed(2)}px`);
11231201
}
11241202

11251203
function paintProgressFromAudio() {
47.8 KB
31.7 KB
30.2 KB
59.4 KB

atlas/media/photos/bald-eagle.webp

2.45 KB
15.6 KB
70.2 KB
27 KB

0 commit comments

Comments
 (0)