Skip to content

Commit ca8bad5

Browse files
perf(app): progressive thumbnail loading (400→800→1200) on zoom
Replaces the static _800.webp variant with a tier-aware loader: - Initial paint loads _400.webp (~6 KB, ~1.9 MB total) — fast. - nodeCanvasObject receives globalScale, computes the on-screen device-pixel size of each visible node, and picks the smallest pipeline tier (400/800/1200) that comfortably covers it. - If that tier isn't loaded yet, ensureNodeTier() kicks off a fetch and force-graph repaints when it lands (no jank, no physics restart). - pickBestLoadedTier falls back to a smaller tier during the upgrade so nodes never go blank. force-graph only invokes nodeCanvasObject for visible nodes, so zooming in fetches hi-res only for what the user is actually looking at — no fan-out to off-screen specs. New helpers (all unit-tested): RESOLUTION_TIERS, buildVariantUrl, pickTier, pickBestLoadedTier, ensureNodeTier. Smoke test fixtures updated for the new MapNode shape (imgs Map + pendingTiers Set). Refs #5646 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c68f153 commit ca8bad5

4 files changed

Lines changed: 201 additions & 53 deletions

File tree

app/src/pages/MapPage.helpers.test.ts

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {
66
weightedJaccard,
77
buildKNNLinks,
88
selectMapThumbUrl,
9+
buildVariantUrl,
10+
pickTier,
11+
pickBestLoadedTier,
912
type SpecMapItem,
1013
} from './MapPage.helpers';
1114

@@ -160,28 +163,79 @@ describe('buildKNNLinks', () => {
160163

161164

162165
describe('selectMapThumbUrl', () => {
163-
it('returns the _800.webp variant for the current theme', () => {
166+
it('returns the dark URL in dark mode and light URL in light mode', () => {
164167
const s = spec('a', null);
165-
expect(selectMapThumbUrl(s, true)).toBe('https://example.com/a-dark_800.webp');
166-
expect(selectMapThumbUrl(s, false)).toBe('https://example.com/a-light_800.webp');
168+
expect(selectMapThumbUrl(s, true)).toBe('https://example.com/a-dark.png');
169+
expect(selectMapThumbUrl(s, false)).toBe('https://example.com/a-light.png');
167170
});
168171

169-
it('falls back to the other theme variant when the preferred URL is missing', () => {
172+
it('falls back to the other theme when the preferred URL is missing', () => {
170173
const s: SpecMapItem = { ...spec('a', null), preview_url_dark: null };
171-
expect(selectMapThumbUrl(s, true)).toBe('https://example.com/a-light_800.webp');
174+
expect(selectMapThumbUrl(s, true)).toBe('https://example.com/a-light.png');
172175
});
173176

174177
it('returns null when no preview URLs at all', () => {
175178
const s: SpecMapItem = { ...spec('a', null), preview_url_light: null, preview_url_dark: null };
176179
expect(selectMapThumbUrl(s, false)).toBeNull();
177180
});
181+
});
182+
183+
184+
describe('buildVariantUrl', () => {
185+
it('rewrites .png to _{tier}.webp', () => {
186+
expect(buildVariantUrl('https://example.com/plot.png', 400)).toBe('https://example.com/plot_400.webp');
187+
expect(buildVariantUrl('https://example.com/plot-light.png', 800)).toBe('https://example.com/plot-light_800.webp');
188+
expect(buildVariantUrl('https://example.com/plot-dark.png', 1200)).toBe('https://example.com/plot-dark_1200.webp');
189+
});
190+
191+
it('passes through URLs that do not end in .png', () => {
192+
expect(buildVariantUrl('https://example.com/plot.svg', 400)).toBe('https://example.com/plot.svg');
193+
});
194+
});
195+
196+
197+
describe('pickTier', () => {
198+
it('returns 400 when device pixel size fits in 400 with headroom', () => {
199+
expect(pickTier(100)).toBe(400);
200+
expect(pickTier(300)).toBe(400);
201+
});
202+
203+
it('returns 800 when 400 would require upscaling', () => {
204+
expect(pickTier(500)).toBe(800);
205+
expect(pickTier(600)).toBe(800);
206+
});
207+
208+
it('returns 1200 for very large device sizes', () => {
209+
expect(pickTier(1000)).toBe(1200);
210+
expect(pickTier(2000)).toBe(1200);
211+
});
212+
});
213+
214+
215+
describe('pickBestLoadedTier', () => {
216+
function img(): HTMLImageElement {
217+
return document.createElement('img');
218+
}
219+
220+
it('returns the desired tier when loaded', () => {
221+
const a = img();
222+
const imgs = new Map([[400 as const, a]]);
223+
expect(pickBestLoadedTier(imgs, 400)).toBe(a);
224+
});
225+
226+
it('returns a higher-resolution variant when desired is not loaded', () => {
227+
const a = img();
228+
const imgs = new Map([[800 as const, a]]);
229+
expect(pickBestLoadedTier(imgs, 400)).toBe(a);
230+
});
231+
232+
it('falls back to a smaller tier when nothing larger is loaded', () => {
233+
const a = img();
234+
const imgs = new Map([[400 as const, a]]);
235+
expect(pickBestLoadedTier(imgs, 800)).toBe(a);
236+
});
178237

179-
it('returns the original URL unchanged if it does not end in .png', () => {
180-
const s: SpecMapItem = {
181-
...spec('a', null),
182-
preview_url_light: 'https://example.com/a-light.svg',
183-
preview_url_dark: null,
184-
};
185-
expect(selectMapThumbUrl(s, false)).toBe('https://example.com/a-light.svg');
238+
it('returns null when nothing is loaded', () => {
239+
expect(pickBestLoadedTier(new Map(), 400)).toBeNull();
186240
});
187241
});

app/src/pages/MapPage.helpers.ts

Lines changed: 97 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,22 @@ export interface SpecMapItem {
2121
impl_tags: Record<string, string[]> | null;
2222
}
2323

24-
/** Node shape passed to ForceGraph2D. `img` populated lazily as thumbnails resolve. */
24+
/** Resolution tiers baked by the responsive-image pipeline (responsiveImage.ts). */
25+
export const RESOLUTION_TIERS = [400, 800, 1200] as const;
26+
export type ResolutionTier = (typeof RESOLUTION_TIERS)[number];
27+
28+
/**
29+
* Node shape passed to ForceGraph2D. Holds a lazy collection of image variants
30+
* keyed by resolution tier (400/800/1200). The page populates the 400 tier
31+
* eagerly on load and progressively upgrades on zoom-in.
32+
*/
2533
export interface MapNode {
2634
id: string;
2735
title: string;
2836
tags: string[];
29-
thumbUrl: string | null;
30-
img?: HTMLImageElement;
37+
thumbUrl: string | null; // base theme-aware .png URL
38+
imgs: Map<ResolutionTier, HTMLImageElement>; // loaded variants
39+
pendingTiers: Set<ResolutionTier>; // tiers with an in-flight fetch
3140
}
3241

3342
/** Link shape passed to ForceGraph2D. `weight` = weighted-Jaccard sim ∈ (0, 1]. */
@@ -143,36 +152,101 @@ export function buildKNNLinks(
143152
}
144153

145154
/**
146-
* Pick the best thumbnail URL for the current theme and downsize it to the
147-
* `_800.webp` variant produced by the responsive-image pipeline. _800 stays
148-
* crisp under typical zoom-in (the smaller _400 variant pixelates quickly),
149-
* while keeping the 312-thumbnail payload at ~5 MB total instead of the
150-
* ~15 MB the full-size originals would cost.
151-
*
152-
* Falls back to the original full-size URL if the convention can't be
153-
* applied (e.g. URL doesn't end in `.png`).
155+
* Pick the theme-aware base preview URL (the original `.png`). Variant
156+
* selection happens at draw time via {@link buildVariantUrl} + {@link pickTier}
157+
* so we only fetch higher-resolution thumbnails for nodes the user actually
158+
* zooms into.
154159
*/
155160
export function selectMapThumbUrl(spec: SpecMapItem, isDark: boolean): string | null {
156-
const full = selectPreviewUrl(spec, isDark);
157-
if (!full) return null;
158-
if (!full.endsWith('.png')) return full;
159-
return full.replace(/\.png$/, '_800.webp');
161+
return selectPreviewUrl(spec, isDark);
162+
}
163+
164+
/**
165+
* Derive the URL of a specific resolution variant from the base `.png` URL.
166+
* `.../plot-light.png` + 800 → `.../plot-light_800.webp`. Returns the original
167+
* URL unchanged if it doesn't end in `.png` (no variants available).
168+
*/
169+
export function buildVariantUrl(baseUrl: string, tier: ResolutionTier): string {
170+
if (!baseUrl.endsWith('.png')) return baseUrl;
171+
return baseUrl.replace(/\.png$/, `_${tier}.webp`);
172+
}
173+
174+
/**
175+
* Pick the smallest pipeline tier whose source resolution comfortably covers
176+
* the requested device-pixel size. Source needs to be ≥ device pixels for
177+
* crisp rendering — we add a small headroom factor so a tiny zoom-in nudge
178+
* doesn't immediately re-fetch the next tier.
179+
*/
180+
export function pickTier(devicePxSize: number): ResolutionTier {
181+
const HEADROOM = 1.25;
182+
const target = devicePxSize * HEADROOM;
183+
if (target <= 400) return 400;
184+
if (target <= 800) return 800;
185+
return 1200;
186+
}
187+
188+
/**
189+
* Return the highest-resolution tier that's already loaded and at least as
190+
* big as `desired`. Falls back to a smaller tier if nothing larger is loaded
191+
* yet (better than blank during the lazy upgrade).
192+
*/
193+
export function pickBestLoadedTier(
194+
imgs: Map<ResolutionTier, HTMLImageElement>,
195+
desired: ResolutionTier
196+
): HTMLImageElement | null {
197+
for (const t of RESOLUTION_TIERS) {
198+
if (t >= desired && imgs.has(t)) return imgs.get(t)!;
199+
}
200+
for (let i = RESOLUTION_TIERS.length - 1; i >= 0; i--) {
201+
const t = RESOLUTION_TIERS[i];
202+
if (imgs.has(t)) return imgs.get(t)!;
203+
}
204+
return null;
205+
}
206+
207+
/**
208+
* Lazily fetch the requested tier for a node and call `onLoad` when it lands.
209+
* Idempotent — safe to call repeatedly from `nodeCanvasObject` on every paint.
210+
* force-graph only invokes that callback for visible nodes, so off-screen
211+
* specs never trigger a higher-tier fetch.
212+
*/
213+
export function ensureNodeTier(
214+
node: MapNode,
215+
tier: ResolutionTier,
216+
onLoad: () => void
217+
): void {
218+
if (!node.thumbUrl) return;
219+
if (node.imgs.has(tier) || node.pendingTiers.has(tier)) return;
220+
node.pendingTiers.add(tier);
221+
const img = document.createElement('img');
222+
img.onload = () => {
223+
node.imgs.set(tier, img);
224+
node.pendingTiers.delete(tier);
225+
onLoad();
226+
};
227+
img.onerror = () => {
228+
node.pendingTiers.delete(tier);
229+
};
230+
img.src = buildVariantUrl(node.thumbUrl, tier);
160231
}
161232

162233
/**
163-
* Eager-preload every node's thumbnail. Resolves once all images either
164-
* loaded or errored — failures are swallowed (image stays undefined and
165-
* the node renders as a plain dot in nodeCanvasObject's fallback path).
234+
* Eager-preload every node's thumbnail at the smallest tier (400 px wide ≈ 6 KB
235+
* webp). Resolves once all images either loaded or errored — failures are
236+
* swallowed (the node renders as a plain dot in the fallback path).
166237
*
167238
* `onLoad` fires per-image so the page can call fgRef.refresh() to re-paint
168-
* without re-running the simulation. This is what produces the "thumbnails
169-
* pop in organically" UX rather than a blocking wait.
239+
* without re-running the simulation, producing the "thumbnails pop in
240+
* organically" UX rather than a blocking wait. Higher-resolution tiers are
241+
* lazy-loaded on demand by {@link ensureNodeTier} from `nodeCanvasObject`
242+
* when the user zooms in.
170243
*/
171244
export async function preloadImages(
172245
items: { id: string; thumbUrl: string | null }[],
173-
onLoad?: (id: string, img: HTMLImageElement) => void
246+
onLoad?: (id: string, tier: ResolutionTier, img: HTMLImageElement) => void
174247
): Promise<Map<string, HTMLImageElement>> {
175248
const out = new Map<string, HTMLImageElement>();
249+
const tier: ResolutionTier = 400;
176250
await Promise.all(
177251
items.map(({ id, thumbUrl }) => {
178252
if (!thumbUrl) return Promise.resolve();
@@ -186,11 +260,11 @@ export async function preloadImages(
186260
// becomes "tainted", which is fine — we never read it back).
187261
img.onload = () => {
188262
out.set(id, img);
189-
onLoad?.(id, img);
263+
onLoad?.(id, tier, img);
190264
resolve();
191265
};
192266
img.onerror = () => resolve();
193-
img.src = thumbUrl;
267+
img.src = buildVariantUrl(thumbUrl, tier);
194268
});
195269
})
196270
);

app/src/pages/MapPage.test.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,15 @@ function mockFetchSuccess() {
109109
// jsdom doesn't ship ResizeObserver; stub it so the page's useEffect doesn't crash
110110
// AND fire the callback once with non-zero dimensions so the `size.w > 0` gate that
111111
// guards <ForceGraph2D> mounting is satisfied.
112+
type ResizeCb = (entries: { contentRect: { width: number; height: number } }[]) => void;
112113
class MockResizeObserver {
113-
cb: ResizeObserverCallback;
114-
constructor(cb: ResizeObserverCallback) {
114+
cb: ResizeCb;
115+
constructor(cb: ResizeCb) {
115116
this.cb = cb;
116117
}
117-
observe(target: Element) {
118+
observe(_target: Element) {
118119
setTimeout(() => {
119-
this.cb(
120-
[{ contentRect: { width: 800, height: 600 } } as unknown as ResizeObserverEntry],
121-
this as unknown as ResizeObserver,
122-
);
120+
this.cb([{ contentRect: { width: 800, height: 600 } }]);
123121
}, 0);
124122
}
125123
unobserve() {}
@@ -188,9 +186,9 @@ describe('MapPage', () => {
188186
render(<MapPage />);
189187
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
190188

191-
const drawNode = lastFgProps.current!.nodeCanvasObject as (n: unknown, c: unknown) => void;
189+
const drawNode = lastFgProps.current!.nodeCanvasObject as (n: unknown, c: unknown, gs?: number) => void;
192190
const ctx = makeCtxStub();
193-
drawNode({ id: 'scatter-basic', x: 100, y: 100 }, ctx);
191+
drawNode({ id: 'scatter-basic', x: 100, y: 100, imgs: new Map(), pendingTiers: new Set() }, ctx, 1);
194192

195193
// Without an attached image, the fallback rect path runs.
196194
expect(ctx.fillRect).toHaveBeenCalled();
@@ -203,10 +201,14 @@ describe('MapPage', () => {
203201
render(<MapPage />);
204202
await waitFor(() => expect(lastFgProps.current).not.toBeNull());
205203

206-
const drawNode = lastFgProps.current!.nodeCanvasObject as (n: unknown, c: unknown) => void;
204+
const drawNode = lastFgProps.current!.nodeCanvasObject as (n: unknown, c: unknown, gs?: number) => void;
207205
const ctx = makeCtxStub();
208206
const fakeImg = { src: 'x' } as unknown as HTMLImageElement;
209-
drawNode({ id: 'scatter-basic', x: 50, y: 50, img: fakeImg }, ctx);
207+
drawNode(
208+
{ id: 'scatter-basic', x: 50, y: 50, imgs: new Map([[400, fakeImg]]), pendingTiers: new Set() },
209+
ctx,
210+
1,
211+
);
210212

211213
expect(ctx.drawImage).toHaveBeenCalledWith(fakeImg, expect.any(Number), expect.any(Number), expect.any(Number), expect.any(Number));
212214
expect(ctx.strokeRect).toHaveBeenCalled();

app/src/pages/MapPage.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@ import { colors, fontSize, typography } from '../theme';
1414
import {
1515
buildKNNLinks,
1616
computeIDF,
17+
ensureNodeTier,
1718
flattenTags,
19+
pickBestLoadedTier,
20+
pickTier,
1821
preloadImages,
1922
selectMapThumbUrl,
2023
type MapLink,
2124
type MapNode,
25+
type ResolutionTier,
2226
type SpecMapItem,
2327
} from './MapPage.helpers';
2428

@@ -108,23 +112,25 @@ export function MapPage() {
108112
title: s.title,
109113
tags: flattenTags(s),
110114
thumbUrl: selectMapThumbUrl(s, isDark),
115+
imgs: new Map(),
116+
pendingTiers: new Set(),
111117
}));
112118
const links = buildKNNLinks(specs, idf, KNN_K, KNN_MIN_SIM);
113119
return { nodes, links };
114120
}, [specs, isDark]);
115121

116-
// Preload thumbnails as a side effect; attach to nodes by reference and
117-
// ask force-graph to repaint without restarting the physics simulation.
122+
// Eager-load the 400-tier thumbnails so something paints fast. Higher tiers
123+
// are fetched lazily from nodeCanvasObject when the user zooms in.
118124
useEffect(() => {
119125
if (graphData.nodes.length === 0) return;
120126
const nodeById = new Map(graphData.nodes.map(n => [n.id, n]));
121127
let cancelled = false;
122128
preloadImages(
123129
graphData.nodes.map(n => ({ id: n.id, thumbUrl: n.thumbUrl })),
124-
(id, img) => {
130+
(id, tier, img) => {
125131
if (cancelled) return;
126132
const n = nodeById.get(id);
127-
if (n) n.img = img;
133+
if (n) n.imgs.set(tier, img);
128134
fgRef.current?.refresh?.();
129135
}
130136
);
@@ -220,7 +226,7 @@ export function MapPage() {
220226
width={size.w}
221227
height={size.h}
222228
backgroundColor={'transparent'}
223-
nodeCanvasObject={(node, ctx) => {
229+
nodeCanvasObject={(node, ctx, globalScale) => {
224230
const n = node as WithCoords;
225231
if (n.x == null || n.y == null) return;
226232
const isHover = hoverId === n.id;
@@ -230,10 +236,22 @@ export function MapPage() {
230236
const x = n.x - baseSize / 2;
231237
const y = n.y - baseSize / 2;
232238

239+
// Pick the smallest variant whose source resolution comfortably
240+
// covers the on-screen size, then lazy-load it if not yet present.
241+
// force-graph only invokes nodeCanvasObject for visible nodes, so
242+
// off-screen specs never trigger a higher-tier fetch.
243+
const screenPx = baseSize * (globalScale ?? 1);
244+
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
245+
const desired: ResolutionTier = pickTier(screenPx * dpr);
246+
if (n.imgs && !n.imgs.has(desired) && !n.pendingTiers?.has(desired)) {
247+
ensureNodeTier(n, desired, () => fgRef.current?.refresh?.());
248+
}
249+
const img = n.imgs ? pickBestLoadedTier(n.imgs, desired) : null;
250+
233251
ctx.save();
234252
if (dim) ctx.globalAlpha = 0.18;
235-
if (n.img) {
236-
ctx.drawImage(n.img, x, y, baseSize, baseSize);
253+
if (img) {
254+
ctx.drawImage(img, x, y, baseSize, baseSize);
237255
} else {
238256
ctx.fillStyle = isDark ? '#242420' : '#FFFDF6';
239257
ctx.fillRect(x, y, baseSize, baseSize);

0 commit comments

Comments
 (0)