// Per-trader portfolio-value line chart. A thin uPlot wrapper that starts at a // 1x1 canvas and syncs to its container once the DOM has laid out. The line and // its soft fill are coloured green or red by whether the trader is up overall. import uPlot, { type Options } from "uplot"; import "uplot/dist/uPlot.min.css"; import type { ChartPoint } from "./state"; const MIN_HEIGHT = 120; const Y_AXIS_WIDTH = 72; export class PortfolioChart { private plot: uPlot; private host: HTMLElement; constructor(host: HTMLElement) { this.host = host; const opts: Options = { width: 1, height: 1, pxAlign: false, cursor: { show: false }, legend: { show: false }, scales: { // A single point would otherwise auto-range to a span of years; show a // five-minute window until enough points arrive to tighten it. x: { time: true, range: (_u, min, max) => (min === max ? [min - 300, max + 30] : [min, max]), }, y: { range: (_u, min, max) => min === max ? [min - 100, max + 100] : [min - (max - min) * 0.1, max + (max - min) * 0.1], }, }, axes: [ { stroke: () => getVar("--fg-muted") || "#8a93a1", grid: { stroke: () => getVar("--grid") || "#232831" }, }, { stroke: () => getVar("--fg-muted") || "#8a93a1", grid: { stroke: () => getVar("--grid") || "#232831" }, size: Y_AXIS_WIDTH, values: (_u, splits) => splits.map(formatCompact), }, ], series: [ {}, { stroke: (u) => trendColor(u), fill: (u) => fillGradient(u), width: 2, }, ], }; this.plot = new uPlot(opts, [[], []], host); // Double-RAF lets the panel grid finish layout before we read dimensions. requestAnimationFrame(() => requestAnimationFrame(() => this.syncSize())); new ResizeObserver(() => this.syncSize()).observe(this.host); // Re-read the theme colours when the user toggles light/dark. window.addEventListener("themechange", () => this.plot.redraw()); } update(points: ChartPoint[]): void { const xs = points.map((p) => p.t); const ys = points.map((p) => p.value); this.plot.setData([xs, ys]); } private syncSize(): void { const rect = this.host.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; this.plot.setSize({ width: Math.floor(rect.width), height: Math.max(MIN_HEIGHT, Math.floor(rect.height)), }); } } function isUp(u: uPlot): boolean { const ys = u.data[1] as number[]; return ys.length > 1 ? ys[ys.length - 1] >= ys[0] : true; } function trendColor(u: uPlot): string { const up = isUp(u); return getVar(up ? "--trend-up" : "--trend-down") || (up ? "#3fbf7f" : "#e05560"); } function fillGradient(u: uPlot): CanvasGradient { const color = trendColor(u); const { ctx, bbox } = u; const grad = ctx.createLinearGradient(0, bbox.top, 0, bbox.top + bbox.height); grad.addColorStop(0, hexToRgba(color, 0.28)); grad.addColorStop(1, hexToRgba(color, 0)); return grad; } function getVar(name: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } function hexToRgba(hex: string, alpha: number): string { const h = hex.replace("#", ""); const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } function formatCompact(n: number): string { // Accounts sit around $10k, so a tight range needs full figures to stay distinct. if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`; return `$${Math.round(n).toLocaleString("en-US")}`; }