{"spec_id":"area-stacked-confidence","library":"d3","language":"javascript","code":"// anyplot.ai\n// area-stacked-confidence: Stacked Area Chart with Confidence Bands\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 94/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Deterministic PRNG (fixed-seed LCG — no seeded RNG in the browser) ----\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1103515245 + 12345) & 0x7fffffff;\n  return seed / 0x7fffffff;\n}\n\n// --- Data: quarterly revenue forecast by product line, with 90% prediction\n//     intervals that widen further into the forecast horizon — the newer,\n//     faster-growing lines (Subscriptions) are inherently harder to forecast\n//     than the mature ones (Hardware), so each series gets its own band-\n//     growth rate rather than a shared fractional curve -------------------\nconst productLines = [\"Hardware\", \"Software\", \"Services\", \"Subscriptions\"];\nconst baseRevenue = [18, 10, 7, 4];\nconst growthPerQuarter = [0.012, 0.045, 0.028, 0.065];\nconst bandBaseFrac = [0.03, 0.05, 0.04, 0.06];\nconst bandGrowthFrac = [0.1, 0.2, 0.16, 0.3];\nconst nQuarters = 20;\nconst dates = d3.range(nQuarters).map((i) => new Date(2024, i * 3, 1));\nconst keys = productLines.map((_, s) => `y${s}`);\n\nconst data = dates.map((date, i) => {\n  const row = { date };\n  productLines.forEach((_, s) => {\n    const key = keys[s];\n    const wobble = 1 + (rand() - 0.5) * 0.05;\n    row[key] = baseRevenue[s] * Math.pow(1 + growthPerQuarter[s], i) * wobble;\n    row[`${key}_band`] = bandBaseFrac[s] + (i / (nQuarters - 1)) * bandGrowthFrac[s];\n  });\n  return row;\n});\n\n// --- Stack central values; derive confidence bands from the same stack\n//     order so each layer's band sits atop its own cumulative baseline ----\nconst stacked = d3.stack().keys(keys)(data);\n\nconst bands = stacked.map((layer) =>\n  layer.map((point, j) => {\n    const key = layer.key;\n    const y0 = point[0];\n    const central = data[j][key];\n    const frac = data[j][`${key}_band`];\n    return { date: data[j].date, low: y0 + central * (1 - frac), high: y0 + central * (1 + frac) };\n  }),\n);\n\n// --- Layout -----------------------------------------------------------------\nconst margin = { top: 130, right: 280, bottom: 80, left: 100 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst x = d3.scaleTime().domain(d3.extent(dates)).range([0, iw]);\nconst yMax = d3.max(bands[bands.length - 1], (d) => d.high);\nconst y = d3.scaleLinear().domain([0, yMax]).nice().range([ih, 0]);\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Gridlines (y-axis only) -------------------------------------------------\ng.append(\"g\")\n  .attr(\"class\", \"grid\")\n  .call(d3.axisLeft(y).ticks(6).tickSize(-iw).tickFormat(\"\"))\n  .call((gr) => gr.select(\".domain\").remove())\n  .call((gr) => gr.selectAll(\"line\").attr(\"stroke\", t.grid));\n\n// --- Areas: shape generators --------------------------------------------------\nconst areaCentral = d3\n  .area()\n  .x((d) => x(d.data.date))\n  .y0((d) => y(d[0]))\n  .y1((d) => y(d[1]))\n  .curve(d3.curveMonotoneX);\n\nconst areaBand = d3\n  .area()\n  .x((d) => x(d.date))\n  .y0((d) => y(d.low))\n  .y1((d) => y(d.high))\n  .curve(d3.curveMonotoneX);\n\nconst lineTop = d3\n  .line()\n  .x((d) => x(d.data.date))\n  .y((d) => y(d[1]))\n  .curve(d3.curveMonotoneX);\n\n// Per-series vertical gradient for the confidence bands: opacity peaks at the\n// midpoint (close to each column's central estimate, since bands are built\n// symmetric around it) and tapers toward the low/high edges — reads as a\n// soft \"cloud\" of uncertainty rather than a flat tint.\nconst defs = svg.append(\"defs\");\nproductLines.forEach((_, i) => {\n  const gradient = defs\n    .append(\"linearGradient\")\n    .attr(\"id\", `band-gradient-${i}`)\n    .attr(\"x1\", \"0\")\n    .attr(\"x2\", \"0\")\n    .attr(\"y1\", \"0\")\n    .attr(\"y2\", \"1\");\n  gradient.append(\"stop\").attr(\"offset\", \"0%\").attr(\"stop-color\", t.palette[i]).attr(\"stop-opacity\", 0.12);\n  gradient.append(\"stop\").attr(\"offset\", \"50%\").attr(\"stop-color\", t.palette[i]).attr(\"stop-opacity\", 0.42);\n  gradient.append(\"stop\").attr(\"offset\", \"100%\").attr(\"stop-color\", t.palette[i]).attr(\"stop-opacity\", 0.12);\n});\n\n// Stacked central areas first, translucent gradient bands on top (so a band\n// bleeding past its series' own boundary blends into the neighbor, showing\n// where the two series' uncertainty overlaps), then crisp boundary lines on\n// top of both.\nstacked.forEach((layer, i) => {\n  g.append(\"path\").datum(layer).attr(\"fill\", t.palette[i]).attr(\"fill-opacity\", 0.88).attr(\"d\", areaCentral);\n});\nbands.forEach((band, i) => {\n  g.append(\"path\").datum(band).attr(\"fill\", `url(#band-gradient-${i})`).attr(\"d\", areaBand);\n});\nstacked.forEach((layer, i) => {\n  g.append(\"path\")\n    .datum(layer)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", t.palette[i])\n    .attr(\"stroke-width\", 2.5)\n    .attr(\"d\", lineTop);\n});\n\n// --- Axes ---------------------------------------------------------------------\nconst xAxis = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).ticks(d3.timeYear.every(1)).tickFormat(d3.timeFormat(\"%Y\")));\nxAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nxAxis.selectAll(\"line\").attr(\"stroke\", t.grid);\nxAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nconst yAxis = g.append(\"g\").call(\n  d3\n    .axisLeft(y)\n    .ticks(6)\n    .tickFormat((d) => `$${d}M`),\n);\nyAxis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\nyAxis.selectAll(\"line\").attr(\"stroke\", t.grid);\nyAxis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// --- Axis titles ----------------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 56)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Fiscal Quarter\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -70)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Stacked Revenue ($M)\");\n\n// --- Legend ---------------------------------------------------------------------\nconst legend = svg.append(\"g\").attr(\"transform\", `translate(${width - margin.right + 40},${margin.top + 10})`);\nproductLines.forEach((name, i) => {\n  const row = legend.append(\"g\").attr(\"transform\", `translate(0,${i * 42})`);\n  row.append(\"rect\").attr(\"width\", 22).attr(\"height\", 22).attr(\"rx\", 4).attr(\"fill\", t.palette[i]);\n  row\n    .append(\"text\")\n    .attr(\"x\", 32)\n    .attr(\"y\", 17)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"16px\")\n    .text(name);\n});\nlegend\n  .append(\"text\")\n  .attr(\"x\", 0)\n  .attr(\"y\", productLines.length * 42 + 16)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text(\"Shaded band: 90%\")\n  .append(\"tspan\")\n  .attr(\"x\", 0)\n  .attr(\"dy\", 20)\n  .text(\"prediction interval\");\n\n// --- Title ------------------------------------------------------------------\nconst title = \"Product Line Forecast · area-stacked-confidence · javascript · d3 · anyplot.ai\";\n// Descriptive-prefixed titles run well past the 67-char mandated baseline; a\n// strict linear shrink off the 22px default undershoots (reads at ~43% of\n// canvas width). Scale off a taller 28px baseline instead, floored at 18px,\n// so long titles stay prominent (~50-60% of width) without overflowing.\nconst titleFontSize = Math.max(18, Math.round(28 * (title.length > 67 ? 67 / title.length : 1)));\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 56)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(title);\n"}