{"spec_id":"heatmap-calendar","library":"d3","language":"javascript","code":"// anyplot.ai\n// heatmap-calendar: Basic Calendar Heatmap\n// Library: d3 7.9.0 | JavaScript 22.23.1\n// Quality: 93/100 | Created: 2026-07-23\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Tiny fixed-seed LCG (no seeded RNG in the browser) --------------------\nlet seed = 42;\nconst rand = () => {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n};\n\n// --- Data: daily step count (thousands) for 2025, with a handful of gaps ---\n// where the tracker wasn't worn — a realistic \"missing data\" scenario.\nconst YEAR = 2025;\nconst yearStart = new Date(Date.UTC(YEAR, 0, 1));\nconst yearEnd = new Date(Date.UTC(YEAR + 1, 0, 1));\nconst monthName = d3.utcFormat(\"%b\");\n\nconst isMissing = (date) => {\n  const m = date.getUTCMonth();\n  const d = date.getUTCDate();\n  return (m === 3 && d >= 14 && d <= 20) || (m === 0 && d === 15) || (m === 8 && d === 7) || (m === 10 && d === 11);\n};\n\n// d3-time idiom for calendar-view layouts: Sunday-boundary week count gives the\n// column, (getUTCDay()+6)%7 remaps Sunday=0 to a Monday-first row index.\nconst days = d3.utcDays(yearStart, yearEnd).map((date) => {\n  const dayOfYear = d3.utcDay.count(yearStart, date);\n  const isWeekend = date.getUTCDay() === 0 || date.getUTCDay() === 6;\n\n  const seasonal = 1.7 * Math.sin((2 * Math.PI * (dayOfYear - 105)) / 365);\n  const weekdayAdj = isWeekend ? -0.6 : 1.1;\n  const noiseAmp = isWeekend ? 4.2 : 3.0;\n  const noise = (rand() - 0.5) * noiseAmp;\n  const value = Math.max(0.6, 7.0 + seasonal + weekdayAdj + noise);\n\n  return {\n    date,\n    row: (date.getUTCDay() + 6) % 7, // 0=Mon .. 6=Sun\n    col: d3.utcSunday.count(yearStart, date),\n    value: isMissing(date) ? null : Math.round(value * 10) / 10,\n  };\n});\nconst numWeeks = d3.max(days, (d) => d.col) + 1;\n\nconst values = days.map((d) => d.value).filter((v) => v !== null);\nconst [minVal, maxVal] = d3.extent(values);\n\n// --- Monthly averages (for the summary panel below the grid) --------------\nconst monthlyMeans = d3.rollup(\n  days.filter((d) => d.value !== null),\n  (v) => d3.mean(v, (d) => d.value),\n  (d) => d.date.getUTCMonth(),\n);\nconst monthly = d3.range(12).map((m) => ({ m, avg: monthlyMeans.get(m) ?? 0 }));\nconst peak = monthly.reduce((a, b) => (b.avg > a.avg ? b : a));\n\n// --- Layout -----------------------------------------------------------------\nconst margin = { left: 90, right: 90 };\nconst iw = width - margin.left - margin.right;\nconst step = iw / numWeeks;\nconst cellSize = step * 0.78;\n\nconst titleY = 60;\nconst subtitleY = 98;\nconst monthLabelY = subtitleY + 60;\nconst gridTop = monthLabelY + 22;\nconst gridHeight = 7 * step;\nconst gridBottom = gridTop + gridHeight;\n\nconst legendY = gridBottom + 56;\nconst swatchSize = 24;\nconst legendBottom = legendY + swatchSize;\n\nconst sectionTitleY = legendBottom + 54;\nconst chartTop = sectionTitleY + 30;\nconst chartHeight = 140;\nconst chartBottom = chartTop + chartHeight;\nconst peakAnnotH = 22; // headroom above bars reserved for the \"Peak\" callout\nconst chartInnerTop = chartTop + peakAnnotH;\nconst xAxisLabelY = chartBottom + 22;\n\n// --- SVG mount ------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Sequential color scale: Imprint seq (brand green → blue) -------------\nconst colorScale = d3.scaleSequential(d3.interpolateRgbBasis(t.seq)).domain([minVal, maxVal]);\n\n// --- Calendar grid ----------------------------------------------------------\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${gridTop})`);\n\ng.selectAll(\".cell\")\n  .data(days)\n  .join(\"rect\")\n  .attr(\"class\", \"cell\")\n  .attr(\"x\", (d) => d.col * step)\n  .attr(\"y\", (d) => d.row * step)\n  .attr(\"width\", cellSize)\n  .attr(\"height\", cellSize)\n  .attr(\"rx\", 3)\n  .attr(\"fill\", (d) => (d.value === null ? t.elevatedBg : colorScale(d.value)))\n  .attr(\"stroke\", (d) => (d.value === null ? t.grid : \"none\"))\n  .attr(\"stroke-width\", (d) => (d.value === null ? 1.2 : 0))\n  .attr(\"stroke-dasharray\", (d) => (d.value === null ? \"3,3\" : null));\n\n// --- Weekday labels (y-axis, Mon..Sun) ---------------------------------\nconst weekdayNames = [\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\"];\ng.selectAll(\".weekday-label\")\n  .data(weekdayNames)\n  .join(\"text\")\n  .attr(\"class\", \"weekday-label\")\n  .attr(\"x\", -14)\n  .attr(\"y\", (d, i) => i * step + cellSize / 2)\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text((d) => d);\n\n// --- Month labels (above the grid) --------------------------------------\nconst monthStarts = d3.range(12).map((m) => {\n  const first = days.find((d) => d.date.getUTCMonth() === m && d.date.getUTCDate() === 1);\n  return { name: monthName(Date.UTC(YEAR, m, 1)), col: first.col };\n});\n\nsvg\n  .selectAll(\".month-label\")\n  .data(monthStarts)\n  .join(\"text\")\n  .attr(\"class\", \"month-label\")\n  .attr(\"x\", (d) => margin.left + d.col * step)\n  .attr(\"y\", monthLabelY)\n  .attr(\"text-anchor\", \"start\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .style(\"font-weight\", \"500\")\n  .text((d) => d.name);\n\n// --- Legend: \"no data\" swatch + sequential gradient bar --------------------\nsvg\n  .append(\"rect\")\n  .attr(\"x\", margin.left)\n  .attr(\"y\", legendY)\n  .attr(\"width\", swatchSize)\n  .attr(\"height\", swatchSize)\n  .attr(\"rx\", 3)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1.2)\n  .attr(\"stroke-dasharray\", \"3,3\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", margin.left + swatchSize + 10)\n  .attr(\"y\", legendY + swatchSize / 2)\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"No data\");\n\nconst barW = 280;\nconst barH = swatchSize;\nconst barX = margin.left + iw - barW;\n\nconst defs = svg.append(\"defs\");\nconst grad = defs\n  .append(\"linearGradient\")\n  .attr(\"id\", \"calendar-seq\")\n  .attr(\"x1\", \"0%\")\n  .attr(\"y1\", \"0%\")\n  .attr(\"x2\", \"100%\")\n  .attr(\"y2\", \"0%\");\n[0, 25, 50, 75, 100].forEach((p) => {\n  grad.append(\"stop\").attr(\"offset\", `${p}%`).attr(\"stop-color\", colorScale(minVal + (p / 100) * (maxVal - minVal)));\n});\n\nsvg\n  .append(\"rect\")\n  .attr(\"x\", barX)\n  .attr(\"y\", legendY)\n  .attr(\"width\", barW)\n  .attr(\"height\", barH)\n  .attr(\"rx\", 3)\n  .attr(\"fill\", \"url(#calendar-seq)\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", barX - 12)\n  .attr(\"y\", legendY + barH / 2)\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"Less\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", barX + barW + 12)\n  .attr(\"y\", legendY + barH / 2)\n  .attr(\"text-anchor\", \"start\")\n  .attr(\"dominant-baseline\", \"central\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"More\");\n\n// --- Monthly average summary panel (below the legend) ---------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", margin.left)\n  .attr(\"y\", sectionTitleY)\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"17px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Monthly average — steps (thousands)\");\n\nconst monthX = d3.scaleBand().domain(d3.range(12)).range([0, iw]).padding(0.3);\nconst monthY = d3.scaleLinear().domain([0, d3.max(monthly, (d) => d.avg)]).nice().range([chartBottom, chartInnerTop]);\n\nconst chartG = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},0)`);\n\nchartG\n  .append(\"g\")\n  .call(d3.axisLeft(monthY).ticks(3).tickSize(-iw))\n  .call((axis) => axis.select(\".domain\").remove())\n  .call((axis) => axis.selectAll(\".tick line\").attr(\"stroke\", t.grid))\n  .call((axis) => axis.selectAll(\".tick text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\"));\n\nchartG\n  .selectAll(\".month-bar\")\n  .data(monthly)\n  .join(\"rect\")\n  .attr(\"class\", \"month-bar\")\n  .attr(\"x\", (d) => monthX(d.m))\n  .attr(\"y\", (d) => monthY(d.avg))\n  .attr(\"width\", monthX.bandwidth())\n  .attr(\"height\", (d) => chartBottom - monthY(d.avg))\n  .attr(\"rx\", 2)\n  .attr(\"fill\", (d) => colorScale(d.avg))\n  .attr(\"stroke\", (d) => (d.m === peak.m ? t.ink : \"none\"))\n  .attr(\"stroke-width\", (d) => (d.m === peak.m ? 2 : 0));\n\nchartG\n  .selectAll(\".month-tick\")\n  .data(monthly)\n  .join(\"text\")\n  .attr(\"class\", \"month-tick\")\n  .attr(\"x\", (d) => monthX(d.m) + monthX.bandwidth() / 2)\n  .attr(\"y\", xAxisLabelY)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text((d) => monthName(Date.UTC(YEAR, d.m, 1)));\n\n// Focal-point annotation: call out the seasonal peak month.\nchartG\n  .append(\"text\")\n  .attr(\"x\", monthX(peak.m) + monthX.bandwidth() / 2)\n  .attr(\"y\", monthY(peak.avg) - 8)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(`Peak · ${monthName(Date.UTC(YEAR, peak.m, 1))} · ${peak.avg.toFixed(1)}k`);\n\n// --- Title & subtitle -------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", titleY)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"26px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"heatmap-calendar · javascript · d3 · anyplot.ai\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", subtitleY)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"17px\")\n  .style(\"font-weight\", \"500\")\n  .text(\"Daily step count (thousands) · 2025\");\n"}