{"spec_id":"heatmap-calendar","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// heatmap-calendar: Basic Calendar Heatmap\n// Library: chartjs 4.4.7 | JavaScript 22.23.1\n// Quality: 80/100 | Updated: 2026-07-24\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n// Semantic \"muted\" anchor (other/rest/no-activity) — theme-adaptive, not part\n// of window.ANYPLOT_TOKENS, so it's derived here per prompts/default-style-guide.md.\nconst MUTED = t.theme === \"dark\" ? \"#A8A79F\" : \"#6B6A63\";\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Daily coding-commit counts across a full year, GitHub-contribution-graph style.\nlet seed = 42;\nfunction nextRandom() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\n\nconst YEAR = 2023;\nconst startDate = new Date(Date.UTC(YEAR, 0, 1));\nconst startWeekday = (startDate.getUTCDay() + 6) % 7; // Monday = 0 ... Sunday = 6\nconst weekdayLabels = [\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\"];\nconst monthLabels = [\n  \"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\",\n  \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\",\n];\nconst weekdayBaseline = [4, 5, 6, 6, 5, 2, 1]; // Mon..Sun commit baseline\n\nconst days = [];\nfor (let i = 0; i < 365; i++) {\n  const date = new Date(startDate.getTime() + i * 86400000);\n  const weekday = (date.getUTCDay() + 6) % 7;\n  const week = Math.floor((i + startWeekday) / 7);\n  let value = Math.round(weekdayBaseline[weekday] + (nextRandom() - 0.5) * 6);\n  if (i % 47 === 3) value += 14; // occasional hackathon burst\n  value = Math.max(0, value);\n  days.push({ date, week, weekday, value });\n}\nconst weekCount = days[days.length - 1].week + 1;\n\n// --- Month tick positions (first week each month first appears) ------------\nconst monthTicks = [];\nconst seenMonths = new Set();\nfor (const day of days) {\n  const monthKey = day.date.getUTCMonth();\n  if (!seenMonths.has(monthKey)) {\n    seenMonths.add(monthKey);\n    monthTicks.push({ week: day.week, label: monthLabels[monthKey] });\n  }\n}\nconst monthLabelAt = Object.fromEntries(monthTicks.map((m) => [m.week, m.label]));\n\n// --- Sequential color mapping (Imprint imprint_seq: brand green -> blue) ---\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\nfunction rgbToHex([r, g, b]) {\n  return (\n    \"#\" +\n    [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, \"0\")).join(\"\")\n  );\n}\nconst [seqR1, seqG1, seqB1] = hexToRgb(t.seq[0]);\nconst [seqR2, seqG2, seqB2] = hexToRgb(t.seq[1]);\nfunction seqColor(frac) {\n  return rgbToHex([\n    seqR1 + (seqR2 - seqR1) * frac,\n    seqG1 + (seqG2 - seqG1) * frac,\n    seqB1 + (seqB2 - seqB1) * frac,\n  ]);\n}\n\nconst maxValue = Math.max(...days.map((d) => d.value));\nfunction colorForValue(value) {\n  if (value === 0) return MUTED;\n  return seqColor(Math.min(1, Math.sqrt(value / maxValue)));\n}\n\n// --- Legend bins (data-driven, matches the sequential scale) ---------------\nconst nonZeroSorted = days\n  .map((d) => d.value)\n  .filter((v) => v > 0)\n  .sort((a, b) => a - b);\nconst quantile = (p) => nonZeroSorted[Math.floor(p * (nonZeroSorted.length - 1))];\nconst q1 = quantile(0.33);\nconst q2 = quantile(0.66);\nconst legendBins = [\n  { label: \"No activity\", color: MUTED },\n  { label: `1–${q1}`, color: colorForValue(Math.max(1, Math.round(q1 / 2))) },\n  { label: `${q1 + 1}–${q2}`, color: colorForValue(Math.round((q1 + q2) / 2)) },\n  { label: `${q2 + 1}+`, color: colorForValue(maxValue) },\n];\n\n// --- Compact-grid layout plugin ---------------------------------------------\n// Chart.js gives the y scale the *entire* leftover chartArea height\n// (chartArea.bottom - chartArea.top) no matter how few data rows it holds —\n// with only 7 weekday rows and 53 columns, shrinking all the way down to\n// bare square cells (px-per-row == px-per-week) leaves the grid a thin,\n// isolated island in a mostly-empty canvas. Right after layout:\n//  1. Grow the row pitch well past the bare square-cell height so the grid\n//     consumes most of the reclaimed vertical space -- cells stay square\n//     (sized by column width in the pointRadius callback below) but rows\n//     get generous breathing room between them -- then pull the legend up\n//     to sit close under the grid instead of pinned to the canvas bottom.\n//  2. Re-center the whole title/grid/legend block vertically in the canvas,\n//     so the composition reads as an intentionally spacious layout rather\n//     than a small block adrift in empty space.\nconst compactGrid = {\n  id: \"compactGrid\",\n  afterLayout(chart) {\n    const { x, y } = chart.scales;\n    const legend = chart.legend;\n    const title = chart.titleBlock;\n    if (!x || !y) return;\n    const pxPerWeek = Math.abs(x.getPixelForValue(1) - x.getPixelForValue(0));\n    const squareHeight = (y.max - y.min) * pxPerWeek; // bare square-cell height\n    const available = y.bottom - y.top; // full leftover space before shrink\n    const gridHeight = Math.min(available * 0.72, squareHeight * 2.6);\n\n    y.top += 6; // small breathing room under the month labels\n    y.bottom = y.top + gridHeight;\n    chart.chartArea.top = y.top;\n    chart.chartArea.bottom = y.bottom;\n    // top/bottom alone don't affect pixel conversion — LinearScale caches\n    // _startPixel/_length in configure() during layout, so it must be\n    // re-run for the new box to actually move the drawn points/ticks.\n    y.configure();\n\n    if (legend) {\n      const legendHeight = legend.bottom - legend.top;\n      legend.top = y.bottom + 28;\n      legend.bottom = legend.top + legendHeight;\n    }\n\n    if (title && legend) {\n      const blockTop = title.top;\n      const blockBottom = legend.bottom;\n      const shiftDown = (chart.height - (blockBottom - blockTop)) / 2 - blockTop;\n      if (shiftDown > 0) {\n        title.top += shiftDown;\n        title.bottom += shiftDown;\n        x.top += shiftDown;\n        x.bottom += shiftDown;\n        y.top += shiftDown;\n        y.bottom += shiftDown;\n        chart.chartArea.top += shiftDown;\n        chart.chartArea.bottom += shiftDown;\n        legend.top += shiftDown;\n        legend.bottom += shiftDown;\n        y.configure();\n      }\n    }\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart ---------------------------------------------------------------\nconst dateFormatter = new Intl.DateTimeFormat(\"en-US\", {\n  month: \"short\",\n  day: \"numeric\",\n  year: \"numeric\",\n  timeZone: \"UTC\",\n});\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  plugins: [compactGrid],\n  data: {\n    datasets: [\n      {\n        label: \"Commits\",\n        data: days.map((d) => ({ x: d.week, y: d.weekday, v: d.value, date: d.date })),\n        showLine: false,\n        pointStyle: \"rect\",\n        pointBackgroundColor: (ctx) => colorForValue(ctx.raw.v),\n        pointBorderColor: t.pageBg,\n        pointBorderWidth: 1,\n        pointRadius: (ctx) => {\n          const scale = ctx.chart.scales;\n          if (!scale.x || !scale.y) return 8;\n          const pxPerWeek = Math.abs(scale.x.getPixelForValue(1) - scale.x.getPixelForValue(0));\n          const pxPerDay = Math.abs(scale.y.getPixelForValue(1) - scale.y.getPixelForValue(0));\n          return Math.max(3, Math.min(pxPerWeek, pxPerDay) / 2 - 1);\n        },\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 4, right: 20, bottom: 4, left: 4 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"heatmap-calendar · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 26, weight: \"500\" },\n        padding: { bottom: 24 },\n      },\n      legend: {\n        display: true,\n        position: \"bottom\",\n        onClick: () => {},\n        labels: {\n          color: t.inkSoft,\n          font: { size: 17 },\n          boxWidth: 22,\n          boxHeight: 22,\n          padding: 16,\n          generateLabels: () =>\n            // A subtle page-background stroke (matching the cell borders)\n            // sharpens the visual step between adjacent same-hue bins.\n            // Chart.js's legend draw step reads `legendItem.fontColor` (not\n            // `options.labels.color`) for the text fillStyle, so custom\n            // generateLabels() must set it explicitly or the text falls\n            // back to an uninitialized near-black canvas fillStyle.\n            legendBins.map((bin) => ({\n              text: bin.label,\n              fillStyle: bin.color,\n              fontColor: t.inkSoft,\n              strokeStyle: t.pageBg,\n              lineWidth: 1,\n            })),\n        },\n      },\n      tooltip: {\n        callbacks: {\n          title: () => \"\",\n          label: (ctx) => `${dateFormatter.format(ctx.raw.date)}: ${ctx.raw.v} commits`,\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        position: \"top\",\n        min: -0.6,\n        max: weekCount - 0.4,\n        afterBuildTicks: (axis) => {\n          axis.ticks = monthTicks.map((m) => ({ value: m.week }));\n        },\n        ticks: {\n          callback: (value) => monthLabelAt[value] ?? \"\",\n          color: t.inkSoft,\n          font: { size: 16 },\n        },\n        grid: { display: false },\n        border: { display: false },\n      },\n      y: {\n        type: \"linear\",\n        reverse: true,\n        min: -0.6,\n        max: 6.6,\n        afterBuildTicks: (axis) => {\n          axis.ticks = weekdayLabels.map((_, i) => ({ value: i }));\n        },\n        ticks: {\n          callback: (value) => weekdayLabels[value] ?? \"\",\n          color: t.inkSoft,\n          font: { size: 16 },\n        },\n        grid: { display: false },\n        border: { display: false },\n      },\n    },\n  },\n});\n"}