mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
215 lines
8.2 KiB
TypeScript
215 lines
8.2 KiB
TypeScript
/**
|
|
* Terminal drawing primitives for the stats dashboard: colour, block-glyph
|
|
* charts, and the alternate-screen plumbing.
|
|
*
|
|
* Hand-rolled for the same reason apps/api/src/charts.ts and png.ts are — the
|
|
* repo carries near-zero runtime dependencies, and everything a progress
|
|
* dashboard needs is a bar, a sparkline and a coloured cell. The palette is
|
|
* charts.ts's, so the terminal, the README SVGs and the digest email are the
|
|
* same picture in three renderers.
|
|
*
|
|
* Side-effect-free on import except for reading the colour environment once;
|
|
* nothing is written to the terminal until openScreen() is called.
|
|
*/
|
|
|
|
// ── palette (apps/api/src/charts.ts) ─────────────────────────────
|
|
|
|
export const FG = "#fafafa"; // zinc-50 text
|
|
export const MUTED = "#a1a1aa"; // zinc-400 secondary text
|
|
export const LINE = "#27272a"; // zinc-800 structure / empty track
|
|
export const GREEN = "#16a34a"; // green-600 (core / pass)
|
|
export const BLUE = "#2563eb"; // blue-600 (optional)
|
|
export const YELLOW = "#eab308"; // yellow-500 (attention)
|
|
export const RED = "#f87171"; // red-400 (fail, on dark)
|
|
|
|
/** GitHub dark-mode contribution hues; level 0 is the empty cell. */
|
|
export const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
|
|
|
// ── colour ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Honour NO_COLOR and pipes, and let FORCE_COLOR override both — the dashboard
|
|
* is legible without colour, and `bun run stats | less -R` should still be.
|
|
*/
|
|
export const COLOR =
|
|
process.env.FORCE_COLOR !== undefined
|
|
? process.env.FORCE_COLOR !== "0"
|
|
: process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
|
|
|
|
const RESET = "\x1b[0m";
|
|
|
|
function channels(hex: string): string {
|
|
return `${parseInt(hex.slice(1, 3), 16)};${parseInt(hex.slice(3, 5), 16)};${parseInt(hex.slice(5, 7), 16)}`;
|
|
}
|
|
|
|
export function fg(hex: string, text: string): string {
|
|
return COLOR ? `\x1b[38;2;${channels(hex)}m${text}${RESET}` : text;
|
|
}
|
|
|
|
export function bg(hex: string, text: string): string {
|
|
return COLOR ? `\x1b[48;2;${channels(hex)}m${text}${RESET}` : text;
|
|
}
|
|
|
|
export function bold(text: string): string {
|
|
return COLOR ? `\x1b[1m${text}${RESET}` : text;
|
|
}
|
|
|
|
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
|
|
/** Printable width — every glyph used here is one cell wide. */
|
|
export function width(text: string): number {
|
|
return text.replace(ANSI, "").length;
|
|
}
|
|
|
|
/** Pad or cut a *plain* string to exactly n cells; colour it afterwards. */
|
|
export function cell(text: string, n: number, align: "left" | "right" = "left"): string {
|
|
const clipped = text.length > n ? `${text.slice(0, Math.max(0, n - 1))}…` : text;
|
|
return align === "left" ? clipped.padEnd(n) : clipped.padStart(n);
|
|
}
|
|
|
|
/** Pad an already-coloured string to n cells. */
|
|
export function padTo(text: string, n: number): string {
|
|
return text + " ".repeat(Math.max(0, n - width(text)));
|
|
}
|
|
|
|
// ── charts ───────────────────────────────────────────────────────
|
|
|
|
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
|
const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
|
|
/**
|
|
* Filled and empty. The two differ by GLYPH as well as by colour, so a bar
|
|
* still reads under NO_COLOR or in a pipe — every chart here has to survive
|
|
* `bun run stats > notes.txt`.
|
|
*/
|
|
const FILL = "█";
|
|
const TRACK = "░";
|
|
|
|
/**
|
|
* One bar over a muted track, `cells` wide. Sub-cell remainders use the
|
|
* eighth-block glyphs, so one solved problem out of 161 still shows.
|
|
*/
|
|
export function bar(value: number, total: number, cells: number, color = GREEN): string {
|
|
const units = Math.round(Math.max(0, Math.min(1, total > 0 ? value / total : 0)) * cells * 8);
|
|
const filled = FILL.repeat(Math.floor(units / 8)) + EIGHTHS[units % 8]!;
|
|
return fg(color, filled) + fg(LINE, TRACK.repeat(Math.max(0, cells - filled.length)));
|
|
}
|
|
|
|
export interface Segment {
|
|
value: number;
|
|
color: string;
|
|
/** Defaults to the solid block; pass TRACK-ish glyphs for "not done yet". */
|
|
glyph?: string;
|
|
}
|
|
|
|
/**
|
|
* Segments laid end to end, `total` mapped to `cells`. Anything short of
|
|
* `total` is left BLANK, not tracked: bars sharing a scale must show their
|
|
* different lengths. A caller wanting a visible remainder passes it as a
|
|
* segment of its own.
|
|
*
|
|
* Non-zero segments are floored but never vanish; the overshoot that floor
|
|
* creates is taken back from the widest segment, which can spare it.
|
|
*/
|
|
export function stackedBar(segments: Segment[], total: number, cells: number): string {
|
|
if (total <= 0) return " ".repeat(cells);
|
|
const widths = segments.map((s) =>
|
|
s.value > 0 ? Math.max(1, Math.floor((s.value / total) * cells)) : 0,
|
|
);
|
|
let over = widths.reduce((a, b) => a + b, 0) - cells;
|
|
while (over > 0) {
|
|
const i = widths.indexOf(Math.max(...widths));
|
|
if (widths[i]! <= 1) break;
|
|
widths[i]!--;
|
|
over--;
|
|
}
|
|
const drawn = widths.map((n, i) => fg(segments[i]!.color, (segments[i]!.glyph ?? FILL).repeat(n)));
|
|
return drawn.join("") + " ".repeat(Math.max(0, cells - widths.reduce((a, b) => a + b, 0)));
|
|
}
|
|
|
|
/**
|
|
* One cell of the campaign heatmap. With colour it is the GREENS ramp the
|
|
* README and the digest use; without it, the same five levels as shading.
|
|
*/
|
|
export function heatCell(attempts: number, body: string): string {
|
|
const level = Math.min(attempts, GREENS.length - 1);
|
|
return COLOR ? bg(GREENS[level]!, body) : [" ", "·", "▒", "▓", "█"][level]!.repeat(body.length);
|
|
}
|
|
|
|
/** One glyph per value, scaled to the largest. All-zero renders as a floor. */
|
|
export function sparkline(values: number[], color = GREEN): string {
|
|
const peak = Math.max(...values, 0);
|
|
const line = values
|
|
.map((v) => (peak === 0 ? SPARKS[0]! : SPARKS[Math.min(7, Math.round((v / peak) * 7))]!))
|
|
.join("");
|
|
return fg(color, line);
|
|
}
|
|
|
|
// ── layout ───────────────────────────────────────────────────────
|
|
|
|
/** A section rule that fills the width: `── name ────────────`. */
|
|
export function rule(title: string, cells: number): string {
|
|
const label = ` ${title} `;
|
|
return (
|
|
fg(LINE, "──") + fg(MUTED, label) + fg(LINE, "─".repeat(Math.max(0, cells - 2 - label.length)))
|
|
);
|
|
}
|
|
|
|
// ── screen ───────────────────────────────────────────────────────
|
|
|
|
const ALT_ON = "\x1b[?1049h\x1b[?25l";
|
|
const ALT_OFF = "\x1b[?25h\x1b[?1049l";
|
|
|
|
export interface Screen {
|
|
readonly rows: number;
|
|
readonly columns: number;
|
|
/** Draw a frame, clipped to the terminal height. */
|
|
draw(lines: string[]): void;
|
|
close(): void;
|
|
}
|
|
|
|
/**
|
|
* Take over the terminal: alternate buffer, hidden cursor, raw keys. `onKey`
|
|
* receives the raw chunk (`"\t"`, `"\x1b[B"`, `"q"`, …) — decoding belongs to
|
|
* the app, which is the only thing that knows what a key means.
|
|
*
|
|
* The caller MUST close() in a finally: raw mode survives a thrown exception.
|
|
*/
|
|
export function openScreen(onKey: (chunk: string) => void, onResize: () => void): Screen {
|
|
const out = process.stdout;
|
|
const input = process.stdin;
|
|
|
|
out.write(ALT_ON);
|
|
input.setRawMode?.(true);
|
|
input.setEncoding("utf8");
|
|
input.resume();
|
|
input.on("data", onKey);
|
|
out.on("resize", onResize);
|
|
|
|
let closed = false;
|
|
return {
|
|
get rows() {
|
|
return out.rows ?? 24;
|
|
},
|
|
get columns() {
|
|
return out.columns ?? 80;
|
|
},
|
|
draw(lines: string[]): void {
|
|
const visible = lines.slice(0, this.rows);
|
|
// Home, then erase each line as it is rewritten plus everything below the
|
|
// last one. No full clear, so a redraw does not flash, and no trailing
|
|
// newline, which would scroll the alternate screen by a row per frame.
|
|
out.write(`\x1b[H${visible.map((l) => `${l}\x1b[K`).join("\r\n")}\x1b[J`);
|
|
},
|
|
close(): void {
|
|
if (closed) return;
|
|
closed = true;
|
|
input.off("data", onKey);
|
|
out.off("resize", onResize);
|
|
input.setRawMode?.(false);
|
|
input.pause();
|
|
out.write(ALT_OFF);
|
|
},
|
|
};
|
|
}
|