termino
~/docs/custom/area-chart

AreaChartcustom

LineChart with a stronger under-line gradient

~/custom/area-chart
live
$ termino line-chart — bklit-inspired
cpu load▁▂▃▄▅▆▇█
crosshair ←/→ or click buttons
▐ —
reveal 0%

##API

Same renderer as LineChart with a brighter default `fill` — the gradient becomes the feature. Shares all props and the shared chart helpers (`lib/custom/chart.ts`).

properties
proptypedefaultdescription
datanumber[] | {x,y}[][]Series; auto-indexed when plain numbers
colorstring"#7dcfff"Line color
fillstring"#24283b"Gradient end color (brighter than LineChart)
titlestring-Optional header row with hi/lo labels
loading / revealboolean / numberfalse / 1Shimmer skeleton / clip-reveal
crosshair / focusedbooleanfalse / trueSelection + tooltip, keyboard gate

##Keymap

~/keymap
← / hMove crosshair left
→ / lMove crosshair right

##Notes

  • LineChart and AreaChart are the same component with different defaults — one source file, two registrations.
  • Pair with Sparkline for mini/detail chart hierarchies.

##Source

Real implementation — this is the code that runs in your terminal.

tsxline-chart.tsx
1/* eslint-disable react/no-array-index-key -- These renderers draw a fixed
2 terminal grid: a child's identity *is* its row and column, and the grid
3 never reorders, so the index is the stable key rather than a stand-in
4 for one. */
5
6import { createElement as h, useEffect, useMemo, useState } from "react";
7import { useKeyboard } from "@opentui/react";
8import {
9 clamp,
10 halfBlock,
11 linearScale,
12 type ColumnSample,
13 mergeRuns,
14 mixColor,
15 sampleColumns,
16 toPoints,
17 HALF_BLOCK,
18} from "./chart";
19
20export interface LineChartProps {
21 data: number[] | { x: number; y: number }[];
22 width?: number;
23 height?: number;
24 color?: string;
25 fill?: string;
26 min?: number;
27 max?: number;
28 title?: string;
29 loading?: boolean;
30 reveal?: number;
31 dots?: boolean;
32 crosshair?: boolean;
33 focused?: boolean;
34 selected?: number | null;
35 onSelect?: (index: number) => void;
36 format?: (value: number, index: number) => string;
37}
38
39interface Cell {
40 ch: string;
41 fg: string;
42}
43
44const GLYPH = HALF_BLOCK;
45
46function renderCells(
47 width: number,
48 height: number,
49 edge: (number | null)[],
50 color: string,
51 fill: string,
52): Cell[][] {
53 const maxDepth = Math.max(1, height * 2 - 2);
54 const rows: Cell[][] = [];
55 for (let r = 0; r < height; r++) {
56 rows.push(
57 Array.from({ length: width }, (_, c) => fillCell(edge[c], r, maxDepth, color, fill)),
58 );
59 }
60 return rows;
61}
62
63/**
64 * One cell of the filled area under the line. `p` is the half-cell row the
65 * line sits at in this column; everything below it is filled, darkening with
66 * distance from the line.
67 */
68function fillCell(
69 p: number | null | undefined,
70 r: number,
71 maxDepth: number,
72 color: string,
73 fill: string,
74): Cell {
75 const blank = { ch: GLYPH.none, fg: color };
76 if (p === null || p === undefined) return blank;
77
78 const topFilled = r * 2 <= p;
79 const bottomFilled = r * 2 + 1 <= p;
80 if (!topFilled && !bottomFilled) return blank;
81
82 const depth = p - (bottomFilled ? r * 2 + 1 : r * 2);
83 const fg = depth <= 0 ? color : mixColor(color, fill, clamp(depth / maxDepth, 0, 1));
84 return { ch: halfBlock(topFilled, bottomFilled), fg };
85}
86
87function shimmerCells(
88 width: number,
89 height: number,
90 sweep: number,
91 color: string,
92 fill: string,
93): Cell[][] {
94 const rows: Cell[][] = [];
95 for (let r = 0; r < height; r++) {
96 const row: Cell[] = [];
97 for (let c = 0; c < width; c++) {
98 const dist = sweep - c;
99 if (dist >= -2 && dist <= 0) {
100 row.push({ ch: "▓", fg: mixColor(color, "#ffffff", 0.45) });
101 } else if (dist > 0 && dist <= 2) {
102 row.push({ ch: "░", fg: mixColor(color, fill, 0.25) });
103 } else {
104 row.push({ ch: "░", fg: mixColor(color, fill, 0.55) });
105 }
106 }
107 rows.push(row);
108 }
109 return rows;
110}
111
112function runs(cells: Cell[]): Cell[] {
113 return mergeRuns(cells);
114}
115
116function useSweep(active: boolean, width: number): number {
117 const [sweep, setSweep] = useState(-4);
118 useEffect(() => {
119 if (!active) return;
120 const timer = setInterval(() => setSweep((s) => (s + 1) % (width + 8)), 90);
121 return () => clearInterval(timer);
122 }, [active, width]);
123 return sweep;
124}
125
126export function Chart({
127 data,
128 width = 40,
129 height = 10,
130 color = "#7dcfff",
131 fill = "#1a1b26",
132 min,
133 max,
134 title,
135 loading = false,
136 reveal = 1,
137 dots = false,
138 crosshair = false,
139 focused = true,
140 selected: selectedProp,
141 onSelect,
142 format,
143}: Readonly<LineChartProps>) {
144 const points = useMemo(() => toPoints(data), [data]);
145 const cols = useMemo(() => sampleColumns(points, width), [points, width]);
146 const sweep = useSweep(loading, width);
147
148 const [selected, setSelected] = useState<number | null>(null);
149 const active = crosshair && selectedProp !== undefined ? selectedProp : selected;
150
151 // `sampleColumns` leaves a column null where no point landed on it.
152 const values = cols.filter((c): c is ColumnSample => c !== null).map((c) => c.value);
153 const lo = min ?? (cols.length ? Math.min(...values) : 0);
154 const hi = max ?? (cols.length ? Math.max(...values) : 1);
155
156 const yScale = useMemo(
157 () => linearScale(lo, hi === lo ? lo + 1 : hi, 0, height * 2 - 2),
158 [lo, hi, height],
159 );
160
161 useKeyboard((key) => {
162 if (!crosshair || !focused || loading) return;
163 const last = cols.length - 1;
164 if (key.name === "left" || key.name === "h") {
165 const next = clamp((active ?? 0) - 1, 0, last);
166 setSelected(next);
167 onSelect?.(next);
168 } else if (key.name === "right" || key.name === "l") {
169 const next = clamp((active ?? 0) + 1, 0, last);
170 setSelected(next);
171 onSelect?.(next);
172 }
173 });
174
175 const edge: (number | null)[] = useMemo(() => {
176 const out: (number | null)[] = new Array(width).fill(null);
177 const limit = (reveal ?? 1) * (width - 1);
178 for (let c = 0; c < width; c++) {
179 const sample = cols[c];
180 if (!sample) continue;
181 if (c > limit && reveal < 1) continue;
182 out[c] = Math.round(yScale.to(sample.value));
183 }
184 return out;
185 }, [cols, width, yScale, reveal]);
186
187 const cells = loading
188 ? shimmerCells(width, height, sweep, color, fill)
189 : renderCells(width, height, edge, color, fill);
190
191 const sel = active !== null && active >= 0 ? cols[active] : null;
192 const selValue = sel?.value ?? null;
193 const selIndex = sel?.index ?? 0;
194 const delta =
195 selValue !== null && points.length > 0
196 ? selValue - (points[selIndex]?.y ?? selValue)
197 : 0;
198
199 const titleRow = title
200 ? h(
201 "box",
202 { flexDirection: "row", justifyContent: "space-between", width },
203 h("text", { fg: "#565f89" }, ` ${title}`),
204 h("text", { fg: "#565f89" }, `${hi.toFixed(1)} ▾ ${lo.toFixed(1)}`),
205 )
206 : null;
207
208 const renderTooltip = (value: number) => {
209 const parts = [
210 h("text", { fg: color }, `▐ ${format ? format(value, selIndex) : value.toFixed(2)}`),
211 ];
212 if (delta !== 0) {
213 const rising = delta > 0;
214 parts.push(
215 h(
216 "text",
217 { fg: rising ? "#9ece6a" : "#f7768e" },
218 `${rising ? "▲" : "▼"} ${Math.abs(delta).toFixed(2)}`,
219 ),
220 );
221 }
222 parts.push(h("text", { fg: "#565f89" }, ` col ${active ?? 0}`));
223 return h("box", { flexDirection: "row", gap: 1, width }, ...parts);
224 };
225
226 const tooltipRow = crosshair && selValue !== null ? renderTooltip(selValue) : null;
227
228 const body = cells.map((row, r) => {
229 const highlighted = active !== null && active !== undefined;
230 const styled = row.map((cell, c) => {
231 if (highlighted && c === active) {
232 return { ...cell, fg: "#c0caf5" };
233 }
234 if (dots && edge[c] !== null && cell.ch !== GLYPH.none && edge[c] === r * 2 + (cell.ch === GLYPH.both ? 1 : 0)) {
235 return { ...cell, ch: "·" };
236 }
237 return cell;
238 });
239 return h(
240 "box",
241 { key: r, flexDirection: "row" },
242 runs(styled).map((seg, i) => h("text", { key: i, fg: seg.fg }, seg.ch)),
243 );
244 });
245
246 return h(
247 "box",
248 { flexDirection: "column", gap: 0, width },
249 titleRow,
250 h(
251 "box",
252 { flexDirection: "column", width },
253 body,
254 ),
255 tooltipRow,
256 loading ? h("text", { fg: "#565f89" }, " loading…") : null,
257 );
258}
259
260export function LineChart(props: LineChartProps) {
261 return h(Chart, {
262 width: 40,
263 height: 10,
264 dots: true,
265 ...props,
266 });
267}
268
269export function AreaChart(props: LineChartProps) {
270 return h(Chart, {
271 width: 40,
272 height: 10,
273 fill: props.fill ?? "#24283b",
274 ...props,
275 });
276}
277