{"spec_id":"heatmap-polar","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// heatmap-polar: Polar Heatmap for Cyclic Two-Dimensional Data\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-05\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Hourly e-commerce site traffic across the 7 days of the week (radial rings,\n// Monday innermost) and 24 hours of the day (angular position, midnight at\n// the top, running clockwise so the angular axis reads like a 24h clock).\nconst dayLabels = [\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\"];\nconst hourLabels = Array.from({ length: 24 }, (_, hour) =>\n  hour === 0 ? \"12am\" : hour < 12 ? `${hour}am` : hour === 12 ? \"12pm\" : `${hour - 12}pm`\n);\n\n// Small fixed-seed LCG — the browser has no seeded RNG.\nlet seed = 42;\nconst rand = () => {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n};\n\nconst gaussian = (x, mu, sigma) => Math.exp(-((x - mu) ** 2) / (2 * sigma * sigma));\n\n// Weekends skew toward leisure browsing (higher overall, broader afternoon\n// peak); weekdays peak after office hours.\nconst dayWeight = { Mon: 0.75, Tue: 0.8, Wed: 0.85, Thu: 0.95, Fri: 1.2, Sat: 1.6, Sun: 1.35 };\nconst baseVisits = 2200;\n\nconst visitsByDayHour = dayLabels.map((day) => {\n  const isWeekend = day === \"Sat\" || day === \"Sun\";\n  return hourLabels.map((_, hour) => {\n    const eveningPeak = gaussian(hour, isWeekend ? 14 : 20, isWeekend ? 4.5 : 3);\n    const lunchPeak = gaussian(hour, 12, 2);\n    const nightFloor = 0.08;\n    const intensity = nightFloor + 0.55 * eveningPeak + 0.3 * lunchPeak;\n    const noise = 1 + (rand() - 0.5) * 0.16;\n    return Math.round(baseVisits * dayWeight[day] * intensity * noise);\n  });\n});\n\nconst allValues = visitsByDayHour.flat();\nconst valueMin = Math.min(...allValues);\nconst valueMax = Math.max(...allValues);\n\n// --- Color mapping: imprint_seq (single-polarity continuous) ---------------\nconst hexToRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));\nconst [seqLo, seqHi] = t.seq.map(hexToRgb);\nconst valueToColor = (value) => {\n  const f = (value - valueMin) / (valueMax - valueMin || 1);\n  const [r, g, b] = seqLo.map((c, i) => Math.round(c + (seqHi[i] - c) * f));\n  return `rgb(${r}, ${g}, ${b})`;\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Rings -------------------------------------------------------------------\n// Chart.js stacks doughnut datasets with dataset[0] as the OUTERMOST ring, so\n// the render order is reversed relative to dayLabels — Monday (the spec's\n// first radial category) must be the LAST dataset to land innermost.\nconst renderOrder = [...dayLabels].reverse();\nconst datasets = renderOrder.map((day) => {\n  const row = visitsByDayHour[dayLabels.indexOf(day)];\n  const colors = row.map(valueToColor);\n  return {\n    label: day,\n    data: hourLabels.map(() => 1), // equal angular width per hour — color alone carries the value\n    backgroundColor: colors,\n    hoverBackgroundColor: colors,\n    borderColor: t.pageBg,\n    hoverBorderColor: t.pageBg,\n    borderWidth: 2,\n  };\n});\n\nconst title = \"Hourly Website Traffic · heatmap-polar · javascript · chartjs · anyplot.ai\";\n// Title fontsize scaled from the 67-char baseline (default 22px): round(22 × 67/74) = 20\nconst titleFontSize = 20;\n\n// --- Chrome plugin: angular hour ticks, radial day-ring labels, colorbar ----\nconst radialHeatmapChrome = {\n  id: \"radialHeatmapChrome\",\n  afterDraw(chart) {\n    const { ctx } = chart;\n    const outerArcs = chart.getDatasetMeta(0).data; // outermost ring (Sun)\n    if (!outerArcs.length) return;\n    const cx = outerArcs[0].x;\n    const cy = outerArcs[0].y;\n    const outerRadius = outerArcs[0].outerRadius;\n\n    ctx.save();\n\n    // Angular tick labels at the 4 cardinal hours (12am/6am/12pm/6pm)\n    ctx.font = \"500 15px -apple-system, BlinkMacSystemFont, sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    [0, 6, 12, 18].forEach((hourIdx) => {\n      const arc = outerArcs[hourIdx];\n      const mid = (arc.startAngle + arc.endAngle) / 2;\n      const lx = cx + (outerRadius + 26) * Math.cos(mid);\n      const ly = cy + (outerRadius + 26) * Math.sin(mid);\n      ctx.textAlign = Math.cos(mid) > 0.3 ? \"left\" : Math.cos(mid) < -0.3 ? \"right\" : \"center\";\n      ctx.textBaseline = Math.sin(mid) > 0.3 ? \"top\" : Math.sin(mid) < -0.3 ? \"bottom\" : \"middle\";\n      ctx.fillText(hourLabels[hourIdx], lx, ly);\n    });\n\n    // Radial ring labels (day names), placed on a spoke between the 12am and\n    // 6am ticks so they never collide with the angular labels above.\n    const spokeAngle = -Math.PI / 4;\n    ctx.font = \"600 15px -apple-system, BlinkMacSystemFont, sans-serif\";\n    renderOrder.forEach((day, ringIdx) => {\n      const arc = chart.getDatasetMeta(ringIdx).data[0];\n      const midRadius = (arc.innerRadius + arc.outerRadius) / 2;\n      const lx = cx + midRadius * Math.cos(spokeAngle);\n      const ly = cy + midRadius * Math.sin(spokeAngle);\n      const textWidth = ctx.measureText(day).width;\n\n      ctx.globalAlpha = 0.82;\n      ctx.fillStyle = t.elevatedBg;\n      ctx.beginPath();\n      ctx.roundRect(lx - textWidth / 2 - 8, ly - 11, textWidth + 16, 22, 11);\n      ctx.fill();\n      ctx.globalAlpha = 1;\n\n      ctx.fillStyle = t.ink;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n      ctx.fillText(day, lx, ly);\n    });\n\n    // Colorbar legend (imprint_seq) in the bottom margin reserved by layout.padding\n    const barWidth = chart.width * 0.34;\n    const barHeight = 22;\n    const barX = chart.width / 2 - barWidth / 2;\n    const barY = chart.height - 78;\n    const gradient = ctx.createLinearGradient(barX, 0, barX + barWidth, 0);\n    gradient.addColorStop(0, t.seq[0]);\n    gradient.addColorStop(1, t.seq[1]);\n    ctx.fillStyle = gradient;\n    ctx.fillRect(barX, barY, barWidth, barHeight);\n    ctx.strokeStyle = t.grid;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(barX, barY, barWidth, barHeight);\n\n    ctx.font = \"500 14px -apple-system, BlinkMacSystemFont, sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.textBaseline = \"middle\";\n    ctx.textAlign = \"right\";\n    ctx.fillText(valueMin.toLocaleString(), barX - 10, barY + barHeight / 2);\n    ctx.textAlign = \"left\";\n    ctx.fillText(valueMax.toLocaleString(), barX + barWidth + 10, barY + barHeight / 2);\n    ctx.textAlign = \"center\";\n    ctx.fillText(\"Visits per hour\", barX + barWidth / 2, barY + barHeight + 20);\n\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"doughnut\",\n  data: { labels: hourLabels, datasets },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    cutout: \"8%\",\n    layout: { padding: { top: 10, bottom: 130 } },\n    plugins: {\n      legend: { display: false },\n      title: {\n        display: true,\n        text: title,\n        color: t.ink,\n        font: { size: titleFontSize, weight: \"500\" },\n        // Generous bottom padding keeps the 12am tick label (drawn just\n        // outside the outer radius) clear of the title text above it.\n        padding: { bottom: 100 },\n      },\n      tooltip: {\n        callbacks: {\n          title: (items) => {\n            const day = renderOrder[items[0].datasetIndex];\n            return `${day} · ${hourLabels[items[0].dataIndex]}`;\n          },\n          label: (item) => {\n            const day = renderOrder[item.datasetIndex];\n            const value = visitsByDayHour[dayLabels.indexOf(day)][item.dataIndex];\n            return `${value.toLocaleString()} visits`;\n          },\n        },\n      },\n    },\n  },\n  plugins: [radialHeatmapChrome],\n});\n"}