Bar chart from JSON (Canvas 2D)
Render a labelled bar chart from a JSON payload. The "build a chart in a Worker without chart.js, d3-node, or a headless browser" pattern. ~5 ms to render a 800×500 chart with 10 bars at the edge.
What it covers
- Drawing axes, gridlines, tick labels.
- Bar rendering with a vertical gradient.
- Auto-scaling the y-axis with sensible nice numbers.
- Multi-line title block and value labels above each bar.
- JSON-in / PNG-out in a single Worker handler.
Full code
ts
// src/index.ts
import { __initSync, createCanvas } from 'simdra/wasm';
import wasm from 'simdra/wasm/simdra.wasm';
__initSync(wasm);
interface ChartPayload {
title: string;
subtitle?: string;
labels: string[];
values: number[];
units?: string; // 'USD', 'ms', '%' — appended to value labels
accent?: string; // bar gradient top colour
background?: string;
width?: number; // default 800
height?: number; // default 500
}
const DEFAULT_W = 800;
const DEFAULT_H = 500;
const PAD = { top: 90, right: 32, bottom: 70, left: 56 };
// Find a "nice" round step size for the y-axis grid given the data range.
function niceStep(range: number, targetTicks = 5): number {
const rough = range / targetTicks;
const power = Math.pow(10, Math.floor(Math.log10(rough)));
const norm = rough / power;
if (norm < 1.5) return 1 * power;
if (norm < 3.5) return 2 * power;
if (norm < 7.5) return 5 * power;
return 10 * power;
}
function fmtValue(v: number, units?: string): string {
const formatted = Number.isInteger(v)
? v.toLocaleString('en-US')
: v.toLocaleString('en-US', { maximumFractionDigits: 2 });
return units ? `${formatted} ${units}` : formatted;
}
function renderChart(p: ChartPayload): Uint8Array {
const W = p.width ?? DEFAULT_W;
const H = p.height ?? DEFAULT_H;
const accent = p.accent ?? '#3b82f6';
const bg = p.background ?? '#ffffff';
if (p.labels.length !== p.values.length || p.values.length === 0) {
throw new Error('labels and values must be non-empty arrays of equal length');
}
const canvas = createCanvas(W, H);
const ctx = canvas.getContext('2d');
// Background
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// Y-axis range — nice steps from 0 (or below if values are negative)
const dataMax = Math.max(...p.values);
const dataMin = Math.min(...p.values, 0);
const step = niceStep(dataMax - dataMin || 1, 5);
const yMax = Math.ceil(dataMax / step) * step;
const yMin = Math.floor(dataMin / step) * step;
const plotX = PAD.left;
const plotY = PAD.top;
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
const yToPx = (v: number) => plotY + plotH - ((v - yMin) / (yMax - yMin)) * plotH;
// Title
ctx.fillStyle = '#0f172a';
ctx.font = '700 22px sans-serif';
ctx.fillText(p.title, PAD.left, 42);
if (p.subtitle) {
ctx.fillStyle = '#64748b';
ctx.font = '400 14px sans-serif';
ctx.fillText(p.subtitle, PAD.left, 64);
}
// Gridlines + y-axis tick labels
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
ctx.fillStyle = '#94a3b8';
ctx.font = '400 12px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let v = yMin; v <= yMax + 1e-9; v += step) {
const y = yToPx(v);
ctx.beginPath();
ctx.moveTo(plotX, y);
ctx.lineTo(plotX + plotW, y);
ctx.stroke();
ctx.fillText(fmtValue(v, p.units), plotX - 8, y);
}
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
// Bars
const n = p.values.length;
const slot = plotW / n;
const barW = Math.min(64, slot * 0.65);
for (let i = 0; i < n; i++) {
const x = plotX + slot * i + (slot - barW) / 2;
const yTop = yToPx(p.values[i]);
const yZero = yToPx(0);
const h = Math.abs(yTop - yZero);
const top = Math.min(yTop, yZero);
// Vertical gradient inside each bar
const grad = ctx.createLinearGradient(0, top, 0, top + h);
grad.addColorStop(0, accent);
grad.addColorStop(1, shade(accent, -0.3)); // darken at the bottom
ctx.fillStyle = grad;
ctx.fillRect(x, top, barW, h);
// Value label above the bar
ctx.fillStyle = '#0f172a';
ctx.font = '600 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(fmtValue(p.values[i], p.units), x + barW / 2, top - 8);
// X-axis label below the chart area
ctx.fillStyle = '#475569';
ctx.font = '400 12px sans-serif';
ctx.fillText(p.labels[i], x + barW / 2, plotY + plotH + 22);
ctx.textAlign = 'left';
}
// Axis lines
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(plotX, plotY);
ctx.lineTo(plotX, plotY + plotH);
ctx.lineTo(plotX + plotW, plotY + plotH);
ctx.stroke();
return canvas.toBytes();
}
// Lighten / darken a hex colour by a -1..1 factor.
function shade(hex: string, factor: number): string {
const m = /^#([0-9a-f]{3,8})$/i.exec(hex);
if (!m) return hex;
let h = m[1];
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const adj = (c: number) => {
const t = factor < 0 ? 0 : 255;
return Math.round(c + (t - c) * Math.abs(factor));
};
const ar = adj(r), ag = adj(g), ab = adj(b);
return `#${[ar, ag, ab].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
}
export default {
async fetch(req: Request): Promise<Response> {
if (req.method !== 'POST') {
return new Response('POST a JSON chart payload', { status: 405 });
}
let payload: ChartPayload;
try {
payload = (await req.json()) as ChartPayload;
} catch {
return new Response('Invalid JSON', { status: 400 });
}
let png: Uint8Array;
try {
png = renderChart(payload);
} catch (err) {
return new Response(JSON.stringify({ error: (err as Error).message }), {
status: 400,
headers: { 'content-type': 'application/json' },
});
}
return new Response(png, {
headers: { 'content-type': 'image/png' },
});
},
};Try it
bash
curl -X POST https://simdra-chart.your-worker.dev/ \
-H 'content-type: application/json' \
-d '{
"title": "Quarterly revenue",
"subtitle": "FY2024",
"labels": ["Q1","Q2","Q3","Q4"],
"values": [120, 185, 240, 310],
"units": "k USD",
"accent": "#10b981"
}' \
-o chart.png
open chart.pngWhy these choices
niceStepfor the y-axis — without it, a chart with values up to 237 would have ugly tick labels like 47.4, 94.8, etc. Snapping to powers of 1/2/5 × 10ⁿ gives "nice" tick numbers (50, 100, 150, …).- Vertical gradient inside each bar — adds depth without a third-party library. The
shade(accent, -0.3)derives a darker stop from the user's accent colour automatically. - Value labels above bars — clearer than hovering tooltips when the chart is rendered as a static PNG (no JS at the consumer).
textAlign/textBaselineinstead ofmeasureTextmath —measureTextonly populateswidthin simdra; alignment via the spec attributes is portable and matches what browsers do.- Negative values supported —
yMin = Math.floor(dataMin / step) * stepextends the axis below 0 if the data needs it. Bars draw downward from the zero line in that case.
Extending
- Stacked / grouped bars — accept
values: number[][](one inner array per series), draw side-by-side or stacked. - Line chart variant — replace the bar loop with a
Path2D+lineTo, thenctx.stroke(). Optional area fill viactx.fillStylebetween the line andyZero. - Pie / donut chart —
ctx.arc()for each slice, label outside viaMath.cos/Math.sinfrom the slice midpoint angle. - Data labels with units in the y-axis — pass
units: 'USD'and the y-axis ticks become100 USD, 200 USD, …. - Brand font — register a TTF with
createCanvas(w, h, { fonts: [{ name: 'Brand', data: ttf }] }), thenctx.font = '600 12px Brand'. - Chart on Vercel Edge / Deno — same code, swap
__initSync(wasm)forawait __init(fetch(wasmUrl)). See Installation.