[ 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.
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:
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:
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:
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:
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:
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:
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
Draw social card images — gradients, wrapped text and R2 storage for an image drawn once per page.
Accept PNG avatars and draw placeholders — reading and writing PNG pixels directly.
Send email — the message the chart goes into.
@sdxc/raster—mask,layerand every text option.