DEV Community

albert nahas
albert nahas

Posted on

Building a Real-Time Data Visualization in Vanilla JS — No Libraries, No Framework

💡 Full disclosure: I told Claude Sonnet 4.6 to build this. My prompt: "Build a web app showing how AI will take white-collar jobs over time — visualize it, publish it online, write articles about it." The simulation, code, deployment, and these articles were all produced by Claude in one session.

💡 Full disclosure: I told Claude Sonnet 4.6 to build this. My prompt: "Build a web app showing how AI will take white-collar jobs over time — visualize it, publish it online, write articles about it." The simulation, code, deployment, and these articles were all produced by Claude in one session.

The technical breakdown of building sim-wine.vercel.app — an AI job displacement simulator.

When I built the AI Job Displacement Simulator, I made a deliberate choice: no D3.js, no Chart.js, no React, no external dependencies. Just the Canvas 2D API, raw math, and ~700 lines of well-organized JavaScript.

Here's exactly how it works.

Architecture: One File, Two Canvases

The entire app is a single index.html file. It runs entirely in the browser with no server-side logic. Two <canvas> elements do all the visualization work:

  • #areaCanvas — stacked area chart showing workforce breakdown over time
  • #barCanvas — horizontal displacement bar race, one bar per job category

Both canvases redraw on every animation frame, driven by a requestAnimationFrame loop.

The Simulation Model

Each of the 20 job categories is defined with 5 parameters:

const JOBS = [
  {
    id: 'admin',
    name: 'Admin Assistants',
    w0: 3_400_000,    // 2026 worker count (from BLS OES 2024)
    maxD: 0.83,       // max displacement fraction (S-curve ceiling)
    mid: 2029.5,      // inflection year — fastest displacement point
    k: 1.0,           // S-curve steepness factor
    tr: 0.22          // fraction of displaced who transition to new roles
  },
  // ... 19 more categories
];
Enter fullscreen mode Exit fullscreen mode

The core math is a sigmoid (S-curve) function:

function sigmoid(year, mid, k) {
  return 1 / (1 + Math.exp(-k * (year - mid)));
}

function computeState(year) {
  let employed = 0, displaced = 0, transitioned = 0;

  const jobData = JOBS.map(j => {
    // S-curve clamped to max displacement
    const dispRate = Math.min(j.maxD, sigmoid(year, j.mid, j.k) * j.maxD);

    const stillWorking = Math.round(j.w0 * (1 - dispRate));
    const dispTotal    = Math.round(j.w0 * dispRate);
    const trans        = Math.round(dispTotal * j.tr);
    const disp         = dispTotal - trans;

    employed     += stillWorking;
    transitioned += trans;
    displaced    += disp;

    return { ...j, dispRate, stillWorking, trans, disp };
  });

  return { year, employed, displaced, transitioned, 
           newJobs: newAIJobs(year), jobData };
}
Enter fullscreen mode Exit fullscreen mode

New AI-era job creation uses a saturating exponential — grows fast initially, then levels off as the market matures:

function newAIJobs(year) {
  const t = year - 2026;
  return Math.round(6_200_000 * (1 - Math.exp(-0.145 * t)));
}
Enter fullscreen mode Exit fullscreen mode

Pre-Computing the Timeline

The stacked area chart needs to draw the full 2026–2050 projection on every frame. Computing 400 simulation states on each frame would be wasteful. Instead, we pre-compute at startup:

const N = 400; // sample points
const allSamples = Array.from({ length: N + 1 }, (_, i) =>
  computeState(2026 + (i / N) * 24) // 24-year span
);
Enter fullscreen mode Exit fullscreen mode

This runs once (negligible time) and gives us a smooth curve to draw from.

Drawing the Stacked Area Chart

Each layer of the stacked chart traces the top boundary forward (left to right) and the bottom boundary backward (right to left), forming a closed polygon:

for (let layer = 3; layer >= 0; layer--) {
  const [r, g, b] = hexToRgb(COLORS[layer]);

  // Gradient fill — opaque at top, semi-transparent at bottom
  const grad = aCtx.createLinearGradient(0, PAD.t, 0, PAD.t + CH);
  grad.addColorStop(0, `rgba(${r},${g},${b},0.92)`);
  grad.addColorStop(1, `rgba(${r},${g},${b},0.50)`);
  aCtx.fillStyle = grad;

  aCtx.beginPath();

  // Forward: top boundary of this layer
  for (let i = 0; i <= N; i++) {
    const vals = getStackedVals(allSamples[i]);
    const x = xOf(i);
    const y = yOf(vals[layer]); // cumulative top value
    if (i === 0) aCtx.moveTo(x, y); else aCtx.lineTo(x, y);
  }

  // Backward: bottom boundary (= top of the layer below)
  for (let i = N; i >= 0; i--) {
    const vals = getStackedVals(allSamples[i]);
    const y = layer > 0 ? yOf(vals[layer - 1]) : yOf(0);
    aCtx.lineTo(xOf(i), y);
  }

  aCtx.closePath();
  aCtx.fill();
}
Enter fullscreen mode Exit fullscreen mode

The getStackedVals function returns cumulative values for the 4 layers:

function getStackedVals(s) {
  const e = s.employed / 1e6;
  const t = s.transitioned / 1e6;
  const n = s.newJobs / 1e6;
  const d = s.displaced / 1e6;
  return [e, e+t, e+t+n, e+t+n+d]; // cumulative stack
}
Enter fullscreen mode Exit fullscreen mode

The Year Cursor

A dashed vertical line shows the current simulation year on the area chart. At the intersection with each layer boundary, I draw colored dots:

const curX = PAD.l + frac * CW;

// Dashed cursor line
aCtx.strokeStyle = '#38bdf8';
aCtx.lineWidth = 1.5;
aCtx.setLineDash([5, 3]);
aCtx.beginPath();
aCtx.moveTo(curX, PAD.t);
aCtx.lineTo(curX, PAD.t + CH);
aCtx.stroke();
aCtx.setLineDash([]);

// Colored dots at each stack boundary
const curVals = getStackedVals(allSamples[curIdx]);
dotColors.forEach((color, i) => {
  aCtx.beginPath();
  aCtx.arc(curX, yOf(curVals[i]), 4, 0, Math.PI * 2);
  aCtx.fillStyle = color;
  aCtx.fill();
});
Enter fullscreen mode Exit fullscreen mode

The Color Scale

Displacement level maps to a continuous gradient — green → yellow → red — using linear interpolation between hex colors:

function dispColor(rate) {
  if (rate < 0.3) return lerpColor('#10b981', '#22c55e', rate / 0.3);
  if (rate < 0.6) return lerpColor('#22c55e', '#f59e0b', (rate - 0.3) / 0.3);
  return lerpColor('#f59e0b', '#ef4444', Math.min(1, (rate - 0.6) / 0.4));
}

function lerpColor(a, b, t) {
  const p = parseInt(a.slice(1), 16), q = parseInt(b.slice(1), 16);
  const r  = Math.round(((p >> 16) & 0xff) * (1 - t) + ((q >> 16) & 0xff) * t);
  const g  = Math.round(((p >> 8)  & 0xff) * (1 - t) + ((q >> 8)  & 0xff) * t);
  const bl = Math.round( (p        & 0xff) * (1 - t) + ( q        & 0xff) * t);
  return '#' + [r, g, bl].map(x => x.toString(16).padStart(2, '0')).join(');
}
Enter fullscreen mode Exit fullscreen mode

The Animation Loop

Delta-time based animation so speed is consistent regardless of monitor refresh rate:

let lastTime = null;

function animLoop(timestamp) {
  if (!lastTime) lastTime = timestamp;
  const dt = (timestamp - lastTime) / 1000; // seconds
  lastTime = timestamp;

  const speed = parseFloat(speedSelector.value); // 0.5, 1, 2, or 5
  currentYear = Math.min(2050, currentYear + dt * speed);

  updateAll(currentYear); // redraws both canvases, updates stats

  if (currentYear < 2050) {
    requestAnimationFrame(animLoop);
  }
}
Enter fullscreen mode Exit fullscreen mode

At 1× speed: 1 simulated year per real second. At 5×: the full 24-year span plays in under 5 seconds.

Canvas Tooltip Hit-Testing

Tooltips on the bar chart require mapping mouse Y position to the correct bar row — no DOM elements to attach event listeners to:

barCanvas.addEventListener('mousemove', function(e) {
  const rect = barCanvas.getBoundingClientRect();
  const my = e.clientY - rect.top;

  // Reverse the layout math to find which row was hovered
  const idx = Math.floor((my - PAD_TOP - HEADER_HEIGHT) / (BAR_H + BAR_GAP));

  if (idx >= 0 && idx < SORTED_JOBS.length) {
    const job = SORTED_JOBS[idx];
    showTooltip(e, job, currentState);
  }
});
Enter fullscreen mode Exit fullscreen mode

Why Not D3 or Chart.js?

D3.js is powerful but heavy (~280KB gzipped for full build). For a focused visualization, Canvas 2D with direct math is:

  • Faster: no DOM overhead, no virtual nodes, no transition engine
  • Simpler: exactly as complex as needed, nothing more
  • More portable: works anywhere, loads instantly, zero dependencies

The tradeoff: no automatic scales, axes, or transitions. You write the math yourself. For this simulation — where the rendering logic is custom anyway — it was the right call.


Full source (single HTML file, ~700 lines): sim-wine.vercel.app

Top comments (0)