{"spec_id":"spiral-timeseries","library":"muix","language":"javascript","code":"// anyplot.ai\n// spiral-timeseries: Spiral Time Series Chart\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 87/100 | Created: 2026-09-09\n//# anyplot-orientation: square\n// anyplot.ai\n// spiral-timeseries: Spiral Time Series Chart\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-09-09\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ChartsText } from \"@mui/x-charts/ChartsText\";\nimport { ScatterPlot } from \"@mui/x-charts/ScatterChart\";\nimport { ChartsTooltip } from \"@mui/x-charts/ChartsTooltip\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Daily average temperature, 5 years, deterministic (in-memory) ----------\n// cycle_period = \"year\" — every full revolution of the spiral is one year, so\n// seasons from different years line up radially and the multi-year warming\n// trend shows up as later revolutions running consistently warmer.\nconst DAYS_PER_YEAR = 365;\nconst NUM_YEARS = 5;\nconst TOTAL_DAYS = DAYS_PER_YEAR * NUM_YEARS; // 1825 points — within the spec's 100–3000 range\nconst START_YEAR = 2020;\n\nconst BASE_TEMP_C = 14; // annual mean, mid-latitude climate\nconst SEASONAL_AMPLITUDE_C = 13; // coldest ~Jan, warmest ~Jul\nconst WARMING_PER_YEAR_C = 0.35; // slow trend, visible as outer revolutions running warmer\nconst NOISE_AMPLITUDE_C = 3.5;\n\n// Tiny fixed-seed LCG — the browser has no seeded Math.random().\nlet lcgState = 42;\nfunction nextRandom() {\n  lcgState = (lcgState * 1103515245 + 12345) % 2147483648;\n  return lcgState / 2147483648;\n}\n\nconst dailyTemps = [];\nfor (let day = 0; day < TOTAL_DAYS; day++) {\n  const dayOfYear = day % DAYS_PER_YEAR;\n  const yearIndex = Math.floor(day / DAYS_PER_YEAR);\n  const seasonal = -Math.cos((2 * Math.PI * dayOfYear) / DAYS_PER_YEAR) * SEASONAL_AMPLITUDE_C;\n  const trend = WARMING_PER_YEAR_C * yearIndex;\n  const noise = (nextRandom() - 0.5) * NOISE_AMPLITUDE_C;\n  dailyTemps.push(BASE_TEMP_C + seasonal + trend + noise);\n}\nconst V_MIN = Math.min(...dailyTemps);\nconst V_MAX = Math.max(...dailyTemps);\n\n// --- Archimedean spiral geometry (constant spacing between revolutions) -----\n// Radius grows continuously with elapsed time (not reset per cycle), so the\n// earliest data sits closest to the center and each revolution is one year.\nconst R_MAX = 1;\nconst R_INNER = 0.14;\nconst REV_GAP = (R_MAX - R_INNER) / NUM_YEARS;\n\nconst spiralPoints = dailyTemps.map((value, day) => {\n  const revolutions = day / DAYS_PER_YEAR;\n  const angle = Math.PI / 2 - 2 * Math.PI * revolutions; // day 0 at top, clockwise like a clock face\n  const radius = R_INNER + REV_GAP * revolutions;\n  return { x: radius * Math.cos(angle), y: radius * Math.sin(angle), value };\n});\n\n// --- Imprint sequential colormap (green -> blue), value -> color ------------\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\nfunction mixColor(hexA, hexB, ratio) {\n  const [ra, ga, ba] = hexToRgb(hexA);\n  const [rb, gb, bb] = hexToRgb(hexB);\n  const clamped = Math.min(1, Math.max(0, ratio));\n  return `rgb(${Math.round(ra + (rb - ra) * clamped)}, ${Math.round(ga + (gb - ga) * clamped)}, ${Math.round(ba + (bb - ba) * clamped)})`;\n}\nfunction colorForValue(value) {\n  return mixColor(t.seq[0], t.seq[1], (value - V_MIN) / (V_MAX - V_MIN));\n}\n\nconst MONTH_LABELS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n\n// --- Overlay: the spiral itself, one short segment per day pair -------------\n// Community `@mui/x-charts/hooks` (useXScale/useYScale) map the spiral's\n// data-space (x, y) to pixels, same technique as the mohr-circle and\n// smith-chart-basic implementations use for parametric curves.\nfunction SpiralPath() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  const toPx = (x, y) => ({ x: xScale(x), y: yScale(y) });\n\n  const segments = [];\n  for (let i = 1; i < spiralPoints.length; i++) {\n    const a = spiralPoints[i - 1];\n    const b = spiralPoints[i];\n    const pa = toPx(a.x, a.y);\n    const pb = toPx(b.x, b.y);\n    const segValue = (a.value + b.value) / 2;\n    // Color is the primary value encoding; a modest width ramp is a secondary one.\n    const width = 2 + ((segValue - V_MIN) / (V_MAX - V_MIN)) * 3.5;\n    segments.push(\n      <line\n        key={i}\n        x1={pa.x}\n        y1={pa.y}\n        x2={pb.x}\n        y2={pb.y}\n        stroke={colorForValue(segValue)}\n        strokeWidth={width}\n        strokeLinecap=\"round\"\n      />,\n    );\n  }\n  return <g>{segments}</g>;\n}\n\n// --- Overlay: radial grid lines subdividing each cycle into months ----------\nfunction CycleGrid() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  const toPx = (x, y) => ({ x: xScale(x), y: yScale(y) });\n  const originPx = toPx(0, 0);\n\n  return (\n    <g>\n      {MONTH_LABELS.map((label, m) => {\n        const angle = Math.PI / 2 - (m / 12) * 2 * Math.PI;\n        // Short tick near the outer rim (not a full-radius spoke) so the\n        // grid marks the month position without cutting across all 5\n        // spiral revolutions.\n        const tickInnerPx = toPx(R_MAX * 0.96 * Math.cos(angle), R_MAX * 0.96 * Math.sin(angle));\n        const tickOuterPx = toPx(R_MAX * 1.03 * Math.cos(angle), R_MAX * 1.03 * Math.sin(angle));\n        const labelPx = toPx(R_MAX * 1.1 * Math.cos(angle), R_MAX * 1.1 * Math.sin(angle));\n        const dx = labelPx.x - originPx.x;\n        const dy = labelPx.y - originPx.y;\n        const dist = Math.hypot(dx, dy) || 1;\n        const ux = dx / dist;\n        const uy = dy / dist;\n        return (\n          <g key={label}>\n            <line x1={tickInnerPx.x} y1={tickInnerPx.y} x2={tickOuterPx.x} y2={tickOuterPx.y} stroke={t.grid} strokeWidth={1} />\n            <ChartsText\n              x={labelPx.x}\n              y={labelPx.y}\n              text={label}\n              style={{\n                fontSize: 14,\n                fill: t.inkSoft,\n                textAnchor: ux >= 0.3 ? \"start\" : ux <= -0.3 ? \"end\" : \"middle\",\n                dominantBaseline: uy >= 0.3 ? \"hanging\" : uy <= -0.3 ? \"auto\" : \"central\",\n              }}\n            />\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Cycle-start (year) markers, as a real MUI X scatter series -------------\n// Routed through the library's own scatter series/plugin (rendered by\n// <ScatterPlot/> below) rather than hand-drawn <circle> elements, so the\n// component exercises actual MUI X charting machinery — including its\n// built-in item tooltip — not just the coordinate-scale hooks.\nconst cycleStartPoints = Array.from({ length: NUM_YEARS }, (_, k) => ({\n  x: 0,\n  y: R_INNER + REV_GAP * k, // angle = top spoke, where every cycle begins\n  id: k,\n  year: START_YEAR + k,\n}));\nconst cycleStartSeries = [\n  {\n    type: \"scatter\",\n    id: \"cycle-start\",\n    label: \"Cycle start (year)\",\n    color: t.ink,\n    markerSize: 6,\n    data: cycleStartPoints,\n    valueFormatter: (point) => `${point.year}`,\n  },\n];\n\n// --- Overlay: label the start of each cycle (year) for orientation ----------\n// The scatter series above draws the marker dot; this only adds the year text.\nfunction CycleStartLabels() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  const toPx = (x, y) => ({ x: xScale(x), y: yScale(y) });\n\n  return (\n    <g>\n      {cycleStartPoints.map((point) => {\n        const pointPx = toPx(point.x, point.y);\n        return (\n          <ChartsText\n            key={point.id}\n            x={pointPx.x + 16}\n            y={pointPx.y}\n            text={String(point.year)}\n            style={{ fontSize: 15, fontWeight: 500, fill: t.ink, dominantBaseline: \"central\" }}\n          />\n        );\n      })}\n    </g>\n  );\n}\n\n// --- Overlay: color legend for the value-to-color mapping -------------------\n// Drawn in the SVG's own pixel space (no data scale) inside the right margin\n// reserved by MARGIN below.\nfunction ColorLegend() {\n  const legendX = window.ANYPLOT_SIZE.width - LEGEND_WIDTH + 46;\n  const legendTop = BASE_MARGIN + 20;\n  const legendBottom = CHART_HEIGHT - BASE_MARGIN - 20;\n  const barWidth = 22;\n\n  return (\n    <g>\n      <defs>\n        <linearGradient id=\"spiralTempScale\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop offset=\"0%\" stopColor={t.seq[1]} />\n          <stop offset=\"100%\" stopColor={t.seq[0]} />\n        </linearGradient>\n      </defs>\n      <ChartsText\n        x={legendX + barWidth / 2}\n        y={legendTop - 22}\n        text=\"Avg Temp\"\n        style={{ fontSize: 14, fontWeight: 500, fill: t.ink, textAnchor: \"middle\" }}\n      />\n      <rect x={legendX} y={legendTop} width={barWidth} height={legendBottom - legendTop} fill=\"url(#spiralTempScale)\" rx={3} />\n      <ChartsText\n        x={legendX + barWidth + 10}\n        y={legendTop}\n        text={`${Math.round(V_MAX)}°C`}\n        style={{ fontSize: 14, fill: t.inkSoft, dominantBaseline: \"hanging\" }}\n      />\n      <ChartsText\n        x={legendX + barWidth + 10}\n        y={(legendTop + legendBottom) / 2}\n        text={`${Math.round((V_MIN + V_MAX) / 2)}°C`}\n        style={{ fontSize: 14, fill: t.inkSoft, dominantBaseline: \"central\" }}\n      />\n      <ChartsText\n        x={legendX + barWidth + 10}\n        y={legendBottom}\n        text={`${Math.round(V_MIN)}°C`}\n        style={{ fontSize: 14, fill: t.inkSoft, dominantBaseline: \"auto\" }}\n      />\n    </g>\n  );\n}\n\nconst TITLE = \"spiral-timeseries · javascript · muix · anyplot.ai\";\nconst TITLE_HEIGHT = 70;\nconst LEGEND_WIDTH = 150;\nconst BASE_MARGIN = 60;\nconst CHART_HEIGHT = window.ANYPLOT_SIZE.height - TITLE_HEIGHT;\n\n// Equal-size square drawing area (left/right margins absorb the legend\n// gutter) is required for the spiral to render as a true circle, not an oval.\nconst AVAILABLE_WIDTH = window.ANYPLOT_SIZE.width - LEGEND_WIDTH;\nconst SQUARE_SIDE = CHART_HEIGHT - 2 * BASE_MARGIN;\nconst SIDE_MARGIN = (AVAILABLE_WIDTH - SQUARE_SIDE) / 2;\nconst MARGIN = { top: BASE_MARGIN, bottom: BASE_MARGIN, left: SIDE_MARGIN, right: SIDE_MARGIN + LEGEND_WIDTH };\n\n// --- Chart (default-exported component — the harness mounts it) -------------\nexport default function Chart() {\n  return (\n    <div\n      style={{\n        width: window.ANYPLOT_SIZE.width,\n        height: window.ANYPLOT_SIZE.height,\n        display: \"flex\",\n        flexDirection: \"column\",\n      }}\n    >\n      <div\n        style={{\n          height: TITLE_HEIGHT,\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          fontSize: 22,\n          fontWeight: 500,\n          color: t.ink,\n        }}\n      >\n        {TITLE}\n      </div>\n      <ChartContainer\n        width={window.ANYPLOT_SIZE.width}\n        height={CHART_HEIGHT}\n        margin={MARGIN}\n        series={cycleStartSeries}\n        skipAnimation\n        disableVoronoi\n        xAxis={[{ scaleType: \"linear\", min: -R_MAX * 1.2, max: R_MAX * 1.2 }]}\n        yAxis={[{ scaleType: \"linear\", min: -R_MAX * 1.2, max: R_MAX * 1.2 }]}\n      >\n        <CycleGrid />\n        <SpiralPath />\n        <ScatterPlot />\n        <CycleStartLabels />\n        <ColorLegend />\n        <ChartsTooltip trigger=\"item\" />\n      </ChartContainer>\n    </div>\n  );\n}\n"}