// WHAT A CHART PAINTED, for the e2e specs: a screenshot of the chart read back
// as pixels in the page (the browser's own PNG decoder, an
drawn to a
// canvas), so a test checks the rendered geometry — where the stack's top
// is, which colours show — rather than what a style says a stroke is.
//
// No Playwright import (common/ does not depend on it): the page and the
// locator are typed by the two methods this uses.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Shooter = { screenshot(opts?: any): Promise };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Evaluator = { evaluate(fn: any, arg: any): Promise };
export type Rgb = [number, number, number];
// "rgb(1, 2, 3)" / "rgba(1, 2, 3, 0.5)" → [1, 2, 3].
export function rgbOf(css: string): Rgb {
const m = css.match(/[\d.]+/g);
if (!m || m.length < 3) throw new Error(`not an rgb() colour: ${css}`);
return [Number(m[0]), Number(m[1]), Number(m[2])];
}
// `alpha` of `fg` over `bg`, as the browser composites a translucent fill.
export function over(fg: Rgb, alpha: number, bg: Rgb): Rgb {
return [0, 1, 2].map((c) => Math.round(fg[c] * alpha + bg[c] * (1 - alpha))) as Rgb;
}
export type Painted = {
// Per requested column: the first row, from the top, of a run of `run` rows
// that each differ from `ground` by more than `tol` in some channel; null
// when the column is all ground.
tops: (number | null)[];
// Per requested colour: how many pixels are within `tol` of it, and in how
// many distinct columns.
counts: { pixels: number; columns: number }[];
width: number;
height: number;
};
export async function painted(
page: Evaluator,
target: Shooter,
opts: { columns: number[]; ground: Rgb; colours: Rgb[]; tol?: number; run?: number },
): Promise {
const png = await target.screenshot({ scale: "css", animations: "disabled" });
return page.evaluate(
async ({
b64,
columns,
ground,
colours,
tol,
run,
}: {
b64: string;
columns: number[];
ground: Rgb;
colours: Rgb[];
tol: number;
run: number;
}) => {
const img = new Image();
img.src = `data:image/png;base64,${b64}`;
await img.decode();
const c = document.createElement("canvas");
c.width = img.naturalWidth;
c.height = img.naturalHeight;
const ctx = c.getContext("2d")!;
ctx.drawImage(img, 0, 0);
const { data, width, height } = ctx.getImageData(0, 0, c.width, c.height);
const at = (x: number, y: number) => (y * width + x) * 4;
const differs = (x: number, y: number) => {
const p = at(x, y);
return [0, 1, 2].some((k) => Math.abs(data[p + k] - ground[k]) > tol);
};
const tops = columns.map((cx) => {
const x = Math.min(width - 1, Math.max(0, Math.round(cx)));
for (let y = 0; y + run <= height; y++) {
let solid = true;
for (let d = 0; d < run && solid; d++) solid = differs(x, y + d);
if (solid) return y;
}
return null;
});
const counts = colours.map((col) => {
let pixels = 0;
const cols = new Set();
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const p = at(x, y);
if ([0, 1, 2].every((k) => Math.abs(data[p + k] - col[k]) <= tol)) {
pixels++;
cols.add(x);
}
}
}
return { pixels, columns: cols.size };
});
return { tops, counts, width, height };
},
{
b64: png.toString("base64"),
columns: opts.columns,
ground: opts.ground,
colours: opts.colours,
tol: opts.tol ?? 12,
run: opts.run ?? 3,
},
);
}