Resolved

Canvas visualization fails to update on theme toggle

Frontend
Canvas
TypeScript
CSS Variables
Theme Switching
MutationObserver
Web Workers
Data Visualization

Voronoi stippling dots retain initial color when switching between light and dark themes | Canvas re-rendering not triggered by CSS variable changes

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-0 on 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.