Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions packages/hqtui/src/graphics/canvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* A canvas you can draw on in your own coordinates.
*
* `BrailleCanvas` works in pixels: good primitives, but the caller does every
* unit conversion, and a drawing written for one panel size is wrong in the
* next. This wraps it with a domain per axis and a list of shapes placed in
* that domain, so the same drawing fits whatever region it is given.
*
* Y increases upwards, as it does on paper and in every plot, rather than
* downwards as it does in a terminal. A canvas is for drawing things that have
* their own geometry; making the caller flip every y would be handing them back
* the conversion this exists to take away.
*/
import type { Surface } from "../surface.ts";
import { type Color, mix } from "../color.ts";
import { BrailleCanvas } from "./braille.ts";
import { blit } from "./plot.ts";

export interface Bounds {
min: number;
max: number;
}

export type Shape =
| { type: "line"; x1: number; y1: number; x2: number; y2: number; color?: Color }
| { type: "polyline"; points: [number, number][]; color?: Color }
| { type: "points"; points: [number, number][]; color?: Color }
| { type: "circle"; x: number; y: number; radius: number; color?: Color }
| {
type: "rect";
x: number;
y: number;
width: number;
height: number;
color?: Color;
fill?: boolean;
};

export interface CanvasOptions {
shapes: Shape[];
/** The span the drawing is in. Defaults to 0-1 on both axes. */
x?: Bounds;
y?: Bounds;
/** Colour for shapes that do not name their own. */
color?: Color;
background?: Color;
/** A faint dotted grid behind the shapes. */
grid?: boolean;
gridColor?: Color;
}

const UNIT: Bounds = { min: 0, max: 1 };

/** A usable span: a zero-width one cannot be mapped onto anything. */
function span(bounds: Bounds | undefined): Bounds {
if (!bounds) return UNIT;
const { min, max } = bounds;
if (!Number.isFinite(min) || !Number.isFinite(max) || !(max > min)) return UNIT;
return { min, max };
}

/**
* A projection from the caller's coordinates onto the canvas's pixels.
*
* Handed out so a caller can place their own labels against the same drawing:
* a chart axis or a map legend has to agree with the shapes, and re-deriving
* the mapping by hand is exactly the arithmetic this is here to remove.
*/
export interface Projection {
x(value: number): number;
y(value: number): number;
}

export function projection(
canvas: BrailleCanvas,
xBounds: Bounds,
yBounds: Bounds,
): Projection {
const width = Math.max(1, canvas.width - 1);
const height = Math.max(1, canvas.height - 1);
return {
x: (value) => ((value - xBounds.min) / (xBounds.max - xBounds.min)) * width,
// Flipped: the caller's y goes up, the canvas's goes down.
y: (value) => (1 - (value - yBounds.min) / (yBounds.max - yBounds.min)) * height,
};
}

function drawGrid(surface: Surface, color: Color, bg?: Color): void {
const { width: w, height: h } = surface;
for (let y = 0; y < h; y += Math.max(2, Math.floor(h / 4))) {
for (let x = 0; x < w; x += 2) surface.char(x, y, "·", { fg: color, bg });
}
}

/** Whether a shape has anything finite to draw. */
function usable(shape: Shape): boolean {
const finite = (...values: number[]) => values.every((v) => Number.isFinite(v));
switch (shape.type) {
case "line":
return finite(shape.x1, shape.y1, shape.x2, shape.y2);
case "circle":
return finite(shape.x, shape.y, shape.radius);
case "rect":
return finite(shape.x, shape.y, shape.width, shape.height);
default:
return shape.points.some((p) => finite(p[0], p[1]));
}
}

export function drawCanvas(surface: Surface, options: CanvasOptions): void {
if (surface.empty || options.shapes.length === 0) return;
const theme = surface.theme;
const bg = options.background;
const xb = span(options.x);
const yb = span(options.y);

if (options.grid) {
drawGrid(surface, options.gridColor ?? mix(theme.border, theme.background, 0.4), bg);
}

const canvas = new BrailleCanvas(surface.width, surface.height);
const at = projection(canvas, xb, yb);
const base = options.color ?? theme.accent;

// One shape at a time, blitted before the next is drawn, so each keeps its
// own colour. A shared canvas would make the last colour win everywhere the
// shapes overlap.
for (const shape of options.shapes) {
if (!usable(shape)) continue;
canvas.clear();
switch (shape.type) {
case "line":
canvas.line(at.x(shape.x1), at.y(shape.y1), at.x(shape.x2), at.y(shape.y2));
break;
case "polyline": {
const points = shape.points
.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]))
.map((p) => [at.x(p[0]), at.y(p[1])] as [number, number]);
if (points.length === 1) canvas.pixel(points[0][0], points[0][1]);
else canvas.polyline(points);
break;
}
case "points":
for (const [px, py] of shape.points) {
if (Number.isFinite(px) && Number.isFinite(py)) canvas.pixel(at.x(px), at.y(py));
}
break;
case "circle": {
// A radius is a distance, not a position, so it is scaled by the span
// rather than projected. The two axes rarely scale alike in a terminal
// cell, and the x one is what a circle is measured against.
const scale = Math.max(1, canvas.width - 1) / (xb.max - xb.min);
canvas.circle(at.x(shape.x), at.y(shape.y), Math.abs(shape.radius) * scale);
break;
}
case "rect": {
// Given as a corner and a size, in the caller's own direction: a
// positive height goes up, because their y does.
const x0 = at.x(shape.x);
const x1 = at.x(shape.x + shape.width);
const y0 = at.y(shape.y);
const y1 = at.y(shape.y + shape.height);
if (shape.fill) canvas.fillRect(x0, Math.min(y0, y1), x1, Math.max(y0, y1));
else canvas.rect(x0, Math.min(y0, y1), x1, Math.max(y0, y1));
break;
}
}
const color = shape.color ?? base;
blit(surface, canvas, () => color, bg);
}
}
1 change: 1 addition & 0 deletions packages/hqtui/src/graphics/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { BrailleCanvas } from "./braille.ts";
export * from "./blocks.ts";
export * from "./plot.ts";
export * from "./canvas.ts";
export * from "./chart.ts";
4 changes: 2 additions & 2 deletions packages/hqtui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ export { BrailleCanvas } from "./graphics/braille.ts";
export {
plot, blit, sparkline, bar, gauge, donut, histogram,
verticalGlyph, horizontalGlyph, shadeGlyph, bestMode,
plotPoints, domainOf,
plotPoints, domainOf, drawCanvas, projection,
type Series, type PlotOptions, type FillMode,
type Point, type MarkType, type ChartSeries, type AxisOptions, type ChartPlotOptions,
type Domain,
type Domain, type Shape, type Bounds, type CanvasOptions, type Projection,
} from "./graphics/index.ts";

// Widgets (for drawing straight onto a Surface)
Expand Down
12 changes: 12 additions & 0 deletions packages/hqtui/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { stringWidth, wrap } from "./unicode.ts";
import { isRich, toSpanLines, wrapRich, type RichText } from "./richtext.ts";
import { BrailleCanvas } from "./graphics/braille.ts";
import { drawCanvas, type CanvasOptions } from "./graphics/canvas.ts";
import * as W from "./widgets/index.ts";

export interface HitRegion {
Expand Down Expand Up @@ -619,6 +620,17 @@ export class Container {
return this.add(fn, this.sizeOf(options, "fill"));
}

/**
* A canvas drawn in your own coordinates rather than in pixels.
*
* `canvas` hands you the pixel grid and leaves the unit conversion to you,
* which means a drawing written for one panel size is wrong in the next.
* This takes bounds and shapes placed inside them, and y goes up.
*/
shapes(options: CanvasOptions & ContainerOptions): this {
return this.add((s) => drawCanvas(s, options), this.sizeOf(options, "fill"));
}

/** A Braille pixel canvas sized to the region, blitted when you are done. */
canvas(
fn: (canvas: BrailleCanvas, surface: Surface) => void,
Expand Down
143 changes: 143 additions & 0 deletions packages/hqtui/test/canvas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { renderToScreen } from "../src/index.ts";
import { BrailleCanvas } from "../src/graphics/braille.ts";
import { projection } from "../src/graphics/canvas.ts";
import type { CanvasOptions } from "../src/graphics/canvas.ts";

const rows = (options: CanvasOptions, width = 20, height = 6): string[] =>
renderToScreen(({ ui }) => ui.shapes(options), { width, height })
.text().split("\n").map((line) => line.trimEnd());

/** Which rows carry ink, top first. */
const inkedRows = (lines: string[]): number[] =>
lines.map((line, i) => (line.trim().length > 0 ? i : -1)).filter((i) => i >= 0);

const inkedColumns = (lines: string[]): number[] => {
const columns = new Set<number>();
for (const line of lines) {
for (let x = 0; x < line.length; x++) if (line[x] !== " ") columns.add(x);
}
return [...columns].sort((a, b) => a - b);
};

test("canvas: y goes up, as it does on paper", () => {
// The whole reason this exists: a caller drawing a graph or a map should not
// have to flip every coordinate because a terminal counts rows downwards.
const low = rows({ shapes: [{ type: "points", points: [[0.5, 0]] }], x: { min: 0, max: 1 }, y: { min: 0, max: 1 } });
const high = rows({ shapes: [{ type: "points", points: [[0.5, 1]] }], x: { min: 0, max: 1 }, y: { min: 0, max: 1 } });
assert.deepEqual(inkedRows(high), [0], "y = max is the top row");
assert.deepEqual(inkedRows(low), [5], "y = min is the bottom row");
});

test("canvas: the same drawing fits whatever region it is given", () => {
// A drawing in its own coordinates is size-independent; that is the point.
const shapes: CanvasOptions = {
shapes: [{ type: "line", x1: 0, y1: 0, x2: 10, y2: 10 }],
x: { min: 0, max: 10 },
y: { min: 0, max: 10 },
};
for (const [w, h] of [[20, 6], [40, 12], [11, 3]]) {
const lines = rows(shapes, w, h);
const columns = inkedColumns(lines);
assert.equal(columns[0], 0, `${w}x${h} did not start at the left edge`);
assert.equal(columns[columns.length - 1], w - 1, `${w}x${h} did not reach the right edge`);
// A diagonal: the top row has ink and so does the bottom.
assert.ok(lines[0].trim().length > 0, `${w}x${h} missed the top`);
assert.ok(lines[h - 1].trim().length > 0, `${w}x${h} missed the bottom`);
}
});

test("canvas: bounds place a shape, they do not merely scale it", () => {
// The same point under two different domains lands in two different places.
const shapes: CanvasOptions["shapes"] = [{ type: "points", points: [[1, 0.5]] }];
const near = inkedColumns(rows({ shapes, x: { min: 0, max: 2 }, y: { min: 0, max: 1 } }));
const far = inkedColumns(rows({ shapes, x: { min: 0, max: 100 }, y: { min: 0, max: 1 } }));
assert.ok(near[0] > far[0], `expected the wider domain to push it left: ${near} vs ${far}`);
assert.equal(far[0], 0, "1 in a domain of 100 is at the left edge");
});

test("canvas: a zero-width domain falls back rather than dividing by it", () => {
// Every point would map to the same place, and the division would blow up.
const lines = rows({
shapes: [{ type: "points", points: [[0.5, 0.5]] }],
x: { min: 5, max: 5 },
y: { min: 0, max: 1 },
});
assert.ok(lines.join("").trim().length >= 0, "did not throw");
const nan = rows({
shapes: [{ type: "points", points: [[0.5, 0.5]] }],
x: { min: Number.NaN, max: 1 },
});
assert.ok(nan.join("").trim().length > 0, "a broken bound left nothing drawn");
});

test("canvas: a rectangle is a corner and a size, in the caller's direction", () => {
// A positive height goes up, because the caller's y does.
const outline = rows({
shapes: [{ type: "rect", x: 0.2, y: 0.2, width: 0.6, height: 0.6 }],
x: { min: 0, max: 1 },
y: { min: 0, max: 1 },
}, 20, 6);
const filled = rows({
shapes: [{ type: "rect", x: 0.2, y: 0.2, width: 0.6, height: 0.6, fill: true }],
x: { min: 0, max: 1 },
y: { min: 0, max: 1 },
}, 20, 6);
const ink = (lines: string[]) => lines.join("").replace(/\s/g, "").length;
assert.ok(ink(outline) > 0, "the outline drew nothing");
assert.ok(ink(filled) > ink(outline), "the fill was no denser than the outline");
});

test("canvas: shapes keep their own colours", () => {
const screen = renderToScreen(
({ ui, theme }) => ui.shapes({
shapes: [
{ type: "line", x1: 0, y1: 0, x2: 1, y2: 0, color: theme.danger },
{ type: "line", x1: 0, y1: 1, x2: 1, y2: 1, color: theme.success },
],
x: { min: 0, max: 1 },
y: { min: 0, max: 1 },
}),
{ width: 20, height: 4 },
);
const colours = new Set(
[...screen.buffer.chars].map((c, i) => (c !== 0 && c !== 32 ? screen.buffer.fg[i] : -1)),
);
colours.delete(-1);
// Two shapes, two colours: a shared canvas would let the last one win.
assert.equal(colours.size, 2, `expected two colours, got ${[...colours]}`);
});

test("canvas: a shape with nothing finite in it is skipped, not drawn at zero", () => {
const good = rows({
shapes: [{ type: "line", x1: 0, y1: 0, x2: 1, y2: 1 }],
x: { min: 0, max: 1 },
y: { min: 0, max: 1 },
});
const withJunk = rows({
shapes: [
{ type: "line", x1: Number.NaN, y1: 0, x2: 1, y2: 1 },
{ type: "line", x1: 0, y1: 0, x2: 1, y2: 1 },
],
x: { min: 0, max: 1 },
y: { min: 0, max: 1 },
});
assert.deepEqual(withJunk, good);
});

test("canvas: the projection is the one the shapes were drawn with", () => {
// Handed out so a caller can put their own labels against the same drawing.
const canvas = new BrailleCanvas(10, 4);
const at = projection(canvas, { min: 0, max: 10 }, { min: 0, max: 10 });
assert.equal(at.x(0), 0);
assert.equal(at.x(10), canvas.width - 1);
// Flipped: the caller's maximum is the canvas's row zero.
assert.equal(at.y(10), 0);
assert.equal(at.y(0), canvas.height - 1);
});

test("canvas: an empty shape list draws nothing at all", () => {
const lines = rows({ shapes: [] });
assert.deepEqual(lines, ["", "", "", "", "", ""]);
});
Loading