Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 | 1x 19x 6x 6x 6x 2x 2x 4x 4x 4x 3x 1x 1x 6x 13x 13x 11x 11x 10x 10x 10x 10x 9x 9x 9x 9x 7x 7x 2x 2x 13x 16x 16x 2x 2x 14x 14x 13x | /**
* @module Browser/LazyLoader
* @description Lazy loads dashboard modules using IntersectionObserver.
* Defers dynamic import() calls until the dashboard container enters the viewport,
* reducing initial page load and improving Time to Interactive (TTI).
*
* Falls back to immediate loading when IntersectionObserver is unavailable.
*
* @performance Each lazy dashboard defers its dynamic import() until the section
* scrolls into view (plus a 200 px pre-fetch margin), preventing Chart.js (~200 KB),
* D3 (~250 KB) and PapaParse (~50 KB) from blocking the initial parse/render.
*/
import { logger } from './shared/logger.js';
// ─── Types ────────────────────────────────────────────────────────────────────
/** A dashboard that should be loaded lazily when its container enters the viewport. */
export interface LazyDashboard {
/** The `id` attribute of the section/container element to observe. */
containerId: string;
/** Async function that dynamically imports and initialises the dashboard. */
loader: () => Promise<void>;
}
/** CSS class applied to a container while its module is loading. */
export const CHART_SKELETON_CLASS = 'chart-skeleton';
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Register dashboard modules for lazy loading via IntersectionObserver.
*
* When a container element intersects the viewport (with a 200 px pre-fetch
* margin), its loader is called, the skeleton class is added, and removed
* once the promise resolves or rejects.
*
* If `IntersectionObserver` is unavailable (old browser), all loaders are
* invoked immediately as a graceful fallback (containers still checked for DOM
* presence before loading).
*
* The created `IntersectionObserver` is returned so callers can hold a
* reference (preventing GC) and call `disconnect()` if needed. `undefined` is
* returned when the fallback path is taken.
*
* @param dashboards - Array of lazy-loadable dashboard descriptors.
* @param options - Optional `IntersectionObserver` init overrides.
* @returns The active `IntersectionObserver`, or `undefined` in fallback mode.
*/
export function initLazyDashboards(
dashboards: LazyDashboard[],
options: IntersectionObserverInit = { rootMargin: '200px', threshold: 0.01 },
): IntersectionObserver | undefined {
if (typeof IntersectionObserver === 'undefined') {
// Graceful fallback: load immediately, but only when the container exists in the DOM
for (const { containerId, loader } of dashboards) {
const el = document.getElementById(containerId);
if (!el) {
logger.debug(`Lazy loader (fallback): #${containerId} not in DOM, skipping`);
continue;
}
el.classList.add(CHART_SKELETON_CLASS);
// Wrap in Promise.resolve() so a synchronous throw becomes a rejection
Promise.resolve()
.then(() => loader())
.then(() => {
el.classList.remove(CHART_SKELETON_CLASS);
})
.catch((err: unknown) => {
el.classList.remove(CHART_SKELETON_CLASS);
logger.error(`Lazy load failed for #${containerId}:`, err);
});
}
return undefined;
}
// Map element → loader for O(1) lookup inside the observer callback
const pending = new Map<Element, () => Promise<void>>();
const observer = new IntersectionObserver((entries: IntersectionObserverEntry[]) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target as HTMLElement;
observer.unobserve(el);
const loaderFn = pending.get(el);
if (!loaderFn) continue;
pending.delete(el);
// Show skeleton while the module is downloading / initialising
el.classList.add(CHART_SKELETON_CLASS);
// Wrap in Promise.resolve() so a synchronous throw becomes a rejection
Promise.resolve()
.then(() => loaderFn())
.then(() => {
el.classList.remove(CHART_SKELETON_CLASS);
logger.debug(`✓ lazy loaded #${el.id}`);
})
.catch((err: unknown) => {
el.classList.remove(CHART_SKELETON_CLASS);
logger.error(`✗ lazy load failed #${el.id}:`, err);
});
}
}, options);
for (const { containerId, loader } of dashboards) {
const el = document.getElementById(containerId);
if (!el) {
logger.debug(`Lazy loader: #${containerId} not in DOM, skipping`);
continue;
}
pending.set(el, loader);
observer.observe(el);
}
// Return the observer so the caller retains a reference (prevents GC)
return observer;
}
|