Spiral Time Series Chart — Chart.js

A time series plotted along a spiral (Archimedean or logarithmic), where each full revolution represents one cycle period (e.g., one year). By wrapping temporal data around a spiral, corresponding periods from different cycles align vertically, making recurring seasonal or periodic patterns immediately visible. This layout provides a compact alternative to long horizontal time series for cyclic data, revealing periodicity and trend simultaneously.

Spiral Time Series Chart rendered with Chart.js

Renders

JavaScript source (Chart.js)

// anyplot.ai
// spiral-timeseries: Spiral Time Series Chart
// Library: chartjs 4.4.7 | JavaScript 22.23.2
// Quality: 90/100 | Created: 2026-09-09
//# anyplot-orientation: square

const t = window.ANYPLOT_TOKENS;
const size = window.ANYPLOT_SIZE;

// --- Data (in-memory, deterministic) ----------------------------------------
// Daily average temperature over 3 years — each revolution is one year, so the
// same calendar day from different years lines up along the same spoke.
const DAYS_PER_CYCLE = 365;
const NUM_CYCLES = 3;
const TOTAL_DAYS = DAYS_PER_CYCLE * NUM_CYCLES;
const START_YEAR = 2022;
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

// Tiny fixed-seed LCG — the browser has no seeded Math.random().
let lcgSeed = 20220101;
function lcgNoise() {
  lcgSeed = (lcgSeed * 1103515245 + 12345) & 0x7fffffff;
  return (lcgSeed / 0x7fffffff) * 2 - 1; // [-1, 1]
}

const temperatures = [];
for (let day = 0; day < TOTAL_DAYS; day++) {
  const dayOfYear = day % DAYS_PER_CYCLE;
  const yearIndex = Math.floor(day / DAYS_PER_CYCLE);
  const seasonal = 12 - 10 * Math.cos((2 * Math.PI * dayOfYear) / DAYS_PER_CYCLE);
  const warmingTrend = yearIndex * 3;
  temperatures.push(seasonal + warmingTrend + lcgNoise() * 0.8);
}
const minTemp = Math.min(...temperatures);
const maxTemp = Math.max(...temperatures);

function dateLabel(dayIndex) {
  const yearIndex = Math.floor(dayIndex / DAYS_PER_CYCLE);
  const dayOfYear = dayIndex % DAYS_PER_CYCLE;
  const monthIdx = Math.min(11, Math.floor(dayOfYear / 30.44));
  return `${MONTH_NAMES[monthIdx]} ${START_YEAR + yearIndex}`;
}

// --- Archimedean spiral geometry --------------------------------------------
// r = R0 + RING * theta/(2π), theta = 2π * day/DAYS_PER_CYCLE (continuously
// increasing across the whole series), rotated so day 1 sits at the top.
const R0 = 14;
const RING = 20;
const maxR = R0 + RING * NUM_CYCLES;

const spiralPoints = [];
for (let day = 0; day < TOTAL_DAYS; day++) {
  const cycleFrac = day / DAYS_PER_CYCLE;
  const theta = 2 * Math.PI * cycleFrac - Math.PI / 2;
  const r = R0 + RING * cycleFrac;
  spiralPoints.push({ x: r * Math.cos(theta), y: r * Math.sin(theta) });
}

function circlePoints(r) {
  const pts = [];
  const STEPS = 180;
  for (let s = 0; s <= STEPS; s++) {
    const a = (2 * Math.PI * s) / STEPS;
    pts.push({ x: r * Math.cos(a), y: r * Math.sin(a) });
  }
  return pts;
}

// Concentric rings mark each cycle boundary (one per year, plus the start).
const cycleRingDatasets = [];
for (let k = 0; k <= NUM_CYCLES; k++) {
  cycleRingDatasets.push({
    label: `cycle-boundary-${k}`,
    data: circlePoints(R0 + RING * k),
    borderColor: t.grid,
    borderWidth: 1,
    borderDash: [5, 5],
    pointRadius: 0,
    fill: false,
  });
}

// Month spokes subdivide each cycle radially.
const labelR = maxR * 1.22;
const monthSpokeDatasets = MONTH_NAMES.map((_, m) => {
  const angle = (2 * Math.PI * m) / 12 - Math.PI / 2;
  return {
    label: `month-spoke-${m}`,
    data: [
      { x: 0, y: 0 },
      { x: maxR * Math.cos(angle), y: maxR * Math.sin(angle) },
    ],
    borderColor: t.grid,
    borderWidth: 1,
    borderDash: [2, 4],
    pointRadius: 0,
    fill: false,
  };
});

// --- Color encodes value magnitude (Imprint sequential ramp) ---------------
function hexToRgb(hex) {
  const n = parseInt(hex.slice(1), 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const [seqR0, seqG0, seqB0] = hexToRgb(t.seq[0]);
const [seqR1, seqG1, seqB1] = hexToRgb(t.seq[1]);
function valueColor(v) {
  const tt = Math.min(1, Math.max(0, (v - minTemp) / (maxTemp - minTemp)));
  const r = Math.round(seqR0 + (seqR1 - seqR0) * tt);
  const g = Math.round(seqG0 + (seqG1 - seqG0) * tt);
  const b = Math.round(seqB0 + (seqB1 - seqB0) * tt);
  return `rgb(${r}, ${g}, ${b})`;
}

const spiralDataset = {
  label: "Daily average temperature",
  data: spiralPoints,
  borderWidth: 3,
  pointRadius: 0,
  fill: false,
  tension: 0,
  segment: {
    borderColor: (ctx) => valueColor(temperatures[ctx.p1DataIndex]),
  },
};

// --- Mount -------------------------------------------------------------------
const canvas = document.createElement("canvas");
document.getElementById("container").appendChild(canvas);

// --- Axis extents (keep the spiral visually circular on the square canvas) --
// Cartesian x/y scales have no built-in "equal aspect" option; approximate it
// from the known CSS mount size minus the title bar the mandated title reserves.
const axisMax = labelR * 1.12;
const titleReservePx = 70;
const aspectComp = Math.max(0.75, (size.height - titleReservePx) / size.width);
const yAxisMax = axisMax * aspectComp;

// --- Custom plugin: month/year labels + color-bar legend --------------------
// Native Chart.js plugin API (inline object passed to `plugins:`), not a
// chartjs-chart-* community package — draws directly with the canvas 2D API.
const spiralAnnotationsPlugin = {
  id: "spiralAnnotations",
  afterDraw(chart) {
    const { ctx, scales } = chart;
    const xScale = scales.x;
    const yScale = scales.y;
    ctx.save();

    // Month labels around the outer ring.
    ctx.font = "600 20px sans-serif";
    ctx.fillStyle = t.inkSoft;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    MONTH_NAMES.forEach((name, m) => {
      const angle = (2 * Math.PI * m) / 12 - Math.PI / 2;
      const px = xScale.getPixelForValue(labelR * Math.cos(angle));
      const py = yScale.getPixelForValue(labelR * Math.sin(angle));
      ctx.fillText(name, px, py);
    });

    // Cycle-start (year) labels, nudged off the 12-o'clock spoke.
    ctx.font = "600 22px sans-serif";
    ctx.fillStyle = t.ink;
    ctx.textAlign = "left";
    ctx.textBaseline = "middle";
    for (let k = 0; k < NUM_CYCLES; k++) {
      const r = R0 + RING * k + RING * 0.4;
      const angle = -Math.PI / 2 - 0.4;
      const px = xScale.getPixelForValue(r * Math.cos(angle));
      const py = yScale.getPixelForValue(r * Math.sin(angle));
      ctx.fillText(String(START_YEAR + k), px + 6, py);
    }

    // Color-bar legend in the empty corner outside the circular spiral.
    const barX = chart.chartArea.left + 24;
    const barY = chart.chartArea.bottom - 56;
    const barW = 220;
    const barH = 20;
    const gradient = ctx.createLinearGradient(barX, 0, barX + barW, 0);
    gradient.addColorStop(0, t.seq[0]);
    gradient.addColorStop(1, t.seq[1]);
    ctx.fillStyle = gradient;
    ctx.fillRect(barX, barY, barW, barH);
    ctx.strokeStyle = t.grid;
    ctx.lineWidth = 1;
    ctx.strokeRect(barX, barY, barW, barH);

    ctx.font = "500 16px sans-serif";
    ctx.fillStyle = t.inkSoft;
    ctx.textBaseline = "bottom";
    ctx.textAlign = "left";
    ctx.fillText(`${minTemp.toFixed(1)}°C`, barX, barY - 6);
    ctx.textAlign = "right";
    ctx.fillText(`${maxTemp.toFixed(1)}°C`, barX + barW, barY - 6);
    ctx.textAlign = "left";
    ctx.textBaseline = "top";
    ctx.fillText("Avg. temperature", barX, barY + barH + 8);

    ctx.restore();
  },
};

// --- Chart -------------------------------------------------------------------
new Chart(canvas, {
  type: "line",
  data: {
    datasets: [...cycleRingDatasets, ...monthSpokeDatasets, spiralDataset],
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    layout: { padding: 16 },
    plugins: {
      title: {
        display: true,
        text: "spiral-timeseries · javascript · chartjs · anyplot.ai",
        color: t.ink,
        font: { size: 22, weight: "600" },
        padding: { bottom: 20 },
      },
      legend: { display: false },
      tooltip: {
        filter: (item) => item.dataset.label === "Daily average temperature",
        callbacks: {
          title: (items) => dateLabel(items[0].dataIndex),
          label: (item) => `${temperatures[item.dataIndex].toFixed(1)}°C`,
        },
      },
    },
    scales: {
      x: { type: "linear", display: false, min: -axisMax, max: axisMax },
      y: { type: "linear", display: false, min: -yAxisMax, max: yAxisMax },
    },
  },
  plugins: [spiralAnnotationsPlugin],
});

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/spiral-timeseries/chartjs/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "spiral-timeseries",
  "language": "javascript",
  "library": "chartjs",
  "page": "https://anyplot.ai/spiral-timeseries/javascript/chartjs",
  "hub": "https://anyplot.ai/spiral-timeseries",
  "code_json": "https://api.anyplot.ai/specs/spiral-timeseries/chartjs/code",
  "spec_json": "https://api.anyplot.ai/specs/spiral-timeseries",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/spiral-timeseries/javascript/chartjs/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/spiral-timeseries/javascript/chartjs/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/spiral-timeseries/javascript/chartjs/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/spiral-timeseries/javascript/chartjs/plot-dark.html",
  "quality_score": 90.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Spiral Time Series Chart on anyplot.ai.

Other implementations