Charts & Stats chartsvgzero-dependencylineforecastprojectionconfidence
Forecast Line Chart
Actuals as a solid line and projections as a dashed line with a widening confidence cone and a Today divider.
Live Preview
Actuals vs. projected, with 80% confidence
$90k +32% projected
- Actual
- Forecast
View data
| Period | Actual | Forecast | Low | High |
|---|---|---|---|---|
| Jan | $48k | — | — | — |
| Feb | $51k | — | — | — |
| Mar | $55k | — | — | — |
| Apr | $54k | — | — | — |
| May | $59k | — | — | — |
| Jun | $63k | — | — | — |
| Jul | $68k | — | — | — |
| Aug | — | $72k | $69.98k | $74.02k |
| Sep | — | $76k | $71.74k | $80.26k |
| Oct | — | $80k | $73.28k | $86.72k |
| Nov | — | $85k | $75.48k | $94.52k |
| Dec | — | $90k | $77.4k | $102.6k |
Usage
Copy and paste this code into your Astro page:
astro
---
import { ForecastLineChart } from 'astro-component-kit/charts';
---
<ForecastLineChart
categories={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}
actual={[48, 51, 55, 54]}
forecast={[59, 63]}
confidence={0.12}
prefix="$" suffix="k"
/> ---
import { ForecastLineChart } from 'astro-component-kit/charts';
---
<ForecastLineChart
categories={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}
actual={[48, 51, 55, 54]}
forecast={[59, 63]}
confidence={0.12}
prefix="$" suffix="k"
/> Manual Installation
If you are not using the npm package, create a new file and paste the following code: src/components/lib/ForecastLineChart.astro:
astro
---
/**
* ForecastLineChart — Actuals as a solid line, projection as a dashed line with
* a widening confidence cone and a "today" divider.
*
* @param {string[]} categories - X-axis labels covering actual + forecast periods.
* @param {number[]} actual - Observed values (first N periods).
* @param {number[]} forecast - Projected values for the remaining periods.
* @param {number} confidence - Relative spread of the cone (0.1 = ±10% at the end).
* @param {string} prefix - Value prefix.
* @param {string} suffix - Value suffix.
* @param {string} variant - Card style (glass|solid|ghost).
* @param {string} size - Density (sm|md|lg).
*/
import ChartFrame from './ChartFrame.astro';
import { scaleLinear, niceTicks, linePath, bandPath, formatValue, type Point } from './chart-utils';
interface Props {
title?: string;
subtitle?: string;
categories?: string[];
actual?: number[];
forecast?: number[];
confidence?: number;
prefix?: string;
suffix?: string;
height?: number;
color?: string;
variant?: 'glass' | 'solid' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title = 'MRR forecast',
subtitle = 'Actuals vs. projected, with 80% confidence',
categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
actual = [48, 51, 55, 54, 59, 63, 68],
forecast = [72, 76, 80, 85, 90],
confidence = 0.14,
prefix = '$',
suffix = 'k',
height = 280,
color = 'var(--chart-1, #3987e5)',
variant = 'glass',
size = 'md',
className = '',
} = Astro.props;
const W = 640;
const H = height;
const m = { t: 16, r: 16, b: 28, l: 48 };
const n = categories.length;
const a = actual.length;
const all = [...actual, ...forecast];
const hi = forecast.map((v, i) => v * (1 + confidence * ((i + 1) / forecast.length)));
const lo = forecast.map((v, i) => v * (1 - confidence * ((i + 1) / forecast.length)));
const ticks = niceTicks(Math.min(...all, ...lo) * 0.9, Math.max(...hi), 4);
const y = scaleLinear(ticks[0], ticks[ticks.length - 1], H - m.b, m.t);
const x = (i: number) => m.l + (i / Math.max(1, n - 1)) * (W - m.l - m.r);
const step = (W - m.l - m.r) / Math.max(1, n - 1);
const actualPts: Point[] = actual.map((v, i) => [x(i), y(v)]);
const anchor = actualPts[a - 1];
const fcPts: Point[] = [anchor, ...forecast.map((v, i): Point => [x(a + i), y(v)])];
const conePts = {
up: [anchor, ...hi.map((v, i): Point => [x(a + i), y(v)])],
down: [anchor, ...lo.map((v, i): Point => [x(a + i), y(v)])],
};
const growth = ((forecast[forecast.length - 1] - actual[a - 1]) / actual[a - 1]) * 100;
const fmt = (v: number) => formatValue(v, prefix, suffix);
---
<ChartFrame
title={title}
subtitle={subtitle}
value={fmt(forecast[forecast.length - 1])}
delta={`${growth >= 0 ? '+' : ''}${growth.toFixed(0)}% projected`}
deltaPositive={growth >= 0}
variant={variant}
size={size}
className={className}
legend={[{ label: 'Actual', color }, { label: 'Forecast', color: `color-mix(in srgb, ${color} 55%, transparent)` }]}
table={{
headers: ['Period', 'Actual', 'Forecast', 'Low', 'High'],
rows: categories.map((c, i) => a > i
? [c, fmt(actual[i]), '—', '—', '—']
: [c, '—', fmt(forecast[i - a]), fmt(lo[i - a]), fmt(hi[i - a])]),
}}
>
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: actuals and forecast line chart`}>
{ticks.map((t) => (
<g>
<line class="cf-grid" x1={m.l} x2={W - m.r} y1={y(t)} y2={y(t)} />
<text class="cf-label" x={m.l - 8} y={y(t) + 4} text-anchor="end">{fmt(t)}</text>
</g>
))}
{categories.map((c, i) => <text class="cf-label" x={x(i)} y={H - 8} text-anchor="middle">{c}</text>)}
<rect x={anchor[0]} y={m.t} width={W - m.r - anchor[0]} height={H - m.t - m.b} fill="rgb(var(--ink-rgb, 255 255 255) / 0.025)" aria-hidden="true" />
<line class="cf-axis" x1={anchor[0]} x2={anchor[0]} y1={m.t} y2={H - m.b} />
<text class="cf-label" x={anchor[0] + 6} y={m.t + 10}>Today</text>
<path d={bandPath(conePts.up, conePts.down, 'linear')} fill={color} fill-opacity="0.14" aria-hidden="true" />
<path d={linePath(fcPts)} fill="none" stroke={color} stroke-width="2" stroke-dasharray="5 5" stroke-opacity="0.85" aria-hidden="true" />
<path d={linePath(actualPts)} fill="none" stroke={color} stroke-width="2" stroke-linejoin="round" aria-hidden="true" />
<circle cx={anchor[0]} cy={anchor[1]} r="4.5" fill={color} stroke="var(--chart-surface, #0d0d0f)" stroke-width="2" aria-hidden="true" />
{categories.map((c, i) => {
const isF = i >= a;
const v = isF ? forecast[i - a] : actual[i];
const tip = isF
? `${color}::Forecast: ${fmt(v)}|Range: ${fmt(lo[i - a])} – ${fmt(hi[i - a])}`
: `${color}::Actual: ${fmt(v)}`;
return (
<g class="cf-hit-group">
<line class="cf-cross" x1={x(i)} x2={x(i)} y1={m.t} y2={H - m.b} />
<circle class="cf-cross" cx={x(i)} cy={y(v)} r="4.5" fill={color} stroke="#fff" stroke-width="1.5" />
<rect class="cf-hit" x={x(i) - step / 2} y={m.t} width={step} height={H - m.t - m.b} tabindex="0" role="img"
aria-label={`${c}: ${isF ? 'forecast' : 'actual'} ${fmt(v)}`} data-tip-title={c} data-tip={tip} />
</g>
);
})}
</svg>
</ChartFrame> ---
/**
* ForecastLineChart — Actuals as a solid line, projection as a dashed line with
* a widening confidence cone and a "today" divider.
*
* @param {string[]} categories - X-axis labels covering actual + forecast periods.
* @param {number[]} actual - Observed values (first N periods).
* @param {number[]} forecast - Projected values for the remaining periods.
* @param {number} confidence - Relative spread of the cone (0.1 = ±10% at the end).
* @param {string} prefix - Value prefix.
* @param {string} suffix - Value suffix.
* @param {string} variant - Card style (glass|solid|ghost).
* @param {string} size - Density (sm|md|lg).
*/
import ChartFrame from './ChartFrame.astro';
import { scaleLinear, niceTicks, linePath, bandPath, formatValue, type Point } from './chart-utils';
interface Props {
title?: string;
subtitle?: string;
categories?: string[];
actual?: number[];
forecast?: number[];
confidence?: number;
prefix?: string;
suffix?: string;
height?: number;
color?: string;
variant?: 'glass' | 'solid' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title = 'MRR forecast',
subtitle = 'Actuals vs. projected, with 80% confidence',
categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
actual = [48, 51, 55, 54, 59, 63, 68],
forecast = [72, 76, 80, 85, 90],
confidence = 0.14,
prefix = '$',
suffix = 'k',
height = 280,
color = 'var(--chart-1, #3987e5)',
variant = 'glass',
size = 'md',
className = '',
} = Astro.props;
const W = 640;
const H = height;
const m = { t: 16, r: 16, b: 28, l: 48 };
const n = categories.length;
const a = actual.length;
const all = [...actual, ...forecast];
const hi = forecast.map((v, i) => v * (1 + confidence * ((i + 1) / forecast.length)));
const lo = forecast.map((v, i) => v * (1 - confidence * ((i + 1) / forecast.length)));
const ticks = niceTicks(Math.min(...all, ...lo) * 0.9, Math.max(...hi), 4);
const y = scaleLinear(ticks[0], ticks[ticks.length - 1], H - m.b, m.t);
const x = (i: number) => m.l + (i / Math.max(1, n - 1)) * (W - m.l - m.r);
const step = (W - m.l - m.r) / Math.max(1, n - 1);
const actualPts: Point[] = actual.map((v, i) => [x(i), y(v)]);
const anchor = actualPts[a - 1];
const fcPts: Point[] = [anchor, ...forecast.map((v, i): Point => [x(a + i), y(v)])];
const conePts = {
up: [anchor, ...hi.map((v, i): Point => [x(a + i), y(v)])],
down: [anchor, ...lo.map((v, i): Point => [x(a + i), y(v)])],
};
const growth = ((forecast[forecast.length - 1] - actual[a - 1]) / actual[a - 1]) * 100;
const fmt = (v: number) => formatValue(v, prefix, suffix);
---
<ChartFrame
title={title}
subtitle={subtitle}
value={fmt(forecast[forecast.length - 1])}
delta={`${growth >= 0 ? '+' : ''}${growth.toFixed(0)}% projected`}
deltaPositive={growth >= 0}
variant={variant}
size={size}
className={className}
legend={[{ label: 'Actual', color }, { label: 'Forecast', color: `color-mix(in srgb, ${color} 55%, transparent)` }]}
table={{
headers: ['Period', 'Actual', 'Forecast', 'Low', 'High'],
rows: categories.map((c, i) => a > i
? [c, fmt(actual[i]), '—', '—', '—']
: [c, '—', fmt(forecast[i - a]), fmt(lo[i - a]), fmt(hi[i - a])]),
}}
>
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: actuals and forecast line chart`}>
{ticks.map((t) => (
<g>
<line class="cf-grid" x1={m.l} x2={W - m.r} y1={y(t)} y2={y(t)} />
<text class="cf-label" x={m.l - 8} y={y(t) + 4} text-anchor="end">{fmt(t)}</text>
</g>
))}
{categories.map((c, i) => <text class="cf-label" x={x(i)} y={H - 8} text-anchor="middle">{c}</text>)}
<rect x={anchor[0]} y={m.t} width={W - m.r - anchor[0]} height={H - m.t - m.b} fill="rgb(var(--ink-rgb, 255 255 255) / 0.025)" aria-hidden="true" />
<line class="cf-axis" x1={anchor[0]} x2={anchor[0]} y1={m.t} y2={H - m.b} />
<text class="cf-label" x={anchor[0] + 6} y={m.t + 10}>Today</text>
<path d={bandPath(conePts.up, conePts.down, 'linear')} fill={color} fill-opacity="0.14" aria-hidden="true" />
<path d={linePath(fcPts)} fill="none" stroke={color} stroke-width="2" stroke-dasharray="5 5" stroke-opacity="0.85" aria-hidden="true" />
<path d={linePath(actualPts)} fill="none" stroke={color} stroke-width="2" stroke-linejoin="round" aria-hidden="true" />
<circle cx={anchor[0]} cy={anchor[1]} r="4.5" fill={color} stroke="var(--chart-surface, #0d0d0f)" stroke-width="2" aria-hidden="true" />
{categories.map((c, i) => {
const isF = i >= a;
const v = isF ? forecast[i - a] : actual[i];
const tip = isF
? `${color}::Forecast: ${fmt(v)}|Range: ${fmt(lo[i - a])} – ${fmt(hi[i - a])}`
: `${color}::Actual: ${fmt(v)}`;
return (
<g class="cf-hit-group">
<line class="cf-cross" x1={x(i)} x2={x(i)} y1={m.t} y2={H - m.b} />
<circle class="cf-cross" cx={x(i)} cy={y(v)} r="4.5" fill={color} stroke="#fff" stroke-width="1.5" />
<rect class="cf-hit" x={x(i) - step / 2} y={m.t} width={step} height={H - m.t - m.b} tabindex="0" role="img"
aria-label={`${c}: ${isF ? 'forecast' : 'actual'} ${fmt(v)}`} data-tip-title={c} data-tip={tip} />
</g>
);
})}
</svg>
</ChartFrame>
Quick Info
- Category
- Charts & Stats
- Filename
ForecastLineChart.astro- Dependencies
- apexcharts (peer)
- Tags
- chartsvgzero-dependencylineforecastprojectionconfidence