Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
952a9ad
feat: lazy-load dashboard chart modules with IntersectionObserver
Copilot Mar 1, 2026
d7a4d7d
Merge branch 'main' into copilot/implement-lazy-loading-dashboard-charts
pethers Mar 1, 2026
60bc0eb
Merge branch 'main' into copilot/implement-lazy-loading-dashboard-charts
pethers Mar 1, 2026
29043a3
fix: address lazy-loader fallback DOM check and politician-dashboard …
Copilot Mar 1, 2026
bf147aa
Merge branch 'main' into copilot/implement-lazy-loading-dashboard-charts
pethers Mar 1, 2026
775e7a8
Merge branch 'main' into copilot/implement-lazy-loading-dashboard-charts
pethers Mar 1, 2026
b166ec6
fix: move initLazyDashboards before initStats and extract loadDashboa…
Copilot Mar 1, 2026
ab02261
Merge branch 'main' into copilot/implement-lazy-loading-dashboard-charts
pethers Mar 1, 2026
1577eba
Merge branch 'main' into copilot/implement-lazy-loading-dashboard-charts
pethers Mar 1, 2026
77515a5
fix: correct SSR docstring and remove redundant CDN Chart.js from pol…
Copilot Mar 1, 2026
f3bb31c
Merge branch 'main' into copilot/implement-lazy-loading-dashboard-charts
pethers Mar 1, 2026
9341bd1
fix: prevent IO observer GC and guard against sync-throwing loaders
Copilot Mar 1, 2026
e3d49be
Merge branch 'main' into copilot/implement-lazy-loading-dashboard-charts
pethers Mar 1, 2026
21e2600
fix: store lazy observer reference in module scope; fix Chart.js Cypr…
Copilot Mar 1, 2026
8c1cb99
Merge branch 'main' into copilot/implement-lazy-loading-dashboard-charts
pethers Mar 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/browser/lazy-loader.ts
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.
*
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says the IntersectionObserver fallback covers β€œSSR”, but the fallback path still assumes document exists (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 for typeof document === 'undefined' (and no-op) to make the SSR statement accurate.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

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.

* @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;
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initLazyDashboards() fallback path (when IntersectionObserver is unavailable) calls every dashboard loader() unconditionally. This bypasses the DOM presence check used in the observer path and can trigger dashboard init on pages where that container does not exist (e.g., different routes), causing avoidable errors. Suggestion: mirror the normal path by checking document.getElementById(containerId) before invoking loader() (and optionally apply/remove the skeleton class consistently).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 29043a3. The fallback path now mirrors the observer path: it calls document.getElementById(containerId) before invoking the loader (skipping and logging when the element is absent), and applies/removes the chart-skeleton class around the loader promise β€” matching the observer path's skeleton lifecycle.

}

// 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
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In both the IntersectionObserver path and the fallback path, loader()/loaderFn() is assumed to always return a Promise and never throw synchronously. If a loader throws before returning (e.g., due to a coding error), the skeleton class won’t be removed and the error won’t be logged by the .catch() handler. Wrapping the invocation in Promise.resolve() (or a try/catch that rethrows as a rejected Promise) would make the skeleton lifecycle and error logging robust to sync exceptions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9341bd1. Both the IO path and fallback path now wrap loader invocations in Promise.resolve().then(() => loader()). A synchronous throw inside a loader becomes a rejected promise, which is caught by the existing .catch() handler β€” ensuring the skeleton class is always removed and the error is always logged.

}
}, 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
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initLazyDashboards() creates an IntersectionObserver but doesn’t keep any reference to it outside the function. After initLazyDashboards() returns, the observer can be garbage-collected in some browsers/conditions, which would stop observations and prevent dashboards from ever loading. Consider retaining the observer (e.g., module-level variable) and/or returning a handle (observer or cleanup function) so its lifetime is guaranteed and it can be disconnected if needed.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9341bd1. initLazyDashboards() now returns IntersectionObserver | undefined (undefined in the no-IO fallback path). The caller holds the reference, preventing GC. The docstring documents both the return type and when undefined is returned.

}
165 changes: 113 additions & 52 deletions src/browser/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header comment claims Chart.js/D3/PapaParse are β€œonly downloaded when the user scrolls their containing section into view”, but politician-dashboard.html currently includes Chart.js via a CDN <script> tag in the <head>, so Chart.js is still downloaded unconditionally (and register-globals will also import Chart.js again when the dashboard lazy-loads). To match the intended lazy-loading behavior, remove the CDN Chart.js script from that page (preferred), or update the runtime loading logic to avoid importing/registering Chart.js when it’s already present on globalThis.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

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 CDN chart.js@4.4.2 <script> tag from politician-dashboard.html. Chart.js is now only fetched when the politician-dashboard container enters the viewport, via register-globals inside the lazy loader β€” matching the intended lazy-loading behaviour.

*
* @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.
*
Expand All @@ -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();
},
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LAZY_DASHBOARDS registry repeats the same await import('./shared/register-globals.js') + const { init } = await import(...) pattern in every loader. This duplication makes it easy for future edits to drift (e.g., forgetting to load globals for one dashboard). Consider extracting a small helper (e.g., a wrapper that loads globals once and then imports/runs init) to centralize the pattern.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b166ec6. Extracted a loadDashboard(moduleLoader) helper that handles the register-globals import and init() call. All 11 lazy entries now use it β€” the repeated 3-line pattern is gone.

},
{
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();
},
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lazy entry for the politician dashboard uses containerId: 'politician-dashboard', but there is no element with id="politician-dashboard" in politician-dashboard.html (the page uses id="main-content"). With this change the Politician Dashboard module will never be imported/initialized on that page. Update the containerId to an ID that actually exists on the politician dashboard page (or add the missing ID in the HTML as part of this PR).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 29043a3. Added id="politician-dashboard" to the <div class="dashboard-container"> element in politician-dashboard.html. Using id="main-content" wasn't viable since that ID exists on all 14 language home pages as well, which would have triggered the politician dashboard module on every page.

},
];

// ─── Initialization ─────────────────────────────────────────────────────────
Expand All @@ -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);
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initLazyDashboards(LAZY_DASHBOARDS) is only registered after awaiting initStats(). Since initStats() does async I/O (CSV fetch/parse), this can delay setting up the IntersectionObserver and postpone (or miss) early lazy-load triggers for dashboards that are already in/near the viewport on initial render. Consider registering initLazyDashboards() before awaiting initStats() (or running them in parallel) so lazy dashboards can start prefetching as soon as DOMContentLoaded fires.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b166ec6. initLazyDashboards(LAZY_DASHBOARDS) is now called before await initStats(), so the IntersectionObserver is live before any async I/O can delay it.


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
Expand Down
19 changes: 19 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -9967,12 +9967,31 @@ svg:focus-visible,
border-radius: var(--border-radius-lg);
}

/* Chart container placeholder shown while a lazy dashboard module loads */
.chart-skeleton {
background: linear-gradient(
90deg,
var(--skeleton-base) 25%,
var(--skeleton-shine) 50%,
var(--skeleton-base) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--border-radius-lg, 8px);
min-height: 300px;
pointer-events: none;
}

/* Respect reduced-motion preference */
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
opacity: 0.6;
}
.chart-skeleton {
animation: none;
opacity: 0.6;
}
}

/* ── 2. Enhanced Card Styles (hover lift + focus-within ring) ───────────────── */
Expand Down
Loading
Loading