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/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/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/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/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 e822de9..37e8187 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 @@ -67,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/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/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/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"); +}); 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 3403f80..8bb1d6d 100644 Binary files a/ports/conformance/generate.ts and b/ports/conformance/generate.ts differ diff --git a/ports/cpp/CMakeLists.txt b/ports/cpp/CMakeLists.txt index d812c58..908d7ac 100644 --- a/ports/cpp/CMakeLists.txt +++ b/ports/cpp/CMakeLists.txt @@ -14,7 +14,7 @@ if(HQTUI_LTO) set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON) endif() add_library(hqtui_cpp INTERFACE) -add_library(hqtui_cpp_widgets src/widgets.cpp src/scrollbar.cpp) +add_library(hqtui_cpp_widgets src/widgets.cpp src/scrollbar.cpp src/chart.cpp) set_target_properties(hqtui_cpp_widgets PROPERTIES POSITION_INDEPENDENT_CODE ON) target_compile_features(hqtui_cpp_widgets PUBLIC cxx_std_17) if(MSVC) diff --git a/ports/cpp/examples/widgets.cpp b/ports/cpp/examples/widgets.cpp index 95a6352..ed2f9ff 100644 --- a/ports/cpp/examples/widgets.cpp +++ b/ports/cpp/examples/widgets.cpp @@ -99,6 +99,29 @@ void widget_scrollbar(Surface s) { } // @end +// @widget chart +void widget_chart(Surface s) { + // Points carry their own x, so a sparse series and a dense one line up. + Chart c; + c.series = { + {{{0, 1}, {2, 6}, {5, 3}, {8, 9}, {10, 4}}, 0, "load"}, + {{{0, 8}, {10, 2}}, 0, "limit"}, + }; + c.axis = true; + c.legend = true; + Axis x; + x.min = 0; + x.max = 10; + x.ticks = 3; + Axis y; + y.min = 0; + y.max = 10; + c.plot.x = x; + c.plot.y = y; + draw_chart(s, c); +} +// @end + // @widget meter void widget_meter(Surface s) { const int width = s.rect().width; @@ -508,6 +531,7 @@ int main() { {"tree", widget_tree}, {"log", widget_log}, {"scrollbar", widget_scrollbar}, + {"chart", widget_chart}, {"meter", widget_meter}, {"meters", widget_meters}, {"progress", widget_progress}, diff --git a/ports/cpp/include/hqtui/widgets.hpp b/ports/cpp/include/hqtui/widgets.hpp index d9a25c7..7c4acf8 100644 --- a/ports/cpp/include/hqtui/widgets.hpp +++ b/ports/cpp/include/hqtui/widgets.hpp @@ -63,6 +63,8 @@ inline std::string fit(std::string_view s, int columns, int align = HQ_LEFT, return std::string(left, ' ') + out + std::string(padding - left, ' '); } inline const hq_theme &theme(Surface s) { return *s.native().theme; } +/// `4` rather than `4.0`, `4.2` rather than `4.20`. +std::string number(double); /// Returns the columns written, so a caller laying items out in a row can /// advance by what was actually drawn rather than by what it hoped to draw. inline std::size_t text(Surface s, int x, int y, std::string_view value, Color fg, @@ -329,6 +331,58 @@ struct Progress { }; void draw_progress(Surface, const Progress &); void draw_graph(Surface, const Graph &); + +/// A point in a chart's own coordinates, not the grid's. +struct ChartPoint { + double x = 0, y = 0; +}; +/// How a series is marked: joined, dotted, or dropped to the baseline. +enum MarkType { HQ_MARK_LINE, HQ_MARK_SCATTER, HQ_MARK_BAR }; +struct ChartSeries { + std::vector 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 })); + } + } +}