@@ -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+ */
2533export 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 */
155160export 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 ( / \. p n g $ / , '_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 ( / \. p n g $ / , `_${ 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 */
171244export 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 ) ;
0 commit comments