Radial Seasonality
freeA radial clock that loops a cyclic metric around the year, with a prior-cycle ghost ring and the peak period marked — reading the seasonal shape at a glance.
Live preview
npx shadcn add @sextant/chart-radial-seasonalityState
Size
Mode
Acme · MRR seasonality
5.2k
peak · Nov 26
- This year
- Last year
Peaks in Nov 26 at 5.2k, 14% above the 12-period average and the year is up 24% vs last; the low is Jan 26 (4.2k).
| Period | This year | Last year |
|---|---|---|
| Jan 26 | 4.2k | 3.4k |
| Feb 26 | 4.2k | 3.4k |
| Mar 26 | 4.4k | 3.5k |
| Apr 26 | 4.5k | 3.6k |
| May 26 | 4.7k | 3.8k |
| Jun 26 | 4.4k | 3.5k |
| Jul 26 | 4.3k | 3.5k |
| Aug 26 | 4.4k | 3.5k |
| Sep 26 | 4.7k | 3.8k |
| Oct 26 | 4.9k | 4k |
| Nov 26 | 5.2k | 4.2k |
| Dec 26 | 4.9k | 3.9k |
Acme analytics · updated daily at 12am UTC
Source — this is the whole widget
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { ChartCard } from "@/registry/default/widget-kit/chart-card";
import {
ChartNarrative,
resolveNarrative,
type NarrativeProp,
} from "@/registry/default/chart-narrative/chart-narrative";
import { ChartFigure, type ChartTable } from "@/registry/default/chart-a11y/chart-a11y";
import { ChartErrorBoundary } from "@/registry/default/sextant-contracts/chart-error-boundary";
import { ChartStates } from "@/registry/default/chart-states/chart-states";
import { safeData } from "@/registry/default/sextant-contracts/contracts";
import { useReducedMotion } from "@/registry/default/widget-kit/motion";
import { SparkTile } from "@/registry/default/widget-kit/spark-tile";
import { StudioTarget, useStudio } from "@/registry/default/widget-kit/studio";
import { useSizePlan } from "@/registry/default/widget-kit/use-chart-size";
import type { SizeProp } from "@/registry/default/widget-kit/chart-size";
import type { SizePlan } from "@/registry/default/widget-kit/size-plan";
import type {
ChartClassNames,
SeriesConfig,
} from "@/registry/default/widget-kit/contract";
import { seasonalityData, seasonalityDataSchema, type SeasonalityDatum } from "./data";
// Re-export the data contract so consumers get the schema + types from a
// single import (no reaching into ./data).
export { seasonalityDataSchema, type SeasonalityDatum } from "./data";
import { polar, radialLayout, seasonalityStats } from "./seasonality";
const COLOR_PRESETS = [
{ name: "Maroon (brand)", value: "var(--chart-1)" },
{ name: "Sage", value: "var(--chart-positive)" },
{ name: "Honey", value: "var(--chart-warning)" },
{ name: "Ink", value: "var(--chart-2)" },
{ name: "Cool gray", value: "var(--chart-3)" },
];
const RADIAL_PLAN: SizePlan = {
lg: { shape: "full", legend: "full" },
md: { shape: "full", legend: "inline" },
sm: { shape: "spark" },
mobile: { shape: "full", legend: "inline", stack: true },
};
function fmtNum(v: number): string {
const abs = Math.abs(v);
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
if (abs >= 1000) return `${(v / 1000).toFixed(1).replace(/\.0$/, "")}k`;
return Math.round(v).toLocaleString();
}
/** Recolor the current-cycle ring (`current`) and prior-cycle ghost (`prior`). */
export type ChartRadialSeasonalitySeries = {
current?: SeriesConfig;
prior?: SeriesConfig;
};
type Props = {
// ── Data ────────────────────────────────────────────────────────────────
/** Cyclic periods + values. Validated at runtime; a bad shape shows the error state. */
data?: SeasonalityDatum;
// ── Card chrome ───────────────────────────────────────────────────────────
/** Card title (also the export filename). */
label?: string;
attribution?: string;
/** Shorthand for `classNames.root` — width, ring, shadow, margins, etc. */
className?: string;
/** Targeted class overrides per region (root/header/body/narrative/footnote). */
classNames?: ChartClassNames;
/** Force a size tier, or "auto" (default) to adapt to the card's own width. */
size?: SizeProp;
// ── Customize ───────────────────────────────────────────────────────────
/** Recolor the current ring (`current`) and the prior-year ghost (`prior`). */
series?: ChartRadialSeasonalitySeries;
/** Override value formatting (hero, labels, tooltip). Default is compact (k/M). */
valueFormatter?: (n: number) => string;
/** Replace, hide (`false`), or fully render the one-line narrative. */
narrative?: NarrativeProp;
};
export function ChartRadialSeasonality({
data = seasonalityData,
label,
attribution,
className,
classNames,
size = "auto",
series,
valueFormatter,
narrative,
}: Props) {
const studio = useStudio();
const title = studio.text("title", label ?? "Acme · MRR seasonality", {
label: "Chart title",
});
const currentColor = studio.color(
"currentColor",
series?.current?.color ?? "var(--chart-1)",
{ label: "Current color", presets: COLOR_PRESETS },
);
const priorColor = studio.color(
"priorColor",
series?.prior?.color ?? "var(--chart-3)",
{
label: "Prior color",
presets: COLOR_PRESETS,
},
);
const attributionText = studio.text(
"attribution",
attribution ?? "Acme analytics · updated daily at 12am UTC",
{ label: "Attribution" },
);
const fmt = valueFormatter ?? fmtNum;
const currentLabel = series?.current?.label ?? "This year";
const priorLabel = series?.prior?.label ?? "Last year";
const parsed = React.useMemo(() => safeData(seasonalityDataSchema, data), [data]);
const safe = parsed.ok ? parsed.data : null;
const stats = React.useMemo(() => (safe ? seasonalityStats(safe) : null), [safe]);
if (!parsed.ok) {
return (
<ChartStates status="error" shape="donut" error={{ message: parsed.error }}>
{null}
</ChartStates>
);
}
if (!safe || !stats || safe.periods.length < 3) {
return (
<ChartStates
status="empty"
shape="donut"
empty={{
title: "Not enough of the cycle",
description: "At least three periods are needed to read a seasonal shape.",
}}
>
{null}
</ChartStates>
);
}
const yoyText =
stats.yoy != null
? ` and the year is ${stats.yoy >= 0 ? "up" : "down"} ${Math.abs(stats.yoy * 100).toFixed(0)}% vs last`
: "";
const autoNarrative =
`Peaks in ${stats.peak.label} at ${fmt(stats.peak.value)}, ` +
`${(stats.peakVsMean * 100).toFixed(0)}% above the ${safe.periods.length}-period average` +
`${yoyText}; the low is ${stats.trough.label} (${fmt(stats.trough.value)}).`;
const chipText = `▲ ${stats.peak.label} · ${fmt(stats.peak.value)}`;
return (
<ChartCard.Root
size={size}
className={cn(className, classNames?.root)}
exportable
exportTitle={title}
>
<RadialInner
data={safe}
title={title}
heroValue={fmt(stats.peak.value)}
peakLabel={stats.peak.label}
chipText={chipText}
narrative={narrative}
autoNarrative={autoNarrative}
peakIndex={stats.peak.index}
currentColor={currentColor}
priorColor={priorColor}
currentLabel={currentLabel}
priorLabel={priorLabel}
attributionText={attributionText}
fmt={fmt}
classNames={classNames}
/>
</ChartCard.Root>
);
}
type InnerProps = {
data: SeasonalityDatum;
title: string;
heroValue: string;
peakLabel: string;
chipText: string;
narrative: NarrativeProp | undefined;
autoNarrative: string;
peakIndex: number;
currentColor: string;
priorColor: string;
currentLabel: string;
priorLabel: string;
attributionText: string;
fmt: (n: number) => string;
classNames?: ChartClassNames;
};
function RadialInner({
data,
title,
heroValue,
peakLabel,
chipText,
narrative,
autoNarrative,
peakIndex,
currentColor,
priorColor,
currentLabel,
priorLabel,
attributionText,
fmt,
classNames,
}: InnerProps) {
const spec = useSizePlan(RADIAL_PLAN);
if (spec.shape === "spark") {
return (
<SparkTile
label={title}
value={heroValue}
chip={
narrative === false ? undefined : (
<ChartNarrative
text={typeof narrative === "string" ? narrative : chipText}
variant="chip"
className={classNames?.narrative}
/>
)
}
spark={<RadialSpark data={data} currentColor={currentColor} />}
/>
);
}
const table: ChartTable = {
caption: `${title} — value by period`,
columns: data.prior
? ["Period", currentLabel, priorLabel]
: ["Period", currentLabel],
rows: data.periods.map((p, i) =>
data.prior
? [p, fmt(data.current[i]), fmt(data.prior[i])]
: [p, fmt(data.current[i])],
),
};
const legendItems = [
{ label: currentLabel, color: currentColor },
...(data.prior ? [{ label: priorLabel, color: priorColor }] : []),
];
return (
<>
<ChartCard.Header className={classNames?.header}>
<ChartCard.HeaderMain>
<StudioTarget fieldKey="title" variant="inline">
<ChartCard.Label>{title}</ChartCard.Label>
</StudioTarget>
<ChartCard.HeroNumber>{heroValue}</ChartCard.HeroNumber>
<p className="tabular text-sm text-muted-foreground">peak · {peakLabel}</p>
</ChartCard.HeaderMain>
{(spec.legend === "full" || spec.legend === "inline") && (
<ChartCard.Legend items={legendItems} />
)}
</ChartCard.Header>
{resolveNarrative(narrative ?? autoNarrative, undefined, {
className: classNames?.narrative,
})}
<ChartCard.Body className={classNames?.body}>
<ChartErrorBoundary minHeight={260}>
<ChartFigure label={autoNarrative} table={table}>
<div className="mx-auto max-w-[340px]">
<ChartRadialSeasonalityPlot
data={data}
currentColor={currentColor}
priorColor={priorColor}
peakIndex={peakIndex}
fmt={fmt}
/>
</div>
</ChartFigure>
</ChartErrorBoundary>
</ChartCard.Body>
<StudioTarget fieldKey="attribution" variant="inline">
<ChartCard.Footnote className={classNames?.footnote}>
{attributionText}
</ChartCard.Footnote>
</StudioTarget>
</>
);
}
type PlotProps = {
/** Cyclic periods + values — the polar layout is derived internally. */
data: SeasonalityDatum;
currentColor?: string;
priorColor?: string;
/** Index of the peak period to mark; pass -1 to skip the marker. */
peakIndex?: number;
fmt?: (n: number) => string;
};
/**
* Just the radial clock — concentric grid rings, an optional prior-year ghost
* loop, the current loop as a filled blob, and a marked peak. No card chrome.
* Pure SVG, no chart library.
*/
export function ChartRadialSeasonalityPlot({
data,
currentColor = "var(--chart-1)",
priorColor = "var(--chart-3)",
peakIndex = -1,
fmt = (n) => String(n),
}: PlotProps) {
const reduced = useReducedMotion();
const SIZE = 340;
const c = SIZE / 2;
const inner = 34;
const outer = 128;
const layout = React.useMemo(
() => radialLayout(data, { innerRadius: inner, outerRadius: outer }),
[data],
);
const rings = [0.25, 0.5, 0.75, 1];
return (
<>
{!reduced && (
<style
href="sx-radial-kf"
precedence="default"
>{`@keyframes sx-radial-in{from{opacity:0;transform:scale(0.9) rotate(-6deg)}to{opacity:1;transform:scale(1) rotate(0)}}`}</style>
)}
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
width="100%"
height="auto"
role="presentation"
style={{ display: "block" }}
>
<g transform={`translate(${c},${c})`}>
{/* Grid rings + radius labels. */}
<g>
{rings.map((t, i) => {
const r = inner + t * (outer - inner);
return (
<circle
key={i}
r={r}
fill="none"
stroke="var(--chart-grid)"
strokeWidth={1}
/>
);
})}
{/* spokes */}
{layout.angles.map((a, i) => {
const [x, y] = polar(a, outer);
return (
<line
key={i}
x1={0}
y1={0}
x2={x}
y2={y}
stroke="var(--chart-grid)"
strokeWidth={0.5}
strokeOpacity={0.6}
/>
);
})}
</g>
<g
style={
reduced
? undefined
: {
animation: `sx-radial-in 560ms cubic-bezier(0.25,1,0.5,1) both`,
transformOrigin: "center",
}
}
>
{/* Prior-year ghost loop. */}
{layout.prior && (
<path
d={layout.prior.path}
fill={priorColor}
fillOpacity={0.1}
stroke={priorColor}
strokeWidth={1}
strokeDasharray="3 3"
/>
)}
{/* Current loop. */}
<path
d={layout.current.path}
fill={currentColor}
fillOpacity={0.18}
stroke={currentColor}
strokeWidth={2}
/>
{/* Per-period hit dots (tooltips). */}
{layout.current.points.map((p, i) => (
<circle key={i} cx={p[0]} cy={p[1]} r={2.5} fill={currentColor}>
<title>{`${data.periods[i]}: ${fmt(data.current[i])}`}</title>
</circle>
))}
{/* Peak marker. */}
{peakIndex >= 0 && layout.current.points[peakIndex] && (
<g>
<circle
cx={layout.current.points[peakIndex][0]}
cy={layout.current.points[peakIndex][1]}
r={5}
fill={currentColor}
stroke="var(--card)"
strokeWidth={1.5}
/>
</g>
)}
</g>
{/* Period labels around the rim. */}
<g>
{layout.labelPoints.map((lp, i) => {
const anchor = lp[0] > 1 ? "start" : lp[0] < -1 ? "end" : "middle";
const isPeak = i === peakIndex;
return (
<text
key={i}
x={lp[0]}
y={lp[1]}
textAnchor={anchor}
dominantBaseline="middle"
className="smallcaps tabular"
style={{
fontSize: 9,
fontWeight: isPeak ? 700 : 400,
fill: isPeak ? currentColor : "var(--muted-foreground)",
}}
>
{data.periods[i]}
</text>
);
})}
</g>
</g>
</svg>
</>
);
}
/** A glance glyph for the Small tile: a tiny current-ring polygon. */
function RadialSpark({
data,
currentColor,
}: {
data: SeasonalityDatum;
currentColor: string;
}) {
const layout = radialLayout(data, { innerRadius: 5, outerRadius: 20 });
return (
<svg viewBox="0 0 48 48" width="100%" height="100%" role="presentation">
<g transform="translate(24,24)">
<path
d={layout.current.path}
fill={currentColor}
fillOpacity={0.25}
stroke={currentColor}
strokeWidth={1.5}
/>
</g>
</svg>
);
}