💡 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
];
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 };
}
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)));
}
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
);
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();
}
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
}
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();
});
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(');
}
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);
}
}
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);
}
});
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)