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.
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
| Line | Weight | Dash | Colour |
|---|---|---|---|
| Historical | 2 px | Solid | Status colour |
| Forecast | 1.5 px | [5, 3] | Status colour 55% |
| Planned pace | 1 px | [2, 3] | Gray 30% |
| Budget ceiling | 1 px | [3, 3] | --status-danger 35% |
| Today line | 1 px | [2, 2] | Secondary 35% |
Markers and fills
| Element | Spec |
|---|---|
| Today dot | 6 px filled circle, status colour |
| Exhaustion dot | 6 px filled circle, --status-danger |
| Historical area | Status colour 7% fill |
| Forecast area | Status colour 3% fill |
| Highlighted metric tile | Status colour border |
Supporting panels
Below the chart, render two rows of supporting data:
- KPI panel: two paired stat tiles — Exhaustion Date and Projected-at-End.
- Burn rate metrics: 4-column row — Weekly burn, Recent daily, Avg daily, Planned daily. Highlight the most relevant tile with a status-colour border.
- 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" }} />;
}