Spaces:
Paused
Paused
File size: 3,839 Bytes
5545e4d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | // 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")}`;
}
|