repos
3.6 KB raw
// Growth-of-$10,000 chart (Phase 28). A small area chart on the ETF symbol
// page that scales the fund's daily closes so $10,000 invested at the
// series' first bar reads as that, then runs forward to today. When a
// benchmark is configured, a dashed line of the same $10,000 in the
// benchmark runs alongside so the relative path is read at a glance.
//
// Driven by GET /api/symbols/{ticker}/growth, which returns
// `{ fund: [{date, value}, ...], benchmark: [...], benchmark_ticker }`.

import {
  createChart,
  AreaSeries,
  LineSeries,
  ColorType,
} from "lightweight-charts";

// Paper Ledger palette: ink fill (very translucent), the same warm-ink line
// the symbol price chart uses, and the dashed benchmark in the chart's
// non-semantic wayfinding-ink palette.
const FUND_LINE = "#2f7d4f";
const FUND_FILL_TOP = "rgba(47, 125, 79, 0.20)";
const FUND_FILL_BTM = "rgba(47, 125, 79, 0.00)";
const BENCH_LINE = "#7a5237";

const chartOptions = {
  autoSize: true,
  handleScroll: false,
  handleScale: false,
  layout: {
    background: { type: ColorType.Solid, color: "transparent" },
    textColor: "#6b6456",
    fontFamily: "'JetBrains Mono', monospace",
    attributionLogo: false,
  },
  grid: {
    vertLines: { color: "rgba(33,31,26,0.07)" },
    horzLines: { color: "rgba(33,31,26,0.07)" },
  },
  rightPriceScale: {
    borderColor: "rgba(33,31,26,0.16)",
    // Format the y-axis in compact dollars (e.g. $12.4K, $58K) so the
    // growth path reads cleanly without a bare 50000 number.
    mode: 0,
  },
  timeScale: { borderColor: "rgba(33,31,26,0.16)", rightOffset: 0 },
  crosshair: { mode: 1 },
};

export function initGrowth() {
  const el = document.getElementById("growth-chart");
  if (!el) return;
  const ticker = el.dataset.ticker;

  fetch(`/api/symbols/${encodeURIComponent(ticker)}/growth`)
    .then((res) => {
      if (!res.ok) throw new Error(`growth ${res.status}`);
      return res.json();
    })
    .then((d) => {
      if (!d.fund || d.fund.length < 2) {
        el.innerHTML = '<p class="growth-empty">Not enough history to draw the growth path.</p>';
        return;
      }
      const chart = createChart(el, chartOptions);
      const fund = chart.addSeries(AreaSeries, {
        lineColor: FUND_LINE,
        topColor: FUND_FILL_TOP,
        bottomColor: FUND_FILL_BTM,
        lineWidth: 2,
        priceFormat: {
          type: "custom",
          // Compact dollar formatter for the y-axis and crosshair: $42.1K,
          // $1.2M etc., which keeps the labels short across the 4-orders-of-
          // magnitude growth a multi-decade fund accrues.
          formatter: fmtCompactUsd,
          minMove: 1,
        },
      });
      fund.setData(
        d.fund.map((p) => ({ time: p.date, value: p.value })),
      );
      if (d.benchmark && d.benchmark.length >= 2) {
        const bench = chart.addSeries(LineSeries, {
          color: BENCH_LINE,
          lineWidth: 2,
          lineStyle: 2,
          priceFormat: {
            type: "custom",
            formatter: fmtCompactUsd,
            minMove: 1,
          },
          priceLineVisible: false,
        });
        bench.setData(
          d.benchmark.map((p) => ({ time: p.date, value: p.value })),
        );
      }
      chart.timeScale().fitContent();
    })
    .catch((err) => {
      console.error("growth load failed", err);
    });
}

/** $1,234,567 -> "$1.2M", $42_100 -> "$42.1K". */
function fmtCompactUsd(n) {
  const abs = Math.abs(n);
  if (abs >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;
  if (abs >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
  if (abs >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
  return `$${n.toFixed(0)}`;
}