From b492b6e05920a61fba6988fa46f3c45a94ca74b5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 22:39:24 +0000 Subject: [PATCH 1/2] Inline and fixed viewports, so an app can live in the command line hqtui took over the whole screen or it did not run. `alternateScreen: false` wrote to the main screen but still cleared it and still owned every row, so the one shape it could not build was the common one: an installer, a build, a deploy watcher -- a few live rows pinned low, finished work scrolling away above them, and a readable transcript left behind at the end. const app = await createApp({ viewport: { mode: "inline", height: 3 } }); app.insertBefore(1, ui => ui.text("compiled in 1.2s")); `fullscreen` is the default and nothing about it changes. `inline` reserves a strip in the normal flow of the terminal and never clears anything. `fixed` takes a rectangle of a screen something else is driving. The hard part is that an inline strip has no coordinates it can trust. It does not know which screen row it started on, and the terminal scrolls it upwards without saying so. It could ask -- ratatui does, with a cursor position report -- but the answer is stale the moment anything scrolls. So the anchor here is never a number: it is a saved cursor, re-saved whenever the strip moves, and the encoder gained a relative addressing mode that walks from it rather than jumping to absolute rows. Two things that cost real debugging: DECRC is a pop, not a peek. Restore twice against one save and some terminals send the cursor home instead -- the top of the user's screen, where the strip then redraws itself over their shell. A frame restores many times, once per row it cannot track the cursor through. Every restore now re-arms its save, so the sequence means the same thing on the stack terminals and the single-slot ones. There is a test asserting exactly that, because a single frame looks perfectly fine either way and only the fourth one is wrong. DECRC restores the pen along with the position, so the encoder's model of the current colour was quietly wrong after every jump. Anchors are saved with the pen reset, and the encoder admits it knows nothing about the pen after a restore. Verified in a real pty against a VT emulator, not just by reading the escapes back: on an eight-row terminal the finished lines land in scrollback in order, the strip stays put at the bottom, and the prompt returns below the last frame with the shell's earlier output untouched. TypeScript only, as the issue proposes -- each port has its own terminal and the semantics are worth settling in one place first. Closes #61 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy --- examples/inline.ts | 61 ++++++++ packages/hqtui/src/ansi.ts | 18 +++ packages/hqtui/src/app.ts | 94 ++++++++++-- packages/hqtui/src/diff.ts | 100 ++++++++++++- packages/hqtui/src/index.ts | 5 +- packages/hqtui/src/terminal.ts | 167 ++++++++++++++++++++- packages/hqtui/test/viewport.test.ts | 211 +++++++++++++++++++++++++++ 7 files changed, 633 insertions(+), 23 deletions(-) create mode 100644 examples/inline.ts create mode 100644 packages/hqtui/test/viewport.test.ts diff --git a/examples/inline.ts b/examples/inline.ts new file mode 100644 index 0000000..9d85f54 --- /dev/null +++ b/examples/inline.ts @@ -0,0 +1,61 @@ +/** + * An inline viewport: `bun examples/inline.ts`. + * + * The shape `npm`, `cargo`, `docker pull` and every installer use. A few live + * rows sit in the normal flow of the command line, finished work scrolls away + * above them into the terminal's scrollback, and when the process exits the + * shell holds a readable transcript instead of a blanked alternate screen. + * + * Nothing here clears the screen, and your prompt comes back below the last + * frame rather than on top of it. Run it after something else and you will see + * that output still there. + */ +import { createApp } from "@profullstack/hqtui"; + +const steps = [ + "resolve dependencies", + "compile core", + "compile widgets", + "link", + "run tests", +]; + +const app = await createApp({ + // Three rows of live UI. Everything else belongs to the shell. + viewport: { mode: "inline", height: 3 }, + quitKeys: [], + alwaysRender: true, + fps: 20, +}); + +let done = 0; +let spinner = 0; +const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +app.render(({ ui, theme }) => { + ui.text(`building ${done}/${steps.length}`, { fg: theme.primary, bold: true }); + ui.meter({ label: "", value: done / steps.length }); + ui.text( + done < steps.length ? `${frames[spinner % frames.length]} ${steps[done]}` : "done", + { fg: theme.muted }, + ); +}); + +void app.start(); + +const timer = setInterval(() => { + spinner++; + if (spinner % 8 !== 0) return; + + // The finished line goes into the scrollback, permanently. The live rows + // below it keep redrawing where they are. + app.insertBefore(1, (ui) => { + ui.text(` ✓ ${steps[done]}`); + }); + done++; + + if (done >= steps.length) { + clearInterval(timer); + setTimeout(() => app.stop(), 300); + } +}, 60); diff --git a/packages/hqtui/src/ansi.ts b/packages/hqtui/src/ansi.ts index 78a8a91..7957047 100644 --- a/packages/hqtui/src/ansi.ts +++ b/packages/hqtui/src/ansi.ts @@ -50,6 +50,24 @@ export function moveToColumn(x: number): string { return `${CSI}${x + 1}G`; } +/** + * Cursor up and down, in rows. + * + * An inline viewport cannot use absolute addressing: it does not know which + * screen row it starts on, and a scroll moves it without telling anyone. It + * returns to its own top-left with a saved cursor and walks from there, which + * is what these are for. + */ +export function moveUp(n: number): string { + if (n <= 0) return ""; + return n === 1 ? `${CSI}A` : `${CSI}${n}A`; +} + +export function moveDown(n: number): string { + if (n <= 0) return ""; + return n === 1 ? `${CSI}B` : `${CSI}${n}B`; +} + export function setTitle(title: string): string { // The title is interpolated into an OSC sequence, so anything that could end // or restart it has to go. `stripUnsafe` is the same policy the grid uses: diff --git a/packages/hqtui/src/app.ts b/packages/hqtui/src/app.ts index 40f4bb5..d919536 100644 --- a/packages/hqtui/src/app.ts +++ b/packages/hqtui/src/app.ts @@ -1,5 +1,5 @@ import { FrameBuffer } from "./buffer.ts"; -import { Encoder } from "./diff.ts"; +import { Encoder, encodeRows } from "./diff.ts"; import { ansi } from "./ansi.ts"; import { Terminal, type TerminalOptions, emergencyRestore } from "./terminal.ts"; import type { Capabilities } from "./capabilities.ts"; @@ -111,12 +111,14 @@ export class App { this.capabilities = this.terminal.capabilities; this.theme = resolveTheme(options.theme); - const { columns, rows } = this.terminal.size(); - this.current = new FrameBuffer(columns, rows); - this.previous = new FrameBuffer(columns, rows); + const rect = this.terminal.viewportRect(); + this.current = new FrameBuffer(rect.width, rect.height); + this.previous = new FrameBuffer(rect.width, rect.height); this.encoder = new Encoder({ colors: this.capabilities.colors, monochrome: options.monochrome ?? this.capabilities.colors === "none", + origin: { x: rect.x, y: rect.y }, + relative: this.terminal.viewport.mode === "inline", }); } @@ -200,6 +202,67 @@ export class App { this.dirty = true; } + /** + * Write `height` rows into the terminal's scrollback, above the live view. + * + * This is what an inline app is for. The live rows stay where they are and + * keep redrawing; what you pass here scrolls away above them and is still + * there when the process exits, which is how `npm`, `cargo` and every + * installer behave and what the alternate screen can never do. + * + * app.insertBefore(1, ui => ui.text("compiled in 1.2s", { fg: theme.success })); + * + * It is a no-op for a fullscreen or fixed viewport, where there is no "above" + * to write into -- the app owns every row it can see. + */ + insertBefore(height: number, draw: (ui: Container) => void): void { + if (this.terminal.viewport.mode !== "inline" || height <= 0) return; + const width = this.current.width; + if (width <= 0) return; + + const buffer = new FrameBuffer(width, height); + // No background: these lines join the user's terminal, and a block of + // theme colour across their scrollback is not ours to paint. + buffer.clear(undefined, this.theme.foreground); + const surface = createSurface(buffer, this.theme); + const container = new Container(surface, this.scrollbackContext(), "column"); + draw(container); + container.flush(); + + this.terminal.insertBefore(encodeRows(buffer, { + colors: this.capabilities.colors, + monochrome: this.options.monochrome ?? this.capabilities.colors === "none", + })); + // Everything below the anchor is now whatever the terminal shifted there. + this.forceRepaint = true; + this.dirty = true; + this.frame(); + } + + /** + * A render context for lines that are printed once and never redrawn. + * + * Scrollback is not interactive: it cannot take focus, a click cannot reach + * it, and nothing about it can ask for another frame -- by the time anyone + * looks, it has scrolled away. + */ + private scrollbackContext(): RenderContext { + return { + theme: this.theme, + capabilities: this.capabilities, + width: this.current.width, + height: 0, + frame: this.frameCount, + elapsed: Date.now() - this.startedAt, + focusIndex: -1, + collapseBorders: this.options.collapseBorders ?? false, + registerFocus: () => ({ index: -1, focused: false }), + hit: () => {}, + overlay: () => {}, + invalidate: () => {}, + }; + } + /** Start the loop. Resolves when the app exits. */ async start(): Promise { if (this.running) return; @@ -214,12 +277,14 @@ export class App { // host, the render loop must not keep drawing into the restored shell. this.subscriptions.push(this.terminal.onTeardown(() => this.stop())); this.subscriptions.push(this.terminal.onInput((event) => this.handleInput(event))); - this.subscriptions.push(this.terminal.onResizeEvent(({ columns, rows }) => { - this.current.resize(columns, rows); - this.previous.resize(columns, rows); + this.subscriptions.push(this.terminal.onResizeEvent(() => { + const rect = this.terminal.viewportRect(); + this.current.resize(rect.width, rect.height); + this.previous.resize(rect.width, rect.height); + this.encoder.origin = { x: rect.x, y: rect.y }; this.forceRepaint = true; this.dirty = true; - this.emit("resize", { width: columns, height: rows }); + this.emit("resize", { width: rect.width, height: rect.height }); this.frame(); })); @@ -312,10 +377,11 @@ export class App { const started = performance.now(); this.dirty = false; - const size = this.terminal.size(); - if (size.columns !== this.current.width || size.rows !== this.current.height) { - this.current.resize(size.columns, size.rows); - this.previous.resize(size.columns, size.rows); + const rect = this.terminal.viewportRect(); + if (rect.width !== this.current.width || rect.height !== this.current.height) { + this.current.resize(rect.width, rect.height); + this.previous.resize(rect.width, rect.height); + this.encoder.origin = { x: rect.x, y: rect.y }; this.forceRepaint = true; } @@ -368,6 +434,10 @@ export class App { let output = result.output; if (output.length > 0) { + // An inline viewport measures everything from its own top-left, so the + // cursor has to be put there before the frame rather than assumed to be + // wherever the last one finished. + if (this.encoder.relative) output = ansi.cursorRestore + ansi.cursorSave + "\r" + output; if (this.capabilities.synchronizedOutput) output = ansi.beginSync + output + ansi.endSync; this.terminal.write(output); } diff --git a/packages/hqtui/src/diff.ts b/packages/hqtui/src/diff.ts index 24f6ca0..6ec26a4 100644 --- a/packages/hqtui/src/diff.ts +++ b/packages/hqtui/src/diff.ts @@ -1,9 +1,10 @@ -import type { FrameBuffer } from "./buffer.ts"; +import { FrameBuffer } from "./buffer.ts"; import { Attr } from "./buffer.ts"; import type { Capabilities } from "./capabilities.ts"; import { DEFAULT_COLOR, type Color, blue, green, grayscale, red, to16, to256 } from "./color.ts"; import { - CSI, bg16, bg256, bgDefault, bgTrue, fg16, fg256, fgDefault, fgTrue, moveTo, moveToColumn, moveRight, + CSI, ESC, bg16, bg256, bgDefault, bgTrue, fg16, fg256, fgDefault, fgTrue, moveDown, moveTo, + moveToColumn, moveRight, moveUp, } from "./ansi.ts"; import { CONTINUATION, cellText, cellWidth } from "./unicode.ts"; @@ -35,6 +36,23 @@ export interface EncoderOptions { colors?: Capabilities["colors"]; /** Drain all color, keeping attributes. */ monochrome?: boolean; + /** + * Where cell (0, 0) of the buffer sits on the screen. For a viewport that + * owns a fixed region of somebody else's terminal rather than the whole of + * one. + */ + origin?: { x: number; y: number }; + /** + * Address cells relative to wherever the cursor is when `encode` is called, + * rather than by absolute screen coordinates. + * + * An inline viewport has no absolute coordinates it can trust: it does not + * know which screen row it starts on, and the terminal can scroll it upwards + * at any moment without saying so. The caller parks the cursor at the + * viewport's top-left before each frame and everything is measured from + * there. + */ + relative?: boolean; } /** @@ -46,10 +64,15 @@ export class Encoder { private parts: string[] = []; colors: Capabilities["colors"]; monochrome: boolean; + /** Screen position of buffer cell (0, 0). Ignored when `relative`. */ + origin: { x: number; y: number }; + relative: boolean; constructor(options: EncoderOptions = {}) { this.colors = options.colors ?? "truecolor"; this.monochrome = options.monochrome ?? false; + this.origin = options.origin ?? { x: 0, y: 0 }; + this.relative = options.relative ?? false; } /** Forget what we believe about the terminal; the next write re-states everything. */ @@ -115,18 +138,43 @@ export class Encoder { private moveCursor(x: number, y: number): void { const s = this.state; + // A carriage return goes to column 0 of the screen, not of the viewport, + // so it is only the same thing when the viewport starts there. + const home = this.relative ? 0 : this.origin.x; if (s.known && s.y === y) { if (s.x === x) return; if (x > s.x && x - s.x <= 3) { // Short hop: cheaper than a full CUP, and never repaints cells. this.parts.push(moveRight(x - s.x)); - } else if (x === 0) { + } else if (x === 0 && home === 0) { this.parts.push("\r"); + } else if (this.relative) { + this.parts.push("\r" + moveRight(x)); + } else { + this.parts.push(moveToColumn(x + this.origin.x)); + } + } else if (this.relative) { + // Relative mode has no absolute coordinate to jump to, so it walks: down + // or up to the row, then back to the left edge and across. Where the + // cursor is no longer trusted -- writing a row's last column leaves it in + // the terminal's pending-wrap limbo -- it goes back to the saved anchor + // and walks from there, which is exact whatever the terminal did. + if (s.known) { + this.parts.push(y > s.y ? moveDown(y - s.y) : moveUp(s.y - y)); } else { - this.parts.push(moveToColumn(x)); + // Restore, and re-save: in some terminals DECRC pops the saved + // position rather than peeking at it, and a second restore against one + // save sends the cursor home instead. Restoring also brings back the + // saved pen, which is the default one, so the style model has to admit + // it no longer knows what is in force. + this.parts.push(`${ESC}8${ESC}7`, moveDown(y)); + s.fg = DEFAULT_COLOR; + s.bg = DEFAULT_COLOR; + s.attrs = 0; } + this.parts.push(x === 0 ? "\r" : "\r" + moveRight(x)); } else { - this.parts.push(moveTo(x, y)); + this.parts.push(moveTo(x + this.origin.x, y + this.origin.y)); } s.x = x; s.y = y; @@ -147,6 +195,17 @@ export class Encoder { const sameSize = prev.width === w && prev.height === h; const repaint = full || !sameSize; if (repaint) this.invalidateState(); + // The caller parks the cursor at the viewport's top-left before every frame in + // relative mode, so that is where the walk starts from. + if (this.relative) { + this.state.x = 0; + this.state.y = 0; + this.state.known = true; + // Getting there meant a DECRC, which brought the saved pen back with it. + this.state.fg = DEFAULT_COLOR; + this.state.bg = DEFAULT_COLOR; + this.state.attrs = 0; + } const nc = next.chars; const nf = next.fg; @@ -215,6 +274,37 @@ export class Encoder { } } +/** + * One string per row, styled but never positioned. + * + * For output that is printed rather than painted: the lines an inline viewport + * pushes up into the user's scrollback, which the terminal lays out itself and + * which must therefore carry no cursor movement at all. Trailing blanks are + * dropped so a finished line does not paint a bar of background across the + * width of the terminal, and each row ends by putting the pen back. + */ +export function encodeRows(buffer: FrameBuffer, options: EncoderOptions = {}): string[] { + const encoder = new Encoder({ ...options, origin: { x: 0, y: 0 }, relative: false }); + const width = buffer.width; + const row = new FrameBuffer(width, 1); + const blank = new FrameBuffer(-1, -1); + const rows: string[] = []; + for (let y = 0; y < buffer.height; y++) { + const from = y * width; + row.chars.set(buffer.chars.subarray(from, from + width)); + row.fg.set(buffer.fg.subarray(from, from + width)); + row.bg.set(buffer.bg.subarray(from, from + width)); + row.attrs.set(buffer.attrs.subarray(from, from + width)); + encoder.invalidateState(); + // The row is encoded as row 0 of a one-row buffer, so the only positioning + // in it is the jump home. Dropping that leaves the styling, which is all a + // printed line may carry. + const line = encoder.encode(blank, row, true).output.replace(`${CSI}1;1H`, ""); + rows.push(line.replace(/[ ]+$/, "") + `${CSI}0m`); + } + return rows; +} + /** One-shot encode of a whole buffer, e.g. for `renderToAnsi` in tests. */ export function encodeFull(buffer: FrameBuffer, options: EncoderOptions = {}): string { const encoder = new Encoder(options); diff --git a/packages/hqtui/src/index.ts b/packages/hqtui/src/index.ts index e822de9..774c0e8 100644 --- a/packages/hqtui/src/index.ts +++ b/packages/hqtui/src/index.ts @@ -20,7 +20,10 @@ export type { } from "./ui.ts"; // Terminal + capabilities -export { Terminal, createTerminal, emergencyRestore, type TerminalOptions, type TerminalSize } from "./terminal.ts"; +export { + Terminal, createTerminal, emergencyRestore, + type TerminalOptions, type TerminalSize, type Viewport, type Rect as ViewportRect, +} from "./terminal.ts"; export { detectCapabilities, type Capabilities, type ColorDepth, type CapabilityOverrides } from "./capabilities.ts"; // Rendering core diff --git a/packages/hqtui/src/terminal.ts b/packages/hqtui/src/terminal.ts index 8ef11ea..ebf0dac 100644 --- a/packages/hqtui/src/terminal.ts +++ b/packages/hqtui/src/terminal.ts @@ -1,7 +1,34 @@ -import { ansi, setTitle } from "./ansi.ts"; +import { ansi, moveTo, moveUp, setTitle } from "./ansi.ts"; import { type Capabilities, type CapabilityOverrides, detectCapabilities } from "./capabilities.ts"; import { InputParser, type InputEvent } from "./input.ts"; +/** + * How much of the terminal the app owns. + * + * `fullscreen` is what hqtui has always done: the alternate screen, the whole + * grid, and the user's shell handed back untouched at the end. + * + * `inline` draws a bounded strip in the normal flow of the command line, the + * shape every installer and build tool uses -- a few live rows pinned below + * output that scrolls away above them, and a readable transcript left behind + * when the process exits. It has no absolute coordinates it can trust, because + * it does not know which screen row it started on and the terminal can scroll + * it up at any moment; it navigates from a saved cursor instead. + * + * `fixed` claims a rectangle of a terminal something else is driving. + */ +export type Viewport = + | { mode: "fullscreen" } + | { mode: "inline"; height: number } + | { mode: "fixed"; x: number; y: number; width: number; height: number }; + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + export interface TerminalOptions { input?: NodeJS.ReadStream; output?: NodeJS.WriteStream; @@ -17,6 +44,8 @@ export interface TerminalOptions { installExitHandlers?: boolean; /** How long to wait before a lone ESC counts as the Escape key. Default 30ms. */ escapeTimeout?: number; + /** How much of the terminal to draw into. Default the whole of it. */ + viewport?: Viewport; } export interface TerminalSize { @@ -34,7 +63,7 @@ export class Terminal { readonly input: NodeJS.ReadStream; readonly output: NodeJS.WriteStream; readonly capabilities: Capabilities; - private options: Required> & { title?: string }; + private options: Required> & { title?: string }; private parser = new InputParser(); private entered = false; /** Milliseconds to wait before deciding a lone ESC was the Escape key. */ @@ -45,6 +74,13 @@ export class Terminal { private cleanupHandlers: (() => void)[] = []; private teardownListeners = new Set<() => void>(); private escapeTimer: NodeJS.Timeout | null = null; + private viewportMode: Viewport = { mode: "fullscreen" }; + /** + * Rows an inline viewport has reserved below the anchor. Kept because a + * resize can shrink the terminal under it, and because `restore` has to know + * how far down to move before handing the shell back. + */ + private reserved = 0; private onData = (chunk: Buffer | string): void => { const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); this.dispatch(this.parser.parse(text)); @@ -75,8 +111,12 @@ export class Terminal { this.output = options.output ?? process.stdout; this.capabilities = detectCapabilities(options.capabilities ?? {}, process.env, this.output); this.escapeTimeout = options.escapeTimeout ?? 30; + this.viewportMode = options.viewport ?? { mode: "fullscreen" }; + // Only a fullscreen app may take the alternate screen. The whole point of + // the other two is to leave what is already on the terminal alone. + const fullscreen = this.viewportMode.mode === "fullscreen"; this.options = { - alternateScreen: options.alternateScreen ?? true, + alternateScreen: fullscreen ? options.alternateScreen ?? true : false, mouse: options.mouse ?? this.capabilities.mouse, hideCursor: options.hideCursor ?? true, bracketedPaste: options.bracketedPaste ?? this.capabilities.bracketedPaste, @@ -100,6 +140,40 @@ export class Terminal { }; } + /** How much of the terminal the app owns. */ + get viewport(): Viewport { + return this.viewportMode; + } + + /** + * The region to draw into, in screen cells. + * + * For an inline viewport the `y` is a fiction -- it is always 0, because the + * strip is addressed from its own saved cursor rather than from the top of + * the screen -- but the width and height are real, and they are what the + * framebuffer is sized from. + */ + viewportRect(): Rect { + const { columns, rows } = this.size(); + const v = this.viewportMode; + if (v.mode === "inline") { + // A viewport taller than the terminal would scroll itself off the top + // every frame, so it gives up the rows it cannot have. + return { x: 0, y: 0, width: columns, height: Math.max(1, Math.min(v.height, rows)) }; + } + if (v.mode === "fixed") { + const x = Math.max(0, Math.min(v.x, Math.max(0, columns - 1))); + const y = Math.max(0, Math.min(v.y, Math.max(0, rows - 1))); + return { + x, + y, + width: Math.max(0, Math.min(v.width, columns - x)), + height: Math.max(0, Math.min(v.height, rows - y)), + }; + } + return { x: 0, y: 0, width: columns, height: rows }; + } + write(data: string): void { if (data.length === 0) return; this.output.write(data); @@ -117,8 +191,12 @@ export class Terminal { if (this.options.bracketedPaste) setup += ansi.bracketedPasteOn; if (this.options.focusEvents) setup += ansi.focusOn; if (this.options.title) setup += setTitle(this.options.title); - setup += ansi.clearScreen + ansi.cursorHome; + // Only a fullscreen app owns the grid, so only a fullscreen app may wipe + // it. An inline strip or a fixed region is a guest on somebody else's + // screen and has no business clearing it. + if (this.viewportMode.mode === "fullscreen") setup += ansi.clearScreen + ansi.cursorHome; this.write(setup); + if (this.viewportMode.mode === "inline") this.reserveInline(); if (this.input.isTTY && typeof this.input.setRawMode === "function") { this.input.setRawMode(true); @@ -148,17 +226,72 @@ export class Terminal { this.input.pause?.(); let teardown = ansi.reset; + // An inline app leaves its last frame behind as part of the transcript, so + // the cursor has to come out below it rather than on top of it. + if (this.viewportMode.mode === "inline" && this.reserved > 0) { + teardown = toAnchor() + "\r" + "\n".repeat(this.reserved) + teardown; + this.reserved = 0; + } if (this.options.focusEvents) teardown += ansi.focusOff; if (this.options.bracketedPaste) teardown += ansi.bracketedPasteOff; if (this.options.mouse) teardown += ansi.mouseOff; if (this.options.hideCursor) teardown += ansi.cursorShow; - teardown += this.options.alternateScreen ? ansi.alternateScreenOff : `\n`; + teardown += this.options.alternateScreen + ? ansi.alternateScreenOff + : this.viewportMode.mode === "inline" ? "" : `\n`; this.write(teardown); for (const off of this.cleanupHandlers) off(); this.cleanupHandlers = []; } + /** + * Make room for an inline viewport and remember where it starts. + * + * There is no way to ask where the cursor is without a round trip the caller + * would have to await, and no way to trust the answer afterwards -- any + * output scrolls the screen and moves the strip without a word. So the + * anchor is never a number: it is a saved cursor position, re-saved whenever + * the strip moves. + * + * Printing the newlines first is what reserves the space. If the cursor was + * near the bottom the terminal scrolls, which is exactly what should happen; + * walking back up then lands on the strip's first row wherever it ended up. + */ + private reserveInline(): void { + const height = this.viewportRect().height; + this.reserved = height; + this.write("\r" + "\n".repeat(Math.max(0, height - 1)) + moveUp(height - 1) + "\r"); + this.write(anchor()); + } + + /** + * Write lines above an inline viewport, permanently. + * + * This is the half of inline mode that makes it worth having: finished work + * scrolls away into the user's scrollback while the live rows stay put. The + * lines are printed where the strip currently begins and the strip is + * re-anchored below them, so if that runs off the bottom the terminal scrolls + * and the oldest lines leave through the top -- into scrollback, which is + * where they were always going. + * + * The caller repaints the viewport afterwards: everything below the anchor is + * now whatever the terminal happened to shift there. + */ + insertBefore(lines: string[]): void { + if (this.viewportMode.mode !== "inline" || lines.length === 0) return; + const height = this.reserved; + let out = toAnchor() + "\r"; + for (const line of lines) out += ansi.clearLine + line + ansi.reset + "\r\n"; + // Re-reserve from the new anchor, then walk back to it. Writing the rows + // is what forces the terminal to scroll if the strip no longer fits, and + // walking back afterwards finds it wherever the scroll left it. + out += ansi.clearLine; + for (let i = 1; i < height; i++) out += "\r\n" + ansi.clearLine; + out += moveUp(height - 1) + "\r" + anchor(); + this.write(out); + } + onInput(listener: Listener): () => void { this.inputListeners.add(listener); return () => this.inputListeners.delete(listener); @@ -230,6 +363,30 @@ export class Terminal { } } +/** + * Save the anchor an inline viewport measures from. + * + * The pen is reset first so that the saved graphic rendition is always the + * default one. DECRC restores attributes along with the position, so without + * that the colour in force at some arbitrary moment would come back with every + * jump and quietly desynchronise the renderer's model of the terminal. + */ +function anchor(): string { + return ansi.reset + ansi.cursorSave; +} + +/** + * Go back to the anchor, and immediately save it again. + * + * DECRC is a pop rather than a peek in some terminals: restore twice against + * one save and the second sends the cursor home, which is the top of the + * user's screen and not remotely where the viewport is. Re-arming after every + * restore makes the sequence mean the same thing on both kinds. + */ +function toAnchor(): string { + return ansi.cursorRestore + anchor(); +} + export function createTerminal(options: TerminalOptions = {}): Terminal { return new Terminal(options); } diff --git a/packages/hqtui/test/viewport.test.ts b/packages/hqtui/test/viewport.test.ts new file mode 100644 index 0000000..b5063b7 --- /dev/null +++ b/packages/hqtui/test/viewport.test.ts @@ -0,0 +1,211 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import { App } from "../src/app.ts"; +import { Terminal } from "../src/terminal.ts"; + +/** A terminal whose output we can read back, since the escapes are the contract. */ +function fakeTty(columns = 20, rows = 10) { + const input = new PassThrough() as unknown as NodeJS.ReadStream; + const output = new PassThrough() as unknown as NodeJS.WriteStream; + let written = ""; + (output as unknown as PassThrough).on("data", (chunk) => { written += String(chunk); }); + Object.assign(output, { columns, rows }); + return { input, output, read: () => written, clear: () => { written = ""; } }; +} + +const options = { + installExitHandlers: false, + quitKeys: [] as string[], + capabilities: { mouse: false, synchronizedOutput: false }, + bracketedPaste: false, + focusEvents: false, +} as const; + +test("viewport: fullscreen is still the default, alternate screen and all", () => { + const tty = fakeTty(); + const terminal = new Terminal({ ...tty, installExitHandlers: false }); + assert.deepEqual(terminal.viewport, { mode: "fullscreen" }); + assert.deepEqual(terminal.viewportRect(), { x: 0, y: 0, width: 20, height: 10 }); + terminal.enter(); + assert.ok(tty.read().includes("\x1b[?1049h"), "took the alternate screen"); + terminal.restore(); +}); + +test("viewport: inline neither takes the alternate screen nor clears what is there", () => { + const tty = fakeTty(); + const terminal = new Terminal({ + ...tty, + installExitHandlers: false, + viewport: { mode: "inline", height: 3 }, + }); + terminal.enter(); + const out = tty.read(); + // The whole point is that the user's terminal survives. + assert.ok(!out.includes("\x1b[?1049h"), "must not take the alternate screen"); + assert.ok(!out.includes("\x1b[2J"), "must not clear the screen"); + // Three rows reserved: two newlines to make the room, then back to the top. + assert.ok(out.includes("\n\n"), "reserved its rows"); + assert.ok(out.includes("\x1b[2A"), "walked back to the first of them"); + assert.ok(out.endsWith("\x1b[0m\x1b7"), "reset the pen, then saved the anchor"); + terminal.restore(); +}); + +test("viewport: inline asks for no more rows than the terminal has", () => { + const tty = fakeTty(20, 4); + const terminal = new Terminal({ + ...tty, + installExitHandlers: false, + viewport: { mode: "inline", height: 40 }, + }); + // A strip taller than the screen would scroll itself away every frame. + assert.equal(terminal.viewportRect().height, 4); +}); + +test("viewport: leaving an inline app puts the cursor below its last frame", () => { + const tty = fakeTty(); + const terminal = new Terminal({ + ...tty, + installExitHandlers: false, + viewport: { mode: "inline", height: 3 }, + }); + terminal.enter(); + tty.clear(); + terminal.restore(); + const out = tty.read(); + assert.ok(out.startsWith("\x1b8"), "went back to the anchor first"); + assert.equal((out.match(/\n/g) ?? []).length, 3, "then down past all three rows"); +}); + +test("viewport: a fixed region is clamped to the terminal it is placed in", () => { + const tty = fakeTty(20, 10); + const terminal = new Terminal({ + ...tty, + installExitHandlers: false, + viewport: { mode: "fixed", x: 15, y: 8, width: 30, height: 30 }, + }); + assert.deepEqual(terminal.viewportRect(), { x: 15, y: 8, width: 5, height: 2 }); +}); + +test("viewport: an app draws into its viewport, not the whole screen", async () => { + const tty = fakeTty(20, 10); + const app = new App({ ...tty, ...options, viewport: { mode: "inline", height: 3 } }); + app.render(({ ui }) => ui.text("hello")); + assert.equal(app.height, 3, "the buffer is the strip, not the screen"); + assert.equal(app.width, 20); + void app.start(); + await new Promise((r) => setImmediate(r)); + app.stop(); +}); + +test("viewport: an inline frame is addressed from its saved anchor", async () => { + const tty = fakeTty(20, 10); + const app = new App({ ...tty, ...options, viewport: { mode: "inline", height: 2 } }); + let label = "hello"; + app.render(({ ui }) => ui.text(label)); + void app.start(); + await new Promise((r) => setImmediate(r)); + + tty.clear(); + label = "goodbye"; + app.frame(); + const frame = tty.read(); + app.stop(); + + // Absolute addressing is exactly what an inline strip cannot use: it does not + // know its screen row, and a scroll moves it without saying so. Every jump in + // a frame is measured from the saved anchor instead. + assert.ok(frame.startsWith("\x1b8\x1b7\r"), `frame did not start at the anchor: ${JSON.stringify(frame)}`); + assert.ok(!/\x1b\[\d+;\d+H/.test(frame), `frame used absolute addressing: ${JSON.stringify(frame)}`); + assert.ok(frame.includes("goodbye")); +}); + +test("viewport: a fixed region offsets its addressing instead", async () => { + const tty = fakeTty(40, 10); + const app = new App({ + ...tty, + ...options, + viewport: { mode: "fixed", x: 4, y: 6, width: 10, height: 2 }, + }); + app.render(({ ui }) => ui.text("hi")); + void app.start(); + await new Promise((r) => setImmediate(r)); + const out = tty.read(); + app.stop(); + // Row 7, column 5 in one-based terms: the region's own (0, 0). + assert.ok(out.includes("\x1b[7;5H"), `expected the region's origin: ${JSON.stringify(out)}`); +}); + +test("viewport: insertBefore writes above the strip and leaves it anchored", async () => { + const tty = fakeTty(20, 10); + const app = new App({ ...tty, ...options, viewport: { mode: "inline", height: 2 } }); + app.render(({ ui }) => ui.text("live")); + void app.start(); + await new Promise((r) => setImmediate(r)); + tty.clear(); + + app.insertBefore(1, (ui) => ui.text("done")); + const out = tty.read(); + app.stop(); + + assert.ok(out.includes("done"), "the line was written"); + assert.ok(out.includes("live"), "and the strip was repainted after it"); + assert.ok(out.indexOf("done") < out.indexOf("live"), "the finished line goes above"); + // Re-anchored: the strip has to be findable again after the terminal may + // have scrolled it. + assert.ok(out.includes("\x1b7"), "saved the new anchor"); +}); + +test("viewport: insertBefore is a no-op where there is no above", async () => { + const tty = fakeTty(20, 10); + const app = new App({ ...tty, ...options }); + app.render(({ ui }) => ui.text("live")); + void app.start(); + await new Promise((r) => setImmediate(r)); + tty.clear(); + + // A fullscreen app owns every row it can see; there is nothing to insert into. + app.insertBefore(1, (ui) => ui.text("done")); + assert.ok(!tty.read().includes("done")); + app.stop(); +}); + +test("viewport: every restore re-arms the save it just spent", async () => { + const tty = fakeTty(24, 10); + const app = new App({ ...tty, ...options, viewport: { mode: "inline", height: 3 } }); + let n = 0; + app.render(({ ui }) => { + ui.text(`step ${n}`); + ui.meter({ label: "x", value: n / 5 }); + ui.text("working"); + }); + void app.start(); + await new Promise((r) => setImmediate(r)); + + for (; n < 4; n++) { + app.insertBefore(1, (ui) => ui.text(`ok ${n}`)); + app.frame(); + } + const out = tty.read(); + app.stop(); + + // DECRC pops the saved position in some terminals rather than peeking at it. + // Spend the save without putting it back and the next restore sends the + // cursor to the top of the user's screen -- which is where the whole strip + // then redraws itself, over their shell. Every restore must re-arm. + const restores = [...out.matchAll(/\x1b8/g)].map((m) => m.index ?? 0); + assert.ok(restores.length > 4, `expected several restores, got ${restores.length}`); + for (const at of restores) { + // A pen reset may sit between the two; nothing that moves the cursor may. + const after = out.slice(at + 2, at + 12); + assert.ok( + after.startsWith("\x1b7") || after.startsWith("\x1b[0m\x1b7"), + `restore at ${at} left the save spent: ${JSON.stringify(out.slice(at, at + 14))}`, + ); + } + + // And the finished lines are all there, in order. + const order = ["ok 0", "ok 1", "ok 2", "ok 3"].map((line) => out.indexOf(line)); + assert.ok(order.every((at) => at >= 0), `a line went missing: ${order}`); + assert.deepEqual(order, [...order].sort((a, b) => a - b), "lines arrived out of order"); +}); From bf23187001e5dda114e8c6b32f7b6587458bcad4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 23:04:11 +0000 Subject: [PATCH 2/2] Charts of arbitrary (x, y) data, in every language `plot` takes an array of numbers and puts one sample per column, so the x axis is the array index. That is exactly right for a history buffer and wrong for everything else: two series of different lengths silently render at different horizontal scales, a gap in the data is indistinguishable from a shorter series, and there is no way at all to say where on the x axis a point belongs. ui.chart({ series: [{ points: [[0, 1], [2.5, 4], [9, 3]], type: "scatter" }], x: { min: 0, max: 10, ticks: 3, format: v => `${v}s` }, y: { min: 0, max: 5 }, }); A domain on each axis, points that carry their own x, and three marks: line, scatter and bar. `plot`, `graph`, `areaGraph` and `multiGraph` are untouched and mean exactly what they meant -- the 2699 lines added to widgets.json are all new cases, and not one existing fixture moved. Three things worth knowing. Points are drawn in the order given rather than sorted by x. Sorting would be the obvious tidy-up and it would quietly make a loop or a path that doubles back impossible to draw. A fill interpolates along the line rather than sampling the points that land in each column. Sampling is what the history-buffer fill does and it is fine there, because every column has a sample; with arbitrary x values most columns have none, and the area came out striped. There is a test for it. A flat series -- every point at the same height -- has a domain of zero width, which cannot be mapped: every point lands in the same place and the division blows up. It gets room around itself instead of being collapsed onto a line, and a fixture pins that. Six ports, and each matched the reference on all eight chart fixtures. The Ruby, PHP and Perl bridges gained a chart node and COBOL a CHART/CHARTPT pair, which both COBOL bridges render identically. The widget gallery now has 30, still one runnable example per language per widget. Verified: 298 TS tests under bun and node; 69 widget scenes matching the reference in Rust, Go, Python, Zig and C++; 11/11 ctest; the bindings ABI test and check.py; all eleven galleries; both COBOL bridges diff clean; the site builds. Closes #63 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy --- apps/web/content/widgets.json | 55 + apps/web/test/widgets.test.ts | 2 +- examples/widgets/build-catalog.ts | 2 + examples/widgets/gallery.js | 16 + examples/widgets/gallery.ts | 16 + packages/hqtui/src/graphics/chart.ts | 328 ++ packages/hqtui/src/graphics/index.ts | 1 + packages/hqtui/src/index.ts | 3 + packages/hqtui/src/ui.ts | 11 + packages/hqtui/src/widgets/chart.ts | 106 + packages/hqtui/src/widgets/index.ts | 1 + packages/hqtui/test/chart.test.ts | 159 + ports/bindings/src/bridge.cpp | 38 +- ports/cobol/adapter/render.ts | 27 + ports/cobol/examples/widgets.cbl | 41 + ports/conformance/fixtures/widgets.json | 2699 +++++++++++++++++ ports/conformance/generate.ts | Bin 43974 -> 45914 bytes ports/cpp/CMakeLists.txt | 2 +- ports/cpp/examples/widgets.cpp | 24 + ports/cpp/include/hqtui/widgets.hpp | 62 + ports/cpp/src/chart.cpp | 364 +++ ports/cpp/src/widgets.cpp | 2 +- ports/cpp/tests/conformance_widgets.cpp | 77 + ports/go/chart.go | 397 +++ ports/go/conformance_widgets_test.go | 63 + ports/go/examples/widgets/main.go | 22 +- ports/go/ui.go | 11 + ports/go/widgets_chart.go | 176 ++ ports/perl/examples/widgets.pl | 17 + ports/perl/lib/Hqtui.pm | 1 + ports/php/examples/widgets.php | 17 + ports/php/src/Hqtui.php | 1 + ports/python/examples/widgets.py | 21 +- ports/python/hqtui/graphics/__init__.py | 18 + ports/python/hqtui/graphics/chart.py | 341 +++ ports/python/hqtui/ui.py | 12 + ports/python/hqtui/widgets/__init__.py | 2 + ports/python/hqtui/widgets/chart.py | 123 + .../python/tests/test_conformance_widgets.py | 55 + ports/ruby/examples/widgets.rb | 16 + ports/ruby/lib/hqtui.rb | 1 + ports/rust/examples/cobol-bridge.rs | 46 + ports/rust/examples/widgets.rs | 23 + ports/rust/src/graphics/chart.rs | 441 +++ ports/rust/src/graphics/mod.rs | 4 + ports/rust/src/graphics/plot.rs | 2 +- ports/rust/src/ui.rs | 10 + ports/rust/src/widgets/chart.rs | 152 + ports/rust/src/widgets/mod.rs | 2 + ports/rust/tests/conformance_widgets.rs | 92 + ports/zig/examples/widgets.zig | 22 + ports/zig/src/conformance_widgets.zig | 60 + ports/zig/src/graphics.zig | 8 + ports/zig/src/graphics/chart.zig | 389 +++ ports/zig/src/ui.zig | 11 + ports/zig/src/widgets.zig | 3 + ports/zig/src/widgets/chart.zig | 141 + 57 files changed, 6729 insertions(+), 7 deletions(-) create mode 100644 packages/hqtui/src/graphics/chart.ts create mode 100644 packages/hqtui/src/widgets/chart.ts create mode 100644 packages/hqtui/test/chart.test.ts create mode 100644 ports/cpp/src/chart.cpp create mode 100644 ports/go/chart.go create mode 100644 ports/go/widgets_chart.go create mode 100644 ports/python/hqtui/graphics/chart.py create mode 100644 ports/python/hqtui/widgets/chart.py create mode 100644 ports/rust/src/graphics/chart.rs create mode 100644 ports/rust/src/widgets/chart.rs create mode 100644 ports/zig/src/graphics/chart.zig create mode 100644 ports/zig/src/widgets/chart.zig diff --git a/apps/web/content/widgets.json b/apps/web/content/widgets.json index af719a8..4f3d703 100644 --- a/apps/web/content/widgets.json +++ b/apps/web/content/widgets.json @@ -729,6 +729,61 @@ } } }, + { + "id": "chart", + "title": "Chart", + "category": "Meters", + "width": 48, + "height": 9, + "blurb": "Arbitrary (x, y) data with a domain on both axes. Lines, scatters and bars.", + "preview": "
 10                                            \n   ⠤⢄⣀⣀                            ⢀⡠⠊⠁⠑⢄⡀      \n       ⠉⠉⠒⠒⠢⠤⠤⣀⣀                 ⡠⠔⠁     ⠈⠢⡀    \n          ⢀⠔⠉⠒⠢⢄⠉⠉⠑⠒⠒⠤⠤⣀⣀⡀    ⣀⠔⠉          ⠈⠢⣀  \n        ⡠⠊⠁      ⠉⠑⠢⠤⣀⡀  ⠈⠉⠉⠒⠒⠤⠤⢄⣀⣀           ⠑⢄\n      ⡠⠊              ⠈⠑⠒⠤⠒⠁       ⠉⠉⠒⠒⠢⠤⠤⣀⣀    \n   ⢀⠔⠉                                      ⠉⠉⠑⠒\n  0load ■ limit                               \n   0s                    5s                  10s
", + "examples": { + "typescript": { + "code": "export function chart(ui: Container, theme: Theme): void {\n // Points carry their own x, so a sparse series and a dense one line up.\n ui.chart({\n series: [\n { points: [[0, 1], [2, 6], [5, 3], [8, 9], [10, 4]], label: \"load\" },\n { points: [[0, 8], [10, 2]], label: \"limit\", color: theme.muted },\n ],\n axis: true,\n legend: true,\n x: { min: 0, max: 10, ticks: 3, format: (v) => `${v}s` },\n y: { min: 0, max: 10 },\n });\n}", + "syntax": "ts" + }, + "javascript": { + "code": "export function chart(ui, theme) {\n // Points carry their own x, so a sparse series and a dense one line up.\n ui.chart({\n series: [\n { points: [[0, 1], [2, 6], [5, 3], [8, 9], [10, 4]], label: \"load\" },\n { points: [[0, 8], [10, 2]], label: \"limit\", color: theme.muted },\n ],\n axis: true,\n legend: true,\n x: { min: 0, max: 10, ticks: 3, format: (v) => `${v}s` },\n y: { min: 0, max: 10 },\n });\n}", + "syntax": "js" + }, + "rust": { + "code": "pub fn chart(ui: &mut Container) {\n // Points carry their own x, so a sparse series and a dense one line up.\n ui.chart(ChartOptions {\n series: vec![\n ChartSeries::new(vec![(0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0)])\n .label(\"load\"),\n ChartSeries::new(vec![(0.0, 8.0), (10.0, 2.0)]).label(\"limit\"),\n ],\n axis: true,\n legend: true,\n plot: ChartPlotOptions {\n x: Some(AxisOptions { min: Some(0.0), max: Some(10.0), ticks: Some(3), format: None }),\n y: Some(AxisOptions { min: Some(0.0), max: Some(10.0), ticks: None, format: None }),\n ..Default::default()\n },\n ..Default::default()\n });\n}", + "syntax": "rust" + }, + "go": { + "code": "func Chart(ui *hqtui.Container) {\n\t// Points carry their own x, so a sparse series and a dense one line up.\n\tzero, ten, seven := 0.0, 10.0, 3\n\tui.Chart(hqtui.ChartOptions{\n\t\tSeries: []hqtui.ChartSeries{\n\t\t\t{Points: []hqtui.Point{{X: 0, Y: 1}, {X: 2, Y: 6}, {X: 5, Y: 3}, {X: 8, Y: 9}, {X: 10, Y: 4}}, Label: \"load\"},\n\t\t\t{Points: []hqtui.Point{{X: 0, Y: 8}, {X: 10, Y: 2}}, Label: \"limit\"},\n\t\t},\n\t\tAxis: true,\n\t\tLegend: true,\n\t\tPlot: hqtui.ChartPlotOptions{\n\t\t\tX: &hqtui.AxisOptions{Min: &zero, Max: &ten, Ticks: seven},\n\t\t\tY: &hqtui.AxisOptions{Min: &zero, Max: &ten},\n\t\t},\n\t})\n}", + "syntax": "go" + }, + "python": { + "code": "def chart(ui: Container) -> None:\n # Points carry their own x, so a sparse series and a dense one line up.\n ui.chart(w.ChartOptions(\n series=[\n g.ChartSeries(points=[(0, 1), (2, 6), (5, 3), (8, 9), (10, 4)], label=\"load\"),\n g.ChartSeries(points=[(0, 8), (10, 2)], label=\"limit\"),\n ],\n axis=True,\n legend=True,\n plot=g.ChartPlotOptions(\n x=g.AxisOptions(min=0, max=10, ticks=3),\n y=g.AxisOptions(min=0, max=10),\n ),\n ))", + "syntax": "python" + }, + "zig": { + "code": "fn chart(ui: *Container) anyerror!void {\n // Points carry their own x, so a sparse series and a dense one line up.\n try ui.chart(.{\n .series = &.{\n .{ .points = &.{\n .{ .x = 0, .y = 1 }, .{ .x = 2, .y = 6 }, .{ .x = 5, .y = 3 },\n .{ .x = 8, .y = 9 }, .{ .x = 10, .y = 4 },\n }, .label = \"load\" },\n .{ .points = &.{ .{ .x = 0, .y = 8 }, .{ .x = 10, .y = 2 } }, .label = \"limit\" },\n },\n .axis = true,\n .legend = true,\n .plot = .{\n .x = .{ .min = 0, .max = 10, .ticks = 3 },\n .y = .{ .min = 0, .max = 10 },\n },\n });\n}", + "syntax": "zig" + }, + "cpp": { + "code": "void widget_chart(Surface s) {\n // Points carry their own x, so a sparse series and a dense one line up.\n Chart c;\n c.series = {\n {{{0, 1}, {2, 6}, {5, 3}, {8, 9}, {10, 4}}, 0, \"load\"},\n {{{0, 8}, {10, 2}}, 0, \"limit\"},\n };\n c.axis = true;\n c.legend = true;\n Axis x;\n x.min = 0;\n x.max = 10;\n x.ticks = 3;\n Axis y;\n y.min = 0;\n y.max = 10;\n c.plot.x = x;\n c.plot.y = y;\n draw_chart(s, c);\n}", + "syntax": "cpp" + }, + "ruby": { + "code": "def chart(ui)\n # Points carry their own x, so a sparse series and a dense one line up.\n ui.chart(\n [\n { points: [{ x: 0, y: 1 }, { x: 2, y: 6 }, { x: 5, y: 3 }, { x: 8, y: 9 }, { x: 10, y: 4 }],\n label: 'load' },\n { points: [{ x: 0, y: 8 }, { x: 10, y: 2 }], label: 'limit' }\n ],\n axis: true, legend: true,\n x: { min: 0, max: 10, ticks: 3 }, y: { min: 0, max: 10 }\n )\nend", + "syntax": "ruby" + }, + "php": { + "code": "function widget_chart(UI $ui): void\n{\n // Points carry their own x, so a sparse series and a dense one line up.\n $ui->chart(\n [\n ['points' => [['x' => 0, 'y' => 1], ['x' => 2, 'y' => 6], ['x' => 5, 'y' => 3],\n ['x' => 8, 'y' => 9], ['x' => 10, 'y' => 4]], 'label' => 'load'],\n ['points' => [['x' => 0, 'y' => 8], ['x' => 10, 'y' => 2]], 'label' => 'limit'],\n ],\n ['axis' => true, 'legend' => true,\n 'x' => ['min' => 0, 'max' => 10, 'ticks' => 3], 'y' => ['min' => 0, 'max' => 10]]\n );\n}", + "syntax": "php" + }, + "perl": { + "code": "sub widget_chart {\n my ($ui) = @_;\n # Points carry their own x, so a sparse series and a dense one line up.\n $ui->chart(\n [\n { points => [ { x => 0, y => 1 }, { x => 2, y => 6 }, { x => 5, y => 3 },\n { x => 8, y => 9 }, { x => 10, y => 4 } ], label => 'load' },\n { points => [ { x => 0, y => 8 }, { x => 10, y => 2 } ], label => 'limit' },\n ],\n axis => 1, legend => 1,\n x => { min => 0, max => 10, ticks => 3 }, y => { min => 0, max => 10 },\n );\n}", + "syntax": "perl" + }, + "cobol": { + "code": "CHART-WIDGET.\n MOVE \"chart\" TO SR-KEY\n PERFORM START-WIDGET\n\n *> CHARTPT carries one point; its key names the series it joins, so a\n *> flat record stream can describe several. The text is \"x|y\".\n MOVE \"CHARTPT\" TO SR-VERB\n MOVE \"load\" TO SR-KEY\n MOVE \"0|1\" TO SR-TEXT\n PERFORM EMIT-RECORD\n\n MOVE \"CHARTPT\" TO SR-VERB\n MOVE \"load\" TO SR-KEY\n MOVE \"5|3\" TO SR-TEXT\n PERFORM EMIT-RECORD\n\n MOVE \"CHARTPT\" TO SR-VERB\n MOVE \"load\" TO SR-KEY\n MOVE \"10|4\" TO SR-TEXT\n PERFORM EMIT-RECORD\n\n MOVE \"CHARTPT\" TO SR-VERB\n MOVE \"limit\" TO SR-KEY\n MOVE \"0|8\" TO SR-TEXT\n PERFORM EMIT-RECORD\n\n MOVE \"CHARTPT\" TO SR-VERB\n MOVE \"limit\" TO SR-KEY\n MOVE \"10|2\" TO SR-TEXT\n PERFORM EMIT-RECORD\n\n *> CHART draws what has accumulated. Its key is the mark, and the text\n *> carries both domains as \"xmin|xmax|ymin|ymax\".\n MOVE \"CHART\" TO SR-VERB\n MOVE \"LINE\" TO SR-KEY\n MOVE \"0|10|0|10\" TO SR-TEXT\n PERFORM EMIT-RECORD.", + "syntax": "cobol" + } + } + }, { "id": "meter", "title": "Meter", diff --git a/apps/web/test/widgets.test.ts b/apps/web/test/widgets.test.ts index c929c87..a087d30 100644 --- a/apps/web/test/widgets.test.ts +++ b/apps/web/test/widgets.test.ts @@ -26,7 +26,7 @@ test("every language has a runnable example for every widget", () => { }); test("the catalog covers the widgets and languages the site promises", () => { - assert.equal(catalog.widgets.length, 29); + assert.equal(catalog.widgets.length, 30); assert.equal(catalog.languages.length, 11); assert.ok(catalog.languages.some((language) => language.id === "cobol")); }); diff --git a/examples/widgets/build-catalog.ts b/examples/widgets/build-catalog.ts index bd674d4..c630717 100644 --- a/examples/widgets/build-catalog.ts +++ b/examples/widgets/build-catalog.ts @@ -97,6 +97,8 @@ export const WIDGETS: WidgetSpec[] = [ blurb: "Levelled log lines that tail by default and scroll back from the end." }, { id: "scrollbar", title: "Scrollbar", category: "Data", width: 46, height: 4, blurb: "A bar over state you own, on any of the four edges, for anything that scrolls." }, + { id: "chart", title: "Chart", category: "Meters", width: 48, height: 9, + blurb: "Arbitrary (x, y) data with a domain on both axes. Lines, scatters and bars." }, { id: "meter", title: "Meter", category: "Meters", width: 48, height: 3, blurb: "A labelled bar. Smooth or segmented, heat-colored by default." }, diff --git a/examples/widgets/gallery.js b/examples/widgets/gallery.js index 660f930..f0eed30 100644 --- a/examples/widgets/gallery.js +++ b/examples/widgets/gallery.js @@ -189,6 +189,22 @@ export function scrollbar(ui, theme) { } // @end +// @widget chart +export function chart(ui, theme) { + // Points carry their own x, so a sparse series and a dense one line up. + ui.chart({ + series: [ + { points: [[0, 1], [2, 6], [5, 3], [8, 9], [10, 4]], label: "load" }, + { points: [[0, 8], [10, 2]], label: "limit", color: theme.muted }, + ], + axis: true, + legend: true, + x: { min: 0, max: 10, ticks: 3, format: (v) => `${v}s` }, + y: { min: 0, max: 10 }, + }); +} +// @end + // ----------------------------------------------------------------- meters // @widget meter diff --git a/examples/widgets/gallery.ts b/examples/widgets/gallery.ts index 42fe5c9..8d3272e 100644 --- a/examples/widgets/gallery.ts +++ b/examples/widgets/gallery.ts @@ -191,6 +191,22 @@ export function scrollbar(ui: Container, theme: Theme): void { } // @end +// @widget chart +export function chart(ui: Container, theme: Theme): void { + // Points carry their own x, so a sparse series and a dense one line up. + ui.chart({ + series: [ + { points: [[0, 1], [2, 6], [5, 3], [8, 9], [10, 4]], label: "load" }, + { points: [[0, 8], [10, 2]], label: "limit", color: theme.muted }, + ], + axis: true, + legend: true, + x: { min: 0, max: 10, ticks: 3, format: (v) => `${v}s` }, + y: { min: 0, max: 10 }, + }); +} +// @end + // ----------------------------------------------------------------- meters // @widget meter diff --git a/packages/hqtui/src/graphics/chart.ts b/packages/hqtui/src/graphics/chart.ts new file mode 100644 index 0000000..fe0a6c0 --- /dev/null +++ b/packages/hqtui/src/graphics/chart.ts @@ -0,0 +1,328 @@ +/** + * Charts of arbitrary (x, y) data. + * + * `plot` takes `number[]` and puts one sample per column: the x axis is the + * array index. That is the right model for a history buffer and the wrong one + * for everything else -- two series of different lengths silently render at + * different horizontal scales, a gap in the data is indistinguishable from a + * shorter series, and there is no way at all to say where on the x axis a + * point belongs. + * + * This takes points and a domain for each axis, so a series is placed rather + * than appended. `plot` is untouched and still means what it meant. + */ +import type { Surface } from "../surface.ts"; +import { type Color, mix } from "../color.ts"; +import { BrailleCanvas } from "./braille.ts"; +import { type FillMode, verticalGlyph } from "./blocks.ts"; +import { blit } from "./plot.ts"; +import { seriesColor } from "../theme.ts"; + +export type Point = [number, number]; + +/** How a series is marked: joined, dotted, or dropped to the baseline. */ +export type MarkType = "line" | "scatter" | "bar"; + +export interface ChartSeries { + points: Point[]; + color?: Color; + label?: string; + type?: MarkType; + /** Shade between the line and the baseline. Ignored for scatter. */ + fill?: boolean; +} + +/** One axis: what it spans and how its numbers read. */ +export interface AxisOptions { + min?: number; + max?: number; + format?: (value: number) => string; + /** How many labels to place. Default 2 -- the ends. */ + ticks?: number; +} + +export interface ChartPlotOptions { + /** braille is sharpest; block and ascii are the graceful degradations. */ + mode?: FillMode; + x?: AxisOptions; + y?: AxisOptions; + background?: Color; + grid?: boolean; + gridColor?: Color; + /** 0-1 opacity of the area fill against the background. */ + fillAlpha?: number; + /** Where a bar or an area is measured from. Defaults to the y minimum. */ + baseline?: number; +} + +export interface Domain { + min: number; + max: number; +} + +/** A finite number, or undefined: a caller's bound is data, and data can be NaN. */ +function bound(value: number | undefined): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** + * The span an axis covers, from the caller where they said and from the data + * where they did not. + * + * A domain of zero width cannot be mapped -- every point would land in the same + * place and a division would blow up -- so a flat series is given a unit of + * room around itself rather than being collapsed onto one line. + */ +export function domainOf( + series: ChartSeries[], + axis: AxisOptions | undefined, + which: 0 | 1, +): Domain { + let min = bound(axis?.min); + let max = bound(axis?.max); + if (min === undefined || max === undefined) { + let lo = Infinity; + let hi = -Infinity; + for (const s of series) { + for (const p of s.points) { + const v = p?.[which]; + if (typeof v !== "number" || !Number.isFinite(v)) continue; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + } + if (!Number.isFinite(lo)) { + lo = 0; + hi = 1; + } + min = min ?? lo; + max = max ?? hi; + } + if (!(max > min)) { + // A flat series still has to be drawn somewhere sensible. + const pad = Math.abs(min) > 0 ? Math.abs(min) * 0.5 : 0.5; + return { min: min - pad, max: min + pad }; + } + return { min, max }; +} + +/** Where a value sits in its domain, 0 at the minimum and 1 at the maximum. */ +function ratio(value: number, domain: Domain): number { + return (value - domain.min) / (domain.max - domain.min); +} + +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 }); + } +} + +/** + * Draw point series across the whole surface. + * + * Points are drawn in the order they are given: a line joins them as they come, + * which is what lets a chart draw a loop or a path that doubles back. Sorting + * them would quietly make that impossible. + */ +export function plotPoints( + surface: Surface, + series: ChartSeries[], + options: ChartPlotOptions = {}, +): void { + if (surface.empty || series.length === 0) return; + const theme = surface.theme; + const mode = options.mode ?? "braille"; + const bg = options.background; + const w = surface.width; + const h = surface.height; + + const xd = domainOf(series, options.x, 0); + const yd = domainOf(series, options.y, 1); + const baseline = bound(options.baseline) ?? yd.min; + + if (options.grid) { + drawGrid(surface, options.gridColor ?? mix(theme.border, theme.background, 0.4), bg); + } + + if (mode === "block" || mode === "ascii" || mode === "half") { + plotCells(surface, series, { mode, xd, yd, baseline, bg, theme }); + return; + } + + const canvas = new BrailleCanvas(w, h); + const px = canvas.width; + const py = canvas.height; + const at = (p: Point): Point => [ + Math.round(Math.max(0, Math.min(1, ratio(p[0], xd))) * (px - 1)), + Math.round((1 - Math.max(0, Math.min(1, ratio(p[1], yd)))) * (py - 1)), + ]; + + series.forEach((s, si) => { + canvas.clear(); + const color = s.color ?? seriesColor(theme, si); + const type = s.type ?? "line"; + const finite = s.points.filter( + (p) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]), + ); + if (finite.length === 0) return; + const pixels = finite.map(at); + + if (type === "scatter") { + for (const [x, y] of pixels) canvas.pixel(x, y); + } else if (type === "bar") { + const floor = Math.round((1 - Math.max(0, Math.min(1, ratio(baseline, yd)))) * (py - 1)); + for (const [x, y] of pixels) canvas.vline(x, Math.min(y, floor), Math.max(y, floor)); + } else if (pixels.length === 1) { + canvas.pixel(pixels[0][0], pixels[0][1]); + } else { + canvas.polyline(pixels); + } + + if (s.fill && type !== "scatter") { + fillUnder(surface, finite, { xd, yd, baseline, color, bg, alpha: options.fillAlpha ?? 0.5 }); + } + blit(surface, canvas, () => color, bg); + }); +} + +/** + * The area between a series and its baseline, in block elements. + * + * Braille would give eight scattered dots per cell, which reads as noise where + * an area should read as an area. The line itself stays Braille, so it keeps + * the sub-cell resolution. + * + * The height of each column is interpolated along the line rather than sampled + * from the points that happen to land in it. Sampling leaves a gap wherever a + * column has no point of its own, which with arbitrary x values is most of + * them -- the area comes out striped instead of solid. + */ +function fillUnder( + surface: Surface, + points: Point[], + o: { xd: Domain; yd: Domain; baseline: number; color: Color; bg?: Color; alpha: number }, +): void { + const w = surface.width; + const h = surface.height; + if (w <= 0 || h <= 0 || points.length === 0) return; + const base = o.bg ?? surface.theme.background; + const floor = Math.max(0, Math.min(1, ratio(o.baseline, o.yd))); + + // Column index of a domain x, as a fraction, so a segment can be walked + // across the columns it actually spans. + const column = (x: number): number => ratio(x, o.xd) * (w - 1); + + const tops = new Array(w).fill(Number.NaN); + const record = (col: number, value: number): void => { + if (col < 0 || col >= w) return; + // A path that doubles back covers a column twice; the outer edge is the + // one that bounds the area. + const previous = tops[col]; + const away = Math.abs(value - floor); + if (Number.isNaN(previous) || away > Math.abs(previous - floor)) tops[col] = value; + }; + + if (points.length === 1) { + record(Math.round(column(points[0][0])), Math.max(0, Math.min(1, ratio(points[0][1], o.yd)))); + } + for (let i = 0; i + 1 < points.length; i++) { + const [x0, y0] = points[i]; + const [x1, y1] = points[i + 1]; + const c0 = column(x0); + const c1 = column(x1); + const from = Math.max(0, Math.floor(Math.min(c0, c1))); + const to = Math.min(w - 1, Math.ceil(Math.max(c0, c1))); + for (let col = from; col <= to; col++) { + const t = c1 === c0 ? 0 : (col - c0) / (c1 - c0); + if (t < -0.5 || t > 1.5) continue; + const y = y0 + (y1 - y0) * Math.max(0, Math.min(1, t)); + record(col, Math.max(0, Math.min(1, ratio(y, o.yd)))); + } + } + + for (let x = 0; x < w; x++) { + const top = tops[x]; + if (Number.isNaN(top)) continue; + const from01 = Math.min(floor, top); + const filled = (Math.max(floor, top) - from01) * h; + const bottom = Math.floor(from01 * h); + const full = Math.floor(filled); + for (let k = 0; k < full && k < h; k++) { + const row = h - 1 - bottom - k; + if (row < 0 || row >= h) continue; + const depth = h <= 1 ? 0 : row / (h - 1); + surface.char(x, row, "█", { fg: mix(base, o.color, o.alpha * (1 - depth * 0.3)), bg: o.bg }); + } + if (full < h) { + const glyph = verticalGlyph(filled - full, "block"); + const row = h - 1 - bottom - full; + if (glyph !== " " && row >= 0 && row < h) { + const depth = h <= 1 ? 0 : row / (h - 1); + surface.char(x, row, glyph, { + fg: mix(base, o.color, o.alpha * (1 - depth * 0.3) + 0.12), + bg: o.bg, + }); + } + } + } +} + +/** + * The block and ascii degradations: one column per cell, tallest point wins. + * + * A scatter keeps its dots rather than growing columns, because a scatter that + * fills to the baseline is a bar chart wearing the wrong name. + */ +function plotCells( + surface: Surface, + series: ChartSeries[], + o: { + mode: FillMode; + xd: Domain; + yd: Domain; + baseline: number; + bg?: Color; + theme: Surface["theme"]; + }, +): void { + const w = surface.width; + const h = surface.height; + const floorRatio = Math.max(0, Math.min(1, ratio(o.baseline, o.yd))); + + series.forEach((s, si) => { + const color = s.color ?? seriesColor(o.theme, si); + const type = s.type ?? "line"; + // Highest value per column, so a column shows the peak that fell in it + // rather than whichever point happened to be last. + const tops = new Array(w).fill(Number.NaN); + for (const p of s.points) { + if (!Array.isArray(p) || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) continue; + const col = Math.min(w - 1, Math.max(0, Math.round(ratio(p[0], o.xd) * (w - 1)))); + const value = Math.max(0, Math.min(1, ratio(p[1], o.yd))); + if (Number.isNaN(tops[col]) || value > tops[col]) tops[col] = value; + } + + for (let x = 0; x < w; x++) { + const top = tops[x]; + if (Number.isNaN(top)) continue; + if (type === "scatter") { + const row = h - 1 - Math.min(h - 1, Math.floor(top * h)); + surface.char(x, row, o.mode === "ascii" ? "*" : "•", { fg: color, bg: o.bg }); + continue; + } + const from = Math.min(floorRatio, top) * h; + const filled = (Math.max(floorRatio, top) - Math.min(floorRatio, top)) * h; + const full = Math.floor(filled); + for (let k = 0; k < full; k++) { + const row = h - 1 - Math.floor(from) - k; + if (row >= 0 && row < h) surface.char(x, row, "█", { fg: color, bg: o.bg }); + } + const glyph = verticalGlyph(filled - full, o.mode); + const row = h - 1 - Math.floor(from) - full; + if (glyph !== " " && row >= 0 && row < h) { + surface.char(x, row, glyph, { fg: color, bg: o.bg }); + } + } + }); +} diff --git a/packages/hqtui/src/graphics/index.ts b/packages/hqtui/src/graphics/index.ts index ceb0189..cc14656 100644 --- a/packages/hqtui/src/graphics/index.ts +++ b/packages/hqtui/src/graphics/index.ts @@ -1,3 +1,4 @@ export { BrailleCanvas } from "./braille.ts"; export * from "./blocks.ts"; export * from "./plot.ts"; +export * from "./chart.ts"; diff --git a/packages/hqtui/src/index.ts b/packages/hqtui/src/index.ts index 774c0e8..37e8187 100644 --- a/packages/hqtui/src/index.ts +++ b/packages/hqtui/src/index.ts @@ -70,7 +70,10 @@ export { BrailleCanvas } from "./graphics/braille.ts"; export { plot, blit, sparkline, bar, gauge, donut, histogram, verticalGlyph, horizontalGlyph, shadeGlyph, bestMode, + plotPoints, domainOf, type Series, type PlotOptions, type FillMode, + type Point, type MarkType, type ChartSeries, type AxisOptions, type ChartPlotOptions, + type Domain, } from "./graphics/index.ts"; // Widgets (for drawing straight onto a Surface) diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts index 102940e..1d22818 100644 --- a/packages/hqtui/src/ui.ts +++ b/packages/hqtui/src/ui.ts @@ -445,6 +445,17 @@ export class Container { return this.add((s) => W.drawGraph(s, options), this.sizeOfData(options, "fill", "min-max")); } + /** + * A chart of arbitrary (x, y) data, with a domain on both axes. + * + * `graph` plots a history buffer, one sample per column. Use this when the + * data has its own x values: two series of different lengths then line up, + * and a point lands where its x says it does. + */ + chart(options: W.ChartOptions & ContainerOptions): this { + return this.add((s) => W.drawChart(s, options), this.sizeOfData(options, "fill", "min-max")); + } + /** A filled area graph — `graph` with `fill` on. */ areaGraph(options: W.GraphOptions & ContainerOptions): this { return this.graph({ fill: true, ...options }); diff --git a/packages/hqtui/src/widgets/chart.ts b/packages/hqtui/src/widgets/chart.ts new file mode 100644 index 0000000..047c2b0 --- /dev/null +++ b/packages/hqtui/src/widgets/chart.ts @@ -0,0 +1,106 @@ +/** + * A chart with two real axes. + * + * `graph` plots a history buffer: one sample per column, x meaning "position + * in the array". This plots data that has its own x values, with a labelled + * domain on both axes, so two series of different lengths line up and a point + * lands where its x says it does. + */ +import type { Align, Surface } from "../surface.ts"; +import type { Color } from "../color.ts"; +import { fit, stringWidth } from "../unicode.ts"; +import { seriesColor } from "../theme.ts"; +import { + type AxisOptions, type ChartPlotOptions, type ChartSeries, domainOf, plotPoints, +} from "../graphics/chart.ts"; + +export interface ChartOptions extends ChartPlotOptions { + series: ChartSeries[]; + /** Numbers down the left edge. */ + axis?: boolean; + axisColor?: Color; + legend?: boolean; + legendAlign?: Align; +} + +/** Readable at a glance: 1.2k rather than 1200, 3 rather than 3.0. */ +export function niceLabel(value: number): string { + if (!Number.isFinite(value)) return ""; + if (Math.abs(value) >= 1000) return `${Math.round(value / 100) / 10}k`; + if (Number.isInteger(value)) return String(value); + return value.toFixed(1); +} + +/** + * Evenly spaced values across a domain, ends included. + * + * Two ticks means the ends and nothing else, which is what an axis wants when + * there is no room to say more. + */ +function ticksFor(min: number, max: number, count: number): number[] { + const n = Math.max(2, Math.floor(count)); + const out: number[] = []; + for (let i = 0; i < n; i++) out.push(min + ((max - min) * i) / (n - 1)); + return out; +} + +export function drawChart(surface: Surface, options: ChartOptions): void { + if (surface.empty) return; + const theme = surface.theme; + const series = options.series ?? []; + const axisColor = options.axisColor ?? theme.muted; + + const xd = domainOf(series, options.x, 0); + const yd = domainOf(series, options.y, 1); + const xFormat = options.x?.format ?? niceLabel; + const yFormat = options.y?.format ?? niceLabel; + + // The x labels take a row, and they can only take one when there is a row to + // spare -- a two-row chart is all plot. + const xTicks = options.x?.ticks ?? (options.axis ? 2 : 0); + const wantXAxis = Boolean(options.axis) && xTicks >= 2 && surface.height > 2; + + let plotSurface = surface; + if (options.axis) { + const width = Math.max(stringWidth(yFormat(yd.max)), stringWidth(yFormat(yd.min))) + 1; + surface.text(0, 0, fit(yFormat(yd.max), width, "right"), { fg: axisColor }); + if (surface.height > 1) { + // The minimum marks the bottom of the plot, which is a row higher when + // the x labels have taken the last one. + const bottom = wantXAxis ? surface.height - 2 : surface.height - 1; + surface.text(0, bottom, fit(yFormat(yd.min), width, "right"), { fg: axisColor }); + } + plotSurface = surface.sub(width, 0, surface.width - width, surface.height); + } + + let area = plotSurface; + if (wantXAxis && plotSurface.height > 1 && plotSurface.width > 0) { + area = plotSurface.sub(0, 0, plotSurface.width, plotSurface.height - 1); + const row = plotSurface.height - 1; + const labels = ticksFor(xd.min, xd.max, xTicks).map(xFormat); + const step = labels.length > 1 ? (plotSurface.width - 1) / (labels.length - 1) : 0; + labels.forEach((label, i) => { + // The last label is right-aligned to the edge, so it cannot run off it. + const x = Math.min(plotSurface.width - stringWidth(label), Math.round(i * step)); + plotSurface.text(Math.max(0, x), row, label, { fg: axisColor }); + }); + } + + plotPoints(area, series, { ...options, x: { ...options.x, ...xd }, y: { ...options.y, ...yd } }); + + if (options.legend) { + const parts = series + .map((s, i) => ({ label: s.label, color: s.color ?? seriesColor(theme, i) })) + .filter((p) => p.label); + let x = options.legendAlign === "right" + ? Math.max(0, area.width - parts.reduce((a, p) => a + stringWidth(p.label!) + 3, 0)) + : 0; + const y = area.height > 3 ? area.height - 1 : 0; + for (const part of parts) { + x += area.text(x, y, "■ ", { fg: part.color }); + x += area.text(x, y, `${part.label} `, { fg: theme.muted }); + } + } +} + +export type { AxisOptions, ChartSeries }; diff --git a/packages/hqtui/src/widgets/index.ts b/packages/hqtui/src/widgets/index.ts index ca92b8c..697df72 100644 --- a/packages/hqtui/src/widgets/index.ts +++ b/packages/hqtui/src/widgets/index.ts @@ -1,4 +1,5 @@ export * from "./text.ts"; +export * from "./chart.ts"; export * from "./scrollbar.ts"; export * from "./table.ts"; export * from "./meters.ts"; diff --git a/packages/hqtui/test/chart.test.ts b/packages/hqtui/test/chart.test.ts new file mode 100644 index 0000000..d687023 --- /dev/null +++ b/packages/hqtui/test/chart.test.ts @@ -0,0 +1,159 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderToScreen } from "../src/index.ts"; +import { domainOf } from "../src/graphics/chart.ts"; +import type { ChartSeries, Point } from "../src/graphics/chart.ts"; + +import type { ChartOptions } from "../src/widgets/chart.ts"; + +const draw = (options: ChartOptions, width = 40, height = 8): string[] => + renderToScreen(({ ui }) => ui.chart(options), { width, height }).text().split("\n"); + +/** Which columns of a rendered chart have any ink in them. */ +const inked = (lines: string[]): number[] => { + const columns = new Set(); + 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); +}; + +const series = (points: Point[], extra: Partial = {}): ChartSeries => ({ + points, + ...extra, +}); + +test("chart: a domain comes from the data when nobody says otherwise", () => { + const s = [series([[0, 5], [10, 15]])]; + assert.deepEqual(domainOf(s, undefined, 0), { min: 0, max: 10 }); + assert.deepEqual(domainOf(s, undefined, 1), { min: 5, max: 15 }); +}); + +test("chart: a stated bound wins, and a broken one does not", () => { + const s = [series([[0, 5], [10, 15]])]; + assert.deepEqual(domainOf(s, { min: -5, max: 20 }, 0), { min: -5, max: 20 }); + // A caller's bound is data too, and data can be NaN. Falling back to the + // extent keeps every plotted coordinate finite. + assert.deepEqual(domainOf(s, { min: Number.NaN, max: 20 }, 0), { min: 0, max: 20 }); +}); + +test("chart: a flat series is given room rather than collapsed onto a line", () => { + // Every point at the same y would otherwise divide by a zero-width domain. + const flat = domainOf([series([[0, 7], [1, 7]])], undefined, 1); + assert.ok(flat.max > flat.min, `${JSON.stringify(flat)}`); + assert.ok(flat.min < 7 && flat.max > 7); + // And at zero, where a proportional pad would itself be zero. + const zero = domainOf([series([[0, 0], [1, 0]])], undefined, 1); + assert.ok(zero.max > zero.min, `${JSON.stringify(zero)}`); +}); + +test("chart: no points at all is a unit domain, not an infinity", () => { + assert.deepEqual(domainOf([series([])], undefined, 0), { min: 0, max: 1 }); + assert.deepEqual(domainOf([], undefined, 1), { min: 0, max: 1 }); +}); + +test("chart: a point lands where its x says, not where its index does", () => { + // Three points bunched at the left of a domain that runs to 100. Indexed + // plotting would spread them across the whole width; this must not. + const lines = draw({ + series: [series([[0, 1], [1, 1], [2, 1]], { type: "scatter" })], + x: { min: 0, max: 100 }, + y: { min: 0, max: 2 }, + }); + const columns = inked(lines); + assert.ok(columns.length > 0, "something was drawn"); + assert.ok(columns[columns.length - 1] < 5, `ink reached column ${columns[columns.length - 1]}`); +}); + +test("chart: two series of different lengths share one horizontal scale", () => { + // The bug this widget exists to fix: with index-based plotting these two + // would end at different places despite covering the same x range. + const dense: Point[] = []; + for (let i = 0; i <= 20; i++) dense.push([i / 2, 5]); + const sparse: Point[] = [[0, 5], [10, 5]]; + + const a = inked(draw({ series: [series(dense)], x: { min: 0, max: 10 }, y: { min: 0, max: 10 } })); + const b = inked(draw({ series: [series(sparse)], x: { min: 0, max: 10 }, y: { min: 0, max: 10 } })); + assert.deepEqual([a[0], a[a.length - 1]], [b[0], b[b.length - 1]]); +}); + +test("chart: the three marks are actually different", () => { + const points: Point[] = [[0, 1], [5, 8], [10, 3]]; + const common = { x: { min: 0, max: 10 }, y: { min: 0, max: 10 } }; + const line = draw({ series: [series(points, { type: "line" })], ...common }).join("\n"); + const scatter = draw({ series: [series(points, { type: "scatter" })], ...common }).join("\n"); + const bar = draw({ series: [series(points, { type: "bar" })], ...common }).join("\n"); + + const ink = (s: string) => s.replace(/[\s\n]/g, "").length; + // A scatter is three marks; a line joins them; bars drop to the baseline. + assert.ok(ink(scatter) < ink(line), `scatter ${ink(scatter)} vs line ${ink(line)}`); + assert.ok(ink(bar) > ink(scatter), `bar ${ink(bar)} vs scatter ${ink(scatter)}`); + assert.notEqual(line, bar); +}); + +test("chart: bars stand on the baseline, wherever it is put", () => { + const points: Point[] = [[5, 8]]; + const common = { series: [series(points, { type: "bar" })], x: { min: 0, max: 10 }, y: { min: 0, max: 10 } }; + const fromZero = draw({ ...common }).filter((l) => l.trim()).length; + // A baseline at the top means the bar hangs down from it instead. + const fromTop = draw({ ...common, baseline: 10 }).filter((l) => l.trim()).length; + assert.ok(fromZero > 1 && fromTop > 1); + assert.notEqual( + draw({ ...common }).join("\n"), + draw({ ...common, baseline: 10 }).join("\n"), + ); +}); + +test("chart: a filled area has no gaps between the points", () => { + // Sampling the points per column leaves a stripe wherever a column has no + // point of its own, which with arbitrary x values is most of them. + const points: Point[] = [[0, 2], [5, 8], [10, 2]]; + const lines = draw({ + series: [series(points, { fill: true })], + x: { min: 0, max: 10 }, + y: { min: 0, max: 10 }, + }, 40, 8); + const bottom = lines[lines.length - 1]; + const filled = [...bottom].map((c) => c !== " "); + const first = filled.indexOf(true); + const last = filled.lastIndexOf(true); + assert.ok(first >= 0 && last > first, "the area reached the bottom row"); + for (let x = first; x <= last; x++) { + assert.ok(filled[x], `column ${x} of the area is a gap: ${JSON.stringify(bottom)}`); + } +}); + +test("chart: both axes get labels, and the x row is not stolen from the plot", () => { + const lines = draw({ + series: [series([[0, 0], [10, 100]])], + axis: true, + x: { min: 0, max: 10, ticks: 3, format: (v) => `${v}s` }, + y: { min: 0, max: 100 }, + }, 40, 8); + assert.ok(lines[0].trimStart().startsWith("100"), `y max: ${JSON.stringify(lines[0])}`); + const last = lines[lines.length - 1]; + assert.ok(last.includes("0s") && last.includes("10s"), `x labels: ${JSON.stringify(last)}`); + // The y minimum belongs to the bottom of the plot, which is a row above the + // x labels rather than on them. + assert.ok(lines[lines.length - 2].trimStart().startsWith("0"), JSON.stringify(lines[lines.length - 2])); +}); + +test("chart: a chart with no room for an x axis still draws", () => { + const lines = draw({ series: [series([[0, 1], [1, 2]])], axis: true }, 20, 2); + assert.equal(lines.length, 2); + assert.ok(lines.join("").trim().length > 0); +}); + +test("chart: points that are not numbers are skipped, not drawn at zero", () => { + const good = draw({ + series: [series([[0, 5], [10, 5]])], + x: { min: 0, max: 10 }, + y: { min: 0, max: 10 }, + }); + const withJunk = draw({ + series: [series([[0, 5], [Number.NaN, 1], [10, 5]] as Point[])], + x: { min: 0, max: 10 }, + y: { min: 0, max: 10 }, + }); + assert.deepEqual(withJunk, good); +}); diff --git a/ports/bindings/src/bridge.cpp b/ports/bindings/src/bridge.cpp index 798ddb7..dd605b8 100644 --- a/ports/bindings/src/bridge.cpp +++ b/ports/bindings/src/bridge.cpp @@ -79,7 +79,7 @@ void validate(const Json &n, int depth, int &count) { "badge", "progress", "sparkline", "heatbar", "columns", "donut", "list", "tree", "button", "checkbox", "select", "input", "tabs", "statusbar", "label", "heading", "meters", "modal", - "commandpalette", "tooltip", "scrollbar"}; + "commandpalette", "tooltip", "scrollbar", "chart"}; if (std::find(types.begin(), types.end(), type) == types.end()) throw std::runtime_error("unknown widget: " + type); if (!n["children"].null() && @@ -271,6 +271,42 @@ void node(UI &ui, const Json &n, std::vector &overlays) { if (d.segments.size() > 64) throw std::runtime_error("too many donut segments"); ui.donut(d, size(n)); + } else if (type == "chart") { + Chart chart; + for (auto &sj : n["series"].array()) { + ChartSeries cs; + for (auto &pj : sj["points"].array()) + cs.points.push_back({pj["x"].n(), pj["y"].n()}); + if (cs.points.size() > 10000) + throw std::runtime_error("chart series too large"); + cs.label = sj["label"].s(""); + auto mark = sj["mark"].s("line"); + cs.mark = mark == "scatter" ? HQ_MARK_SCATTER + : mark == "bar" ? HQ_MARK_BAR + : HQ_MARK_LINE; + cs.fill = sj["fill"].b(false); + chart.series.push_back(std::move(cs)); + } + if (chart.series.size() > 64) + throw std::runtime_error("too many chart series"); + chart.axis = n["axis"].b(false); + chart.legend = n["legend"].b(false); + if (!n["mode"].null()) + chart.plot.mode = n["mode"].s("braille"); + auto read_axis = [](const Json &j) -> std::optional { + if (j.null()) + return std::nullopt; + Axis a; + if (!j["min"].null()) + a.min = j["min"].n(); + if (!j["max"].null()) + a.max = j["max"].n(); + a.ticks = integer(j["ticks"], 0, 0, 64); + return a; + }; + chart.plot.x = read_axis(n["x"]); + chart.plot.y = read_axis(n["y"]); + ui.chart(chart, size(n)); } else if (type == "scrollbar") { Scrollbar bar; bar.total = integer(n["total"], 0, 0, 1000000); diff --git a/ports/cobol/adapter/render.ts b/ports/cobol/adapter/render.ts index 3442c90..5e6bed6 100644 --- a/ports/cobol/adapter/render.ts +++ b/ports/cobol/adapter/render.ts @@ -70,6 +70,9 @@ const ALIGN: Record = { LEFT: "left", CENTER: "center", RIGHT: "r type Edge = "right" | "left" | "bottom" | "top"; const EDGE: Record = { RIGHT: "right", LEFT: "left", BOTTOM: "bottom", TOP: "top" }; +type Mark = "line" | "scatter" | "bar"; +const MARK: Record = { LINE: "line", SCATTER: "scatter", BAR: "bar" }; + type ButtonVariant = "primary" | "success" | "warning" | "danger" | "ghost"; const VARIANT: Record = { PRIMARY: "primary", @@ -106,6 +109,7 @@ export function draw(scene: Scene, ui: Container, theme: Theme): void { let modal: { title: string; width: number } | undefined; let palette: { query: string; selected: number } | undefined; let tooltip: { text: string; x: number; y: number } | undefined; + const chartSeries = new Map(); let selected = 0; let activeTab = 0; @@ -139,6 +143,29 @@ export function draw(scene: Scene, ui: Container, theme: Theme): void { case "SELECT": selected = Number(record.num) || 0; break; + case "CHARTPT": { + // One point per record, like GRAPHPT. key names the series it joins, so + // a flat record stream can describe several of them. + const points = chartSeries.get(record.key) ?? []; + const [px = "", py = ""] = record.text.split("|"); + points.push([Number(px) || 0, Number(py) || 0]); + chartSeries.set(record.key, points); + break; + } + case "CHART": { + // key is the mark every series takes; text is "xmin|xmax|ymin|ymax". + const [xmin = "", xmax = "", ymin = "", ymax = ""] = record.text.split("|"); + const mark = MARK[record.key] ?? "line"; + ui.chart({ + series: [...chartSeries].map(([label, points]) => ({ label, points, type: mark })), + axis: true, + legend: chartSeries.size > 1, + x: { min: Number(xmin) || 0, max: Number(xmax) || 0 }, + y: { min: Number(ymin) || 0, max: Number(ymax) || 0 }, + }); + chartSeries.clear(); + break; + } case "SCROLLBAR": { // key is the edge, num the offset, text "total|viewport". const [total = "", viewport = ""] = record.text.split("|"); diff --git a/ports/cobol/examples/widgets.cbl b/ports/cobol/examples/widgets.cbl index cd484ea..603730f 100644 --- a/ports/cobol/examples/widgets.cbl +++ b/ports/cobol/examples/widgets.cbl @@ -69,6 +69,7 @@ MAIN-PARAGRAPH. PERFORM DONUT-WIDGET PERFORM LIST-WIDGET PERFORM SCROLLBAR-WIDGET + PERFORM CHART-WIDGET PERFORM TREE-WIDGET PERFORM BUTTON-WIDGET PERFORM CHECKBOX-WIDGET @@ -403,6 +404,46 @@ SCROLLBAR-WIDGET. PERFORM EMIT-RECORD. *> @end +*> @widget chart +CHART-WIDGET. + MOVE "chart" TO SR-KEY + PERFORM START-WIDGET + + *> CHARTPT carries one point; its key names the series it joins, so a + *> flat record stream can describe several. The text is "x|y". + MOVE "CHARTPT" TO SR-VERB + MOVE "load" TO SR-KEY + MOVE "0|1" TO SR-TEXT + PERFORM EMIT-RECORD + + MOVE "CHARTPT" TO SR-VERB + MOVE "load" TO SR-KEY + MOVE "5|3" TO SR-TEXT + PERFORM EMIT-RECORD + + MOVE "CHARTPT" TO SR-VERB + MOVE "load" TO SR-KEY + MOVE "10|4" TO SR-TEXT + PERFORM EMIT-RECORD + + MOVE "CHARTPT" TO SR-VERB + MOVE "limit" TO SR-KEY + MOVE "0|8" TO SR-TEXT + PERFORM EMIT-RECORD + + MOVE "CHARTPT" TO SR-VERB + MOVE "limit" TO SR-KEY + MOVE "10|2" TO SR-TEXT + PERFORM EMIT-RECORD + + *> CHART draws what has accumulated. Its key is the mark, and the text + *> carries both domains as "xmin|xmax|ymin|ymax". + MOVE "CHART" TO SR-VERB + MOVE "LINE" TO SR-KEY + MOVE "0|10|0|10" TO SR-TEXT + PERFORM EMIT-RECORD. +*> @end + *> @widget tree TREE-WIDGET. MOVE "tree" TO SR-KEY diff --git a/ports/conformance/fixtures/widgets.json b/ports/conformance/fixtures/widgets.json index 3376bad..832063f 100644 --- a/ports/conformance/fixtures/widgets.json +++ b/ports/conformance/fixtures/widgets.json @@ -7883,6 +7883,2705 @@ ] } }, + { + "name": "chart-line", + "width": 40, + "height": 8, + "result": { + "width": 40, + "height": 8, + "chars": [ + [ + 31, + 32 + ], + [ + 1, + 10368 + ], + [ + 37, + 32 + ], + [ + 1, + 10336 + ], + [ + 1, + 10258 + ], + [ + 1, + 10241 + ], + [ + 1, + 10257 + ], + [ + 1, + 10372 + ], + [ + 32, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10276 + ], + [ + 1, + 10250 + ], + [ + 5, + 32 + ], + [ + 1, + 10257 + ], + [ + 1, + 10372 + ], + [ + 10, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10260 + ], + [ + 1, + 10249 + ], + [ + 1, + 10258 + ], + [ + 1, + 10276 + ], + [ + 1, + 10432 + ], + [ + 1, + 10304 + ], + [ + 11, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10260 + ], + [ + 1, + 10241 + ], + [ + 9, + 32 + ], + [ + 1, + 10257 + ], + [ + 1, + 10372 + ], + [ + 6, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10260 + ], + [ + 1, + 10241 + ], + [ + 5, + 32 + ], + [ + 1, + 10248 + ], + [ + 1, + 10257 + ], + [ + 1, + 10258 + ], + [ + 1, + 10276 + ], + [ + 1, + 10432 + ], + [ + 5, + 32 + ], + [ + 1, + 10336 + ], + [ + 1, + 10250 + ], + [ + 14, + 32 + ], + [ + 1, + 10257 + ], + [ + 1, + 10372 + ], + [ + 2, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10260 + ], + [ + 1, + 10241 + ], + [ + 12, + 32 + ], + [ + 1, + 10249 + ], + [ + 1, + 10257 + ], + [ + 1, + 10274 + ], + [ + 1, + 10260 + ], + [ + 1, + 10249 + ], + [ + 18, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10260 + ], + [ + 1, + 10241 + ], + [ + 37, + 32 + ], + [ + 1, + 10241 + ], + [ + 39, + 32 + ] + ], + "fg": [ + [ + 31, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 37, + 29806811 + ], + [ + 5, + 22587135 + ], + [ + 32, + 29806811 + ], + [ + 3, + 22587135 + ], + [ + 5, + 29806811 + ], + [ + 2, + 22587135 + ], + [ + 10, + 29806811 + ], + [ + 7, + 22587135 + ], + [ + 11, + 29806811 + ], + [ + 3, + 22587135 + ], + [ + 9, + 29806811 + ], + [ + 2, + 22587135 + ], + [ + 6, + 29806811 + ], + [ + 3, + 22587135 + ], + [ + 5, + 29806811 + ], + [ + 5, + 22587135 + ], + [ + 5, + 29806811 + ], + [ + 2, + 22587135 + ], + [ + 14, + 29806811 + ], + [ + 2, + 22587135 + ], + [ + 2, + 29806811 + ], + [ + 3, + 22587135 + ], + [ + 12, + 29806811 + ], + [ + 5, + 22587135 + ], + [ + 18, + 29806811 + ], + [ + 3, + 22587135 + ], + [ + 37, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 39, + 29806811 + ] + ], + "bg": [ + [ + 320, + 17106698 + ] + ], + "attrs": [ + [ + 320, + 0 + ] + ], + "clusters": [], + "text": [ + " ⢀ ", + " ⡠⠒⠁⠑⢄ ", + " ⢀⠤⠊ ⠑⢄ ", + " ⢀⠔⠉⠒⠤⣀⡀ ⣀⠔⠁ ⠑⢄ ", + " ⢀⠔⠁ ⠈⠑⠒⠤⣀ ⡠⠊ ⠑⢄", + " ⢀⠔⠁ ⠉⠑⠢⠔⠉ ", + "⢀⠔⠁ ", + "⠁ " + ] + } + }, + { + "name": "chart-scatter", + "width": 40, + "height": 8, + "result": { + "width": 40, + "height": 8, + "chars": [ + [ + 31, + 32 + ], + [ + 1, + 10368 + ], + [ + 96, + 32 + ], + [ + 1, + 10241 + ], + [ + 70, + 32 + ], + [ + 1, + 10368 + ], + [ + 20, + 32 + ], + [ + 1, + 10244 + ], + [ + 59, + 32 + ], + [ + 1, + 10241 + ], + [ + 39, + 32 + ] + ], + "fg": [ + [ + 31, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 96, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 70, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 20, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 59, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 39, + 29806811 + ] + ], + "bg": [ + [ + 320, + 17106698 + ] + ], + "attrs": [ + [ + 320, + 0 + ] + ], + "clusters": [], + "text": [ + " ⢀ ", + " ", + " ", + " ⠁ ", + " ⢀", + " ⠄ ", + " ", + "⠁ " + ] + } + }, + { + "name": "chart-bar", + "width": 40, + "height": 8, + "result": { + "width": 40, + "height": 8, + "chars": [ + [ + 31, + 32 + ], + [ + 1, + 10368 + ], + [ + 39, + 32 + ], + [ + 1, + 10424 + ], + [ + 39, + 32 + ], + [ + 1, + 10424 + ], + [ + 16, + 32 + ], + [ + 1, + 10311 + ], + [ + 22, + 32 + ], + [ + 1, + 10424 + ], + [ + 16, + 32 + ], + [ + 1, + 10311 + ], + [ + 22, + 32 + ], + [ + 1, + 10424 + ], + [ + 7, + 32 + ], + [ + 1, + 10368 + ], + [ + 8, + 32 + ], + [ + 1, + 10311 + ], + [ + 11, + 32 + ], + [ + 1, + 10308 + ], + [ + 10, + 32 + ], + [ + 1, + 10424 + ], + [ + 7, + 32 + ], + [ + 1, + 10424 + ], + [ + 8, + 32 + ], + [ + 1, + 10311 + ], + [ + 11, + 32 + ], + [ + 1, + 10311 + ], + [ + 10, + 32 + ], + [ + 1, + 10424 + ], + [ + 7, + 32 + ], + [ + 1, + 10424 + ], + [ + 1, + 10311 + ], + [ + 7, + 32 + ], + [ + 1, + 10311 + ], + [ + 11, + 32 + ], + [ + 1, + 10311 + ], + [ + 10, + 32 + ], + [ + 1, + 10424 + ], + [ + 7, + 32 + ], + [ + 1, + 10424 + ] + ], + "fg": [ + [ + 31, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 39, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 39, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 16, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 22, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 16, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 22, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 7, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 8, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 11, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 10, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 7, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 8, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 11, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 10, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 7, + 29806811 + ], + [ + 2, + 22587135 + ], + [ + 7, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 11, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 10, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 7, + 29806811 + ], + [ + 1, + 22587135 + ] + ], + "bg": [ + [ + 320, + 17106698 + ] + ], + "attrs": [ + [ + 320, + 0 + ] + ], + "clusters": [], + "text": [ + " ⢀ ", + " ⢸ ", + " ⢸ ", + " ⡇ ⢸ ", + " ⡇ ⢸ ⢀", + " ⡇ ⡄ ⢸ ⢸", + " ⡇ ⡇ ⢸ ⢸", + "⡇ ⡇ ⡇ ⢸ ⢸" + ] + } + }, + { + "name": "chart-fill", + "width": 40, + "height": 8, + "result": { + "width": 40, + "height": 8, + "chars": [ + [ + 58, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10336 + ], + [ + 1, + 10276 + ], + [ + 1, + 10432 + ], + [ + 32, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10336 + ], + [ + 1, + 10260 + ], + [ + 1, + 10250 + ], + [ + 1, + 10241 + ], + [ + 3, + 9608 + ], + [ + 1, + 10249 + ], + [ + 1, + 10258 + ], + [ + 1, + 10276 + ], + [ + 1, + 10432 + ], + [ + 24, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10336 + ], + [ + 1, + 10260 + ], + [ + 1, + 10250 + ], + [ + 1, + 10241 + ], + [ + 11, + 9608 + ], + [ + 1, + 10249 + ], + [ + 1, + 10258 + ], + [ + 1, + 10276 + ], + [ + 1, + 10432 + ], + [ + 16, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 20, + 9608 + ], + [ + 1, + 10249 + ], + [ + 1, + 10258 + ], + [ + 1, + 10276 + ], + [ + 1, + 10432 + ], + [ + 8, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 28, + 9608 + ], + [ + 1, + 10249 + ], + [ + 1, + 10258 + ], + [ + 1, + 10276 + ], + [ + 1, + 10432 + ], + [ + 2, + 32 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 36, + 9608 + ], + [ + 1, + 10249 + ], + [ + 1, + 10258 + ], + [ + 40, + 9608 + ] + ], + "fg": [ + [ + 58, + 29806811 + ], + [ + 4, + 22587135 + ], + [ + 32, + 29806811 + ], + [ + 5, + 22587135 + ], + [ + 3, + 19615866 + ], + [ + 4, + 22587135 + ], + [ + 24, + 29806811 + ], + [ + 5, + 22587135 + ], + [ + 11, + 19483765 + ], + [ + 4, + 22587135 + ], + [ + 16, + 29806811 + ], + [ + 4, + 22587135 + ], + [ + 20, + 19351920 + ], + [ + 4, + 22587135 + ], + [ + 8, + 29806811 + ], + [ + 4, + 22587135 + ], + [ + 28, + 19285354 + ], + [ + 4, + 22587135 + ], + [ + 2, + 29806811 + ], + [ + 2, + 22587135 + ], + [ + 36, + 19153509 + ], + [ + 2, + 22587135 + ], + [ + 40, + 19021664 + ] + ], + "bg": [ + [ + 320, + 17106698 + ] + ], + "attrs": [ + [ + 320, + 0 + ] + ], + "clusters": [], + "text": [ + " ", + " ⢀⡠⠤⣀ ", + " ⢀⡠⠔⠊⠁███⠉⠒⠤⣀ ", + " ⣀⡠⠔⠊⠁███████████⠉⠒⠤⣀ ", + " ⣀⠤⠒⠉████████████████████⠉⠒⠤⣀ ", + " ⣀⠤⠒⠉████████████████████████████⠉⠒⠤⣀ ", + "⠒⠉████████████████████████████████████⠉⠒", + "████████████████████████████████████████" + ] + } + }, + { + "name": "chart-axes", + "width": 44, + "height": 9, + "result": { + "width": 44, + "height": 9, + "chars": [ + [ + 1, + 32 + ], + [ + 1, + 49 + ], + [ + 2, + 48 + ], + [ + 35, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10336 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 34, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10250 + ], + [ + 1, + 10241 + ], + [ + 33, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10250 + ], + [ + 1, + 10241 + ], + [ + 34, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10260 + ], + [ + 1, + 10250 + ], + [ + 1, + 10241 + ], + [ + 33, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10250 + ], + [ + 1, + 10249 + ], + [ + 33, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10336 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 1, + 10241 + ], + [ + 33, + 32 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10260 + ], + [ + 1, + 10258 + ], + [ + 1, + 10249 + ], + [ + 33, + 32 + ], + [ + 1, + 48 + ], + [ + 1, + 10432 + ], + [ + 1, + 10276 + ], + [ + 1, + 10258 + ], + [ + 1, + 10250 + ], + [ + 1, + 10249 + ], + [ + 39, + 32 + ], + [ + 1, + 48 + ], + [ + 19, + 32 + ], + [ + 1, + 53 + ], + [ + 17, + 32 + ], + [ + 1, + 49 + ], + [ + 1, + 48 + ] + ], + "fg": [ + [ + 4, + 22702973 + ], + [ + 35, + 29806811 + ], + [ + 5, + 22587135 + ], + [ + 34, + 29806811 + ], + [ + 6, + 22587135 + ], + [ + 33, + 29806811 + ], + [ + 6, + 22587135 + ], + [ + 34, + 29806811 + ], + [ + 5, + 22587135 + ], + [ + 33, + 29806811 + ], + [ + 6, + 22587135 + ], + [ + 33, + 29806811 + ], + [ + 6, + 22587135 + ], + [ + 33, + 29806811 + ], + [ + 5, + 22587135 + ], + [ + 30, + 29806811 + ], + [ + 4, + 22702973 + ], + [ + 5, + 22587135 + ], + [ + 39, + 29806811 + ], + [ + 1, + 22702973 + ], + [ + 19, + 29806811 + ], + [ + 1, + 22702973 + ], + [ + 17, + 29806811 + ], + [ + 2, + 22702973 + ] + ], + "bg": [ + [ + 396, + 17106698 + ] + ], + "attrs": [ + [ + 396, + 0 + ] + ], + "clusters": [], + "text": [ + " 100 ⢀⡠⠤⠒⠉", + " ⢀⣀⠤⠒⠊⠁ ", + " ⢀⣀⠤⠒⠊⠁ ", + " ⣀⠤⠔⠊⠁ ", + " ⢀⣀⠤⠒⠊⠉ ", + " ⣀⡠⠤⠒⠉⠁ ", + " ⣀⠤⠔⠒⠉ ", + " 0⣀⠤⠒⠊⠉ ", + " 0 5 10" + ] + } + }, + { + "name": "chart-block", + "width": 40, + "height": 8, + "result": { + "width": 40, + "height": 8, + "chars": [ + [ + 31, + 32 + ], + [ + 1, + 9602 + ], + [ + 39, + 32 + ], + [ + 1, + 9608 + ], + [ + 39, + 32 + ], + [ + 1, + 9608 + ], + [ + 16, + 32 + ], + [ + 1, + 9606 + ], + [ + 22, + 32 + ], + [ + 1, + 9608 + ], + [ + 16, + 32 + ], + [ + 1, + 9608 + ], + [ + 22, + 32 + ], + [ + 1, + 9608 + ], + [ + 7, + 32 + ], + [ + 1, + 9602 + ], + [ + 8, + 32 + ], + [ + 1, + 9608 + ], + [ + 11, + 32 + ], + [ + 1, + 9603 + ], + [ + 10, + 32 + ], + [ + 1, + 9608 + ], + [ + 7, + 32 + ], + [ + 1, + 9608 + ], + [ + 8, + 32 + ], + [ + 1, + 9608 + ], + [ + 11, + 32 + ], + [ + 1, + 9608 + ], + [ + 10, + 32 + ], + [ + 1, + 9608 + ], + [ + 7, + 32 + ], + [ + 1, + 9608 + ], + [ + 1, + 9606 + ], + [ + 7, + 32 + ], + [ + 1, + 9608 + ], + [ + 11, + 32 + ], + [ + 1, + 9608 + ], + [ + 10, + 32 + ], + [ + 1, + 9608 + ], + [ + 7, + 32 + ], + [ + 1, + 9608 + ] + ], + "fg": [ + [ + 31, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 39, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 39, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 16, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 22, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 16, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 22, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 7, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 8, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 11, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 10, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 7, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 8, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 11, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 10, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 7, + 29806811 + ], + [ + 2, + 22587135 + ], + [ + 7, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 11, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 10, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 7, + 29806811 + ], + [ + 1, + 22587135 + ] + ], + "bg": [ + [ + 320, + 17106698 + ] + ], + "attrs": [ + [ + 320, + 0 + ] + ], + "clusters": [], + "text": [ + " ▂ ", + " █ ", + " █ ", + " ▆ █ ", + " █ █ ▂", + " █ ▃ █ █", + " █ █ █ █", + "▆ █ █ █ █" + ] + } + }, + { + "name": "chart-multi", + "width": 44, + "height": 9, + "result": { + "width": 44, + "height": 9, + "chars": [ + [ + 1, + 32 + ], + [ + 1, + 49 + ], + [ + 1, + 48 + ], + [ + 40, + 32 + ], + [ + 1, + 10368 + ], + [ + 3, + 32 + ], + [ + 1, + 10276 + ], + [ + 1, + 10372 + ], + [ + 1, + 10432 + ], + [ + 1, + 10304 + ], + [ + 33, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10336 + ], + [ + 1, + 10250 + ], + [ + 1, + 10241 + ], + [ + 6, + 32 + ], + [ + 1, + 10248 + ], + [ + 1, + 10249 + ], + [ + 1, + 10257 + ], + [ + 1, + 10258 + ], + [ + 1, + 10274 + ], + [ + 2, + 10276 + ], + [ + 2, + 10432 + ], + [ + 15, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10336 + ], + [ + 1, + 10258 + ], + [ + 2, + 10276 + ], + [ + 2, + 10432 + ], + [ + 1, + 32 + ], + [ + 1, + 10336 + ], + [ + 1, + 10260 + ], + [ + 1, + 10241 + ], + [ + 18, + 32 + ], + [ + 2, + 10249 + ], + [ + 2, + 10258 + ], + [ + 2, + 10276 + ], + [ + 1, + 10372 + ], + [ + 1, + 10432 + ], + [ + 1, + 10304 + ], + [ + 4, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10260 + ], + [ + 1, + 10241 + ], + [ + 6, + 32 + ], + [ + 1, + 10249 + ], + [ + 24, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10260 + ], + [ + 1, + 10250 + ], + [ + 1, + 10257 + ], + [ + 1, + 10258 + ], + [ + 1, + 10248 + ], + [ + 1, + 10249 + ], + [ + 1, + 10257 + ], + [ + 2, + 10258 + ], + [ + 2, + 10276 + ], + [ + 2, + 10432 + ], + [ + 19, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10336 + ], + [ + 1, + 10276 + ], + [ + 2, + 10432 + ], + [ + 3, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10336 + ], + [ + 1, + 10250 + ], + [ + 1, + 10241 + ], + [ + 13, + 32 + ], + [ + 2, + 10249 + ], + [ + 2, + 10258 + ], + [ + 1, + 10274 + ], + [ + 1, + 10276 + ], + [ + 1, + 10372 + ], + [ + 1, + 10432 + ], + [ + 1, + 10304 + ], + [ + 6, + 32 + ], + [ + 1, + 10368 + ], + [ + 1, + 10336 + ], + [ + 1, + 10260 + ], + [ + 1, + 10250 + ], + [ + 1, + 10241 + ], + [ + 4, + 32 + ], + [ + 2, + 10249 + ], + [ + 1, + 10258 + ], + [ + 1, + 10241 + ], + [ + 24, + 32 + ], + [ + 1, + 10248 + ], + [ + 1, + 10249 + ], + [ + 1, + 10257 + ], + [ + 1, + 10258 + ], + [ + 2, + 32 + ], + [ + 1, + 48 + ], + [ + 1, + 9632 + ], + [ + 1, + 32 + ], + [ + 1, + 102 + ], + [ + 1, + 105 + ], + [ + 1, + 110 + ], + [ + 1, + 101 + ], + [ + 1, + 32 + ], + [ + 1, + 9632 + ], + [ + 1, + 32 + ], + [ + 1, + 99 + ], + [ + 1, + 111 + ], + [ + 1, + 97 + ], + [ + 1, + 114 + ], + [ + 1, + 115 + ], + [ + 1, + 101 + ], + [ + 29, + 32 + ], + [ + 1, + 48 + ], + [ + 39, + 32 + ], + [ + 1, + 55 + ] + ], + "fg": [ + [ + 3, + 22702973 + ], + [ + 40, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 3, + 29806811 + ], + [ + 4, + 23068551 + ], + [ + 33, + 29806811 + ], + [ + 4, + 22587135 + ], + [ + 6, + 29806811 + ], + [ + 9, + 23068551 + ], + [ + 15, + 29806811 + ], + [ + 7, + 22587135 + ], + [ + 1, + 29806811 + ], + [ + 3, + 22587135 + ], + [ + 18, + 29806811 + ], + [ + 9, + 23068551 + ], + [ + 4, + 29806811 + ], + [ + 3, + 22587135 + ], + [ + 6, + 29806811 + ], + [ + 1, + 22587135 + ], + [ + 24, + 29806811 + ], + [ + 5, + 22587135 + ], + [ + 9, + 23068551 + ], + [ + 19, + 29806811 + ], + [ + 5, + 22587135 + ], + [ + 3, + 29806811 + ], + [ + 4, + 22587135 + ], + [ + 13, + 29806811 + ], + [ + 9, + 23068551 + ], + [ + 6, + 29806811 + ], + [ + 5, + 22587135 + ], + [ + 4, + 29806811 + ], + [ + 4, + 22587135 + ], + [ + 24, + 29806811 + ], + [ + 4, + 23068551 + ], + [ + 3, + 22702973 + ], + [ + 2, + 22587135 + ], + [ + 5, + 22702973 + ], + [ + 2, + 23068551 + ], + [ + 7, + 22702973 + ], + [ + 28, + 29806811 + ], + [ + 1, + 22702973 + ], + [ + 39, + 29806811 + ], + [ + 1, + 22702973 + ] + ], + "bg": [ + [ + 396, + 17106698 + ] + ], + "attrs": [ + [ + 396, + 0 + ] + ], + "clusters": [], + "text": [ + " 10 ⢀", + " ⠤⢄⣀⡀ ⢀⡠⠊⠁", + " ⠈⠉⠑⠒⠢⠤⠤⣀⣀ ⢀⡠⠒⠤⠤⣀⣀ ⡠⠔⠁ ", + " ⠉⠉⠒⠒⠤⠤⢄⣀⡀ ⢀⠔⠁ ⠉ ", + " ⢀⠔⠊⠑⠒⠈⠉⠑⠒⠒⠤⠤⣀⣀ ", + " ⢀⡠⠤⣀⣀ ⢀⡠⠊⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⡀ ", + " ⢀⡠⠔⠊⠁ ⠉⠉⠒⠁ ⠈⠉⠑⠒", + " 0■ fine ■ coarse ", + " 0 7" + ] + } + }, + { + "name": "chart-flat", + "width": 30, + "height": 5, + "result": { + "width": 30, + "height": 5, + "chars": [ + [ + 60, + 32 + ], + [ + 30, + 10276 + ], + [ + 60, + 32 + ] + ], + "fg": [ + [ + 60, + 29806811 + ], + [ + 30, + 22587135 + ], + [ + 60, + 29806811 + ] + ], + "bg": [ + [ + 150, + 17106698 + ] + ], + "attrs": [ + [ + 150, + 0 + ] + ], + "clusters": [], + "text": [ + " ", + " ", + "⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤", + " ", + " " + ] + } + }, { "name": "graph-axis", "width": 30, diff --git a/ports/conformance/generate.ts b/ports/conformance/generate.ts index 3403f80f8fb1bb4bf1f554a413e26dc8d6b4afbe..8bb1d6dae5206c8df42c14499fa6b744eb7d7945 100644 GIT binary patch delta 1848 zcmcgt&59F25Qg<2coq+yiXfQ4xSQY2?x2E*XFUiWLcpGxN~YPF>CoNTWHy93EQou~ zTX+(D2Oq$T-~)K`<^x#W(^+zuNU|&DQZqfNs;{fQ`s(ZVgC9Q+KL5Hbj*j4E%%oO6 zFd1+ynarU+_aJXT#598$*D)}NM9O#uA_HV09n85pya)7CA!b;wg(k?8glAaupxgA| z#DltOz_aJ@?l6*UX0z0l2Mg;)jwm@6ly|UziQt)mFc^?$Yv{qC?ZL6JJr6p@Qn*uN zTl9zSa0ttx=iv}Yd-omzfsR15#iZw3%bh`Uh~*dp;*skDk4K~2jpK2D(TeQnJ7g=fjCD=T7*uPsKqft zf<-pgv4V(?Mkom!9nyp)2UDJ%(pKY4PL|_qr% points; + Color color = 0; + std::string label; + MarkType mark = HQ_MARK_LINE; + /// Shade between the line and the baseline. Ignored for a scatter. + bool fill = false; +}; +/// One axis: what it spans and how its numbers read. +struct Axis { + std::optional min, max; + std::function format; + /// How many labels to place. Default 2 -- the ends. + int ticks = 0; +}; +struct Domain { + double min = 0, max = 1; +}; +struct ChartPlot { + std::string mode = "braille"; + std::optional x, y; + std::optional background; + bool grid = false; + std::optional grid_color; + /// 0-1 opacity of the area fill against the background. + std::optional fill_alpha; + /// Where a bar or an area is measured from. Defaults to the y minimum. + std::optional baseline; +}; +struct Chart { + std::vector series; + ChartPlot plot; + /// Numbers down the left edge. + bool axis = false; + Color axis_color = 0; + bool legend = false; + int legend_align = HQ_LEFT; +}; +/// The span an axis covers, from the caller where they said and from the data +/// where they did not. +Domain domain_of(const std::vector &, const Axis *, int which); +/// Draw point series across the whole surface. +void plot_points(Surface, const std::vector &, const ChartPlot &); +/// A chart of arbitrary (x, y) data, with a domain on both axes. +void draw_chart(Surface, const Chart &); void draw_gauge(Surface, double, std::string_view); void draw_keys(Surface, const std::vector &, bool spread = true); /// Which edge a scrollbar sits on, and therefore which way it runs. @@ -775,6 +829,14 @@ class UI { void progress(Progress o) { draw([=](Surface s) { draw_progress(s, o); }, cells(1)); } + /// A chart of arbitrary (x, y) data, with a domain on both axes. + /// + /// `graph` plots a history buffer, one sample per column. Use this when the + /// data has its own x values: two series of different lengths then line up, + /// and a point lands where its x says it does. + void chart(Chart o, Constraint size = fr()) { + draw([=](Surface s) { draw_chart(s, o); }, size); + } void sparkline(Sparkline o) { draw([=](Surface s) { draw_sparkline(s, o); }, cells(1)); } diff --git a/ports/cpp/src/chart.cpp b/ports/cpp/src/chart.cpp new file mode 100644 index 0000000..37e3e0d --- /dev/null +++ b/ports/cpp/src/chart.cpp @@ -0,0 +1,364 @@ +/// Charts of arbitrary (x, y) data. +/// +/// `draw_graph` takes a vector of doubles and puts one sample per column: the x +/// axis is the vector index. That is the right model for a history buffer and +/// the wrong one for everything else -- two series of different lengths +/// silently render at different horizontal scales, a gap in the data is +/// indistinguishable from a shorter series, and there is no way at all to say +/// where on the x axis a point belongs. +/// +/// This takes points and a domain for each axis, so a series is placed rather +/// than appended. `draw_graph` is untouched and still means what it meant. +#include + +namespace hqtui { +namespace { + +/// A finite number, or nothing: a caller's bound is data, and data can be NaN. +std::optional bound(std::optional value) { + if (value && std::isfinite(*value)) + return value; + return std::nullopt; +} + +double clamp01(double v) { return std::clamp(v, 0.0, 1.0); } + +/// Where a value sits in its domain, 0 at the minimum and 1 at the maximum. +double ratio(double value, Domain d) { return (value - d.min) / (d.max - d.min); } + +/// The eighth-block for a partial cell, as the reference spells it. +uint32_t partial(double r, const std::string &mode) { + int n = std::clamp(iround(clamp01(r) * 8), 0, 8); + if (mode == "ascii") + return r <= 0 ? ' ' : r < .4 ? '.' : r < .7 ? '=' : '#'; + return n ? 0x2580 + n : ' '; +} + +} // namespace + +Domain domain_of(const std::vector &series, const Axis *axis, int which) { + auto min = axis ? bound(axis->min) : std::nullopt; + auto max = axis ? bound(axis->max) : std::nullopt; + if (!min || !max) { + double lo = INFINITY, hi = -INFINITY; + for (auto &s : series) + for (auto &p : s.points) { + double v = which == 0 ? p.x : p.y; + if (!std::isfinite(v)) + continue; + lo = std::min(lo, v); + hi = std::max(hi, v); + } + if (!std::isfinite(lo)) { + lo = 0; + hi = 1; + } + if (!min) + min = lo; + if (!max) + max = hi; + } + if (!(*max > *min)) { + // A flat series still has to be drawn somewhere sensible. + double pad = std::abs(*min) > 0 ? std::abs(*min) * .5 : .5; + return {*min - pad, *min + pad}; + } + return {*min, *max}; +} + +/// The area between a series and its baseline, in block elements. +/// +/// Braille would give eight scattered dots per cell, which reads as noise where +/// an area should read as an area. The line itself stays Braille, so it keeps +/// the sub-cell resolution. +/// +/// The height of each column is interpolated along the line rather than sampled +/// from the points that happen to land in it. Sampling leaves a gap wherever a +/// column has no point of its own, which with arbitrary x values is most of +/// them -- the area comes out striped instead of solid. +static void fill_under(Surface s, const std::vector &points, Domain xd, + Domain yd, double baseline, Color color, + std::optional bg, double alpha) { + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0 || points.empty()) + return; + auto &t = theme(s); + Color base = bg.value_or(t.background); + double floor_at = clamp01(ratio(baseline, yd)); + auto column = [&](double x) { return ratio(x, xd) * (w - 1); }; + + std::vector tops(std::size_t(w), std::numeric_limits::quiet_NaN()); + auto record = [&](int col, double value) { + if (col < 0 || col >= w) + return; + // A path that doubles back covers a column twice; the outer edge is the one + // that bounds the area. + double previous = tops[std::size_t(col)]; + if (std::isnan(previous) || + std::abs(value - floor_at) > std::abs(previous - floor_at)) + tops[std::size_t(col)] = value; + }; + + if (points.size() == 1) + record(iround(column(points[0].x)), clamp01(ratio(points[0].y, yd))); + for (std::size_t i = 0; i + 1 < points.size(); i++) { + double x0 = points[i].x, y0 = points[i].y; + double x1 = points[i + 1].x, y1 = points[i + 1].y; + double c0 = column(x0), c1 = column(x1); + int from = int(std::max(0.0, std::floor(std::min(c0, c1)))); + int to = int(std::min(double(w - 1), std::max(0.0, std::ceil(std::max(c0, c1))))); + for (int col = from; col <= to; col++) { + double u = c1 == c0 ? 0 : (col - c0) / (c1 - c0); + if (u < -.5 || u > 1.5) + continue; + record(col, clamp01(ratio(y0 + (y1 - y0) * clamp01(u), yd))); + } + } + + for (int x = 0; x < w; x++) { + double top = tops[std::size_t(x)]; + if (std::isnan(top)) + continue; + double from01 = std::min(floor_at, top); + double filled = (std::max(floor_at, top) - from01) * h; + int bottom = int(std::floor(from01 * h)); + int full = int(std::floor(filled)); + for (int k = 0; k < full && k < h; k++) { + int row = h - 1 - bottom - k; + if (row < 0 || row >= h) + continue; + double depth = h <= 1 ? 0 : double(row) / (h - 1); + auto st = Style().foreground(hq_mix(base, color, alpha * (1 - depth * .3))); + if (bg) + st = st.background(*bg); + s.set(x, row, 0x2588, st); + } + if (full < h) { + uint32_t glyph = partial(filled - full, "block"); + int row = h - 1 - bottom - full; + if (glyph != ' ' && row >= 0 && row < h) { + double depth = h <= 1 ? 0 : double(row) / (h - 1); + auto st = + Style().foreground(hq_mix(base, color, alpha * (1 - depth * .3) + .12)); + if (bg) + st = st.background(*bg); + s.set(x, row, glyph, st); + } + } + } +} + +/// The block and ascii degradations: one column per cell, tallest point wins. +/// +/// A scatter keeps its dots rather than growing columns, because a scatter that +/// fills to the baseline is a bar chart wearing the wrong name. +static void plot_cells(Surface s, const std::vector &series, + const std::string &mode, Domain xd, Domain yd, + double baseline, std::optional bg) { + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + auto &t = theme(s); + double floor_ratio = clamp01(ratio(baseline, yd)); + + for (std::size_t si = 0; si < series.size(); si++) { + auto &cs = series[si]; + Color color = cs.color ? cs.color : hq_series(&t, si); + // Highest value per column, so a column shows the peak that fell in it + // rather than whichever point happened to be last. + std::vector tops(std::size_t(w), std::numeric_limits::quiet_NaN()); + for (auto &p : cs.points) { + if (!std::isfinite(p.x) || !std::isfinite(p.y)) + continue; + int col = std::clamp(iround(ratio(p.x, xd) * (w - 1)), 0, w - 1); + double value = clamp01(ratio(p.y, yd)); + if (std::isnan(tops[std::size_t(col)]) || value > tops[std::size_t(col)]) + tops[std::size_t(col)] = value; + } + + for (int x = 0; x < w; x++) { + double top = tops[std::size_t(x)]; + if (std::isnan(top)) + continue; + auto st = Style().foreground(color); + if (bg) + st = st.background(*bg); + if (cs.mark == HQ_MARK_SCATTER) { + int row = h - 1 - std::min(int(std::floor(top * h)), h - 1); + s.set(x, row, mode == "ascii" ? '*' : 0x2022, st); + continue; + } + double from = std::min(floor_ratio, top) * h; + double filled = (std::max(floor_ratio, top) - std::min(floor_ratio, top)) * h; + int full = int(std::floor(filled)); + for (int k = 0; k < full; k++) { + int row = h - 1 - int(std::floor(from)) - k; + if (row >= 0 && row < h) + s.set(x, row, 0x2588, st); + } + uint32_t glyph = partial(filled - full, mode); + int row = h - 1 - int(std::floor(from)) - full; + if (glyph != ' ' && row >= 0 && row < h) + s.set(x, row, glyph, st); + } + } +} + +void plot_points(Surface s, const std::vector &series, + const ChartPlot &o) { + if (s.rect().width <= 0 || s.rect().height <= 0 || series.empty()) + return; + auto &t = theme(s); + int w = s.rect().width, h = s.rect().height; + + Domain xd = domain_of(series, o.x ? &*o.x : nullptr, 0); + Domain yd = domain_of(series, o.y ? &*o.y : nullptr, 1); + double baseline = bound(o.baseline).value_or(yd.min); + + if (o.grid) { + Color color = o.grid_color.value_or(hq_mix(t.border, t.background, .4)); + for (int y = 0; y < h; y += std::max(2, h / 4)) + for (int x = 0; x < w; x += 2) + s.set(x, y, 0xb7, Style().foreground(color)); + } + + if (o.mode != "braille") { + plot_cells(s, series, o.mode, xd, yd, baseline, o.background); + return; + } + + for (std::size_t si = 0; si < series.size(); si++) { + auto &cs = series[si]; + Color color = cs.color ? cs.color : hq_series(&t, si); + std::vector finite; + for (auto &p : cs.points) + if (std::isfinite(p.x) && std::isfinite(p.y)) + finite.push_back(p); + if (finite.empty()) + continue; + + Braille canvas(w, h); + double px = canvas.w, py = canvas.h; + std::vector pixels; + pixels.reserve(finite.size()); + for (auto &p : finite) + pixels.push_back({double(iround(clamp01(ratio(p.x, xd)) * (px - 1))), + double(iround((1 - clamp01(ratio(p.y, yd))) * (py - 1)))}); + + if (cs.mark == HQ_MARK_SCATTER) { + for (auto &p : pixels) + canvas.pixel(p.x, p.y); + } else if (cs.mark == HQ_MARK_BAR) { + double floor_px = iround((1 - clamp01(ratio(baseline, yd))) * (py - 1)); + for (auto &p : pixels) + canvas.line(p.x, std::min(p.y, floor_px), p.x, std::max(p.y, floor_px)); + } else if (pixels.size() == 1) { + canvas.pixel(pixels[0].x, pixels[0].y); + } else { + for (std::size_t i = 0; i + 1 < pixels.size(); i++) + canvas.line(pixels[i].x, pixels[i].y, pixels[i + 1].x, pixels[i + 1].y); + } + + if (cs.fill && cs.mark != HQ_MARK_SCATTER) + fill_under(s, finite, xd, yd, baseline, color, o.background, + o.fill_alpha.value_or(.5)); + canvas.blit(s, color, o.background); + } +} + +/// Evenly spaced values across a domain, ends included. +/// +/// Two ticks means the ends and nothing else, which is what an axis wants when +/// there is no room to say more. +static std::vector ticks_for(double min, double max, int count) { + int n = std::max(2, count); + std::vector out; + out.reserve(std::size_t(n)); + for (int i = 0; i < n; i++) + out.push_back(min + (max - min) * i / (n - 1)); + return out; +} + +void draw_chart(Surface surface, const Chart &o) { + if (surface.rect().width <= 0 || surface.rect().height <= 0) + return; + auto &t = theme(surface); + Color axis_color = o.axis_color ? o.axis_color : t.muted; + + Domain xd = domain_of(o.series, o.plot.x ? &*o.plot.x : nullptr, 0); + Domain yd = domain_of(o.series, o.plot.y ? &*o.plot.y : nullptr, 1); + + auto label_of = [](const std::optional &axis, double v) { + if (axis && axis->format) + return axis->format(v); + return std::abs(v) >= 1000 ? number(std::floor(v / 100 + .5) / 10) + "k" + : number(v); + }; + + // The x labels take a row, and they can only take one when there is a row to + // spare -- a two-row chart is all plot. + int x_ticks = o.axis ? 2 : 0; + if (o.plot.x && o.plot.x->ticks > 0) + x_ticks = o.plot.x->ticks; + const bool want_x_axis = o.axis && x_ticks >= 2 && surface.rect().height > 2; + + Surface s = surface; + if (o.axis) { + std::string hi = label_of(o.plot.y, yd.max), lo = label_of(o.plot.y, yd.min); + int lw = std::max(int(width(hi)), int(width(lo))) + 1; + text(s, 0, 0, fit(hi, lw, HQ_RIGHT), axis_color); + if (s.rect().height > 1) { + // The minimum marks the bottom of the plot, which is a row higher when + // the x labels have taken the last one. + int bottom = want_x_axis ? s.rect().height - 2 : s.rect().height - 1; + text(s, 0, bottom, fit(lo, lw, HQ_RIGHT), axis_color); + } + s = s.sub({lw, 0, std::max(0, s.rect().width - lw), s.rect().height}); + } + + Surface area = s; + if (want_x_axis && s.rect().height > 1 && s.rect().width > 0) { + area = s.sub({0, 0, s.rect().width, s.rect().height - 1}); + int row = s.rect().height - 1; + auto values = ticks_for(xd.min, xd.max, x_ticks); + double step = values.size() > 1 ? double(s.rect().width - 1) / (values.size() - 1) : 0; + for (std::size_t i = 0; i < values.size(); i++) { + std::string label = label_of(o.plot.x, values[i]); + // The last label is right-aligned to the edge, so it cannot run off it. + int x = std::min(s.rect().width - int(width(label)), iround(double(i) * step)); + text(s, std::max(0, x), row, label, axis_color); + } + } + + // The domain is resolved once and handed down, so the labels and the marks + // cannot disagree about what the axis spans. + ChartPlot plot = o.plot; + Axis x_axis = plot.x.value_or(Axis{}); + x_axis.min = xd.min; + x_axis.max = xd.max; + Axis y_axis = plot.y.value_or(Axis{}); + y_axis.min = yd.min; + y_axis.max = yd.max; + plot.x = x_axis; + plot.y = y_axis; + plot_points(area, o.series, plot); + + if (o.legend) { + int total = 0; + for (auto &cs : o.series) + if (!cs.label.empty()) + total += int(width(cs.label)) + 3; + int x = o.legend_align == HQ_RIGHT ? std::max(0, area.rect().width - total) : 0; + int y = area.rect().height > 3 ? area.rect().height - 1 : 0; + for (std::size_t i = 0; i < o.series.size(); i++) { + auto &cs = o.series[i]; + if (cs.label.empty()) + continue; + Color color = cs.color ? cs.color : hq_series(&t, i); + x += int(text(area, x, y, "■ ", color)); + x += int(text(area, x, y, cs.label + " ", t.muted)); + } + } +} + +} // namespace hqtui diff --git a/ports/cpp/src/widgets.cpp b/ports/cpp/src/widgets.cpp index d429c72..e2a4854 100644 --- a/ports/cpp/src/widgets.cpp +++ b/ports/cpp/src/widgets.cpp @@ -2,7 +2,7 @@ #include #include namespace hqtui { -static std::string number(double v) { +std::string number(double v) { char b[80]; std::snprintf(b, sizeof b, v == std::floor(v) ? "%.0f" : "%.1f", v); return b; diff --git a/ports/cpp/tests/conformance_widgets.cpp b/ports/cpp/tests/conformance_widgets.cpp index a803c8d..f642177 100644 --- a/ports/cpp/tests/conformance_widgets.cpp +++ b/ports/cpp/tests/conformance_widgets.cpp @@ -32,6 +32,25 @@ const std::vector kSeries{3, 7, 2, 9, 4, 8, 6, 1, 5, 9, 3, 7, 8, 2, 6, 4, 9, 1, 5, 7}; /// Draws one named scene. Returns false when C++ has no implementation yet. + +/// The axis bounds every chart fixture pins, without the ceremony. +static Axis axis_of(double min, double max, int ticks) { + Axis a; + a.min = min; + a.max = max; + a.ticks = ticks; + return a; +} + +/// One series over the standard 0..10 domain. +static Chart chart_fixture(MarkType mark) { + Chart c; + c.series = {{{{0, 1}, {2, 6}, {5, 3}, {8, 9}, {10, 4}}, 0, "", mark}}; + c.plot.x = axis_of(0, 10, 0); + c.plot.y = axis_of(0, 10, 0); + return c; +} + bool draw_scene(const std::string &name, Surface s) { const auto &t = theme(s); @@ -264,6 +283,64 @@ bool draw_scene(const std::string &name, Surface s) { draw_table(s, table); return true; } + if (name == "chart-line") { + draw_chart(s, chart_fixture(HQ_MARK_LINE)); + return true; + } + if (name == "chart-scatter") { + draw_chart(s, chart_fixture(HQ_MARK_SCATTER)); + return true; + } + if (name == "chart-bar") { + draw_chart(s, chart_fixture(HQ_MARK_BAR)); + return true; + } + if (name == "chart-fill") { + Chart c; + c.series = {{{{0, 2}, {5, 8}, {10, 2}}, 0, "", HQ_MARK_LINE, true}}; + c.plot.x = axis_of(0, 10, 0); + c.plot.y = axis_of(0, 10, 0); + draw_chart(s, c); + return true; + } + if (name == "chart-axes") { + Chart c; + c.series = {{{{0, 0}, {5, 50}, {10, 100}}}}; + c.axis = true; + c.plot.x = axis_of(0, 10, 3); + c.plot.y = axis_of(0, 100, 0); + draw_chart(s, c); + return true; + } + if (name == "chart-block") { + Chart c; + c.series = {{{{0, 1}, {2, 6}, {5, 3}, {8, 9}, {10, 4}}, 0, "", HQ_MARK_BAR}}; + c.plot.mode = "block"; + c.plot.x = axis_of(0, 10, 0); + c.plot.y = axis_of(0, 10, 0); + draw_chart(s, c); + return true; + } + if (name == "chart-multi") { + Chart c; + c.series = { + {{{0, 1}, {1, 3}, {2, 2}, {3, 5}, {4, 4}, {5, 7}, {6, 6}, {7, 9}}, 0, "fine"}, + {{{0, 8}, {7, 2}}, 0, "coarse"}, + }; + c.axis = true; + c.legend = true; + c.plot.x = axis_of(0, 7, 0); + c.plot.y = axis_of(0, 10, 0); + draw_chart(s, c); + return true; + } + if (name == "chart-flat") { + Chart c; + c.series = {{{{0, 4}, {5, 4}, {10, 4}}}}; + c.plot.x = axis_of(0, 10, 0); + draw_chart(s, c); + return true; + } if (name == "graph-axis") { Graph g; g.series = {{kSeries, 0, "", false}}; diff --git a/ports/go/chart.go b/ports/go/chart.go new file mode 100644 index 0000000..1685ae9 --- /dev/null +++ b/ports/go/chart.go @@ -0,0 +1,397 @@ +package hqtui + +import "math" + +// Charts of arbitrary (x, y) data. +// +// Plot takes []float64 and puts one sample per column: the x axis is the slice +// index. That is the right model for a history buffer and the wrong one for +// everything else — two series of different lengths silently render at +// different horizontal scales, a gap in the data is indistinguishable from a +// shorter series, and there is no way at all to say where on the x axis a point +// belongs. +// +// This takes points and a domain for each axis, so a series is placed rather +// than appended. Plot is untouched and still means what it meant. + +// MarkType is how a series is marked: joined, dotted, or dropped to the +// baseline. +type MarkType int + +const ( + MarkLine MarkType = iota + MarkScatter + MarkBar +) + +type ChartSeries struct { + Points []Point + Color *Color + Label string + Mark MarkType + // Fill shades between the line and the baseline. Ignored for a scatter. + Fill bool +} + +// AxisOptions is one axis: what it spans and how its numbers read. +type AxisOptions struct { + Min *float64 + Max *float64 + Format func(float64) string + // Ticks is how many labels to place. Default 2 — the ends. + Ticks int +} + +type ChartPlotOptions struct { + // Mode: Braille is sharpest; block and ascii are graceful degradations. + Mode FillMode + X *AxisOptions + Y *AxisOptions + Background *Color + Grid bool + GridColor *Color + // FillAlpha is the 0-1 opacity of the area fill against the background. + FillAlpha *float64 + // Baseline is where a bar or an area is measured from. Defaults to the y + // minimum. + Baseline *float64 +} + +type Domain struct{ Min, Max float64 } + +// DomainOf is the span an axis covers, from the caller where they said and from +// the data where they did not. +// +// A domain of zero width cannot be mapped — every point would land in the same +// place and a division would blow up — so a flat series is given room around +// itself rather than being collapsed onto one line. +func DomainOf(series []ChartSeries, axis *AxisOptions, which int) Domain { + var min, max *float64 + if axis != nil { + min = bound(axis.Min) + max = bound(axis.Max) + } + if min == nil || max == nil { + lo, hi := math.Inf(1), math.Inf(-1) + for _, s := range series { + for _, p := range s.Points { + v := p.X + if which == 1 { + v = p.Y + } + if math.IsNaN(v) || math.IsInf(v, 0) { + continue + } + if v < lo { + lo = v + } + if v > hi { + hi = v + } + } + } + if math.IsInf(lo, 0) { + lo, hi = 0, 1 + } + if min == nil { + min = &lo + } + if max == nil { + max = &hi + } + } + if !(*max > *min) { + // A flat series still has to be drawn somewhere sensible. + pad := 0.5 + if math.Abs(*min) > 0 { + pad = math.Abs(*min) * 0.5 + } + return Domain{Min: *min - pad, Max: *min + pad} + } + return Domain{Min: *min, Max: *max} +} + +// chartRatio is where a value sits in its domain, 0 at the minimum and 1 at the +// maximum. +func chartRatio(value float64, d Domain) float64 { + return (value - d.Min) / (d.Max - d.Min) +} + +func drawChartGrid(s Surface, color Color, bg *Color) { + w, h := s.Width(), s.Height() + step := h / 4 + if step < 2 { + step = 2 + } + for y := 0; y < h; y += step { + for x := 0; x < w; x += 2 { + s.Glyph(x, y, '·', Style{Fg: &color, Bg: bg}) + } + } +} + +// PlotPoints draws point series across the whole surface. +// +// Points are drawn in the order they are given: a line joins them as they come, +// which is what lets a chart draw a loop or a path that doubles back. Sorting +// them would quietly make that impossible. +func PlotPoints(s Surface, series []ChartSeries, o ChartPlotOptions) { + if s.IsEmpty() || len(series) == 0 { + return + } + theme := s.Theme + mode := o.Mode + bg := o.Background + w, h := s.Width(), s.Height() + + xd := DomainOf(series, o.X, 0) + yd := DomainOf(series, o.Y, 1) + baseline := yd.Min + if b := bound(o.Baseline); b != nil { + baseline = *b + } + + if o.Grid { + color := theme.Border.Mix(theme.Background, 0.4) + if o.GridColor != nil { + color = *o.GridColor + } + drawChartGrid(s, color, bg) + } + + if mode != FillBraille { + plotChartCells(s, series, mode, xd, yd, baseline, bg) + return + } + + canvas := NewBrailleCanvas(w, h) + px := float64(canvas.Width) + py := float64(canvas.Height) + + for si, cs := range series { + canvas.Clear() + color := SeriesColor(theme, si) + if cs.Color != nil { + color = *cs.Color + } + finite := make([]Point, 0, len(cs.Points)) + for _, p := range cs.Points { + if math.IsNaN(p.X) || math.IsInf(p.X, 0) || math.IsNaN(p.Y) || math.IsInf(p.Y, 0) { + continue + } + finite = append(finite, p) + } + if len(finite) == 0 { + continue + } + pixels := make([]Point, len(finite)) + for i, p := range finite { + pixels[i] = Point{ + X: roundHalfUp(clamp01(chartRatio(p.X, xd)) * (px - 1)), + Y: roundHalfUp((1 - clamp01(chartRatio(p.Y, yd))) * (py - 1)), + } + } + + switch cs.Mark { + case MarkScatter: + for _, p := range pixels { + canvas.Pixel(p.X, p.Y) + } + case MarkBar: + floor := roundHalfUp((1 - clamp01(chartRatio(baseline, yd))) * (py - 1)) + for _, p := range pixels { + canvas.VLine(p.X, math.Min(p.Y, floor), math.Max(p.Y, floor)) + } + default: + if len(pixels) == 1 { + canvas.Pixel(pixels[0].X, pixels[0].Y) + } else { + canvas.Polyline(pixels) + } + } + + if cs.Fill && cs.Mark != MarkScatter { + alpha := 0.5 + if o.FillAlpha != nil { + alpha = *o.FillAlpha + } + fillUnderPoints(s, finite, xd, yd, baseline, color, bg, alpha) + } + c := color + Blit(s, canvas, func(col, row int) Color { return c }, bg) + } +} + +// fillUnderPoints shades the area between a series and its baseline, in block +// elements. +// +// Braille would give eight scattered dots per cell, which reads as noise where +// an area should read as an area. The line itself stays Braille, so it keeps +// the sub-cell resolution. +// +// The height of each column is interpolated along the line rather than sampled +// from the points that happen to land in it. Sampling leaves a gap wherever a +// column has no point of its own, which with arbitrary x values is most of them +// — the area comes out striped instead of solid. +func fillUnderPoints( + s Surface, points []Point, xd, yd Domain, baseline float64, color Color, bg *Color, alpha float64, +) { + w, h := s.Width(), s.Height() + if w == 0 || h == 0 || len(points) == 0 { + return + } + base := s.Theme.Background + if bg != nil { + base = *bg + } + floor := clamp01(chartRatio(baseline, yd)) + column := func(x float64) float64 { return chartRatio(x, xd) * (float64(w) - 1) } + + tops := make([]float64, w) + for i := range tops { + tops[i] = math.NaN() + } + record := func(col int, value float64) { + if col < 0 || col >= w { + return + } + // A path that doubles back covers a column twice; the outer edge is the + // one that bounds the area. + previous := tops[col] + if math.IsNaN(previous) || math.Abs(value-floor) > math.Abs(previous-floor) { + tops[col] = value + } + } + + if len(points) == 1 { + record(int(roundHalfUp(column(points[0].X))), clamp01(chartRatio(points[0].Y, yd))) + } + for i := 0; i+1 < len(points); i++ { + x0, y0 := points[i].X, points[i].Y + x1, y1 := points[i+1].X, points[i+1].Y + c0, c1 := column(x0), column(x1) + from := int(math.Max(0, math.Floor(math.Min(c0, c1)))) + to := int(math.Min(float64(w-1), math.Max(0, math.Ceil(math.Max(c0, c1))))) + for col := from; col <= to; col++ { + t := 0.0 + if c1 != c0 { + t = (float64(col) - c0) / (c1 - c0) + } + if t < -0.5 || t > 1.5 { + continue + } + y := y0 + (y1-y0)*clamp01(t) + record(col, clamp01(chartRatio(y, yd))) + } + } + + for x := 0; x < w; x++ { + top := tops[x] + if math.IsNaN(top) { + continue + } + from01 := math.Min(floor, top) + filled := (math.Max(floor, top) - from01) * float64(h) + bottom := int(math.Floor(from01 * float64(h))) + full := int(math.Floor(filled)) + for k := 0; k < full && k < h; k++ { + row := h - 1 - bottom - k + if row < 0 || row >= h { + continue + } + depth := 0.0 + if h > 1 { + depth = float64(row) / float64(h-1) + } + c := base.Mix(color, alpha*(1-depth*0.3)) + s.Glyph(x, row, '█', Style{Fg: &c, Bg: bg}) + } + if full < h { + glyph := VerticalGlyph(filled-float64(full), FillBlock) + row := h - 1 - bottom - full + if glyph != " " && row >= 0 && row < h { + depth := 0.0 + if h > 1 { + depth = float64(row) / float64(h-1) + } + c := base.Mix(color, alpha*(1-depth*0.3)+0.12) + s.Glyph(x, row, []rune(glyph)[0], Style{Fg: &c, Bg: bg}) + } + } + } +} + +// plotChartCells is the block and ascii degradation: one column per cell, +// tallest point wins. +// +// A scatter keeps its dots rather than growing columns, because a scatter that +// fills to the baseline is a bar chart wearing the wrong name. +func plotChartCells( + s Surface, series []ChartSeries, mode FillMode, xd, yd Domain, baseline float64, bg *Color, +) { + w, h := s.Width(), s.Height() + theme := s.Theme + floorRatio := clamp01(chartRatio(baseline, yd)) + + for si, cs := range series { + color := SeriesColor(theme, si) + if cs.Color != nil { + color = *cs.Color + } + // Highest value per column, so a column shows the peak that fell in it + // rather than whichever point happened to be last. + tops := make([]float64, w) + for i := range tops { + tops[i] = math.NaN() + } + for _, p := range cs.Points { + if math.IsNaN(p.X) || math.IsInf(p.X, 0) || math.IsNaN(p.Y) || math.IsInf(p.Y, 0) { + continue + } + col := int(roundHalfUp(chartRatio(p.X, xd) * (float64(w) - 1))) + if col < 0 { + col = 0 + } + if col > w-1 { + col = w - 1 + } + value := clamp01(chartRatio(p.Y, yd)) + if math.IsNaN(tops[col]) || value > tops[col] { + tops[col] = value + } + } + + for x := 0; x < w; x++ { + top := tops[x] + if math.IsNaN(top) { + continue + } + if cs.Mark == MarkScatter { + k := int(math.Floor(top * float64(h))) + if k > h-1 { + k = h - 1 + } + glyph := '•' + if mode == FillASCII { + glyph = '*' + } + s.Glyph(x, h-1-k, glyph, Style{Fg: &color, Bg: bg}) + continue + } + from := math.Min(floorRatio, top) * float64(h) + filled := (math.Max(floorRatio, top) - math.Min(floorRatio, top)) * float64(h) + full := int(math.Floor(filled)) + for k := 0; k < full; k++ { + row := h - 1 - int(math.Floor(from)) - k + if row >= 0 && row < h { + s.Glyph(x, row, '█', Style{Fg: &color, Bg: bg}) + } + } + glyph := VerticalGlyph(filled-float64(full), mode) + row := h - 1 - int(math.Floor(from)) - full + if glyph != " " && row >= 0 && row < h { + s.Glyph(x, row, []rune(glyph)[0], Style{Fg: &color, Bg: bg}) + } + } + } +} diff --git a/ports/go/conformance_widgets_test.go b/ports/go/conformance_widgets_test.go index 4696475..9fc5a90 100644 --- a/ports/go/conformance_widgets_test.go +++ b/ports/go/conformance_widgets_test.go @@ -17,6 +17,28 @@ var widgetSeries = []float64{3, 7, 2, 9, 4, 8, 6, 1, 5, 9, 3, 7, 8, 2, 6, 4, 9, func series() []float64 { return append([]float64(nil), widgetSeries...) } +// pts builds a point list from flat x, y pairs, which is all a fixture needs. +func pts(values ...float64) []Point { + out := make([]Point, 0, len(values)/2) + for i := 0; i+1 < len(values); i += 2 { + out = append(out, Point{X: values[i], Y: values[i+1]}) + } + return out +} + +// axisOf is the axis bounds every chart fixture pins, without the ceremony. +func axisOf(min, max float64, ticks int) *AxisOptions { + return &AxisOptions{Min: &min, Max: &max, Ticks: ticks} +} + +// chartFixture is one series over the standard 0..10 domain. +func chartFixture(mark MarkType) ChartOptions { + return ChartOptions{ + Series: []ChartSeries{{Points: pts(0, 1, 2, 6, 5, 3, 8, 9, 10, 4), Mark: mark}}, + Plot: ChartPlotOptions{X: axisOf(0, 10, 0), Y: axisOf(0, 10, 0)}, + } +} + func drawWidgetScene(t *testing.T, name string, s Surface) { switch name { case "text-plain": @@ -91,6 +113,47 @@ func drawWidgetScene(t *testing.T, name string, s Surface) { Gauge(s, GaugeOptions{Value: 0.7, Label: "70%"}) case "donut": Donut(s, DonutOptions{Segments: []DonutSegment{{Value: 3}, {Value: 5}, {Value: 2}}}) + case "chart-line": + DrawChart(s, chartFixture(MarkLine)) + case "chart-scatter": + DrawChart(s, chartFixture(MarkScatter)) + case "chart-bar": + DrawChart(s, chartFixture(MarkBar)) + case "chart-fill": + DrawChart(s, ChartOptions{ + Series: []ChartSeries{{Points: pts(0, 2, 5, 8, 10, 2), Fill: true}}, + Plot: ChartPlotOptions{X: axisOf(0, 10, 0), Y: axisOf(0, 10, 0)}, + }) + case "chart-axes": + DrawChart(s, ChartOptions{ + Series: []ChartSeries{{Points: pts(0, 0, 5, 50, 10, 100)}}, + Axis: true, + Plot: ChartPlotOptions{X: axisOf(0, 10, 3), Y: axisOf(0, 100, 0)}, + }) + case "chart-block": + DrawChart(s, ChartOptions{ + Series: []ChartSeries{{Points: pts(0, 1, 2, 6, 5, 3, 8, 9, 10, 4), Mark: MarkBar}}, + Plot: ChartPlotOptions{ + Mode: FillBlock, + X: axisOf(0, 10, 0), + Y: axisOf(0, 10, 0), + }, + }) + case "chart-multi": + DrawChart(s, ChartOptions{ + Series: []ChartSeries{ + {Points: pts(0, 1, 1, 3, 2, 2, 3, 5, 4, 4, 5, 7, 6, 6, 7, 9), Label: "fine"}, + {Points: pts(0, 8, 7, 2), Label: "coarse"}, + }, + Axis: true, + Legend: true, + Plot: ChartPlotOptions{X: axisOf(0, 7, 0), Y: axisOf(0, 10, 0)}, + }) + case "chart-flat": + DrawChart(s, ChartOptions{ + Series: []ChartSeries{{Points: pts(0, 4, 5, 4, 10, 4)}}, + Plot: ChartPlotOptions{X: axisOf(0, 10, 0)}, + }) case "graph-axis": DrawGraph(s, GraphOptions{Values: series(), Axis: true}) case "graph-legend": diff --git a/ports/go/examples/widgets/main.go b/ports/go/examples/widgets/main.go index 3a6c34f..9b5493c 100644 --- a/ports/go/examples/widgets/main.go +++ b/ports/go/examples/widgets/main.go @@ -204,6 +204,26 @@ func Scrollbar(ui *hqtui.Container) { // @end +// @widget chart +func Chart(ui *hqtui.Container) { + // Points carry their own x, so a sparse series and a dense one line up. + zero, ten, seven := 0.0, 10.0, 3 + ui.Chart(hqtui.ChartOptions{ + Series: []hqtui.ChartSeries{ + {Points: []hqtui.Point{{X: 0, Y: 1}, {X: 2, Y: 6}, {X: 5, Y: 3}, {X: 8, Y: 9}, {X: 10, Y: 4}}, Label: "load"}, + {Points: []hqtui.Point{{X: 0, Y: 8}, {X: 10, Y: 2}}, Label: "limit"}, + }, + Axis: true, + Legend: true, + Plot: hqtui.ChartPlotOptions{ + X: &hqtui.AxisOptions{Min: &zero, Max: &ten, Ticks: seven}, + Y: &hqtui.AxisOptions{Min: &zero, Max: &ten}, + }, + }) +} + +// @end + // @widget meter func Meter(ui *hqtui.Container) { ui.Meter(hqtui.MeterOptions{Value: 0.62, Label: "CPU"}) @@ -406,7 +426,7 @@ func main() { {"text", Text}, {"label", Label}, {"heading", Heading}, {"badge", Badge}, {"divider", Divider}, {"keyValues", KeyValues}, {"statusBar", StatusBar}, {"table", Table}, {"list", List}, {"tree", Tree}, {"log", Log}, - {"scrollbar", Scrollbar}, + {"scrollbar", Scrollbar}, {"chart", Chart}, {"meter", Meter}, {"meters", Meters}, {"progress", Progress}, {"graph", Graph}, {"sparkline", Sparkline}, {"histogram", Histogram}, {"heatBar", HeatBar}, {"gauge", Gauge}, {"donut", Donut}, diff --git a/ports/go/ui.go b/ports/go/ui.go index 2fe39c0..3ffaf6c 100644 --- a/ports/go/ui.go +++ b/ports/go/ui.go @@ -478,6 +478,17 @@ func (c *Container) Graph(o GraphOptions, layout ...Layout) *Container { return c.add(c.filling(firstLayout(layout)), func(s Surface) { DrawGraph(s, o) }) } +// Chart draws arbitrary (x, y) data, with a domain on both axes. +// +// Graph plots a history buffer, one sample per column. Use this when the data +// has its own x values: two series of different lengths then line up, and a +// point lands where its x says it does. +func (c *Container) Chart(o ChartOptions, layout ...Layout) *Container { + return c.add(c.filling(firstLayout(layout)), func(s Surface) { + DrawChart(s, o) + }) +} + func (c *Container) Sparkline(o SparklineWidgetOptions, layout ...Layout) *Container { return c.add(c.leaf(firstLayout(layout), 1), func(s Surface) { DrawSparkline(s, o) }) } diff --git a/ports/go/widgets_chart.go b/ports/go/widgets_chart.go new file mode 100644 index 0000000..2f95aba --- /dev/null +++ b/ports/go/widgets_chart.go @@ -0,0 +1,176 @@ +package hqtui + +import "math" + +// A chart with two real axes. +// +// Graph plots a history buffer: one sample per column, x meaning "position in +// the slice". This plots data that has its own x values, with a labelled domain +// on both axes, so two series of different lengths line up and a point lands +// where its x says it does. + +type ChartOptions struct { + Series []ChartSeries + Plot ChartPlotOptions + // Axis draws numbers down the left edge. + Axis bool + AxisColor *Color + Legend bool + LegendAlign Align +} + +// ticksFor returns evenly spaced values across a domain, ends included. +// +// Two ticks means the ends and nothing else, which is what an axis wants when +// there is no room to say more. +func ticksFor(min, max float64, count int) []float64 { + n := count + if n < 2 { + n = 2 + } + out := make([]float64, n) + for i := 0; i < n; i++ { + out[i] = min + (max-min)*float64(i)/float64(n-1) + } + return out +} + +func formatWith(axis *AxisOptions, value float64) string { + if axis != nil && axis.Format != nil { + return axis.Format(value) + } + return NiceLabel(value) +} + +func DrawChart(s Surface, o ChartOptions) { + if s.IsEmpty() { + return + } + theme := s.Theme + axisColor := theme.Muted + if o.AxisColor != nil { + axisColor = *o.AxisColor + } + + xd := DomainOf(o.Series, o.Plot.X, 0) + yd := DomainOf(o.Series, o.Plot.Y, 1) + + // The x labels take a row, and they can only take one when there is a row + // to spare — a two-row chart is all plot. + xTicks := 0 + if o.Axis { + xTicks = 2 + } + if o.Plot.X != nil && o.Plot.X.Ticks > 0 { + xTicks = o.Plot.X.Ticks + } + wantXAxis := o.Axis && xTicks >= 2 && s.Height() > 2 + + plotSurface := s + if o.Axis { + hi := formatWith(o.Plot.Y, yd.Max) + lo := formatWith(o.Plot.Y, yd.Min) + width := StringWidth(hi) + if w := StringWidth(lo); w > width { + width = w + } + width++ + s.Text(0, 0, Fit(hi, width, AlignRight), TextOptions{Fg: &axisColor}) + if s.Height() > 1 { + // The minimum marks the bottom of the plot, which is a row higher + // when the x labels have taken the last one. + bottom := s.Height() - 1 + if wantXAxis { + bottom = s.Height() - 2 + } + s.Text(0, bottom, Fit(lo, width, AlignRight), TextOptions{Fg: &axisColor}) + } + plotSurface = s.Sub(width, 0, s.Width()-width, s.Height()) + } + + area := plotSurface + if wantXAxis && plotSurface.Height() > 1 && plotSurface.Width() > 0 { + area = plotSurface.Sub(0, 0, plotSurface.Width(), plotSurface.Height()-1) + row := plotSurface.Height() - 1 + values := ticksFor(xd.Min, xd.Max, xTicks) + labels := make([]string, len(values)) + for i, v := range values { + labels[i] = formatWith(o.Plot.X, v) + } + step := 0.0 + if len(labels) > 1 { + step = (float64(plotSurface.Width()) - 1) / float64(len(labels)-1) + } + for i, label := range labels { + // The last label is right-aligned to the edge, so it cannot run off it. + x := int(roundHalfUp(float64(i) * step)) + if limit := plotSurface.Width() - StringWidth(label); x > limit { + x = limit + } + if x < 0 { + x = 0 + } + plotSurface.Text(x, row, label, TextOptions{Fg: &axisColor}) + } + } + + // The domain is resolved once and handed down, so the labels and the marks + // cannot disagree about what the axis spans. + plot := o.Plot + plot.X = withBounds(o.Plot.X, xd) + plot.Y = withBounds(o.Plot.Y, yd) + PlotPoints(area, o.Series, plot) + + if o.Legend { + type part struct { + label string + color Color + } + parts := []part{} + for i, cs := range o.Series { + if cs.Label == "" { + continue + } + color := SeriesColor(theme, i) + if cs.Color != nil { + color = *cs.Color + } + parts = append(parts, part{cs.Label, color}) + } + x := 0 + if o.LegendAlign == AlignRight { + total := 0 + for _, p := range parts { + total += StringWidth(p.label) + 3 + } + x = area.Width() - total + if x < 0 { + x = 0 + } + } + y := 0 + if area.Height() > 3 { + y = area.Height() - 1 + } + for _, p := range parts { + c := p.color + x += area.Text(x, y, "■ ", TextOptions{Fg: &c}) + x += area.Text(x, y, p.label+" ", TextOptions{Fg: &theme.Muted}) + } + } +} + +// withBounds pins an axis to a resolved domain, keeping whatever else it said. +func withBounds(axis *AxisOptions, d Domain) *AxisOptions { + out := AxisOptions{} + if axis != nil { + out = *axis + } + min, max := d.Min, d.Max + if math.IsNaN(min) || math.IsNaN(max) { + return axis + } + out.Min = &min + out.Max = &max + return &out +} diff --git a/ports/perl/examples/widgets.pl b/ports/perl/examples/widgets.pl index 4baa181..c560789 100644 --- a/ports/perl/examples/widgets.pl +++ b/ports/perl/examples/widgets.pl @@ -94,6 +94,22 @@ sub widget_scrollbar { } # @end +# @widget chart +sub widget_chart { + my ($ui) = @_; + # Points carry their own x, so a sparse series and a dense one line up. + $ui->chart( + [ + { points => [ { x => 0, y => 1 }, { x => 2, y => 6 }, { x => 5, y => 3 }, + { x => 8, y => 9 }, { x => 10, y => 4 } ], label => 'load' }, + { points => [ { x => 0, y => 8 }, { x => 10, y => 2 } ], label => 'limit' }, + ], + axis => 1, legend => 1, + x => { min => 0, max => 10, ticks => 3 }, y => { min => 0, max => 10 }, + ); +} +# @end + # @widget meter sub widget_meter { my ($ui) = @_; @@ -305,6 +321,7 @@ sub widget_tooltip { ['table', \&widget_table], ['log', \&widget_log], ['scrollbar', \&widget_scrollbar], + ['chart', \&widget_chart], ['meter', \&widget_meter], ['graph', \&widget_graph], ['gauge', \&widget_gauge], diff --git a/ports/perl/lib/Hqtui.pm b/ports/perl/lib/Hqtui.pm index 6703fff..f0876e4 100644 --- a/ports/perl/lib/Hqtui.pm +++ b/ports/perl/lib/Hqtui.pm @@ -64,6 +64,7 @@ sub columns { my ($s,$values,%o)=@_; $s->add('columns',values=>$values,%o); } sub donut { my ($s,$segments,%o)=@_; $s->add('donut',segments=>$segments,%o); } sub list { my ($s,$items,%o)=@_; $s->add('list',items=>$items,%o); } sub scrollbar { my ($s,$total,%o)=@_; $s->add('scrollbar',total=>$total,%o); } +sub chart { my ($s,$series,%o)=@_; $s->add('chart',series=>$series,%o); } sub tree { my ($s,$nodes,%o)=@_; $s->add('tree',nodes=>$nodes,%o); } sub button { my ($s,$label,%o)=@_; $s->add('button',label=>$label,%o); } sub checkbox { my ($s,$label,%o)=@_; $s->add('checkbox',label=>$label,%o); } diff --git a/ports/php/examples/widgets.php b/ports/php/examples/widgets.php index 349573d..524b4be 100644 --- a/ports/php/examples/widgets.php +++ b/ports/php/examples/widgets.php @@ -97,6 +97,22 @@ function widget_scrollbar(UI $ui): void } // @end +// @widget chart +function widget_chart(UI $ui): void +{ + // Points carry their own x, so a sparse series and a dense one line up. + $ui->chart( + [ + ['points' => [['x' => 0, 'y' => 1], ['x' => 2, 'y' => 6], ['x' => 5, 'y' => 3], + ['x' => 8, 'y' => 9], ['x' => 10, 'y' => 4]], 'label' => 'load'], + ['points' => [['x' => 0, 'y' => 8], ['x' => 10, 'y' => 2]], 'label' => 'limit'], + ], + ['axis' => true, 'legend' => true, + 'x' => ['min' => 0, 'max' => 10, 'ticks' => 3], 'y' => ['min' => 0, 'max' => 10]] + ); +} +// @end + // @widget meter function widget_meter(UI $ui): void { @@ -309,6 +325,7 @@ function widget_tooltip(UI $ui): void 'table' => 'widget_table', 'log' => 'widget_log', 'scrollbar' => 'widget_scrollbar', + 'chart' => 'widget_chart', 'meter' => 'widget_meter', 'graph' => 'widget_graph', 'gauge' => 'widget_gauge', diff --git a/ports/php/src/Hqtui.php b/ports/php/src/Hqtui.php index 437843c..8389299 100644 --- a/ports/php/src/Hqtui.php +++ b/ports/php/src/Hqtui.php @@ -79,6 +79,7 @@ public function columns(array $values, array $o = []): self { return $this->add( public function donut(array $segments, array $o = []): self { return $this->add('donut', ['segments'=>$segments, ...$o]); } public function list(array $items, array $o = []): self { return $this->add('list', ['items'=>$items, ...$o]); } public function scrollbar(int $total, array $o = []): self { return $this->add('scrollbar', ['total'=>$total, ...$o]); } + public function chart(array $series, array $o = []): self { return $this->add('chart', ['series'=>$series, ...$o]); } public function tree(array $nodes, array $o = []): self { return $this->add('tree', ['nodes'=>$nodes, ...$o]); } public function button(string $label, array $o = []): self { return $this->add('button', ['label'=>$label, ...$o]); } public function checkbox(string $label, array $o = []): self { return $this->add('checkbox', ['label'=>$label, ...$o]); } diff --git a/ports/python/examples/widgets.py b/ports/python/examples/widgets.py index 69eca2f..11b7fa5 100644 --- a/ports/python/examples/widgets.py +++ b/ports/python/examples/widgets.py @@ -13,6 +13,7 @@ import sys +import hqtui.graphics.chart as g import hqtui.widgets as w from hqtui.graphics import BarStyle, DonutOptions, DonutSegment, GaugeOptions, PlotOptions, Series from hqtui.testing import render_to_text @@ -205,6 +206,24 @@ def row(r: Container) -> None: # @end +# @widget chart +def chart(ui: Container) -> None: + # Points carry their own x, so a sparse series and a dense one line up. + ui.chart(w.ChartOptions( + series=[ + g.ChartSeries(points=[(0, 1), (2, 6), (5, 3), (8, 9), (10, 4)], label="load"), + g.ChartSeries(points=[(0, 8), (10, 2)], label="limit"), + ], + axis=True, + legend=True, + plot=g.ChartPlotOptions( + x=g.AxisOptions(min=0, max=10, ticks=3), + y=g.AxisOptions(min=0, max=10), + ), + )) +# @end + + # @widget meter def meter(ui: Container) -> None: ui.meter(w.MeterOptions(value=0.62, label="CPU")) @@ -391,7 +410,7 @@ def tooltip(ui: Container) -> None: ("text", text), ("label", label), ("heading", heading), ("badge", badge), ("divider", divider), ("keyValues", key_values), ("statusBar", status_bar), ("table", table), ("list", list_), ("tree", tree), ("log", log), - ("scrollbar", scrollbar), + ("scrollbar", scrollbar), ("chart", chart), ("meter", meter), ("meters", meters), ("progress", progress), ("graph", graph), ("sparkline", sparkline), ("histogram", histogram), ("heatBar", heat_bar), ("gauge", gauge), ("donut", donut), diff --git a/ports/python/hqtui/graphics/__init__.py b/ports/python/hqtui/graphics/__init__.py index ee64313..29757d6 100644 --- a/ports/python/hqtui/graphics/__init__.py +++ b/ports/python/hqtui/graphics/__init__.py @@ -15,6 +15,16 @@ vertical_glyph, ) from .braille import BrailleCanvas +from .chart import ( + AxisOptions, + ChartPlotOptions, + ChartSeries, + Domain, + MarkType, + Point, + domain_of, + plot_points, +) from .plot import ( BarOptions, BarStyle, @@ -35,6 +45,14 @@ ) __all__ = [ + "AxisOptions", + "ChartPlotOptions", + "ChartSeries", + "Domain", + "MarkType", + "Point", + "domain_of", + "plot_points", "ASCII_RAMP", "BarOptions", "BarStyle", "BrailleCanvas", "DonutOptions", "DonutSegment", "FillMode", "GaugeOptions", "HORIZONTAL_EIGHTHS", "HistogramOptions", "PlotOptions", "QUADRANTS", "SHADES", "Series", diff --git a/ports/python/hqtui/graphics/chart.py b/ports/python/hqtui/graphics/chart.py new file mode 100644 index 0000000..f32eae1 --- /dev/null +++ b/ports/python/hqtui/graphics/chart.py @@ -0,0 +1,341 @@ +"""Charts of arbitrary (x, y) data. + +``plot`` takes a sequence of floats and puts one sample per column: the x axis +is the list index. That is the right model for a history buffer and the wrong +one for everything else — two series of different lengths silently render at +different horizontal scales, a gap in the data is indistinguishable from a +shorter series, and there is no way at all to say where on the x axis a point +belongs. + +This takes points and a domain for each axis, so a series is placed rather than +appended. ``plot`` is untouched and still means what it meant. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Callable, Literal, Sequence + +from ..buffer import Style +from ..color import Color, round_half_up +from ..surface import Surface +from ..theme import series_color +from .blocks import FillMode, clamp01, vertical_glyph +from .braille import BrailleCanvas +from .plot import blit + +__all__ = [ + "AxisOptions", + "ChartPlotOptions", + "ChartSeries", + "Domain", + "MarkType", + "Point", + "domain_of", + "plot_points", +] + +Point = tuple[float, float] + +#: How a series is marked: joined, dotted, or dropped to the baseline. +MarkType = Literal["line", "scatter", "bar"] + + +@dataclass(slots=True) +class ChartSeries: + points: Sequence[Point] = () + color: Color | None = None + label: str = "" + mark: MarkType = "line" + #: Shade between the line and the baseline. Ignored for a scatter. + fill: bool = False + + +@dataclass(slots=True) +class AxisOptions: + """One axis: what it spans and how its numbers read.""" + + min: float | None = None + max: float | None = None + format: Callable[[float], str] | None = None + #: How many labels to place. Default 2 — the ends. + ticks: int = 0 + + +@dataclass(slots=True) +class ChartPlotOptions: + #: braille is sharpest; block and ascii are the graceful degradations. + mode: FillMode | str = FillMode.BRAILLE + x: AxisOptions | None = None + y: AxisOptions | None = None + background: Color | None = None + grid: bool = False + grid_color: Color | None = None + #: 0-1 opacity of the area fill against the background. + fill_alpha: float | None = None + #: Where a bar or an area is measured from. Defaults to the y minimum. + baseline: float | None = None + + +@dataclass(frozen=True, slots=True) +class Domain: + min: float + max: float + + +def _bound(value: float | None) -> float | None: + """A finite number, or None: a caller's bound is data, and data can be NaN.""" + if value is None or not math.isfinite(value): + return None + return value + + +def domain_of( + series: Sequence[ChartSeries], axis: AxisOptions | None, which: int +) -> Domain: + """The span an axis covers, from the caller where they said and from the + data where they did not. + + A domain of zero width cannot be mapped — every point would land in the same + place and a division would blow up — so a flat series is given room around + itself rather than being collapsed onto one line. + """ + minimum = _bound(axis.min if axis else None) + maximum = _bound(axis.max if axis else None) + if minimum is None or maximum is None: + lo = math.inf + hi = -math.inf + for s in series: + for point in s.points: + v = point[which] + if not math.isfinite(v): + continue + lo = min(lo, v) + hi = max(hi, v) + if not math.isfinite(lo): + lo, hi = 0.0, 1.0 + minimum = lo if minimum is None else minimum + maximum = hi if maximum is None else maximum + if not maximum > minimum: + # A flat series still has to be drawn somewhere sensible. + pad = abs(minimum) * 0.5 if abs(minimum) > 0 else 0.5 + return Domain(minimum - pad, minimum + pad) + return Domain(minimum, maximum) + + +def _ratio(value: float, domain: Domain) -> float: + """Where a value sits in its domain, 0 at the minimum and 1 at the maximum.""" + return (value - domain.min) / (domain.max - domain.min) + + +def _draw_grid(surface: Surface, color: Color, bg: Color | None) -> None: + w, h = surface.width, surface.height + step = max(2, h // 4) + for y in range(0, h, step): + for x in range(0, w, 2): + surface.char(x, y, "·", Style(fg=color, bg=bg)) + + +def plot_points( + surface: Surface, series: Sequence[ChartSeries], options: ChartPlotOptions | None = None +) -> None: + """Draw point series across the whole surface. + + Points are drawn in the order they are given: a line joins them as they + come, which is what lets a chart draw a loop or a path that doubles back. + Sorting them would quietly make that impossible. + """ + options = options or ChartPlotOptions() + if surface.empty or not series: + return + theme = surface.theme + mode = options.mode or FillMode.BRAILLE + bg = options.background + w, h = surface.width, surface.height + + xd = domain_of(series, options.x, 0) + yd = domain_of(series, options.y, 1) + baseline = _bound(options.baseline) + baseline = yd.min if baseline is None else baseline + + if options.grid: + color = options.grid_color + if color is None: + color = theme.border.mix(theme.background, 0.4) + _draw_grid(surface, color, bg) + + if mode != FillMode.BRAILLE: + _plot_cells(surface, series, mode, xd, yd, baseline, bg) + return + + canvas = BrailleCanvas(w, h) + px = float(canvas.width) + py = float(canvas.height) + + for si, s in enumerate(series): + canvas.clear() + color = s.color if s.color is not None else series_color(theme, si) + finite = [p for p in s.points if math.isfinite(p[0]) and math.isfinite(p[1])] + if not finite: + continue + pixels = [ + ( + round_half_up(clamp01(_ratio(p[0], xd)) * (px - 1)), + round_half_up((1 - clamp01(_ratio(p[1], yd))) * (py - 1)), + ) + for p in finite + ] + + if s.mark == "scatter": + for x, y in pixels: + canvas.pixel(x, y) + elif s.mark == "bar": + floor = round_half_up((1 - clamp01(_ratio(baseline, yd))) * (py - 1)) + for x, y in pixels: + canvas.vline(x, min(y, floor), max(y, floor)) + elif len(pixels) == 1: + canvas.pixel(pixels[0][0], pixels[0][1]) + else: + canvas.polyline(pixels) + + if s.fill and s.mark != "scatter": + alpha = 0.5 if options.fill_alpha is None else options.fill_alpha + _fill_under(surface, finite, xd, yd, baseline, color, bg, alpha) + blit(surface, canvas, lambda col, row, c=color: c, bg) + + +def _fill_under( + surface: Surface, + points: Sequence[Point], + xd: Domain, + yd: Domain, + baseline: float, + color: Color, + bg: Color | None, + alpha: float, +) -> None: + """The area between a series and its baseline, in block elements. + + Braille would give eight scattered dots per cell, which reads as noise where + an area should read as an area. The line itself stays Braille, so it keeps + the sub-cell resolution. + + The height of each column is interpolated along the line rather than sampled + from the points that happen to land in it. Sampling leaves a gap wherever a + column has no point of its own, which with arbitrary x values is most of + them — the area comes out striped instead of solid. + """ + w, h = surface.width, surface.height + if w == 0 or h == 0 or not points: + return + base = bg if bg is not None else surface.theme.background + floor = clamp01(_ratio(baseline, yd)) + + def column(x: float) -> float: + return _ratio(x, xd) * (w - 1) + + tops: list[float] = [math.nan] * w + + def record(col: int, value: float) -> None: + if col < 0 or col >= w: + return + # A path that doubles back covers a column twice; the outer edge is the + # one that bounds the area. + previous = tops[col] + if math.isnan(previous) or abs(value - floor) > abs(previous - floor): + tops[col] = value + + if len(points) == 1: + record(int(round_half_up(column(points[0][0]))), clamp01(_ratio(points[0][1], yd))) + for i in range(len(points) - 1): + x0, y0 = points[i] + x1, y1 = points[i + 1] + c0, c1 = column(x0), column(x1) + start = int(max(0.0, math.floor(min(c0, c1)))) + end = int(min(float(w - 1), max(0.0, math.ceil(max(c0, c1))))) + for col in range(start, end + 1): + t = 0.0 if c1 == c0 else (col - c0) / (c1 - c0) + if t < -0.5 or t > 1.5: + continue + y = y0 + (y1 - y0) * clamp01(t) + record(col, clamp01(_ratio(y, yd))) + + for x in range(w): + top = tops[x] + if math.isnan(top): + continue + from01 = min(floor, top) + filled = (max(floor, top) - from01) * h + bottom = int(math.floor(from01 * h)) + full = int(math.floor(filled)) + for k in range(min(full, h)): + row = h - 1 - bottom - k + if row < 0 or row >= h: + continue + depth = 0.0 if h <= 1 else row / (h - 1) + surface.char(x, row, "█", Style(fg=base.mix(color, alpha * (1 - depth * 0.3)), bg=bg)) + if full < h: + glyph = vertical_glyph(filled - full, FillMode.BLOCK) + row = h - 1 - bottom - full + if glyph != " " and 0 <= row < h: + depth = 0.0 if h <= 1 else row / (h - 1) + surface.char( + x, row, glyph, + Style(fg=base.mix(color, alpha * (1 - depth * 0.3) + 0.12), bg=bg), + ) + + +def _plot_cells( + surface: Surface, + series: Sequence[ChartSeries], + mode: FillMode | str, + xd: Domain, + yd: Domain, + baseline: float, + bg: Color | None, +) -> None: + """The block and ascii degradations: one column per cell, tallest point wins. + + A scatter keeps its dots rather than growing columns, because a scatter that + fills to the baseline is a bar chart wearing the wrong name. + """ + w, h = surface.width, surface.height + theme = surface.theme + floor_ratio = clamp01(_ratio(baseline, yd)) + + for si, s in enumerate(series): + color = s.color if s.color is not None else series_color(theme, si) + # Highest value per column, so a column shows the peak that fell in it + # rather than whichever point happened to be last. + tops: list[float] = [math.nan] * w + for point in s.points: + if not math.isfinite(point[0]) or not math.isfinite(point[1]): + continue + col = int(round_half_up(_ratio(point[0], xd) * (w - 1))) + col = max(0, min(col, w - 1)) + value = clamp01(_ratio(point[1], yd)) + if math.isnan(tops[col]) or value > tops[col]: + tops[col] = value + + for x in range(w): + top = tops[x] + if math.isnan(top): + continue + if s.mark == "scatter": + row = h - 1 - min(int(math.floor(top * h)), h - 1) + surface.char( + x, row, "*" if mode == FillMode.ASCII else "•", Style(fg=color, bg=bg) + ) + continue + start = min(floor_ratio, top) * h + filled = (max(floor_ratio, top) - min(floor_ratio, top)) * h + full = int(math.floor(filled)) + for k in range(full): + row = h - 1 - int(math.floor(start)) - k + if 0 <= row < h: + surface.char(x, row, "█", Style(fg=color, bg=bg)) + glyph = vertical_glyph(filled - full, mode) + row = h - 1 - int(math.floor(start)) - full + if glyph != " " and 0 <= row < h: + surface.char(x, row, glyph, Style(fg=color, bg=bg)) diff --git a/ports/python/hqtui/ui.py b/ports/python/hqtui/ui.py index b503c03..46b276e 100644 --- a/ports/python/hqtui/ui.py +++ b/ports/python/hqtui/ui.py @@ -471,6 +471,18 @@ def graph(self, options: w.GraphOptions, layout: Layout | None = None): """Braille line/area graph. Fills the space it is given.""" return self._add(self._filling(layout or Layout()), lambda s: w.draw_graph(s, options)) + def chart(self, options: w.ChartOptions, layout: Layout | None = None): + """A chart of arbitrary (x, y) data, with a domain on both axes. + + ``graph`` plots a history buffer, one sample per column. Use this when + the data has its own x values: two series of different lengths then line + up, and a point lands where its x says it does. + """ + return self._add( + self._constraint(layout or Layout(), "fill"), + lambda s: w.draw_chart(s, options), + ) + def sparkline(self, options: w.SparklineWidgetOptions, layout: Layout | None = None): return self._add(self._leaf(layout or Layout(), 1), lambda s: w.draw_sparkline(s, options)) diff --git a/ports/python/hqtui/widgets/__init__.py b/ports/python/hqtui/widgets/__init__.py index 856ec5f..972480e 100644 --- a/ports/python/hqtui/widgets/__init__.py +++ b/ports/python/hqtui/widgets/__init__.py @@ -65,6 +65,7 @@ draw_tree, resolve_offset, ) +from .chart import ChartOptions, draw_chart from .scrollbar import ( ScrollbarOptions, ScrollbarOrientation, @@ -105,6 +106,7 @@ "draw_columns", "draw_command_palette", "draw_divider", "draw_donut", "draw_gauge", "draw_graph", "draw_heat_bar", "draw_key_values", "draw_list", "draw_log", "draw_meter", "draw_meters", "draw_modal", "draw_progress", + "ChartOptions", "draw_chart", "ScrollbarOptions", "ScrollbarOrientation", "is_vertical", "offset_for_position", "thumb", "draw_scrollbar", "draw_scrollbar_widget", "draw_select", "draw_sparkline", "draw_status_bar", "draw_table", "draw_tabs", "draw_text", "draw_text_input", "draw_tooltip", diff --git a/ports/python/hqtui/widgets/chart.py b/ports/python/hqtui/widgets/chart.py new file mode 100644 index 0000000..f374333 --- /dev/null +++ b/ports/python/hqtui/widgets/chart.py @@ -0,0 +1,123 @@ +"""A chart with two real axes. + +``graph`` plots a history buffer: one sample per column, x meaning "position in +the list". This plots data that has its own x values, with a labelled domain on +both axes, so two series of different lengths line up and a point lands where +its x says it does. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Sequence + +from ..color import Color, round_half_up +from ..graphics.chart import ( + AxisOptions, + ChartPlotOptions, + ChartSeries, + domain_of, + plot_points, +) +from ..surface import Align, Surface, TextOptions +from ..theme import series_color +from ..unicode import fit, string_width +from .meters import nice_label + +__all__ = ["ChartOptions", "draw_chart"] + + +@dataclass(slots=True) +class ChartOptions: + series: Sequence[ChartSeries] = () + plot: ChartPlotOptions = field(default_factory=ChartPlotOptions) + #: Numbers down the left edge. + axis: bool = False + axis_color: Color | None = None + legend: bool = False + legend_align: Align = "left" + + +def _ticks_for(minimum: float, maximum: float, count: int) -> list[float]: + """Evenly spaced values across a domain, ends included. + + Two ticks means the ends and nothing else, which is what an axis wants when + there is no room to say more. + """ + n = max(2, int(count)) + return [minimum + (maximum - minimum) * i / (n - 1) for i in range(n)] + + +def _format_with(axis: AxisOptions | None, value: float) -> str: + if axis is not None and axis.format is not None: + return axis.format(value) + return nice_label(value) + + +def draw_chart(surface: Surface, options: ChartOptions) -> None: + if surface.empty: + return + theme = surface.theme + series = list(options.series) + axis_color = options.axis_color if options.axis_color is not None else theme.muted + + xd = domain_of(series, options.plot.x, 0) + yd = domain_of(series, options.plot.y, 1) + + # The x labels take a row, and they can only take one when there is a row to + # spare — a two-row chart is all plot. + x_ticks = 2 if options.axis else 0 + if options.plot.x is not None and options.plot.x.ticks > 0: + x_ticks = options.plot.x.ticks + want_x_axis = options.axis and x_ticks >= 2 and surface.height > 2 + + plot_surface = surface + if options.axis: + hi = _format_with(options.plot.y, yd.max) + lo = _format_with(options.plot.y, yd.min) + width = max(string_width(hi), string_width(lo)) + 1 + surface.text(0, 0, fit(hi, width, "right"), TextOptions(fg=axis_color)) + if surface.height > 1: + # The minimum marks the bottom of the plot, which is a row higher + # when the x labels have taken the last one. + bottom = surface.height - 2 if want_x_axis else surface.height - 1 + surface.text(0, bottom, fit(lo, width, "right"), TextOptions(fg=axis_color)) + plot_surface = surface.sub(width, 0, surface.width - width, surface.height) + + area = plot_surface + if want_x_axis and plot_surface.height > 1 and plot_surface.width > 0: + area = plot_surface.sub(0, 0, plot_surface.width, plot_surface.height - 1) + row = plot_surface.height - 1 + labels = [ + _format_with(options.plot.x, v) for v in _ticks_for(xd.min, xd.max, x_ticks) + ] + step = (plot_surface.width - 1) / (len(labels) - 1) if len(labels) > 1 else 0.0 + for i, label in enumerate(labels): + # The last label is right-aligned to the edge, so it cannot run off it. + x = min(plot_surface.width - string_width(label), int(round_half_up(i * step))) + plot_surface.text(max(0, x), row, label, TextOptions(fg=axis_color)) + + # The domain is resolved once and handed down, so the labels and the marks + # cannot disagree about what the axis spans. + plot = replace( + options.plot, + x=replace(options.plot.x or AxisOptions(), min=xd.min, max=xd.max), + y=replace(options.plot.y or AxisOptions(), min=yd.min, max=yd.max), + ) + plot_points(area, series, plot) + + if options.legend: + parts = [ + (s.label, s.color if s.color is not None else series_color(theme, i)) + for i, s in enumerate(series) + if s.label + ] + if options.legend_align == "right": + total = sum(string_width(label) + 3 for label, _ in parts) + x = max(0, area.width - total) + else: + x = 0 + y = area.height - 1 if area.height > 3 else 0 + for label, color in parts: + x += area.text(x, y, "■ ", TextOptions(fg=color)) + x += area.text(x, y, f"{label} ", TextOptions(fg=theme.muted)) diff --git a/ports/python/tests/test_conformance_widgets.py b/ports/python/tests/test_conformance_widgets.py index bd23ce2..f887ed8 100644 --- a/ports/python/tests/test_conformance_widgets.py +++ b/ports/python/tests/test_conformance_widgets.py @@ -11,6 +11,7 @@ import math import unittest +import hqtui.graphics.chart as g import hqtui.widgets as w from hqtui.graphics import ( BarOptions, @@ -32,6 +33,20 @@ from .support import assert_buffer, fixture, scene + +def _axis(minimum: float, maximum: float, ticks: int = 0) -> g.AxisOptions: + """The axis bounds every chart fixture pins, without the ceremony.""" + return g.AxisOptions(min=minimum, max=maximum, ticks=ticks) + + +def _chart(mark: str) -> w.ChartOptions: + """One series over the standard 0..10 domain.""" + return w.ChartOptions( + series=[g.ChartSeries(points=[(0, 1), (2, 6), (5, 3), (8, 9), (10, 4)], mark=mark)], + plot=g.ChartPlotOptions(x=_axis(0, 10), y=_axis(0, 10)), + ) + + SERIES = [3, 7, 2, 9, 4, 8, 6, 1, 5, 9, 3, 7, 8, 2, 6, 4, 9, 1, 5, 7] @@ -119,6 +134,46 @@ def draw_scene(case, name: str, s: Surface) -> None: segments=[DonutSegment(value=3), DonutSegment(value=5), DonutSegment(value=2)] ), ) + elif name == "chart-line": + w.draw_chart(s, _chart("line")) + elif name == "chart-scatter": + w.draw_chart(s, _chart("scatter")) + elif name == "chart-bar": + w.draw_chart(s, _chart("bar")) + elif name == "chart-fill": + w.draw_chart(s, w.ChartOptions( + series=[g.ChartSeries(points=[(0, 2), (5, 8), (10, 2)], fill=True)], + plot=g.ChartPlotOptions(x=_axis(0, 10), y=_axis(0, 10)), + )) + elif name == "chart-axes": + w.draw_chart(s, w.ChartOptions( + series=[g.ChartSeries(points=[(0, 0), (5, 50), (10, 100)])], + axis=True, + plot=g.ChartPlotOptions(x=_axis(0, 10, 3), y=_axis(0, 100)), + )) + elif name == "chart-block": + w.draw_chart(s, w.ChartOptions( + series=[g.ChartSeries(points=[(0, 1), (2, 6), (5, 3), (8, 9), (10, 4)], mark="bar")], + plot=g.ChartPlotOptions(mode="block", x=_axis(0, 10), y=_axis(0, 10)), + )) + elif name == "chart-multi": + w.draw_chart(s, w.ChartOptions( + series=[ + g.ChartSeries( + points=[(0, 1), (1, 3), (2, 2), (3, 5), (4, 4), (5, 7), (6, 6), (7, 9)], + label="fine", + ), + g.ChartSeries(points=[(0, 8), (7, 2)], label="coarse"), + ], + axis=True, + legend=True, + plot=g.ChartPlotOptions(x=_axis(0, 7), y=_axis(0, 10)), + )) + elif name == "chart-flat": + w.draw_chart(s, w.ChartOptions( + series=[g.ChartSeries(points=[(0, 4), (5, 4), (10, 4)])], + plot=g.ChartPlotOptions(x=_axis(0, 10)), + )) elif name == "graph-axis": w.draw_graph(s, w.GraphOptions(values=SERIES, axis=True)) elif name == "graph-legend": diff --git a/ports/ruby/examples/widgets.rb b/ports/ruby/examples/widgets.rb index 72e9037..7de1862 100644 --- a/ports/ruby/examples/widgets.rb +++ b/ports/ruby/examples/widgets.rb @@ -86,6 +86,21 @@ def scrollbar(ui) end # @end +# @widget chart +def chart(ui) + # Points carry their own x, so a sparse series and a dense one line up. + ui.chart( + [ + { points: [{ x: 0, y: 1 }, { x: 2, y: 6 }, { x: 5, y: 3 }, { x: 8, y: 9 }, { x: 10, y: 4 }], + label: 'load' }, + { points: [{ x: 0, y: 8 }, { x: 10, y: 2 }], label: 'limit' } + ], + axis: true, legend: true, + x: { min: 0, max: 10, ticks: 3 }, y: { min: 0, max: 10 } + ) +end +# @end + # @widget meter def meter(ui) ui.meter(0.62, label: 'CPU') @@ -274,6 +289,7 @@ def tooltip(ui) 'table' => method(:table), 'log' => method(:log), 'scrollbar' => method(:scrollbar), + 'chart' => method(:chart), 'meter' => method(:meter), 'graph' => method(:graph), 'gauge' => method(:gauge), diff --git a/ports/ruby/lib/hqtui.rb b/ports/ruby/lib/hqtui.rb index 33c72db..dc6d4d4 100644 --- a/ports/ruby/lib/hqtui.rb +++ b/ports/ruby/lib/hqtui.rb @@ -72,6 +72,7 @@ def columns(values, **options) = add('columns', values: values, **options) def donut(segments, **options) = add('donut', segments: segments, **options) def list(items, **options) = add('list', items: items, **options) def scrollbar(total, **options) = add('scrollbar', total: total, **options) + def chart(series, **options) = add('chart', series: series, **options) def tree(nodes, **options) = add('tree', nodes: nodes, **options) def button(label, **options) = add('button', label: label, **options) def checkbox(label, **options) = add('checkbox', label: label, **options) diff --git a/ports/rust/examples/cobol-bridge.rs b/ports/rust/examples/cobol-bridge.rs index 7e3e8e5..3e3db3d 100644 --- a/ports/rust/examples/cobol-bridge.rs +++ b/ports/rust/examples/cobol-bridge.rs @@ -15,6 +15,7 @@ use std::io::Read; +use hqtui::graphics::chart::{AxisOptions, ChartPlotOptions, ChartSeries, MarkType}; use hqtui::graphics::plot::{DonutOptions, DonutSegment, GaugeOptions, PlotOptions, Series}; use hqtui::prelude::*; use hqtui::testing::render_to_text; @@ -86,6 +87,7 @@ fn draw(scene: &Scene, ui: &mut Container) { let mut columns: Vec = Vec::new(); let mut rows: Vec = Vec::new(); let mut keys: Vec = Vec::new(); + let mut chart_series: Vec<(String, Vec<(f64, f64)>)> = Vec::new(); let mut entries: Vec = Vec::new(); let mut points: Vec = Vec::new(); let mut bars: Vec = Vec::new(); @@ -148,6 +150,50 @@ fn draw(scene: &Scene, ui: &mut Container) { "METER" => { ui.meter(MeterOptions::new(record.num.parse().unwrap_or(0.0)).label(&record.key)); } + "CHARTPT" => { + // One point per record, like GRAPHPT. key names the series it + // joins, so a flat record stream can describe several. + let mut parts = record.text.split('|'); + let x: f64 = parts.next().unwrap_or("").parse().unwrap_or(0.0); + let y: f64 = parts.next().unwrap_or("").parse().unwrap_or(0.0); + if let Some(points) = chart_series.iter_mut().find(|(k, _)| *k == record.key) { + points.1.push((x, y)); + } else { + chart_series.push((record.key.clone(), vec![(x, y)])); + } + } + "CHART" => { + // key is the mark every series takes; text is the domain. + let mut parts = record.text.split('|'); + let mut next = || -> f64 { parts.next().unwrap_or("").parse().unwrap_or(0.0) }; + let (xmin, xmax, ymin, ymax) = (next(), next(), next(), next()); + let mark = match record.key.as_str() { + "SCATTER" => MarkType::Scatter, + "BAR" => MarkType::Bar, + _ => MarkType::Line, + }; + let legend = chart_series.len() > 1; + ui.chart(ChartOptions { + series: chart_series + .drain(..) + .map(|(label, points)| { + ChartSeries::new(points).mark(mark).label(label) + }) + .collect(), + axis: true, + legend, + plot: ChartPlotOptions { + x: Some(AxisOptions { + min: Some(xmin), max: Some(xmax), ticks: None, format: None, + }), + y: Some(AxisOptions { + min: Some(ymin), max: Some(ymax), ticks: None, format: None, + }), + ..Default::default() + }, + ..Default::default() + }); + } "SCROLLBAR" => { // key is the edge, num the offset, text "total|viewport". let mut parts = record.text.split('|'); diff --git a/ports/rust/examples/widgets.rs b/ports/rust/examples/widgets.rs index d1efcce..7e2268d 100644 --- a/ports/rust/examples/widgets.rs +++ b/ports/rust/examples/widgets.rs @@ -8,6 +8,7 @@ //! //! Keep each function self-contained: it takes a container and nothing else. +use hqtui::graphics::chart::{AxisOptions, ChartPlotOptions, ChartSeries}; use hqtui::graphics::plot::{BarStyle, DonutOptions, DonutSegment, GaugeOptions, PlotOptions, Series}; use hqtui::prelude::*; use hqtui::testing::render_to_text; @@ -430,6 +431,27 @@ pub fn scrollbar(ui: &mut Container) { } // @end +// @widget chart +pub fn chart(ui: &mut Container) { + // Points carry their own x, so a sparse series and a dense one line up. + ui.chart(ChartOptions { + series: vec![ + ChartSeries::new(vec![(0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0)]) + .label("load"), + ChartSeries::new(vec![(0.0, 8.0), (10.0, 2.0)]).label("limit"), + ], + axis: true, + legend: true, + plot: ChartPlotOptions { + x: Some(AxisOptions { min: Some(0.0), max: Some(10.0), ticks: Some(3), format: None }), + y: Some(AxisOptions { min: Some(0.0), max: Some(10.0), ticks: None, format: None }), + ..Default::default() + }, + ..Default::default() + }); +} +// @end + /// Renders each widget on its own small screen and prints the lot. fn main() { let examples: Vec<(&str, fn(&mut Container))> = vec![ @@ -445,6 +467,7 @@ fn main() { ("tree", tree), ("log", log), ("scrollbar", scrollbar), + ("chart", chart), ("meter", meter), ("meters", meters), ("progress", progress), diff --git a/ports/rust/src/graphics/chart.rs b/ports/rust/src/graphics/chart.rs new file mode 100644 index 0000000..f941a32 --- /dev/null +++ b/ports/rust/src/graphics/chart.rs @@ -0,0 +1,441 @@ +//! Charts of arbitrary (x, y) data. +//! +//! `plot` takes `&[f64]` and puts one sample per column: the x axis is the +//! array index. That is the right model for a history buffer and the wrong one +//! for everything else -- two series of different lengths silently render at +//! different horizontal scales, a gap in the data is indistinguishable from a +//! shorter series, and there is no way at all to say where on the x axis a +//! point belongs. +//! +//! This takes points and a domain for each axis, so a series is placed rather +//! than appended. `plot` is untouched and still means what it meant. + +use crate::buffer::Style; +use crate::color::{round_half_up, Color}; +use crate::graphics::blocks::{vertical_glyph, FillMode}; +use crate::graphics::braille::BrailleCanvas; +use crate::graphics::plot::{blit, first_char}; +use crate::surface::Surface; +use crate::theme::series_color; + +pub type Point = (f64, f64); + +/// How a series is marked: joined, dotted, or dropped to the baseline. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MarkType { + #[default] + Line, + Scatter, + Bar, +} + +#[derive(Clone, Debug, Default)] +pub struct ChartSeries { + pub points: Vec, + pub color: Option, + pub label: Option, + pub mark: MarkType, + /// Shade between the line and the baseline. Ignored for a scatter. + pub fill: bool, +} + +impl ChartSeries { + pub fn new(points: impl Into>) -> ChartSeries { + ChartSeries { points: points.into(), ..Default::default() } + } + + pub fn mark(mut self, mark: MarkType) -> ChartSeries { + self.mark = mark; + self + } + + pub fn label(mut self, label: impl Into) -> ChartSeries { + self.label = Some(label.into()); + self + } + + pub fn filled(mut self) -> ChartSeries { + self.fill = true; + self + } +} + +/// One axis: what it spans and how its numbers read. +#[derive(Clone, Default)] +pub struct AxisOptions { + pub min: Option, + pub max: Option, + pub format: Option String + Send + Sync>>, + /// How many labels to place. Default 2 -- the ends. + pub ticks: Option, +} + +impl std::fmt::Debug for AxisOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AxisOptions") + .field("min", &self.min) + .field("max", &self.max) + .field("ticks", &self.ticks) + .finish() + } +} + +#[derive(Clone, Debug, Default)] +pub struct ChartPlotOptions { + /// Braille is sharpest; block and ascii are the graceful degradations. + pub mode: Option, + pub x: Option, + pub y: Option, + pub background: Option, + pub grid: bool, + pub grid_color: Option, + /// 0-1 opacity of the area fill against the background. + pub fill_alpha: Option, + /// Where a bar or an area is measured from. Defaults to the y minimum. + pub baseline: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Domain { + pub min: f64, + pub max: f64, +} + +/// A finite number, or none: a caller's bound is data, and data can be NaN. +fn bound(value: Option) -> Option { + value.filter(|v| v.is_finite()) +} + +/// The span an axis covers, from the caller where they said and from the data +/// where they did not. +/// +/// A domain of zero width cannot be mapped -- every point would land in the +/// same place and a division would blow up -- so a flat series is given room +/// around itself rather than being collapsed onto one line. +pub fn domain_of(series: &[ChartSeries], axis: Option<&AxisOptions>, which: usize) -> Domain { + let mut min = bound(axis.and_then(|a| a.min)); + let mut max = bound(axis.and_then(|a| a.max)); + if min.is_none() || max.is_none() { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for s in series { + for p in &s.points { + let v = if which == 0 { p.0 } else { p.1 }; + if !v.is_finite() { + continue; + } + if v < lo { + lo = v; + } + if v > hi { + hi = v; + } + } + } + if !lo.is_finite() { + lo = 0.0; + hi = 1.0; + } + min = min.or(Some(lo)); + max = max.or(Some(hi)); + } + let min = min.unwrap_or(0.0); + let max = max.unwrap_or(1.0); + if !(max > min) { + // A flat series still has to be drawn somewhere sensible. + let pad = if min.abs() > 0.0 { min.abs() * 0.5 } else { 0.5 }; + return Domain { min: min - pad, max: min + pad }; + } + Domain { min, max } +} + +/// Where a value sits in its domain, 0 at the minimum and 1 at the maximum. +fn ratio(value: f64, domain: Domain) -> f64 { + (value - domain.min) / (domain.max - domain.min) +} + +fn clamp01(v: f64) -> f64 { + v.clamp(0.0, 1.0) +} + +fn draw_grid(surface: &Surface, color: Color, bg: Option) { + let w = surface.width(); + let h = surface.height(); + let step = std::cmp::max(2, h / 4); + let mut y = 0; + while y < h { + let mut x = 0; + while x < w { + surface.glyph(x as isize, y as isize, '·', &Style { fg: Some(color), bg, attrs: None }); + x += 2; + } + y += step; + } +} + +/// Draw point series across the whole surface. +/// +/// Points are drawn in the order they are given: a line joins them as they +/// come, which is what lets a chart draw a loop or a path that doubles back. +/// Sorting them would quietly make that impossible. +pub fn plot_points(surface: &Surface, series: &[ChartSeries], options: &ChartPlotOptions) { + if surface.is_empty() || series.is_empty() { + return; + } + let theme = surface.theme.clone(); + let mode = options.mode.unwrap_or(FillMode::Braille); + let bg = options.background; + let w = surface.width(); + let h = surface.height(); + + let xd = domain_of(series, options.x.as_ref(), 0); + let yd = domain_of(series, options.y.as_ref(), 1); + let baseline = bound(options.baseline).unwrap_or(yd.min); + + if options.grid { + let color = options + .grid_color + .unwrap_or_else(|| theme.border.mix(theme.background, 0.4)); + draw_grid(surface, color, bg); + } + + if mode != FillMode::Braille { + plot_cells(surface, series, mode, xd, yd, baseline, bg); + return; + } + + let mut canvas = BrailleCanvas::new(w, h); + let px = canvas.width as f64; + let py = canvas.height as f64; + + for (si, s) in series.iter().enumerate() { + canvas.clear(); + let color = s.color.unwrap_or_else(|| series_color(&theme, si as i64)); + let finite: Vec = s + .points + .iter() + .copied() + .filter(|p| p.0.is_finite() && p.1.is_finite()) + .collect(); + if finite.is_empty() { + continue; + } + let pixels: Vec<(f64, f64)> = finite + .iter() + .map(|p| { + ( + round_half_up(clamp01(ratio(p.0, xd)) * (px - 1.0)), + round_half_up((1.0 - clamp01(ratio(p.1, yd))) * (py - 1.0)), + ) + }) + .collect(); + + match s.mark { + MarkType::Scatter => { + for (x, y) in &pixels { + canvas.pixel(*x, *y); + } + } + MarkType::Bar => { + let floor = round_half_up((1.0 - clamp01(ratio(baseline, yd))) * (py - 1.0)); + for (x, y) in &pixels { + canvas.vline(*x, y.min(floor), y.max(floor)); + } + } + MarkType::Line => { + if pixels.len() == 1 { + canvas.pixel(pixels[0].0, pixels[0].1); + } else { + canvas.polyline(&pixels); + } + } + } + + if s.fill && s.mark != MarkType::Scatter { + fill_under( + surface, + &finite, + xd, + yd, + baseline, + color, + bg, + options.fill_alpha.unwrap_or(0.5), + ); + } + blit(surface, &canvas, |_, _| color, bg); + } +} + +/// The area between a series and its baseline, in block elements. +/// +/// Braille would give eight scattered dots per cell, which reads as noise where +/// an area should read as an area. The line itself stays Braille, so it keeps +/// the sub-cell resolution. +/// +/// The height of each column is interpolated along the line rather than sampled +/// from the points that happen to land in it. Sampling leaves a gap wherever a +/// column has no point of its own, which with arbitrary x values is most of +/// them -- the area comes out striped instead of solid. +#[allow(clippy::too_many_arguments)] +fn fill_under( + surface: &Surface, + points: &[Point], + xd: Domain, + yd: Domain, + baseline: f64, + color: Color, + bg: Option, + alpha: f64, +) { + let w = surface.width(); + let h = surface.height(); + if w == 0 || h == 0 || points.is_empty() { + return; + } + let base = bg.unwrap_or(surface.theme.background); + let floor = clamp01(ratio(baseline, yd)); + let column = |x: f64| ratio(x, xd) * (w as f64 - 1.0); + + let mut tops = vec![f64::NAN; w]; + let mut record = |col: isize, value: f64, tops: &mut Vec| { + if col < 0 || col as usize >= tops.len() { + return; + } + // A path that doubles back covers a column twice; the outer edge is the + // one that bounds the area. + let previous = tops[col as usize]; + if previous.is_nan() || (value - floor).abs() > (previous - floor).abs() { + tops[col as usize] = value; + } + }; + + if points.len() == 1 { + record(round_half_up(column(points[0].0)) as isize, clamp01(ratio(points[0].1, yd)), &mut tops); + } + for pair in points.windows(2) { + let (x0, y0) = pair[0]; + let (x1, y1) = pair[1]; + let c0 = column(x0); + let c1 = column(x1); + let from = c0.min(c1).floor().max(0.0) as usize; + let to = std::cmp::min(w - 1, c0.max(c1).ceil().max(0.0) as usize); + for col in from..=to { + let t = if c1 == c0 { 0.0 } else { (col as f64 - c0) / (c1 - c0) }; + if !(-0.5..=1.5).contains(&t) { + continue; + } + let y = y0 + (y1 - y0) * clamp01(t); + record(col as isize, clamp01(ratio(y, yd)), &mut tops); + } + } + + for x in 0..w { + let top = tops[x]; + if top.is_nan() { + continue; + } + let from01 = floor.min(top); + let filled = (floor.max(top) - from01) * h as f64; + let bottom = (from01 * h as f64).floor() as isize; + let full = filled.floor() as isize; + for k in 0..full.min(h as isize) { + let row = h as isize - 1 - bottom - k; + if row < 0 || row >= h as isize { + continue; + } + let depth = if h <= 1 { 0.0 } else { row as f64 / (h as f64 - 1.0) }; + surface.glyph( + x as isize, + row, + '█', + &Style { + fg: Some(base.mix(color, alpha * (1.0 - depth * 0.3))), + bg, + attrs: None, + }, + ); + } + if full < h as isize { + let glyph = vertical_glyph(filled - full as f64, FillMode::Block); + let row = h as isize - 1 - bottom - full; + if glyph != " " && row >= 0 && row < h as isize { + let depth = if h <= 1 { 0.0 } else { row as f64 / (h as f64 - 1.0) }; + surface.glyph( + x as isize, + row, + first_char(glyph), + &Style { + fg: Some(base.mix(color, alpha * (1.0 - depth * 0.3) + 0.12)), + bg, + attrs: None, + }, + ); + } + } + } +} + +/// The block and ascii degradations: one column per cell, tallest point wins. +/// +/// A scatter keeps its dots rather than growing columns, because a scatter that +/// fills to the baseline is a bar chart wearing the wrong name. +fn plot_cells( + surface: &Surface, + series: &[ChartSeries], + mode: FillMode, + xd: Domain, + yd: Domain, + baseline: f64, + bg: Option, +) { + let w = surface.width(); + let h = surface.height(); + let theme = surface.theme.clone(); + let floor_ratio = clamp01(ratio(baseline, yd)); + + for (si, s) in series.iter().enumerate() { + let color = s.color.unwrap_or_else(|| series_color(&theme, si as i64)); + // Highest value per column, so a column shows the peak that fell in it + // rather than whichever point happened to be last. + let mut tops = vec![f64::NAN; w]; + for p in &s.points { + if !p.0.is_finite() || !p.1.is_finite() { + continue; + } + let col = (round_half_up(ratio(p.0, xd) * (w as f64 - 1.0)) as isize) + .clamp(0, w as isize - 1) as usize; + let value = clamp01(ratio(p.1, yd)); + if tops[col].is_nan() || value > tops[col] { + tops[col] = value; + } + } + + for x in 0..w { + let top = tops[x]; + if top.is_nan() { + continue; + } + if s.mark == MarkType::Scatter { + let row = h as isize - 1 - ((top * h as f64).floor() as isize).min(h as isize - 1); + let glyph = if mode == FillMode::Ascii { '*' } else { '•' }; + surface.glyph(x as isize, row, glyph, &Style { fg: Some(color), bg, attrs: None }); + continue; + } + let from = floor_ratio.min(top) * h as f64; + let filled = (floor_ratio.max(top) - floor_ratio.min(top)) * h as f64; + let full = filled.floor() as isize; + for k in 0..full { + let row = h as isize - 1 - from.floor() as isize - k; + if row >= 0 && row < h as isize { + surface.glyph(x as isize, row, '█', &Style { fg: Some(color), bg, attrs: None }); + } + } + let glyph = vertical_glyph(filled - full as f64, mode); + let row = h as isize - 1 - from.floor() as isize - full; + if glyph != " " && row >= 0 && row < h as isize { + surface.glyph(x as isize, row, first_char(glyph), &Style { fg: Some(color), bg, attrs: None }); + } + } + } +} diff --git a/ports/rust/src/graphics/mod.rs b/ports/rust/src/graphics/mod.rs index f0bddc6..36be133 100644 --- a/ports/rust/src/graphics/mod.rs +++ b/ports/rust/src/graphics/mod.rs @@ -2,6 +2,7 @@ //! plotting primitives built on them. pub mod blocks; +pub mod chart; pub mod braille; pub mod plot; @@ -10,6 +11,9 @@ pub use blocks::{ HORIZONTAL_EIGHTHS, QUADRANTS, SHADES, VERTICAL_EIGHTHS, }; pub use braille::BrailleCanvas; +pub use chart::{ + domain_of, plot_points, AxisOptions, ChartPlotOptions, ChartSeries, Domain, MarkType, Point, +}; pub use plot::{ bar, blit, donut, gauge, histogram, plot, sparkline, BarOptions, BarStyle, DonutOptions, DonutSegment, GaugeOptions, HistogramOptions, PlotOptions, Series, SparklineOptions, diff --git a/ports/rust/src/graphics/plot.rs b/ports/rust/src/graphics/plot.rs index fb58728..985c5ca 100644 --- a/ports/rust/src/graphics/plot.rs +++ b/ports/rust/src/graphics/plot.rs @@ -62,7 +62,7 @@ pub struct PlotOptions { /// Every glyph ramp entry is a single character; this is the reference /// implementation's `codePointAt(0)` on a one-glyph string. -fn first_char(s: &str) -> char { +pub(crate) fn first_char(s: &str) -> char { s.chars().next().unwrap_or(' ') } diff --git a/ports/rust/src/ui.rs b/ports/rust/src/ui.rs index 0ea3ad4..33586be 100644 --- a/ports/rust/src/ui.rs +++ b/ports/rust/src/ui.rs @@ -749,6 +749,16 @@ impl<'a> Container<'a> { self.add(constraint, move |s| w::draw_graph(&s, &options)) } + /// A chart of arbitrary (x, y) data, with a domain on both axes. + /// + /// `graph` plots a history buffer, one sample per column. Use this when the + /// data has its own x values: two series of different lengths then line up, + /// and a point lands where its x says it does. + pub fn chart(&mut self, options: w::ChartOptions) -> &mut Self { + let constraint = self.filling(); + self.add(constraint, move |s| w::draw_chart(&s, &options)) + } + pub fn sparkline(&mut self, options: w::SparklineWidgetOptions) -> &mut Self { let constraint = self.leaf(1); self.add(constraint, move |s| w::draw_sparkline(&s, &options)) diff --git a/ports/rust/src/widgets/chart.rs b/ports/rust/src/widgets/chart.rs new file mode 100644 index 0000000..db57143 --- /dev/null +++ b/ports/rust/src/widgets/chart.rs @@ -0,0 +1,152 @@ +//! A chart with two real axes. +//! +//! `graph` plots a history buffer: one sample per column, x meaning "position +//! in the array". This plots data that has its own x values, with a labelled +//! domain on both axes, so two series of different lengths line up and a point +//! lands where its x says it does. + +use crate::color::{round_half_up, Color}; +use crate::graphics::chart::{ + domain_of, plot_points, AxisOptions, ChartPlotOptions, ChartSeries, +}; +use crate::surface::{Surface, TextOptions}; +use crate::theme::series_color; +use crate::unicode::{fit, string_width, Align}; +use crate::widgets::meters::nice_label; + +#[derive(Clone, Debug, Default)] +pub struct ChartOptions { + pub series: Vec, + pub plot: ChartPlotOptions, + /// Numbers down the left edge. + pub axis: bool, + pub axis_color: Option, + pub legend: bool, + pub legend_align: Option, +} + +/// Evenly spaced values across a domain, ends included. +/// +/// Two ticks means the ends and nothing else, which is what an axis wants when +/// there is no room to say more. +fn ticks_for(min: f64, max: f64, count: usize) -> Vec { + let n = count.max(2); + (0..n).map(|i| min + (max - min) * i as f64 / (n - 1) as f64).collect() +} + +fn format_with(axis: Option<&AxisOptions>, value: f64) -> String { + match axis.and_then(|a| a.format.as_ref()) { + Some(f) => f(value), + None => nice_label(value), + } +} + +pub fn draw_chart(surface: &Surface, options: &ChartOptions) { + if surface.is_empty() { + return; + } + let theme = surface.theme.clone(); + let series = &options.series; + let axis_color = options.axis_color.unwrap_or(theme.muted); + + let xd = domain_of(series, options.plot.x.as_ref(), 0); + let yd = domain_of(series, options.plot.y.as_ref(), 1); + + // The x labels take a row, and they can only take one when there is a row + // to spare -- a two-row chart is all plot. + let x_ticks = options + .plot + .x + .as_ref() + .and_then(|a| a.ticks) + .unwrap_or(if options.axis { 2 } else { 0 }); + let want_x_axis = options.axis && x_ticks >= 2 && surface.height() > 2; + + let mut plot_surface = surface.clone(); + if options.axis { + let hi = format_with(options.plot.y.as_ref(), yd.max); + let lo = format_with(options.plot.y.as_ref(), yd.min); + let width = string_width(&hi).max(string_width(&lo)) + 1; + surface.text(0, 0, &fit(&hi, width, Align::Right), &TextOptions::new().fg(axis_color)); + if surface.height() > 1 { + // The minimum marks the bottom of the plot, which is a row higher + // when the x labels have taken the last one. + let bottom = if want_x_axis { + surface.height() as isize - 2 + } else { + surface.height() as isize - 1 + }; + surface.text( + 0, + bottom, + &fit(&lo, width, Align::Right), + &TextOptions::new().fg(axis_color), + ); + } + plot_surface = surface.sub( + width as isize, + 0, + surface.width().saturating_sub(width), + surface.height(), + ); + } + + let mut area = plot_surface.clone(); + if want_x_axis && plot_surface.height() > 1 && plot_surface.width() > 0 { + area = plot_surface.sub(0, 0, plot_surface.width(), plot_surface.height() - 1); + let row = plot_surface.height() as isize - 1; + let labels: Vec = ticks_for(xd.min, xd.max, x_ticks) + .into_iter() + .map(|v| format_with(options.plot.x.as_ref(), v)) + .collect(); + let step = if labels.len() > 1 { + (plot_surface.width() as f64 - 1.0) / (labels.len() - 1) as f64 + } else { + 0.0 + }; + for (i, label) in labels.iter().enumerate() { + // The last label is right-aligned to the edge, so it cannot run off it. + let x = (plot_surface.width() as isize - string_width(label) as isize) + .min(round_half_up(i as f64 * step) as isize); + plot_surface.text(x.max(0), row, label, &TextOptions::new().fg(axis_color)); + } + } + + // The domain is resolved once and handed down, so the labels and the marks + // cannot disagree about what the axis spans. + let mut plot_options = options.plot.clone(); + plot_options.x = Some(AxisOptions { + min: Some(xd.min), + max: Some(xd.max), + ..options.plot.x.clone().unwrap_or_default() + }); + plot_options.y = Some(AxisOptions { + min: Some(yd.min), + max: Some(yd.max), + ..options.plot.y.clone().unwrap_or_default() + }); + plot_points(&area, series, &plot_options); + + if options.legend { + let parts: Vec<(String, Color)> = series + .iter() + .enumerate() + .filter_map(|(i, s)| { + s.label + .clone() + .map(|l| (l, s.color.unwrap_or_else(|| series_color(&theme, i as i64)))) + }) + .collect(); + let mut x: isize = if options.legend_align == Some(Align::Right) { + let total: usize = parts.iter().map(|(l, _)| string_width(l) + 3).sum(); + (area.width() as isize - total as isize).max(0) + } else { + 0 + }; + let y = if area.height() > 3 { area.height() as isize - 1 } else { 0 }; + for (label, color) in &parts { + x += area.text(x, y, "■ ", &TextOptions::new().fg(*color)) as isize; + x += area.text(x, y, &format!("{label} "), &TextOptions::new().fg(theme.muted)) as isize; + } + } +} diff --git a/ports/rust/src/widgets/mod.rs b/ports/rust/src/widgets/mod.rs index d8b9c34..1848617 100644 --- a/ports/rust/src/widgets/mod.rs +++ b/ports/rust/src/widgets/mod.rs @@ -2,6 +2,7 @@ //! builder in [`ui`](crate::ui) wraps every one of these with layout, so reach //! for these directly only when you are drawing inside a `draw` escape hatch. +pub mod chart; pub mod controls; pub mod meters; pub mod scrollbar; @@ -20,6 +21,7 @@ pub use meters::{ DonutSegment, GaugeOptions, GraphOptions, HeatBarOptions, MeterItem, MeterOptions, MetersOptions, ProgressOptions, SparklineWidgetOptions, }; +pub use chart::{draw_chart, ChartOptions}; pub use scrollbar::{ draw_scrollbar, draw_scrollbar_widget, offset_for_position, thumb, thumb_of, ScrollbarOptions, ScrollbarOrientation, diff --git a/ports/rust/tests/conformance_widgets.rs b/ports/rust/tests/conformance_widgets.rs index 1a55d4f..5927047 100644 --- a/ports/rust/tests/conformance_widgets.rs +++ b/ports/rust/tests/conformance_widgets.rs @@ -13,6 +13,7 @@ use hqtui::graphics::plot::{ bar, donut, gauge, plot, sparkline, BarOptions, BarStyle, DonutOptions, DonutSegment, GaugeOptions, PlotOptions, Series, }; +use hqtui::graphics::chart::{AxisOptions, ChartPlotOptions, ChartSeries, MarkType}; use hqtui::graphics::FillMode; use hqtui::surface::Surface; use hqtui::unicode::Align; @@ -25,6 +26,24 @@ fn series() -> Vec { SERIES.to_vec() } +/// The axis bounds every chart fixture pins, without the ceremony. +fn axis(min: f64, max: f64, ticks: Option) -> AxisOptions { + AxisOptions { min: Some(min), max: Some(max), ticks, format: None } +} + +/// One series over the standard 0..10 domain. +fn chart(points: &[(f64, f64)], mark: MarkType) -> ChartOptions { + ChartOptions { + series: vec![ChartSeries::new(points.to_vec()).mark(mark)], + plot: ChartPlotOptions { + x: Some(axis(0.0, 10.0, None)), + y: Some(axis(0.0, 10.0, None)), + ..Default::default() + }, + ..Default::default() + } +} + fn draw_scene(name: &str, s: &Surface) { match name { "text-plain" => draw_text(s, "hello terminal", &TextStyle::new()), @@ -145,6 +164,79 @@ fn draw_scene(name: &str, s: &Surface) { background: None, }, ), + "chart-line" => draw_chart(s, &chart(&[(0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0)], MarkType::Line)), + "chart-scatter" => draw_chart(s, &chart(&[(0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0)], MarkType::Scatter)), + "chart-bar" => draw_chart(s, &chart(&[(0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0)], MarkType::Bar)), + "chart-fill" => draw_chart( + s, + &ChartOptions { + series: vec![ChartSeries::new(vec![(0.0, 2.0), (5.0, 8.0), (10.0, 2.0)]).filled()], + plot: ChartPlotOptions { + x: Some(axis(0.0, 10.0, None)), + y: Some(axis(0.0, 10.0, None)), + ..Default::default() + }, + ..Default::default() + }, + ), + "chart-axes" => draw_chart( + s, + &ChartOptions { + series: vec![ChartSeries::new(vec![(0.0, 0.0), (5.0, 50.0), (10.0, 100.0)])], + axis: true, + plot: ChartPlotOptions { + x: Some(axis(0.0, 10.0, Some(3))), + y: Some(axis(0.0, 100.0, None)), + ..Default::default() + }, + ..Default::default() + }, + ), + "chart-block" => draw_chart( + s, + &ChartOptions { + series: vec![ChartSeries::new(vec![ + (0.0, 1.0), (2.0, 6.0), (5.0, 3.0), (8.0, 9.0), (10.0, 4.0), + ]) + .mark(MarkType::Bar)], + plot: ChartPlotOptions { + mode: Some(FillMode::Block), + x: Some(axis(0.0, 10.0, None)), + y: Some(axis(0.0, 10.0, None)), + ..Default::default() + }, + ..Default::default() + }, + ), + "chart-multi" => draw_chart( + s, + &ChartOptions { + series: vec![ + ChartSeries::new(vec![ + (0.0, 1.0), (1.0, 3.0), (2.0, 2.0), (3.0, 5.0), + (4.0, 4.0), (5.0, 7.0), (6.0, 6.0), (7.0, 9.0), + ]) + .label("fine"), + ChartSeries::new(vec![(0.0, 8.0), (7.0, 2.0)]).label("coarse"), + ], + axis: true, + legend: true, + plot: ChartPlotOptions { + x: Some(axis(0.0, 7.0, None)), + y: Some(axis(0.0, 10.0, None)), + ..Default::default() + }, + ..Default::default() + }, + ), + "chart-flat" => draw_chart( + s, + &ChartOptions { + series: vec![ChartSeries::new(vec![(0.0, 4.0), (5.0, 4.0), (10.0, 4.0)])], + plot: ChartPlotOptions { x: Some(axis(0.0, 10.0, None)), ..Default::default() }, + ..Default::default() + }, + ), "graph-axis" => draw_graph(s, &GraphOptions::new(series()).with_axis()), "graph-legend" => draw_graph( s, diff --git a/ports/zig/examples/widgets.zig b/ports/zig/examples/widgets.zig index 2b8ddde..0ab3f67 100644 --- a/ports/zig/examples/widgets.zig +++ b/ports/zig/examples/widgets.zig @@ -187,6 +187,27 @@ fn scrollbarRow(r: *Container) anyerror!void { } // @end +// @widget chart +fn chart(ui: *Container) anyerror!void { + // Points carry their own x, so a sparse series and a dense one line up. + try ui.chart(.{ + .series = &.{ + .{ .points = &.{ + .{ .x = 0, .y = 1 }, .{ .x = 2, .y = 6 }, .{ .x = 5, .y = 3 }, + .{ .x = 8, .y = 9 }, .{ .x = 10, .y = 4 }, + }, .label = "load" }, + .{ .points = &.{ .{ .x = 0, .y = 8 }, .{ .x = 10, .y = 2 } }, .label = "limit" }, + }, + .axis = true, + .legend = true, + .plot = .{ + .x = .{ .min = 0, .max = 10, .ticks = 3 }, + .y = .{ .min = 0, .max = 10 }, + }, + }); +} +// @end + // @widget meter fn meter(ui: *Container) anyerror!void { try ui.meter(.{ .value = 0.62, .label = "CPU" }); @@ -375,6 +396,7 @@ const examples = [_]Example{ .{ .name = "tree", .body = hqtui.Body.plain(tree) }, .{ .name = "log", .body = hqtui.Body.plain(log) }, .{ .name = "scrollbar", .body = hqtui.Body.plain(scrollbar) }, + .{ .name = "chart", .body = hqtui.Body.plain(chart) }, .{ .name = "meter", .body = hqtui.Body.plain(meter) }, .{ .name = "meters", .body = hqtui.Body.plain(meters) }, .{ .name = "progress", .body = hqtui.Body.plain(progress) }, diff --git a/ports/zig/src/conformance_widgets.zig b/ports/zig/src/conformance_widgets.zig index 394230b..156eecb 100644 --- a/ports/zig/src/conformance_widgets.zig +++ b/ports/zig/src/conformance_widgets.zig @@ -19,6 +19,26 @@ const Surface = surface_mod.Surface; const series = [_]f64{ 3, 7, 2, 9, 4, 8, 6, 1, 5, 9, 3, 7, 8, 2, 6, 4, 9, 1, 5, 7 }; + +/// The points every single-series chart fixture pins. +const chart_points = [_]graphics.Point{ + .{ .x = 0, .y = 1 }, .{ .x = 2, .y = 6 }, .{ .x = 5, .y = 3 }, + .{ .x = 8, .y = 9 }, .{ .x = 10, .y = 4 }, +}; + +/// The axis bounds every chart fixture pins, without the ceremony. +fn axisOf(min: f64, max: f64, ticks: usize) graphics.AxisOptions { + return .{ .min = min, .max = max, .ticks = ticks }; +} + +/// One series over the standard 0..10 domain. +fn chartScene(allocator: std.mem.Allocator, s: Surface, mark: graphics.MarkType) !void { + try w.drawChart(allocator, s, .{ + .series = &.{.{ .points = &chart_points, .mark = mark }}, + .plot = .{ .x = axisOf(0, 10, 0), .y = axisOf(0, 10, 0) }, + }); +} + fn drawScene(allocator: std.mem.Allocator, name: []const u8, s: Surface) !void { const eq = std.mem.eql; @@ -98,6 +118,46 @@ fn drawScene(allocator: std.mem.Allocator, name: []const u8, s: Surface) !void { try w.drawDonut(allocator, s, .{ .segments = &.{ .{ .value = 3 }, .{ .value = 5 }, .{ .value = 2 }, } }); + } else if (eq(u8, name, "chart-line")) { + try chartScene(allocator, s, .line); + } else if (eq(u8, name, "chart-scatter")) { + try chartScene(allocator, s, .scatter); + } else if (eq(u8, name, "chart-bar")) { + try chartScene(allocator, s, .bar); + } else if (eq(u8, name, "chart-fill")) { + try w.drawChart(allocator, s, .{ + .series = &.{.{ .points = &.{ .{ .x = 0, .y = 2 }, .{ .x = 5, .y = 8 }, .{ .x = 10, .y = 2 } }, .fill = true }}, + .plot = .{ .x = axisOf(0, 10, 0), .y = axisOf(0, 10, 0) }, + }); + } else if (eq(u8, name, "chart-axes")) { + try w.drawChart(allocator, s, .{ + .series = &.{.{ .points = &.{ .{ .x = 0, .y = 0 }, .{ .x = 5, .y = 50 }, .{ .x = 10, .y = 100 } } }}, + .axis = true, + .plot = .{ .x = axisOf(0, 10, 3), .y = axisOf(0, 100, 0) }, + }); + } else if (eq(u8, name, "chart-block")) { + try w.drawChart(allocator, s, .{ + .series = &.{.{ .points = &chart_points, .mark = .bar }}, + .plot = .{ .mode = .block, .x = axisOf(0, 10, 0), .y = axisOf(0, 10, 0) }, + }); + } else if (eq(u8, name, "chart-multi")) { + try w.drawChart(allocator, s, .{ + .series = &.{ + .{ .points = &.{ + .{ .x = 0, .y = 1 }, .{ .x = 1, .y = 3 }, .{ .x = 2, .y = 2 }, .{ .x = 3, .y = 5 }, + .{ .x = 4, .y = 4 }, .{ .x = 5, .y = 7 }, .{ .x = 6, .y = 6 }, .{ .x = 7, .y = 9 }, + }, .label = "fine" }, + .{ .points = &.{ .{ .x = 0, .y = 8 }, .{ .x = 7, .y = 2 } }, .label = "coarse" }, + }, + .axis = true, + .legend = true, + .plot = .{ .x = axisOf(0, 7, 0), .y = axisOf(0, 10, 0) }, + }); + } else if (eq(u8, name, "chart-flat")) { + try w.drawChart(allocator, s, .{ + .series = &.{.{ .points = &.{ .{ .x = 0, .y = 4 }, .{ .x = 5, .y = 4 }, .{ .x = 10, .y = 4 } } }}, + .plot = .{ .x = axisOf(0, 10, 0) }, + }); } else if (eq(u8, name, "graph-axis")) { try w.drawGraph(allocator, s, .{ .values = &series, .axis = true }); } else if (eq(u8, name, "graph-legend")) { diff --git a/ports/zig/src/graphics.zig b/ports/zig/src/graphics.zig index 19f9f2d..f6b11f5 100644 --- a/ports/zig/src/graphics.zig +++ b/ports/zig/src/graphics.zig @@ -3,6 +3,7 @@ pub const blocks = @import("graphics/blocks.zig"); pub const braille = @import("graphics/braille.zig"); +pub const chart_mod = @import("graphics/chart.zig"); pub const plot_mod = @import("graphics/plot.zig"); pub const BrailleCanvas = braille.BrailleCanvas; @@ -32,5 +33,12 @@ pub const donut = plot_mod.donut; pub const gauge = plot_mod.gauge; pub const histogram = plot_mod.histogram; pub const plot = plot_mod.plot; +pub const AxisOptions = chart_mod.AxisOptions; +pub const ChartPlotOptions = chart_mod.ChartPlotOptions; +pub const ChartSeries = chart_mod.ChartSeries; +pub const Domain = chart_mod.Domain; +pub const MarkType = chart_mod.MarkType; +pub const domainOf = chart_mod.domainOf; +pub const plotPoints = chart_mod.plotPoints; pub const sparkline = plot_mod.sparkline; pub const tail = plot_mod.tail; diff --git a/ports/zig/src/graphics/chart.zig b/ports/zig/src/graphics/chart.zig new file mode 100644 index 0000000..c0cebc8 --- /dev/null +++ b/ports/zig/src/graphics/chart.zig @@ -0,0 +1,389 @@ +//! Charts of arbitrary (x, y) data. +//! +//! `plot` takes `[]const f64` and puts one sample per column: the x axis is the +//! slice index. That is the right model for a history buffer and the wrong one +//! for everything else -- two series of different lengths silently render at +//! different horizontal scales, a gap in the data is indistinguishable from a +//! shorter series, and there is no way at all to say where on the x axis a +//! point belongs. +//! +//! This takes points and a domain for each axis, so a series is placed rather +//! than appended. `plot` is untouched and still means what it meant. + +const std = @import("std"); + +const blocks = @import("blocks.zig"); +const braille_mod = @import("braille.zig"); +const buffer_mod = @import("../buffer.zig"); +const color_mod = @import("../color.zig"); +const plot_mod = @import("plot.zig"); +const surface_mod = @import("../surface.zig"); +const theme_mod = @import("../theme.zig"); + +const BrailleCanvas = braille_mod.BrailleCanvas; +const Color = color_mod.Color; +const FillMode = blocks.FillMode; +const Point = braille_mod.Point; +const Style = buffer_mod.Style; +const Surface = surface_mod.Surface; +const clamp01 = blocks.clamp01; +const firstCodepoint = blocks.firstCodepoint; +const roundHalfUp = color_mod.roundHalfUp; +const seriesColor = theme_mod.seriesColor; + +/// How a series is marked: joined, dotted, or dropped to the baseline. +pub const MarkType = enum { line, scatter, bar }; + +pub const ChartSeries = struct { + points: []const Point = &.{}, + color: ?Color = null, + label: []const u8 = "", + mark: MarkType = .line, + /// Shade between the line and the baseline. Ignored for a scatter. + fill: bool = false, +}; + +/// One axis: what it spans and how its numbers read. +pub const AxisOptions = struct { + min: ?f64 = null, + max: ?f64 = null, + format: ?*const fn (f64, []u8) []const u8 = null, + /// How many labels to place. Default 2 -- the ends. + ticks: usize = 0, +}; + +pub const ChartPlotOptions = struct { + /// Braille is sharpest; block and ascii are the graceful degradations. + mode: FillMode = .braille, + x: ?AxisOptions = null, + y: ?AxisOptions = null, + background: ?Color = null, + grid: bool = false, + grid_color: ?Color = null, + /// 0-1 opacity of the area fill against the background. + fill_alpha: ?f64 = null, + /// Where a bar or an area is measured from. Defaults to the y minimum. + baseline: ?f64 = null, +}; + +pub const Domain = struct { min: f64, max: f64 }; + +/// A finite number, or null: a caller's bound is data, and data can be NaN. +fn bound(value: ?f64) ?f64 { + const v = value orelse return null; + return if (std.math.isFinite(v)) v else null; +} + +/// The span an axis covers, from the caller where they said and from the data +/// where they did not. +/// +/// A domain of zero width cannot be mapped -- every point would land in the same +/// place and a division would blow up -- so a flat series is given room around +/// itself rather than being collapsed onto one line. +pub fn domainOf(series: []const ChartSeries, axis: ?AxisOptions, which: usize) Domain { + var min = if (axis) |a| bound(a.min) else null; + var max = if (axis) |a| bound(a.max) else null; + if (min == null or max == null) { + var lo: f64 = std.math.inf(f64); + var hi: f64 = -std.math.inf(f64); + for (series) |s| { + for (s.points) |p| { + const v = if (which == 0) p.x else p.y; + if (!std.math.isFinite(v)) continue; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + } + if (!std.math.isFinite(lo)) { + lo = 0; + hi = 1; + } + if (min == null) min = lo; + if (max == null) max = hi; + } + const lo = min.?; + const hi = max.?; + if (!(hi > lo)) { + // A flat series still has to be drawn somewhere sensible. + const pad: f64 = if (@abs(lo) > 0) @abs(lo) * 0.5 else 0.5; + return .{ .min = lo - pad, .max = lo + pad }; + } + return .{ .min = lo, .max = hi }; +} + +/// Where a value sits in its domain, 0 at the minimum and 1 at the maximum. +fn ratio(value: f64, d: Domain) f64 { + return (value - d.min) / (d.max - d.min); +} + +fn drawGrid(s: Surface, color: Color, bg: ?Color) void { + const w = s.width(); + const h = s.height(); + const step = @max(2, h / 4); + var y: usize = 0; + while (y < h) : (y += step) { + var x: usize = 0; + while (x < w) : (x += 2) { + s.glyph(@intCast(x), @intCast(y), '·', .{ .fg = color, .bg = bg }); + } + } +} + +/// Draw point series across the whole surface. +/// +/// Points are drawn in the order they are given: a line joins them as they come, +/// which is what lets a chart draw a loop or a path that doubles back. Sorting +/// them would quietly make that impossible. +pub fn plotPoints( + allocator: std.mem.Allocator, + s: Surface, + series: []const ChartSeries, + options: ChartPlotOptions, +) !void { + if (s.isEmpty() or series.len == 0) return; + const theme = s.theme; + const bg = options.background; + const w = s.width(); + const h = s.height(); + + const xd = domainOf(series, options.x, 0); + const yd = domainOf(series, options.y, 1); + const baseline = bound(options.baseline) orelse yd.min; + + if (options.grid) { + const color = options.grid_color orelse theme.border.mix(theme.background, 0.4); + drawGrid(s, color, bg); + } + + if (options.mode != .braille) { + plotCells(s, series, options.mode, xd, yd, baseline, bg); + return; + } + + var canvas = try BrailleCanvas.init(allocator, w, h); + defer canvas.deinit(); + const px: f64 = @floatFromInt(canvas.width); + const py: f64 = @floatFromInt(canvas.height); + + for (series, 0..) |cs, si| { + canvas.clear(); + const color = cs.color orelse seriesColor(theme.*, @intCast(si)); + if (cs.points.len == 0) continue; + + // Two buffers per series rather than one shared one: a series is drawn + // and blitted before the next is touched, so nothing outlives the loop. + const finite = try allocator.alloc(Point, cs.points.len); + defer allocator.free(finite); + var count: usize = 0; + for (cs.points) |p| { + if (!std.math.isFinite(p.x) or !std.math.isFinite(p.y)) continue; + finite[count] = p; + count += 1; + } + if (count == 0) continue; + + const pixels = try allocator.alloc(Point, count); + defer allocator.free(pixels); + for (finite[0..count], 0..) |p, i| { + pixels[i] = .{ + .x = roundHalfUp(clamp01(ratio(p.x, xd)) * (px - 1)), + .y = roundHalfUp((1 - clamp01(ratio(p.y, yd))) * (py - 1)), + }; + } + + switch (cs.mark) { + .scatter => for (pixels) |p| canvas.pixel(p.x, p.y), + .bar => { + const floor = roundHalfUp((1 - clamp01(ratio(baseline, yd))) * (py - 1)); + for (pixels) |p| canvas.vline(p.x, @min(p.y, floor), @max(p.y, floor)); + }, + .line => { + if (pixels.len == 1) { + canvas.pixel(pixels[0].x, pixels[0].y); + } else { + canvas.polyline(pixels); + } + }, + } + + if (cs.fill and cs.mark != .scatter) { + try fillUnder( + allocator, + s, + finite[0..count], + xd, + yd, + baseline, + color, + bg, + options.fill_alpha orelse 0.5, + ); + } + plot_mod.blitFlat(s, &canvas, color, bg); + } +} + +/// The area between a series and its baseline, in block elements. +/// +/// Braille would give eight scattered dots per cell, which reads as noise where +/// an area should read as an area. The line itself stays Braille, so it keeps +/// the sub-cell resolution. +/// +/// The height of each column is interpolated along the line rather than sampled +/// from the points that happen to land in it. Sampling leaves a gap wherever a +/// column has no point of its own, which with arbitrary x values is most of +/// them -- the area comes out striped instead of solid. +fn fillUnder( + allocator: std.mem.Allocator, + s: Surface, + points: []const Point, + xd: Domain, + yd: Domain, + baseline: f64, + color: Color, + bg: ?Color, + alpha: f64, +) !void { + const w = s.width(); + const h = s.height(); + if (w == 0 or h == 0 or points.len == 0) return; + const base = bg orelse s.theme.background; + const floor = clamp01(ratio(baseline, yd)); + + const tops = try allocator.alloc(f64, w); + defer allocator.free(tops); + @memset(tops, std.math.nan(f64)); + + const fw: f64 = @floatFromInt(w); + const fh: f64 = @floatFromInt(h); + + for (0..points.len - 1) |i| { + const p0 = points[i]; + const p1 = points[i + 1]; + const c0 = ratio(p0.x, xd) * (fw - 1); + const c1 = ratio(p1.x, xd) * (fw - 1); + const lo = @max(0, @floor(@min(c0, c1))); + const hi = @min(fw - 1, @max(0, @ceil(@max(c0, c1)))); + var col: usize = @intFromFloat(lo); + const last: usize = @intFromFloat(hi); + while (col <= last and col < w) : (col += 1) { + const fc: f64 = @floatFromInt(col); + const t = if (c1 == c0) 0 else (fc - c0) / (c1 - c0); + if (t < -0.5 or t > 1.5) continue; + const y = p0.y + (p1.y - p0.y) * clamp01(t); + const value = clamp01(ratio(y, yd)); + // A path that doubles back covers a column twice; the outer edge is + // the one that bounds the area. + const previous = tops[col]; + if (std.math.isNan(previous) or @abs(value - floor) > @abs(previous - floor)) { + tops[col] = value; + } + } + } + if (points.len == 1) { + const c: f64 = roundHalfUp(ratio(points[0].x, xd) * (fw - 1)); + if (c >= 0 and c < fw) tops[@intFromFloat(c)] = clamp01(ratio(points[0].y, yd)); + } + + for (0..w) |x| { + const top = tops[x]; + if (std.math.isNan(top)) continue; + const from01 = @min(floor, top); + const filled = (@max(floor, top) - from01) * fh; + const bottom: isize = @intFromFloat(@floor(from01 * fh)); + const full: isize = @intFromFloat(@floor(filled)); + var k: isize = 0; + while (k < full and k < @as(isize, @intCast(h))) : (k += 1) { + const row = @as(isize, @intCast(h)) - 1 - bottom - k; + if (row < 0 or row >= @as(isize, @intCast(h))) continue; + const depth: f64 = if (h <= 1) 0 else @as(f64, @floatFromInt(row)) / (fh - 1); + s.glyph(@intCast(x), row, '█', .{ + .fg = base.mix(color, alpha * (1 - depth * 0.3)), + .bg = bg, + }); + } + if (full < @as(isize, @intCast(h))) { + const glyph = blocks.verticalGlyph(filled - @as(f64, @floatFromInt(full)), .block); + const row = @as(isize, @intCast(h)) - 1 - bottom - full; + if (!std.mem.eql(u8, glyph, " ") and row >= 0 and row < @as(isize, @intCast(h))) { + const depth: f64 = if (h <= 1) 0 else @as(f64, @floatFromInt(row)) / (fh - 1); + s.glyph(@intCast(x), row, firstCodepoint(glyph), .{ + .fg = base.mix(color, alpha * (1 - depth * 0.3) + 0.12), + .bg = bg, + }); + } + } + } +} + +/// The block and ascii degradations: one column per cell, tallest point wins. +/// +/// A scatter keeps its dots rather than growing columns, because a scatter that +/// fills to the baseline is a bar chart wearing the wrong name. +fn plotCells( + s: Surface, + series: []const ChartSeries, + mode: FillMode, + xd: Domain, + yd: Domain, + baseline: f64, + bg: ?Color, +) void { + const w = s.width(); + const h = s.height(); + if (w == 0 or h == 0) return; + const theme = s.theme; + const floor_ratio = clamp01(ratio(baseline, yd)); + const fw: f64 = @floatFromInt(w); + const fh: f64 = @floatFromInt(h); + + // Bounded by the surface width, which is a terminal's, so the stack is the + // right place for it and nothing here has to allocate. + var tops: [1024]f64 = undefined; + const columns = @min(w, tops.len); + + for (series, 0..) |cs, si| { + const color = cs.color orelse seriesColor(theme.*, @intCast(si)); + @memset(tops[0..columns], std.math.nan(f64)); + + // Highest value per column, so a column shows the peak that fell in it + // rather than whichever point happened to be last. + for (cs.points) |p| { + if (!std.math.isFinite(p.x) or !std.math.isFinite(p.y)) continue; + const raw = roundHalfUp(ratio(p.x, xd) * (fw - 1)); + const clamped = @max(0, @min(raw, fw - 1)); + const col: usize = @intFromFloat(clamped); + if (col >= columns) continue; + const value = clamp01(ratio(p.y, yd)); + if (std.math.isNan(tops[col]) or value > tops[col]) tops[col] = value; + } + + for (0..columns) |x| { + const top = tops[x]; + if (std.math.isNan(top)) continue; + if (cs.mark == .scatter) { + const k = @min(@as(isize, @intFromFloat(@floor(top * fh))), @as(isize, @intCast(h)) - 1); + const row = @as(isize, @intCast(h)) - 1 - k; + const glyph: u21 = if (mode == .ascii) '*' else '•'; + s.glyph(@intCast(x), row, glyph, .{ .fg = color, .bg = bg }); + continue; + } + const from = @min(floor_ratio, top) * fh; + const filled = (@max(floor_ratio, top) - @min(floor_ratio, top)) * fh; + const full: isize = @intFromFloat(@floor(filled)); + const bottom: isize = @intFromFloat(@floor(from)); + var k: isize = 0; + while (k < full) : (k += 1) { + const row = @as(isize, @intCast(h)) - 1 - bottom - k; + if (row >= 0 and row < @as(isize, @intCast(h))) { + s.glyph(@intCast(x), row, '█', .{ .fg = color, .bg = bg }); + } + } + const glyph = blocks.verticalGlyph(filled - @as(f64, @floatFromInt(full)), mode); + const row = @as(isize, @intCast(h)) - 1 - bottom - full; + if (!std.mem.eql(u8, glyph, " ") and row >= 0 and row < @as(isize, @intCast(h))) { + s.glyph(@intCast(x), row, firstCodepoint(glyph), .{ .fg = color, .bg = bg }); + } + } + } +} diff --git a/ports/zig/src/ui.zig b/ports/zig/src/ui.zig index ab2f68f..b45b11b 100644 --- a/ports/zig/src/ui.zig +++ b/ports/zig/src/ui.zig @@ -372,6 +372,7 @@ const Node = union(enum) { meters: w.MetersOptions, progress: w.ProgressOptions, graph: w.GraphOptions, + chart: w.ChartOptions, sparkline: w.SparklineWidgetOptions, histogram: w.ColumnsOptions, gauge: w.GaugeOptions, @@ -459,6 +460,7 @@ fn drawNode(ctx: *Ctx, s: Surface, node: Node) anyerror!void { .meters => |o| w.drawMeters(s, o), .progress => |o| w.drawProgress(s, o), .graph => |o| try w.drawGraph(allocator, s, o), + .chart => |o| try w.drawChart(allocator, s, o), .sparkline => |o| w.drawSparkline(s, o), .histogram => |o| w.drawColumns(s, o), .gauge => |o| try w.drawGauge(allocator, s, o), @@ -838,6 +840,15 @@ pub const Container = struct { try self.add(self.filling(), .{ .graph = options }); } + /// A chart of arbitrary (x, y) data, with a domain on both axes. + /// + /// `graph` plots a history buffer, one sample per column. Use this when the + /// data has its own x values: two series of different lengths then line up, + /// and a point lands where its x says it does. + pub fn chart(self: *Container, options: w.ChartOptions) !void { + try self.add(self.filling(), .{ .chart = options }); + } + pub fn sparkline(self: *Container, options: w.SparklineWidgetOptions) !void { try self.add(self.leaf(1), .{ .sparkline = options }); } diff --git a/ports/zig/src/widgets.zig b/ports/zig/src/widgets.zig index a349c72..2b73e8d 100644 --- a/ports/zig/src/widgets.zig +++ b/ports/zig/src/widgets.zig @@ -4,6 +4,7 @@ pub const controls = @import("widgets/controls.zig"); pub const meters = @import("widgets/meters.zig"); +pub const chart = @import("widgets/chart.zig"); pub const scrollbar = @import("widgets/scrollbar.zig"); pub const table = @import("widgets/table.zig"); pub const text = @import("widgets/text.zig"); @@ -59,6 +60,8 @@ pub const TreeOptions = table.TreeOptions; pub const TreeValue = table.TreeValue; pub const drawList = table.drawList; pub const drawLog = table.drawLog; +pub const ChartOptions = chart.ChartOptions; +pub const drawChart = chart.drawChart; pub const ScrollbarOptions = scrollbar.ScrollbarOptions; pub const ScrollbarOrientation = scrollbar.ScrollbarOrientation; pub const drawScrollbar = scrollbar.drawScrollbar; diff --git a/ports/zig/src/widgets/chart.zig b/ports/zig/src/widgets/chart.zig new file mode 100644 index 0000000..1e3d8df --- /dev/null +++ b/ports/zig/src/widgets/chart.zig @@ -0,0 +1,141 @@ +//! A chart with two real axes. +//! +//! `graph` plots a history buffer: one sample per column, x meaning "position +//! in the slice". This plots data that has its own x values, with a labelled +//! domain on both axes, so two series of different lengths line up and a point +//! lands where its x says it does. + +const std = @import("std"); + +const chart_mod = @import("../graphics/chart.zig"); +const color_mod = @import("../color.zig"); +const meters = @import("meters.zig"); +const surface_mod = @import("../surface.zig"); +const theme_mod = @import("../theme.zig"); +const unicode = @import("../unicode.zig"); + +const AxisOptions = chart_mod.AxisOptions; +const ChartPlotOptions = chart_mod.ChartPlotOptions; +const ChartSeries = chart_mod.ChartSeries; +const Color = color_mod.Color; +const Surface = surface_mod.Surface; +const roundHalfUp = color_mod.roundHalfUp; +const seriesColor = theme_mod.seriesColor; + +pub const ChartOptions = struct { + series: []const ChartSeries = &.{}, + plot: ChartPlotOptions = .{}, + /// Numbers down the left edge. + axis: bool = false, + axis_color: ?Color = null, + legend: bool = false, + legend_align: unicode.Align = .left, +}; + +/// One evenly spaced tick value, ends included. +/// +/// Two ticks means the ends and nothing else, which is what an axis wants when +/// there is no room to say more. +fn tickAt(min: f64, max: f64, i: usize, count: usize) f64 { + const n = @max(2, count); + return min + (max - min) * @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n - 1)); +} + +fn formatWith(axis: ?AxisOptions, buf: []u8, value: f64) []const u8 { + if (axis) |a| { + if (a.format) |format| return format(value, buf); + } + return meters.niceLabel(buf, value); +} + +pub fn drawChart(allocator: std.mem.Allocator, s: Surface, options: ChartOptions) !void { + if (s.isEmpty()) return; + const theme = s.theme; + const axis_color = options.axis_color orelse theme.muted; + + const xd = chart_mod.domainOf(options.series, options.plot.x, 0); + const yd = chart_mod.domainOf(options.series, options.plot.y, 1); + + // The x labels take a row, and they can only take one when there is a row + // to spare -- a two-row chart is all plot. + var x_ticks: usize = if (options.axis) 2 else 0; + if (options.plot.x) |a| { + if (a.ticks > 0) x_ticks = a.ticks; + } + const want_x_axis = options.axis and x_ticks >= 2 and s.height() > 2; + + var plot_surface = s; + if (options.axis) { + var hi_buf: [32]u8 = undefined; + var lo_buf: [32]u8 = undefined; + const hi = formatWith(options.plot.y, &hi_buf, yd.max); + const lo = formatWith(options.plot.y, &lo_buf, yd.min); + const width = @max(unicode.stringWidth(hi), unicode.stringWidth(lo)) + 1; + + var padded: [64]u8 = undefined; + _ = s.text(0, 0, unicode.fit(&padded, hi, width, .right), .{ .fg = axis_color }); + if (s.height() > 1) { + var padded_lo: [64]u8 = undefined; + // The minimum marks the bottom of the plot, which is a row higher + // when the x labels have taken the last one. + const bottom: isize = if (want_x_axis) + @intCast(s.height() - 2) + else + @intCast(s.height() - 1); + _ = s.text(0, bottom, unicode.fit(&padded_lo, lo, width, .right), .{ .fg = axis_color }); + } + plot_surface = s.sub(@intCast(width), 0, s.width() -| width, s.height()); + } + + var area = plot_surface; + if (want_x_axis and plot_surface.height() > 1 and plot_surface.width() > 0) { + area = plot_surface.sub(0, 0, plot_surface.width(), plot_surface.height() - 1); + const row: isize = @intCast(plot_surface.height() - 1); + const step: f64 = if (x_ticks > 1) + @as(f64, @floatFromInt(plot_surface.width() - 1)) / @as(f64, @floatFromInt(x_ticks - 1)) + else + 0; + for (0..x_ticks) |i| { + var buf: [32]u8 = undefined; + const label = formatWith(options.plot.x, &buf, tickAt(xd.min, xd.max, i, x_ticks)); + // The last label is right-aligned to the edge, so it cannot run off it. + const at: isize = @intFromFloat(roundHalfUp(@as(f64, @floatFromInt(i)) * step)); + const limit: isize = @as(isize, @intCast(plot_surface.width())) - + @as(isize, @intCast(unicode.stringWidth(label))); + _ = plot_surface.text(@max(0, @min(at, limit)), row, label, .{ .fg = axis_color }); + } + } + + // The domain is resolved once and handed down, so the labels and the marks + // cannot disagree about what the axis spans. + var plot = options.plot; + var x_axis = options.plot.x orelse AxisOptions{}; + x_axis.min = xd.min; + x_axis.max = xd.max; + var y_axis = options.plot.y orelse AxisOptions{}; + y_axis.min = yd.min; + y_axis.max = yd.max; + plot.x = x_axis; + plot.y = y_axis; + try chart_mod.plotPoints(allocator, area, options.series, plot); + + if (options.legend) { + var total: usize = 0; + for (options.series) |cs| { + if (cs.label.len > 0) total += unicode.stringWidth(cs.label) + 3; + } + var x: isize = if (options.legend_align == .right) + @max(0, @as(isize, @intCast(area.width())) - @as(isize, @intCast(total))) + else + 0; + const y: isize = if (area.height() > 3) @intCast(area.height() - 1) else 0; + for (options.series, 0..) |cs, i| { + if (cs.label.len == 0) continue; + const color = cs.color orelse seriesColor(theme.*, @intCast(i)); + x += @intCast(area.text(x, y, "■ ", .{ .fg = color })); + var buf: [128]u8 = undefined; + const label = std.fmt.bufPrint(&buf, "{s} ", .{cs.label}) catch cs.label; + x += @intCast(area.text(x, y, label, .{ .fg = theme.muted })); + } + } +}