Sextant
← Playground

Radial Seasonality

free

A 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-seasonality
State
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).

Acme · MRR seasonality — value by period
PeriodThis yearLast year
Jan 264.2k3.4k
Feb 264.2k3.4k
Mar 264.4k3.5k
Apr 264.5k3.6k
May 264.7k3.8k
Jun 264.4k3.5k
Jul 264.3k3.5k
Aug 264.4k3.5k
Sep 264.7k3.8k
Oct 264.9k4k
Nov 265.2k4.2k
Dec 264.9k3.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>
  );
}