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")}`;
}