Working with a Voronoi stippling visualization in an Astro frontend that uses Web Workers for point calculation and CSS custom properties for theme-aware styling. The implementation leverages TypeScript for canvas rendering and MutationObserver for detecting theme changes.
Issue
The Voronoi stippling visualization failed to update dot colors when users toggled between light and dark themes. Dots rendered with the initial theme’s color (white in dark mode) became invisible when switching to light mode (white background), requiring a manual page refresh to correct. The issue manifested because the canvas only rendered once during Web Worker completion, with no mechanism to re-render when --gray-0 CSS variable changed.
Culprit
The rendering logic executed only once when receiving the final point data from the Web Worker (after 80 Lloyd’s relaxation iterations). The handleMessage function read the CSS variable --gray-0 once and stored no reference to the points afterward. While the ThemeToggle component correctly modified the theme-dark class on <html>, nothing observed this change to trigger a canvas re-render with the updated color value.
Key problematic code:
// Executed only once per worker completion
const dotColor = getComputedStyle(document.documentElement)
.getPropertyValue('--gray-0').trim();
Solution
Implemented a three-part solution using MutationObserver to detect theme changes and trigger re-renders:
1. Rendering Logic Extraction
Created a reusable renderStippling() function that:
- Clears and re-draws the canvas
- Re-fetches
--gray-0on every call (critical for theme updates) - Uses stored point data (
currentPoints)
function renderStippling(points: Float64Array) {
const dotColor = getComputedStyle(document.documentElement)
.getPropertyValue('--gray-0').trim(); // Always fresh value
// ... rendering logic
}
2. Point Data Persistence
Modified handleMessage to store the final points in currentPoints:
let currentPoints: Float64Array | null = null;
function handleMessage(event: MessageEvent) {
currentPoints = event.data; // Store for future re-renders
renderStippling(currentPoints);
}
3. Theme Change Observation
Added MutationObserver to watch for class attribute changes on <html>:
const themeObserver = new MutationObserver((mutations) => {
if (currentPoints) {
renderStippling(currentPoints); // Re-render with new theme
}
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
Performance Impact:
- Re-rendering 20,000 dots takes <10ms (imperceptible to users)
- No Web Worker restart needed (points already calculated)
- Observer adds negligible overhead when idle
Modified files: frontend/src/pages/index.astro (lines 255-348) with added cleanup to disconnect the observer on page unload.