{"spec_id":"spiral-timeseries","library":"d3","language":"javascript","code":"// anyplot.ai\n// spiral-timeseries: Spiral Time Series Chart\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-09\n\n//# anyplot-orientation: square\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Daily average temperature over 5 years — one spiral revolution per year, so\n// seasons align vertically across turns.\nconst YEARS = 5;\nconst DAYS_PER_YEAR = 365;\nconst N = YEARS * DAYS_PER_YEAR;\n\nlet seed = 42;\nconst rand = () => {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n};\n\nconst MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];\nconst MONTH_NAMES = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\nconst monthStartDay = [];\nlet cumDays = 0;\nfor (const len of MONTH_LENGTHS) {\n  monthStartDay.push(cumDays);\n  cumDays += len;\n}\n\nconst series = [];\nfor (let i = 0; i < N; i++) {\n  const year = Math.floor(i / DAYS_PER_YEAR);\n  const dayOfYear = i % DAYS_PER_YEAR;\n  const seasonal = 15 + 12 * Math.sin((2 * Math.PI * (dayOfYear - 80)) / DAYS_PER_YEAR);\n  const warmingTrend = year * 0.4;\n  const dailyNoise = (rand() - 0.5) * 3;\n  series.push({ year, dayOfYear, value: seasonal + warmingTrend + dailyNoise });\n}\n\n// --- Spiral geometry ---------------------------------------------------------\nconst margin = { top: height * 0.092, right: width * 0.158, bottom: height * 0.033, left: width * 0.042 };\nconst plotL = margin.left, plotR = width - margin.right;\nconst plotT = margin.top, plotB = height - margin.bottom;\nconst cx = (plotL + plotR) / 2;\nconst cy = (plotT + plotB) / 2;\nconst maxR = Math.min(plotR - plotL, plotB - plotT) / 2 - width * 0.038;\nconst r0 = width * 0.037;\n\nconst totalAngle = YEARS * 2 * Math.PI;\nconst k = (maxR - r0) / totalAngle;\n\nconst points = series.map((d) => {\n  const theta = d.year * 2 * Math.PI + (d.dayOfYear / DAYS_PER_YEAR) * 2 * Math.PI;\n  const r = r0 + k * theta;\n  const a = theta - Math.PI / 2; // Jan 1 at the top, clockwise like a calendar\n  return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a), r, value: d.value };\n});\n\n// --- Color scale: diverging around the 5-year mean, hot -> red, cold -> blue -\nconst values = series.map((d) => d.value);\nconst minV = d3.min(values);\nconst maxV = d3.max(values);\nconst meanV = d3.mean(values);\nconst colorScale = d3\n  .scaleDiverging(d3.interpolateRgbBasis([t.div[2], t.div[1], t.div[0]]))\n  .domain([minV, meanV, maxV]);\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\");\n\n// --- Radial grid: month boundaries (same angle every revolution) -------------\ng.selectAll(\"line.month-spoke\")\n  .data(monthStartDay)\n  .join(\"line\")\n  .attr(\"class\", \"month-spoke\")\n  .attr(\"x1\", cx)\n  .attr(\"y1\", cy)\n  .attr(\"x2\", (d) => cx + (maxR + width * 0.015) * Math.cos((d / DAYS_PER_YEAR) * 2 * Math.PI - Math.PI / 2))\n  .attr(\"y2\", (d) => cy + (maxR + width * 0.015) * Math.sin((d / DAYS_PER_YEAR) * 2 * Math.PI - Math.PI / 2))\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1)\n  .attr(\"stroke-dasharray\", \"2,5\");\n\ng.selectAll(\"text.month-label\")\n  .data(MONTH_NAMES)\n  .join(\"text\")\n  .attr(\"class\", \"month-label\")\n  .attr(\"x\", (_, i) => cx + (maxR + width * 0.033) * Math.cos((monthStartDay[i] / DAYS_PER_YEAR) * 2 * Math.PI - Math.PI / 2))\n  .attr(\"y\", (_, i) => cy + (maxR + width * 0.033) * Math.sin((monthStartDay[i] / DAYS_PER_YEAR) * 2 * Math.PI - Math.PI / 2))\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"dominant-baseline\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text((d) => d);\n\n// --- Spiral line, colored by temperature along its length --------------------\ng.selectAll(\"line.segment\")\n  .data(d3.pairs(points))\n  .join(\"line\")\n  .attr(\"class\", \"segment\")\n  .attr(\"x1\", (d) => d[0].x)\n  .attr(\"y1\", (d) => d[0].y)\n  .attr(\"x2\", (d) => d[1].x)\n  .attr(\"y2\", (d) => d[1].y)\n  .attr(\"stroke\", (d) => colorScale((d[0].value + d[1].value) / 2))\n  .attr(\"stroke-width\", 3.5)\n  .attr(\"stroke-linecap\", \"round\");\n\n// --- Cycle-start labels: identify which turn is which year -------------------\nfor (let year = 0; year < YEARS; year++) {\n  const r = r0 + k * (year * 2 * Math.PI);\n  const angle = -Math.PI / 2 - 0.16;\n  g.append(\"text\")\n    .attr(\"x\", cx + (r + width * 0.01) * Math.cos(angle))\n    .attr(\"y\", cy + (r + width * 0.01) * Math.sin(angle))\n    .attr(\"text-anchor\", \"end\")\n    .attr(\"dominant-baseline\", \"middle\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"13px\")\n    .style(\"font-weight\", \"600\")\n    .text(`Year ${year + 1}`);\n}\n\n// --- Color legend (vertical gradient bar) -------------------------------------\nconst legendX = plotR + width * 0.03;\nconst legendTop = cy - maxR;\nconst legendBottom = cy + maxR;\nconst legendWidth = width * 0.018;\n\nconst gradient = svg\n  .append(\"defs\")\n  .append(\"linearGradient\")\n  .attr(\"id\", \"tempGradient\")\n  .attr(\"x1\", \"0%\")\n  .attr(\"y1\", \"100%\")\n  .attr(\"x2\", \"0%\")\n  .attr(\"y2\", \"0%\");\n\ngradient\n  .selectAll(\"stop\")\n  .data(d3.range(0, 1.0001, 0.1))\n  .join(\"stop\")\n  .attr(\"offset\", (d) => `${d * 100}%`)\n  .attr(\"stop-color\", (d) => colorScale(minV + d * (maxV - minV)));\n\nsvg\n  .append(\"rect\")\n  .attr(\"x\", legendX)\n  .attr(\"y\", legendTop)\n  .attr(\"width\", legendWidth)\n  .attr(\"height\", legendBottom - legendTop)\n  .attr(\"fill\", \"url(#tempGradient)\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + legendWidth / 2)\n  .attr(\"y\", legendTop - 16)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Temp\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + legendWidth + 8)\n  .attr(\"y\", legendTop + 5)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(`${maxV.toFixed(1)}°C`);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + legendWidth + 8)\n  .attr(\"y\", (legendTop + legendBottom) / 2 + 5)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(`${meanV.toFixed(1)}°C avg`);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + legendWidth + 8)\n  .attr(\"y\", legendBottom)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(`${minV.toFixed(1)}°C`);\n\n// --- Title ---------------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", height * 0.042)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"24px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"spiral-timeseries · javascript · d3 · anyplot.ai\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", height * 0.065)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Daily average temperature · one revolution = one year\");\n"}