sdxc

[ Content & feeds ]

Draw charts as PNG images

Draw a response-time chart with an area fill, a stroked line, labels and a daily status strip on a canvas inside a Worker, and serve it as a PNG an email client shows where SVG and scripts never run.

Last updated 2026-10-15

A weekly report email wants a chart, and email is where charts are hardest: most clients strip inline SVG, none run scripts, and some block images from domains they do not trust until the reader clicks. A PNG served from your own domain is the one format every client shows. This guide draws a month of response times as a PNG inside a Worker, with nothing but TypeScript.

@sdxc/raster is a 2D canvas with antialiased paths, gradients, layers, masks and text, which encodes to PNG through @sdxc/png. It sets text in a TrueType font read by @sdxc/font. None of them use WebAssembly, so they run in a Worker, Bun, Node and the browser alike.

npm add @sdxc/font @sdxc/http @sdxc/png @sdxc/raster @sdxc/result remix

Plan the image

The chart is shown 600 pixels wide in the email and drawn at 1200 by 400, twice its display size, so it stays sharp on a high-density screen. Every coordinate below is in the drawn size. The canvas has no transform: it is a surface of pixels, y down, and each call names where it paints.

The data is one row per day: the 95th percentile response time and the share of checks that succeeded.

app/services/reports/latency-chart.ts
import type { Font } from "@sdxc/font";
import type { Raster } from "@sdxc/raster";

import { Canvas, color, linearGradient, Path, withAlpha } from "@sdxc/raster";

export interface Day {
	p95: number;
	uptime: number;
}

export const CHART_SIZE = { width: 1200, height: 400 } as const;

const PLOT = { left: 140, right: 1160, top: 80, bottom: 300 };
const STRIP = { top: 326, height: 28 };
const PAPER = color("#ffffff");
const INK = color("#18181b");
const MUTED = color("#71717a");
const RULE = color("#e4e4e7");
const ACCENT = color("oklch(0.55 0.2 275)");
const UP = color("#16a34a");
const DEGRADED = color("#f59e0b");
const DOWN = color("#dc2626");

color parses hex, rgb() and oklch() into straight-alpha sRGB, so the chart can share the tokens of your stylesheet, and it throws a RangeError naming any value it cannot read. withAlpha derives a translucent variant of one of them.

Draw the axes and labels

Scale each value into the plot, draw three rules with their labels, and set a title and the latest value above the plot. text takes the font, the size in pixels, and x and y for the baseline; align: "end" anchors a label's right edge at x:

app/services/reports/latency-chart.ts
export async function latencyChart(days: Day[], font: Font): Promise<Uint8Array> {
	let canvas = new Canvas(CHART_SIZE.width, CHART_SIZE.height);
	canvas.fillRect(0, 0, CHART_SIZE.width, CHART_SIZE.height, PAPER);

	let highest = Math.max(0, ...days.map((day) => day.p95));
	let ceiling = Math.max(100, Math.ceil(highest / 100) * 100);
	let plotWidth = PLOT.right - PLOT.left;
	let x = (index: number) =>
		PLOT.left + (index / Math.max(1, days.length - 1)) * plotWidth;
	let y = (ms: number) => PLOT.bottom - (ms / ceiling) * (PLOT.bottom - PLOT.top);

	for (let value of [0, ceiling / 2, ceiling]) {
		canvas.fillRect(PLOT.left, y(value) - 1, plotWidth, 2, RULE);
		canvas.text(`${value} ms`, {
			font,
			size: 22,
			x: PLOT.left - 16,
			y: y(value) + 8,
			align: "end",
			paint: MUTED,
		});
	}

	canvas.text("p95 response time, last 30 days", {
		font,
		size: 26,
		x: PLOT.left,
		y: 44,
		paint: INK,
	});
	let latest = days.at(-1);
	if (latest) {
		canvas.text(`${Math.round(latest.p95)} ms today`, {
			font,
			size: 26,
			x: PLOT.right,
			y: 44,
			align: "end",
			paint: ACCENT,
		});
	}

	let points = days.map((day, index): Raster.Point => [x(index), y(day.p95)]);
	drawArea(canvas, points);
	strokeLine(canvas, points, 4, ACCENT);
	drawStrip(canvas, days);

	return canvas.toPNG();
}

fillRect takes fractional edges and covers the pixels they cross partially, so a rule two pixels tall at y(value) - 1 is crisp, and one at a half pixel would be a soft three-pixel band. text also wraps to maxWidth, shrinks toward minSize to fit maxLines, and ends with … when the text still does not fit; labels this short need none of it.

Fill the area under the line

A Path is built from moveTo, lineTo, quadTo, cubicTo and close. The area runs from the plot's floor up to each point and back down, filled with a gradient that fades to nothing at the floor:

app/services/reports/latency-chart.ts
function drawArea(canvas: Canvas, points: Raster.Point[]) {
	let first = points[0];
	let last = points.at(-1);
	if (!first || !last) return;

	let area = new Path().moveTo(first[0], PLOT.bottom);
	for (let [px, py] of points) area.lineTo(px, py);
	area.lineTo(last[0], PLOT.bottom).close();

	canvas.fillPath(
		area,
		linearGradient({
			from: [0, PLOT.top],
			to: [0, PLOT.bottom],
			stops: [
				[0, withAlpha(ACCENT, 0.28)],
				[1, withAlpha(ACCENT, 0)],
			],
		}),
	);
}

Gradient stops are [offset, color] pairs, interpolated in premultiplied sRGB as CSS does, so a fade to transparent keeps its hue all the way down. radialGradient takes a center and a radius, or [rx, ry] for an ellipse, with the same stops.

Stroke the line

The canvas fills shapes, and a line is a shape: one thin rectangle along each segment, plus a round dot at every point so the joins have no notches. Building every segment's rectangle in the same direction lets one fillPath call cover them all, with the overlaps filled once under the default nonzero rule:

app/services/reports/latency-chart.ts
function strokeLine(
	canvas: Canvas,
	points: Raster.Point[],
	width: number,
	paint: Raster.Paint,
) {
	let half = width / 2;
	let segments = new Path();
	for (let index = 1; index < points.length; index++) {
		let from = points[index - 1];
		let to = points[index];
		if (!from || !to) continue;
		let length = Math.hypot(to[0] - from[0], to[1] - from[1]) || 1;
		let nx = (-(to[1] - from[1]) / length) * half;
		let ny = ((to[0] - from[0]) / length) * half;
		segments
			.moveTo(from[0] - nx, from[1] - ny)
			.lineTo(to[0] - nx, to[1] - ny)
			.lineTo(to[0] + nx, to[1] + ny)
			.lineTo(from[0] + nx, from[1] + ny)
			.close();
	}
	canvas.fillPath(segments, paint);

	for (let [px, py] of points) {
		canvas.fillPath(
			Path.roundedRect(px - half, py - half, width, width, half),
			paint,
		);
	}
}

Path.roundedRect caps its radius at half the shorter side, so a square with a radius of half its side is a circle. The dots are separate fills, which is invisible with an opaque paint. To draw the line translucent, wrap the call in canvas.layer({ opacity: 0.8 }, () => …): the layer draws offscreen and composites once, so the overlaps fade as one shape instead of darkening.

Mark each day's status

Under the plot, one rounded cell per day shows whether the checks held, coloured by threshold:

app/services/reports/latency-chart.ts
function drawStrip(canvas: Canvas, days: Day[]) {
	let cell = (PLOT.right - PLOT.left) / Math.max(1, days.length);
	days.forEach((day, index) => {
		let paint = day.uptime >= 0.999 ? UP : day.uptime >= 0.99 ? DEGRADED : DOWN;
		let cellPath = Path.roundedRect(
			PLOT.left + index * cell + 2,
			STRIP.top,
			cell - 4,
			STRIP.height,
			6,
		);
		canvas.fillPath(cellPath, paint);
	});
}

Every pixel of the chart is opaque, so toPNG() writes it without an alpha channel, a quarter smaller before compression. Large flat areas compress well, and a chart like this one is a few tens of kilobytes.

Serve it to the email

Load the font as a data module and parse it on first use, as Draw social card images shows, and serve the chart from a route. routes.reportChart is a route such as /reports/:id/latency.png, and Reports is your own model holding a sent report's days:

app/http/controllers/report-chart.ts
import { internalServerError, notFound } from "@sdxc/http/response/html";
import { isFailure } from "@sdxc/result";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";

import Reports from "~/app/models/report";
import { chartFont } from "~/app/initializers/chart-font";
import { latencyChart } from "~/app/services/reports/latency-chart";
import routes from "~/routes/web";

export default createAction(routes.reportChart, async (ctx) => {
	let { id } = s.parse(s.object({ id: s.string() }), ctx.params);
	let report = await Reports.find(ctx.db, id);
	if (report === null) return notFound("Not Found");

	let font = chartFont();
	if (isFailure(font)) {
		ctx.log.fail(font.error, { report: id });
		return internalServerError("The chart font could not be read.");
	}

	let png = await latencyChart(report.days, font.data);
	return new Response(png.slice(), {
		headers: {
			"Content-Type": "image/png",
			"Cache-Control": "public, max-age=31536000, immutable",
		},
	});
});

A sent report's numbers never change, so the image is cacheable for a year, and mail clients that proxy images, such as Gmail, fetch it once. The report id in the URL should be one a stranger cannot guess, since anyone holding the link can load the image.

In the email, give the image its display size and an alt that states the numbers, which is what a reader sees while images are blocked:

<img
	src="https://example.com/reports/0192f8a4/latency.png"
	width="600"
	height="200"
	alt="p95 response time over the last 30 days: 182 ms today, 99.95% uptime"
/>

Check the pixels in a test

Decoding the PNG gives the pixels back as RGBA, so a test can check that a down day is drawn red without comparing file bytes:

app/services/reports/latency-chart.test.ts
import { readFileSync } from "node:fs";

import { parseFont } from "@sdxc/font";
import { decode } from "@sdxc/png";
import { unwrap } from "@sdxc/result";
import { expect, test } from "vitest";

import { latencyChart } from "~/app/services/reports/latency-chart";

test("a day under 99% uptime is drawn red in the strip", async () => {
	let font = unwrap(
		parseFont(
			readFileSync(
				new URL(
					"../../../resources/fonts/inter-regular.ttf.bin",
					import.meta.url,
				),
			),
		),
	);
	let days = Array.from({ length: 30 }, (_, index) => ({
		p95: 180,
		uptime: index === 0 ? 0.9 : 1,
	}));
	let image = unwrap(await decode(await latencyChart(days, font)));

	let offset = (340 * image.width + 150) * 4;
	expect([...image.data.subarray(offset, offset + 3)]).toEqual([220, 38, 38]);
});

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.