{"spec_id":"pyramid-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// pyramid-basic: Basic Pyramid Chart\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 96/100 | Created: 2026-09-05\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// Data — streaming-service subscriber counts by age bracket (thousands).\n// Two competing services, symmetric axis scale, revealing an age-driven\n// crossover in preference. NovaPlay ticks up slightly at 55–64 before its\n// final drop — a small real-world irregularity rather than a clean decay.\nconst ageGroups = [\n  { category: \"18–24\", auroraStream: 18, novaPlay: 42 },\n  { category: \"25–34\", auroraStream: 28, novaPlay: 38 },\n  { category: \"35–44\", auroraStream: 35, novaPlay: 30 },\n  { category: \"45–54\", auroraStream: 44, novaPlay: 24 },\n  { category: \"55–64\", auroraStream: 52, novaPlay: 26 },\n  { category: \"65+\", auroraStream: 46, novaPlay: 10 },\n];\nconst maxValue = 60;\n// First bracket where AuroraStream overtakes NovaPlay — called out below.\nconst crossoverIndex = ageGroups.findIndex((d) => d.auroraStream > d.novaPlay);\n\n// Layout — two mirrored bar areas separated by a gap for center category labels.\nconst margin = { top: 130, right: 70, bottom: 90, left: 70 };\nconst innerWidth = width - margin.left - margin.right;\nconst innerHeight = height - margin.top - margin.bottom;\nconst centerGap = 140;\nconst sideWidth = (innerWidth - centerGap) / 2;\nconst leftX0 = margin.left;\nconst rightX0 = margin.left + sideWidth + centerGap;\nconst centerX = margin.left + sideWidth + centerGap / 2;\n\n// Scales\nconst xLeft = d3.scaleLinear().domain([0, maxValue]).range([sideWidth, 0]);\nconst xRight = d3.scaleLinear().domain([0, maxValue]).range([0, sideWidth]);\nconst y = d3\n  .scaleBand()\n  .domain(ageGroups.map((d) => d.category))\n  .range([0, innerHeight])\n  .padding(0.28);\n\n// SVG mount\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// Crossover row highlight — the bracket where AuroraStream first overtakes\n// NovaPlay, drawn behind everything else so it reads as a subtle backdrop.\nconst crossoverCategory = ageGroups[crossoverIndex].category;\nsvg\n  .append(\"rect\")\n  .attr(\"x\", leftX0)\n  .attr(\"y\", margin.top + y(crossoverCategory))\n  .attr(\"width\", innerWidth)\n  .attr(\"height\", y.bandwidth())\n  .attr(\"fill\", t.grid)\n  .attr(\"opacity\", 0.5);\n\n// Center divider\nsvg\n  .append(\"line\")\n  .attr(\"x1\", centerX)\n  .attr(\"x2\", centerX)\n  .attr(\"y1\", margin.top)\n  .attr(\"y2\", margin.top + innerHeight)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\n// Left bars (AuroraStream)\nsvg\n  .selectAll(\".bar-left\")\n  .data(ageGroups)\n  .join(\"rect\")\n  .attr(\"x\", (d) => leftX0 + xLeft(d.auroraStream))\n  .attr(\"y\", (d) => margin.top + y(d.category))\n  .attr(\"width\", (d) => sideWidth - xLeft(d.auroraStream))\n  .attr(\"height\", y.bandwidth())\n  .attr(\"fill\", t.palette[0]);\n\n// Right bars (NovaPlay)\nsvg\n  .selectAll(\".bar-right\")\n  .data(ageGroups)\n  .join(\"rect\")\n  .attr(\"x\", rightX0)\n  .attr(\"y\", (d) => margin.top + y(d.category))\n  .attr(\"width\", (d) => xRight(d.novaPlay))\n  .attr(\"height\", y.bandwidth())\n  .attr(\"fill\", t.palette[1]);\n\n// Trend lines through each bar's tip — a d3-shape line generator with a\n// Catmull-Rom curve, distinct from a plain bar/axis composition and doubling\n// as a visual thread that carries the eye through the age-driven crossover.\nconst bandCenter = (d) => margin.top + y(d.category) + y.bandwidth() / 2;\nconst lineGen = d3.line().curve(d3.curveCatmullRom.alpha(0.5));\nsvg\n  .append(\"path\")\n  .attr(\"d\", lineGen(ageGroups.map((d) => [leftX0 + xLeft(d.auroraStream), bandCenter(d)])))\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[0])\n  .attr(\"stroke-width\", 2)\n  .attr(\"stroke-dasharray\", \"2,4\")\n  .attr(\"stroke-linecap\", \"round\")\n  .attr(\"opacity\", 0.8);\nsvg\n  .append(\"path\")\n  .attr(\"d\", lineGen(ageGroups.map((d) => [rightX0 + xRight(d.novaPlay), bandCenter(d)])))\n  .attr(\"fill\", \"none\")\n  .attr(\"stroke\", t.palette[1])\n  .attr(\"stroke-width\", 2)\n  .attr(\"stroke-dasharray\", \"2,4\")\n  .attr(\"stroke-linecap\", \"round\")\n  .attr(\"opacity\", 0.8);\n\n// Value labels at each bar's tip.\nsvg\n  .selectAll(\".value-label-left\")\n  .data(ageGroups)\n  .join(\"text\")\n  .attr(\"x\", (d) => leftX0 + xLeft(d.auroraStream) - 8)\n  .attr(\"y\", bandCenter)\n  .attr(\"dy\", \"0.35em\")\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text((d) => d.auroraStream);\nsvg\n  .selectAll(\".value-label-right\")\n  .data(ageGroups)\n  .join(\"text\")\n  .attr(\"x\", (d) => rightX0 + xRight(d.novaPlay) + 8)\n  .attr(\"y\", bandCenter)\n  .attr(\"dy\", \"0.35em\")\n  .attr(\"text-anchor\", \"start\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text((d) => d.novaPlay);\n\n// Center category labels — the crossover bracket is emphasized (bolder,\n// full-ink) so the point where AuroraStream overtakes NovaPlay reads clearly.\nsvg\n  .selectAll(\".category-label\")\n  .data(ageGroups)\n  .join(\"text\")\n  .attr(\"x\", centerX)\n  .attr(\"y\", bandCenter)\n  .attr(\"dy\", \"0.35em\")\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", (d) => (d.category === crossoverCategory ? t.ink : t.inkSoft))\n  .style(\"font-size\", \"15px\")\n  .style(\"font-weight\", (d) => (d.category === crossoverCategory ? \"700\" : \"400\"))\n  .text((d) => d.category);\n\n// Small caption under the crossover category naming the story explicitly.\nsvg\n  .append(\"text\")\n  .attr(\"x\", centerX)\n  .attr(\"y\", margin.top + y(crossoverCategory) + y.bandwidth() / 2 + 22)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"11px\")\n  .style(\"font-style\", \"italic\")\n  .text(\"crossover\");\n\n// Value axes (bottom, mirrored)\nconst leftAxis = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${leftX0},${margin.top + innerHeight})`)\n  .call(d3.axisBottom(xLeft).ticks(5));\nconst rightAxis = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${rightX0},${margin.top + innerHeight})`)\n  .call(d3.axisBottom(xRight).ticks(5));\nfor (const axis of [leftAxis, rightAxis]) {\n  axis.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  axis.selectAll(\"line\").attr(\"stroke\", t.grid);\n  axis.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// Axis title\nsvg\n  .append(\"text\")\n  .attr(\"x\", margin.left + innerWidth / 2)\n  .attr(\"y\", height - 20)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"16px\")\n  .text(\"Subscribers (thousands)\");\n\n// Legend\nconst legend = svg.append(\"g\").attr(\"transform\", `translate(${margin.left + innerWidth / 2}, 78)`);\nconst legendItems = [\n  { label: \"AuroraStream\", color: t.palette[0], dx: -170 },\n  { label: \"NovaPlay\", color: t.palette[1], dx: 20 },\n];\nfor (const item of legendItems) {\n  legend\n    .append(\"rect\")\n    .attr(\"x\", item.dx)\n    .attr(\"y\", -12)\n    .attr(\"width\", 16)\n    .attr(\"height\", 16)\n    .attr(\"fill\", item.color);\n  legend\n    .append(\"text\")\n    .attr(\"x\", item.dx + 22)\n    .attr(\"y\", 0)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"15px\")\n    .text(item.label);\n}\n\n// Title\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 44)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"pyramid-basic · javascript · d3 · anyplot.ai\");\n"}