New

Burn / Budget Trajectory

A financial forecasting chart showing historical spend, projected burn rate, and budget ceiling. Reference implementation from Warren's BurnTrajectoryChart.

Demo

Historical spend (solid line), forecast (dashed), planned pace (dotted), and budget ceiling (red dashed). The today marker is the vertical dashed line.

$138k$104k$69k$35k$0kFebAprJunAugOctDec
Actual
Forecast
Planned
Budget ceiling
$4,200
Weekly Burn
$600
Recent Daily
$540
Avg Daily
$480
Planned Daily
Optimistic
−20% burn rate
Mar 2027
Est. exhaustion
Current Pace
Steady burn rate
Oct 2026
Est. exhaustion
Pessimistic
+20% burn rate
Jul 2026
Est. exhaustion

Line styles

LineWeightDashColour
Historical2 pxSolidStatus colour
Forecast1.5 px[5, 3]Status colour 55%
Planned pace1 px[2, 3]Gray 30%
Budget ceiling1 px[3, 3]--status-danger 35%
Today line1 px[2, 2]Secondary 35%

Markers and fills

ElementSpec
Today dot6 px filled circle, status colour
Exhaustion dot6 px filled circle, --status-danger
Historical areaStatus colour 7% fill
Forecast areaStatus colour 3% fill
Highlighted metric tileStatus colour border

Supporting panels

Below the chart, render two rows of supporting data:

  1. KPI panel: two paired stat tiles — Exhaustion Date and Projected-at-End.
  2. Burn rate metrics: 4-column row — Weekly burn, Recent daily, Avg daily, Planned daily. Highlight the most relevant tile with a status-colour border.
  3. Scenario cards: 3-column layout — Optimistic (−20%), Current Pace, Pessimistic (+20%). Current pace tile has an accent border. Each shows estimated exhaustion date.

Implementation

BurnTrajectoryChart.tsx
tsx
"use client";

import { useRef, useEffect } from "react";

interface BurnDataPoint {
  date: Date;
  actual?: number;    // undefined for future dates
  forecast?: number;  // undefined for past dates
  planned: number;
}

interface BurnChartProps {
  data: BurnDataPoint[];
  budget: number;
  currency?: string;
  status?: "success" | "warning" | "danger";
}

export function BurnTrajectoryChart({
  data,
  budget,
  currency = "$",
  status = "success",
}: BurnChartProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const statusColor = {
    success: "rgb(var(--status-success))",
    warning: "rgb(var(--status-warning))",
    danger:  "rgb(var(--status-danger))",
  }[status];

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d")!;
    const { width, height } = canvas;

    // DPR scaling
    const dpr = window.devicePixelRatio || 1;
    canvas.width = width * dpr;
    canvas.height = height * dpr;
    ctx.scale(dpr, dpr);

    const pad = { top: 20, right: 20, bottom: 40, left: 60 };
    const w = width - pad.left - pad.right;
    const h = height - pad.top - pad.bottom;

    const maxVal = budget * 1.15;
    const today = new Date();
    const minDate = data[0].date.getTime();
    const maxDate = data[data.length - 1].date.getTime();
    const dateRange = maxDate - minDate;

    function toX(date: Date) { return pad.left + ((date.getTime() - minDate) / dateRange) * w; }
    function toY(val: number) { return pad.top + h - (val / maxVal) * h; }

    ctx.clearRect(0, 0, width, height);

    // Grid lines (horizontal)
    ctx.strokeStyle = "rgba(128,128,128,0.12)";
    ctx.lineWidth = 1;
    for (let i = 0; i <= 4; i++) {
      const y = pad.top + (h / 4) * i;
      ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + w, y); ctx.stroke();
    }

    // Budget ceiling line
    const budgetY = toY(budget);
    ctx.setLineDash([3, 3]);
    ctx.strokeStyle = "rgba(248,113,113,0.5)";
    ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(pad.left, budgetY); ctx.lineTo(pad.left + w, budgetY); ctx.stroke();
    ctx.setLineDash([]);

    // Planned pace line
    ctx.setLineDash([2, 3]);
    ctx.strokeStyle = "rgba(128,128,128,0.35)";
    ctx.lineWidth = 1;
    const plannedPoints = data.filter((d) => d.planned !== undefined);
    if (plannedPoints.length > 1) {
      ctx.beginPath();
      plannedPoints.forEach((d, i) => {
        const x = toX(d.date); const y = toY(d.planned);
        i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
      });
      ctx.stroke();
    }
    ctx.setLineDash([]);

    // Forecast area
    const futurePoints = data.filter((d) => d.forecast !== undefined);
    if (futurePoints.length > 1) {
      ctx.beginPath();
      futurePoints.forEach((d, i) => {
        const x = toX(d.date); const y = toY(d.forecast!);
        i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
      });
      ctx.lineTo(toX(futurePoints[futurePoints.length - 1].date), pad.top + h);
      ctx.lineTo(toX(futurePoints[0].date), pad.top + h);
      ctx.closePath();
      ctx.fillStyle = statusColor.replace("rgb(", "rgba(").replace(")", " / 0.04)").replace(" / 0.04)", ", 0.04)");
      ctx.fill();

      // Forecast line (dashed)
      ctx.setLineDash([5, 3]);
      ctx.strokeStyle = statusColor.replace("rgb(", "rgba(").replace(")", " / 0.55)").replace(" / 0.55)", ", 0.55)");
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      futurePoints.forEach((d, i) => {
        const x = toX(d.date); const y = toY(d.forecast!);
        i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
      });
      ctx.stroke();
      ctx.setLineDash([]);
    }

    // Historical area
    const pastPoints = data.filter((d) => d.actual !== undefined);
    if (pastPoints.length > 1) {
      ctx.beginPath();
      pastPoints.forEach((d, i) => {
        const x = toX(d.date); const y = toY(d.actual!);
        i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
      });
      ctx.lineTo(toX(pastPoints[pastPoints.length - 1].date), pad.top + h);
      ctx.lineTo(toX(pastPoints[0].date), pad.top + h);
      ctx.closePath();
      ctx.fillStyle = statusColor.replace("rgb(", "rgba(").replace(")", " / 0.07)").replace(" / 0.07)", ", 0.07)");
      ctx.fill();

      // Historical line (solid)
      ctx.strokeStyle = statusColor;
      ctx.lineWidth = 2;
      ctx.beginPath();
      pastPoints.forEach((d, i) => {
        const x = toX(d.date); const y = toY(d.actual!);
        i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
      });
      ctx.stroke();
    }

    // Today line
    const todayX = toX(today);
    ctx.setLineDash([2, 2]);
    ctx.strokeStyle = "rgba(128,128,128,0.35)";
    ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(todayX, pad.top); ctx.lineTo(todayX, pad.top + h); ctx.stroke();
    ctx.setLineDash([]);

    // Today dot
    const todayData = data.find((d) => d.actual !== undefined && Math.abs(d.date.getTime() - today.getTime()) < 4 * 86_400_000);
    if (todayData) {
      const ty = toY(todayData.actual!);
      ctx.beginPath(); ctx.arc(todayX, ty, 5, 0, Math.PI * 2);
      ctx.fillStyle = statusColor; ctx.fill();
    }

    // Y-axis labels
    ctx.fillStyle = "rgba(128,128,128,0.8)";
    ctx.font = "11px -apple-system, sans-serif";
    ctx.textAlign = "right";
    for (let i = 0; i <= 4; i++) {
      const val = (maxVal / 4) * (4 - i);
      const y = pad.top + (h / 4) * i;
      ctx.fillText(`${currency}${Math.round(val / 1000)}k`, pad.left - 6, y + 4);
    }
  }, [data, budget, statusColor, currency]);

  return <canvas ref={canvasRef} style={{ width: "100%", height: 200, display: "block" }} />;
}