▚AreaChartcustom
LineChart with a stronger under-line gradient
$ 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
| prop | type | default | description |
|---|---|---|---|
| data | number[] | {x,y}[] | [] | Series; auto-indexed when plain numbers |
| color | string | "#7dcfff" | Line color |
| fill | string | "#24283b" | Gradient end color (brighter than LineChart) |
| title | string | - | Optional header row with hi/lo labels |
| loading / reveal | boolean / number | false / 1 | Shimmer skeleton / clip-reveal |
| crosshair / focused | boolean | false / true | Selection + tooltip, keyboard gate |
##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.
1/* eslint-disable react/no-array-index-key -- These renderers draw a fixed2 terminal grid: a child's identity *is* its row and column, and the grid3 never reorders, so the index is the stable key rather than a stand-in4 for one. */56import { 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";1920export 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}3839interface Cell {40 ch: string;41 fg: string;42}4344const GLYPH = HALF_BLOCK;4546function 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}6263/**64 * One cell of the filled area under the line. `p` is the half-cell row the65 * line sits at in this column; everything below it is filled, darkening with66 * 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;7778 const topFilled = r * 2 <= p;79 const bottomFilled = r * 2 + 1 <= p;80 if (!topFilled && !bottomFilled) return blank;8182 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}8687function 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}111112function runs(cells: Cell[]): Cell[] {113 return mergeRuns(cells);114}115116function 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}125126export 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);147148 const [selected, setSelected] = useState<number | null>(null);149 const active = crosshair && selectedProp !== undefined ? selectedProp : selected;150151 // `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);155156 const yScale = useMemo(157 () => linearScale(lo, hi === lo ? lo + 1 : hi, 0, height * 2 - 2),158 [lo, hi, height],159 );160161 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 });174175 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]);186187 const cells = loading188 ? shimmerCells(width, height, sweep, color, fill)189 : renderCells(width, height, edge, color, fill);190191 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 > 0196 ? selValue - (points[selIndex]?.y ?? selValue)197 : 0;198199 const titleRow = title200 ? 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;207208 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 };225226 const tooltipRow = crosshair && selValue !== null ? renderTooltip(selValue) : null;227228 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 });245246 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}259260export function LineChart(props: LineChartProps) {261 return h(Chart, {262 width: 40,263 height: 10,264 dots: true,265 ...props,266 });267}268269export function AreaChart(props: LineChartProps) {270 return h(Chart, {271 width: 40,272 height: 10,273 fill: props.fill ?? "#24283b",274 ...props,275 });276}277