-
Notifications
You must be signed in to change notification settings - Fork 2
feat: lazy-load dashboard chart modules with IntersectionObserver #702
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
952a9ad
d7a4d7d
60bc0eb
29043a3
bf147aa
775e7a8
b166ec6
ab02261
1577eba
77515a5
f3bb31c
9341bd1
e3d49be
21e2600
8c1cb99
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| /** | ||
| * @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 (SSR, old browser), all loaders | ||
| * are invoked immediately as a graceful fallback. | ||
| * | ||
| * @param dashboards - Array of lazy-loadable dashboard descriptors. | ||
| * @param options - Optional `IntersectionObserver` init overrides. | ||
| */ | ||
| export function initLazyDashboards( | ||
| dashboards: LazyDashboard[], | ||
| options: IntersectionObserverInit = { rootMargin: '200px', threshold: 0.01 }, | ||
| ): void { | ||
| if (typeof IntersectionObserver === 'undefined') { | ||
| // Graceful fallback: load everything immediately | ||
| for (const { containerId, loader } of dashboards) { | ||
| loader().catch((err: unknown) => | ||
| logger.error(`Lazy load failed for #${containerId}:`, err), | ||
| ); | ||
| } | ||
| return; | ||
|
||
| } | ||
|
|
||
| // 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); | ||
|
|
||
| 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); | ||
| }); | ||
|
Comment on lines
+91
to
+104
|
||
| } | ||
| }, 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); | ||
| } | ||
|
Comment on lines
+77
to
+116
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,9 +3,10 @@ | |
| * @description Single entry point for main Riksdagsmonitor pages (index*.html). | ||
| * Replaces 18 individual script tags with one module import. | ||
| * | ||
| * Each dashboard is initialized independently β if one fails, others continue. | ||
| * Libraries (Chart.js, D3) are imported via Vite bundling from npm packages. | ||
|
|
||
| * Above-the-fold dashboards (stats) are initialised eagerly. | ||
| * All chart-heavy dashboards are lazy-loaded via IntersectionObserver so that | ||
| * Chart.js (~200 KB), D3 (~250 KB), and PapaParse (~50 KB) are only downloaded | ||
| * when the user scrolls their containing section into view. | ||
|
Comment on lines
+6
to
+9
|
||
| * | ||
| * @intelligence Central intelligence platform orchestrator β coordinates 12 analytical dashboards covering OSINT data acquisition, political risk assessment, coalition dynamics, electoral forecasting, and behavioral anomaly detection across 349 Swedish MPs and 8 parties. | ||
| * | ||
|
|
@@ -14,46 +15,111 @@ | |
| * @marketing Landing page intelligence showcase β first impression for all 5 target audiences (citizens, journalists, researchers, NGOs, corporations). Each dashboard module is a demonstrable feature for content marketing, social media screenshots, and press coverage. Supports 14-language SEO via separate index files. | ||
| * */ | ||
|
|
||
| // βββ Library Imports (Vite bundles these from node_modules) ββββββββββββββββββ | ||
| // Register Chart.js, D3.js, and Papa Parse on globalThis so dashboard modules can access them. | ||
| // Must be imported before any dashboard module that reads (globalThis as any).Chart / .d3 / .Papa. | ||
| import './shared/register-globals.js'; | ||
|
|
||
| // βββ UI Components βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| import { initBackToTop } from './ui/back-to-top.js'; | ||
|
|
||
| // βββ Dashboard Modules ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| // βββ Eager Dashboard: stats-loader (above-the-fold hero stats, no chart libs) ββ | ||
| import { init as initStats } from './dashboards/stats-loader.js'; | ||
| import { init as initRisk } from './dashboards/risk-dashboard.js'; | ||
| import { init as initParty } from './dashboards/party-dashboard.js'; | ||
| import { init as initMinistry } from './dashboards/ministry-dashboard.js'; | ||
| import { init as initCoalitionLoader } from './dashboards/coalition-loader.js'; | ||
| import { init as initCoalitionDashboard } from './dashboards/coalition-dashboard.js'; | ||
| import { init as initCommittees } from './dashboards/committees-dashboard.js'; | ||
| import { init as initElectionCycle } from './dashboards/election-cycle.js'; | ||
| import { init as initSeasonalPatterns } from './dashboards/seasonal-patterns.js'; | ||
| import { init as initPreElection } from './dashboards/pre-election.js'; | ||
| import { init as initAnomalyDetection } from './dashboards/anomaly-detection.js'; | ||
| import { init as initPolitician } from './dashboards/politician-dashboard.js'; | ||
|
|
||
| // βββ Lazy Loading βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| import { initLazyDashboards } from './lazy-loader.js'; | ||
| import type { LazyDashboard } from './lazy-loader.js'; | ||
|
|
||
| import { logger } from './shared/logger.js'; | ||
|
|
||
| // βββ Dashboard Registry βββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| // Each entry: [name, init function] | ||
| // Order matters for perceived loading (stats & risk first as they're above the fold) | ||
| const DASHBOARDS: Array<[string, () => Promise<void>]> = [ | ||
| ['stats', initStats], | ||
| ['risk', initRisk], | ||
| ['coalition-loader', initCoalitionLoader], | ||
| ['party', initParty], | ||
| ['coalition-dashboard', initCoalitionDashboard], | ||
| ['committees', initCommittees], | ||
| ['ministry', initMinistry], | ||
| ['election-cycle', initElectionCycle], | ||
| ['seasonal-patterns', initSeasonalPatterns], | ||
| ['pre-election', initPreElection], | ||
| ['anomaly-detection', initAnomalyDetection], | ||
| ['politician', initPolitician], | ||
| // βββ Lazy Dashboard Registry βββββββββββββββββββββββββββββββββββββββββββββββββ | ||
| // Each entry triggers a dynamic import() only when the container scrolls into view. | ||
| // The shared/register-globals.js (Chart.js, D3, PapaParse) is loaded as part of | ||
| // the first lazy dashboard β subsequent imports reuse the cached ES module. | ||
| const LAZY_DASHBOARDS: LazyDashboard[] = [ | ||
| { | ||
| containerId: 'coalition-status', | ||
| loader: async () => { | ||
| await import('./shared/register-globals.js'); | ||
| const { init } = await import('./dashboards/coalition-loader.js'); | ||
| await init(); | ||
| }, | ||
|
||
| }, | ||
| { | ||
| containerId: 'election-cycle-dashboard', | ||
| loader: async () => { | ||
| await import('./shared/register-globals.js'); | ||
| const { init } = await import('./dashboards/election-cycle.js'); | ||
| await init(); | ||
| }, | ||
| }, | ||
| { | ||
| containerId: 'party-dashboard', | ||
| loader: async () => { | ||
| await import('./shared/register-globals.js'); | ||
| const { init } = await import('./dashboards/party-dashboard.js'); | ||
| await init(); | ||
| }, | ||
| }, | ||
| { | ||
| containerId: 'committee-dashboard', | ||
| loader: async () => { | ||
| await import('./shared/register-globals.js'); | ||
| const { init } = await import('./dashboards/committees-dashboard.js'); | ||
| await init(); | ||
| }, | ||
| }, | ||
| { | ||
| containerId: 'coalition-dashboard', | ||
| loader: async () => { | ||
| await import('./shared/register-globals.js'); | ||
| const { init } = await import('./dashboards/coalition-dashboard.js'); | ||
| await init(); | ||
| }, | ||
| }, | ||
| { | ||
| containerId: 'seasonal-patterns-dashboard', | ||
| loader: async () => { | ||
| await import('./shared/register-globals.js'); | ||
| const { init } = await import('./dashboards/seasonal-patterns.js'); | ||
| await init(); | ||
| }, | ||
| }, | ||
| { | ||
| containerId: 'pre-election-dashboard', | ||
| loader: async () => { | ||
| await import('./shared/register-globals.js'); | ||
| const { init } = await import('./dashboards/pre-election.js'); | ||
| await init(); | ||
| }, | ||
| }, | ||
| { | ||
| containerId: 'anomaly-detection-dashboard', | ||
| loader: async () => { | ||
| await import('./shared/register-globals.js'); | ||
| const { init } = await import('./dashboards/anomaly-detection.js'); | ||
| await init(); | ||
| }, | ||
| }, | ||
| { | ||
| containerId: 'ministry-dashboard', | ||
| loader: async () => { | ||
| await import('./shared/register-globals.js'); | ||
| const { init } = await import('./dashboards/ministry-dashboard.js'); | ||
| await init(); | ||
| }, | ||
| }, | ||
| { | ||
| containerId: 'risk-dashboard', | ||
| loader: async () => { | ||
| await import('./shared/register-globals.js'); | ||
| const { init } = await import('./dashboards/risk-dashboard.js'); | ||
| await init(); | ||
| }, | ||
| }, | ||
| { | ||
| containerId: 'politician-dashboard', | ||
| loader: async () => { | ||
| await import('./shared/register-globals.js'); | ||
| const { init } = await import('./dashboards/politician-dashboard.js'); | ||
| await init(); | ||
| }, | ||
|
||
| }, | ||
| ]; | ||
|
|
||
| // βββ Initialization βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | ||
|
|
@@ -65,24 +131,19 @@ async function initAll(): Promise<void> { | |
| // Init UI components (sync, fast) | ||
| initBackToTop(); | ||
|
|
||
| // Init dashboards in parallel β each is independent | ||
| const results = await Promise.allSettled( | ||
| DASHBOARDS.map(async ([name, initFn]) => { | ||
| try { | ||
| await initFn(); | ||
| logger.debug(`β ${name} initialized`); | ||
| } catch (error) { | ||
| logger.error(`β ${name} failed:`, error); | ||
| throw error; | ||
| } | ||
| }), | ||
| ); | ||
| // Eager: stats loader populates hero metrics β no chart libraries needed | ||
| try { | ||
| await initStats(); | ||
| logger.debug('β stats initialized'); | ||
| } catch (error) { | ||
| logger.error('β stats failed:', error); | ||
| } | ||
|
|
||
| const succeeded = results.filter((r) => r.status === 'fulfilled').length; | ||
| const failed = results.filter((r) => r.status === 'rejected').length; | ||
| const elapsed = (performance.now() - start).toFixed(0); | ||
| // Lazy: chart-heavy dashboards load only when their section enters the viewport | ||
| initLazyDashboards(LAZY_DASHBOARDS); | ||
|
||
|
|
||
| logger.info(`Initialized ${succeeded}/${DASHBOARDS.length} dashboards in ${elapsed}ms${failed > 0 ? ` (${failed} failed)` : ''}`); | ||
| const elapsed = (performance.now() - start).toFixed(0); | ||
| logger.info(`Core initialized in ${elapsed}ms β ${LAZY_DASHBOARDS.length} dashboards pending lazy load`); | ||
| } | ||
|
|
||
| // Wait for DOM then initialize | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docstring says the
IntersectionObserverfallback covers βSSRβ, but the fallback path still assumesdocumentexists (document.getElementById(...)). In a true SSR/non-DOM environment this will still throw. Either adjust the documentation to remove the SSR claim, or add a guard fortypeof document === 'undefined'(and no-op) to make the SSR statement accurate.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 77515a5. Removed the "SSR" claim from the docstring β the fallback still calls
document.getElementById()so SSR is not supported. Updated to say "old browser" and noted the DOM-presence check.