{"spec_id":"spiral-timeseries","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// spiral-timeseries: Spiral Time Series Chart\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-09\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Daily average temperature over 3 years — each revolution is one year, so the\n// same calendar day from different years lines up along the same spoke.\nconst DAYS_PER_CYCLE = 365;\nconst NUM_CYCLES = 3;\nconst TOTAL_DAYS = DAYS_PER_CYCLE * NUM_CYCLES;\nconst START_YEAR = 2022;\nconst MONTH_NAMES = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n\n// Tiny fixed-seed LCG — the browser has no seeded Math.random().\nlet lcgSeed = 20220101;\nfunction lcgNoise() {\n  lcgSeed = (lcgSeed * 1103515245 + 12345) & 0x7fffffff;\n  return (lcgSeed / 0x7fffffff) * 2 - 1; // [-1, 1]\n}\n\nconst temperatures = [];\nfor (let day = 0; day < TOTAL_DAYS; day++) {\n  const dayOfYear = day % DAYS_PER_CYCLE;\n  const yearIndex = Math.floor(day / DAYS_PER_CYCLE);\n  const seasonal = 12 - 10 * Math.cos((2 * Math.PI * dayOfYear) / DAYS_PER_CYCLE);\n  const warmingTrend = yearIndex * 3;\n  temperatures.push(seasonal + warmingTrend + lcgNoise() * 0.8);\n}\nconst minTemp = Math.min(...temperatures);\nconst maxTemp = Math.max(...temperatures);\n\nfunction dateLabel(dayIndex) {\n  const yearIndex = Math.floor(dayIndex / DAYS_PER_CYCLE);\n  const dayOfYear = dayIndex % DAYS_PER_CYCLE;\n  const monthIdx = Math.min(11, Math.floor(dayOfYear / 30.44));\n  return `${MONTH_NAMES[monthIdx]} ${START_YEAR + yearIndex}`;\n}\n\n// --- Archimedean spiral geometry --------------------------------------------\n// r = R0 + RING * theta/(2π), theta = 2π * day/DAYS_PER_CYCLE (continuously\n// increasing across the whole series), rotated so day 1 sits at the top.\nconst R0 = 14;\nconst RING = 20;\nconst maxR = R0 + RING * NUM_CYCLES;\n\nconst spiralPoints = [];\nfor (let day = 0; day < TOTAL_DAYS; day++) {\n  const cycleFrac = day / DAYS_PER_CYCLE;\n  const theta = 2 * Math.PI * cycleFrac - Math.PI / 2;\n  const r = R0 + RING * cycleFrac;\n  spiralPoints.push({ x: r * Math.cos(theta), y: r * Math.sin(theta) });\n}\n\nfunction circlePoints(r) {\n  const pts = [];\n  const STEPS = 180;\n  for (let s = 0; s <= STEPS; s++) {\n    const a = (2 * Math.PI * s) / STEPS;\n    pts.push({ x: r * Math.cos(a), y: r * Math.sin(a) });\n  }\n  return pts;\n}\n\n// Concentric rings mark each cycle boundary (one per year, plus the start).\nconst cycleRingDatasets = [];\nfor (let k = 0; k <= NUM_CYCLES; k++) {\n  cycleRingDatasets.push({\n    label: `cycle-boundary-${k}`,\n    data: circlePoints(R0 + RING * k),\n    borderColor: t.grid,\n    borderWidth: 1,\n    borderDash: [5, 5],\n    pointRadius: 0,\n    fill: false,\n  });\n}\n\n// Month spokes subdivide each cycle radially.\nconst labelR = maxR * 1.22;\nconst monthSpokeDatasets = MONTH_NAMES.map((_, m) => {\n  const angle = (2 * Math.PI * m) / 12 - Math.PI / 2;\n  return {\n    label: `month-spoke-${m}`,\n    data: [\n      { x: 0, y: 0 },\n      { x: maxR * Math.cos(angle), y: maxR * Math.sin(angle) },\n    ],\n    borderColor: t.grid,\n    borderWidth: 1,\n    borderDash: [2, 4],\n    pointRadius: 0,\n    fill: false,\n  };\n});\n\n// --- Color encodes value magnitude (Imprint sequential ramp) ---------------\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\nconst [seqR0, seqG0, seqB0] = hexToRgb(t.seq[0]);\nconst [seqR1, seqG1, seqB1] = hexToRgb(t.seq[1]);\nfunction valueColor(v) {\n  const tt = Math.min(1, Math.max(0, (v - minTemp) / (maxTemp - minTemp)));\n  const r = Math.round(seqR0 + (seqR1 - seqR0) * tt);\n  const g = Math.round(seqG0 + (seqG1 - seqG0) * tt);\n  const b = Math.round(seqB0 + (seqB1 - seqB0) * tt);\n  return `rgb(${r}, ${g}, ${b})`;\n}\n\nconst spiralDataset = {\n  label: \"Daily average temperature\",\n  data: spiralPoints,\n  borderWidth: 3,\n  pointRadius: 0,\n  fill: false,\n  tension: 0,\n  segment: {\n    borderColor: (ctx) => valueColor(temperatures[ctx.p1DataIndex]),\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Axis extents (keep the spiral visually circular on the square canvas) --\n// Cartesian x/y scales have no built-in \"equal aspect\" option; approximate it\n// from the known CSS mount size minus the title bar the mandated title reserves.\nconst axisMax = labelR * 1.12;\nconst titleReservePx = 70;\nconst aspectComp = Math.max(0.75, (size.height - titleReservePx) / size.width);\nconst yAxisMax = axisMax * aspectComp;\n\n// --- Custom plugin: month/year labels + color-bar legend --------------------\n// Native Chart.js plugin API (inline object passed to `plugins:`), not a\n// chartjs-chart-* community package — draws directly with the canvas 2D API.\nconst spiralAnnotationsPlugin = {\n  id: \"spiralAnnotations\",\n  afterDraw(chart) {\n    const { ctx, scales } = chart;\n    const xScale = scales.x;\n    const yScale = scales.y;\n    ctx.save();\n\n    // Month labels around the outer ring.\n    ctx.font = \"600 20px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    MONTH_NAMES.forEach((name, m) => {\n      const angle = (2 * Math.PI * m) / 12 - Math.PI / 2;\n      const px = xScale.getPixelForValue(labelR * Math.cos(angle));\n      const py = yScale.getPixelForValue(labelR * Math.sin(angle));\n      ctx.fillText(name, px, py);\n    });\n\n    // Cycle-start (year) labels, nudged off the 12-o'clock spoke.\n    ctx.font = \"600 22px sans-serif\";\n    ctx.fillStyle = t.ink;\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    for (let k = 0; k < NUM_CYCLES; k++) {\n      const r = R0 + RING * k + RING * 0.4;\n      const angle = -Math.PI / 2 - 0.4;\n      const px = xScale.getPixelForValue(r * Math.cos(angle));\n      const py = yScale.getPixelForValue(r * Math.sin(angle));\n      ctx.fillText(String(START_YEAR + k), px + 6, py);\n    }\n\n    // Color-bar legend in the empty corner outside the circular spiral.\n    const barX = chart.chartArea.left + 24;\n    const barY = chart.chartArea.bottom - 56;\n    const barW = 220;\n    const barH = 20;\n    const gradient = ctx.createLinearGradient(barX, 0, barX + barW, 0);\n    gradient.addColorStop(0, t.seq[0]);\n    gradient.addColorStop(1, t.seq[1]);\n    ctx.fillStyle = gradient;\n    ctx.fillRect(barX, barY, barW, barH);\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(barX, barY, barW, barH);\n\n    ctx.font = \"500 16px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textBaseline = \"bottom\";\n    ctx.textAlign = \"left\";\n    ctx.fillText(`${minTemp.toFixed(1)}°C`, barX, barY - 6);\n    ctx.textAlign = \"right\";\n    ctx.fillText(`${maxTemp.toFixed(1)}°C`, barX + barW, barY - 6);\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"top\";\n    ctx.fillText(\"Avg. temperature\", barX, barY + barH + 8);\n\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"line\",\n  data: {\n    datasets: [...cycleRingDatasets, ...monthSpokeDatasets, spiralDataset],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 16 },\n    plugins: {\n      title: {\n        display: true,\n        text: \"spiral-timeseries · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"600\" },\n        padding: { bottom: 20 },\n      },\n      legend: { display: false },\n      tooltip: {\n        filter: (item) => item.dataset.label === \"Daily average temperature\",\n        callbacks: {\n          title: (items) => dateLabel(items[0].dataIndex),\n          label: (item) => `${temperatures[item.dataIndex].toFixed(1)}°C`,\n        },\n      },\n    },\n    scales: {\n      x: { type: \"linear\", display: false, min: -axisMax, max: axisMax },\n      y: { type: \"linear\", display: false, min: -yAxisMax, max: yAxisMax },\n    },\n  },\n  plugins: [spiralAnnotationsPlugin],\n});\n"}