{"spec_id":"bar-3d-categorical","library":"d3","language":"javascript","code":"// anyplot.ai\n// bar-3d-categorical: 3D Bar Chart for Categorical Comparison\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-09-04\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Quarterly sales ($k) across product category (x) and sales region (y).\nconst xCategories = [\"Electronics\", \"Apparel\", \"Home Goods\", \"Sporting\", \"Toys\"];\nconst yCategories = [\"North\", \"South\", \"East\", \"West\"];\nconst values = [\n  [82, 45, 38, 29, 51],\n  [64, 58, 42, 33, 47],\n  [91, 39, 55, 61, 28],\n  [73, 66, 49, 44, 36],\n];\nconst bars = [];\nfor (let iy = 0; iy < yCategories.length; iy++) {\n  for (let ix = 0; ix < xCategories.length; ix++) {\n    bars.push({ ix, iy, value: values[iy][ix] });\n  }\n}\nconst minValue = d3.min(bars, (d) => d.value);\nconst maxValue = d3.max(bars, (d) => d.value);\n\n// --- Isometric projection ----------------------------------------------------\n// Elevation/azimuth are named, adjustable parameters (spec calls for a ~30deg\n// elevation / ~45deg azimuth default view); this symmetric 30deg decomposition\n// of both grid axes reproduces that read while keeping the base plane fully\n// legible.\nconst nCols = xCategories.length;\nconst nRows = yCategories.length;\nconst ELEVATION_DEG = 30;\nconst angle = (ELEVATION_DEG * Math.PI) / 180;\nconst cosA = Math.cos(angle);\nconst sinA = Math.sin(angle);\nconst HEIGHT_RATIO = 0.02; // px of bar height per $k, per unit of cellSize\n\nfunction isoUnit(px, py) {\n  return { x: (px - py) * cosA, y: (px + py) * sinA };\n}\n\n// Auto-fit the composition to the canvas: measure the projected footprint at\n// unit scale (grid corners, category-label anchors, and bar tops), then solve\n// for the cellSize/origin that centers it in the available plot area. This\n// keeps the grid+legend balanced regardless of category count or data range,\n// instead of hard-coding a cellSize/origin that only fits one dataset.\nconst gridCorners = [isoUnit(0, 0), isoUnit(nCols, 0), isoUnit(0, nRows), isoUnit(nCols, nRows)];\nconst xLabelPoints = xCategories.map((_, ix) => isoUnit(ix + 0.5, nRows + 0.5));\nconst yLabelPoints = yCategories.map((_, iy) => isoUnit(nCols + 0.5, iy + 0.5));\nconst chromePoints = [...gridCorners, ...xLabelPoints, ...yLabelPoints];\nconst barTopYUnits = bars.map((d) => (d.ix + 0.5 + (d.iy + 0.5)) * sinA - d.value * HEIGHT_RATIO);\n\nconst baseMinY = d3.min(gridCorners, (p) => p.y);\nconst baseMaxY = d3.max(gridCorners, (p) => p.y);\nconst rawMinX = d3.min(chromePoints, (p) => p.x);\nconst rawMaxX = d3.max(chromePoints, (p) => p.x);\nconst rawMinY = Math.min(d3.min(chromePoints, (p) => p.y), d3.min(barTopYUnits));\nconst rawMaxY = d3.max(chromePoints, (p) => p.y);\nconst rawWidth = rawMaxX - rawMinX;\nconst rawHeight = rawMaxY - rawMinY;\n\nconst margin = { top: 110, bottom: 70, left: 90, right: 50 };\nconst legendGap = 60;\nconst legendFootprint = 90;\nconst plotAreaWidth = width - margin.left - margin.right - legendGap - legendFootprint;\nconst plotAreaHeight = height - margin.top - margin.bottom;\n\nconst cellSize = Math.min(plotAreaWidth / rawWidth, plotAreaHeight / rawHeight);\nconst heightScale = cellSize * HEIGHT_RATIO;\nconst originX = margin.left - rawMinX * cellSize + (plotAreaWidth - rawWidth * cellSize) / 2;\nconst originY = margin.top - rawMinY * cellSize + (plotAreaHeight - rawHeight * cellSize) / 2;\n\nfunction iso(px, py) {\n  return {\n    x: originX + (px - py) * cellSize * cosA,\n    y: originY + (px + py) * cellSize * sinA,\n  };\n}\nfunction pts(corners) {\n  return corners.map((p) => `${p.x},${p.y}`).join(\" \");\n}\n\n// --- Color: sequential Imprint scale encodes value magnitude ----------------\nconst colorScale = d3.scaleSequential(d3.interpolateRgbBasis(t.seq)).domain([minValue, maxValue]);\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\nconst defs = svg.append(\"defs\");\nconst gradient = defs\n  .append(\"linearGradient\")\n  .attr(\"id\", \"seqGradient\")\n  .attr(\"x1\", \"0%\")\n  .attr(\"y1\", \"100%\")\n  .attr(\"x2\", \"0%\")\n  .attr(\"y2\", \"0%\");\nd3.range(0, 1.0001, 0.1).forEach((f) => {\n  gradient\n    .append(\"stop\")\n    .attr(\"offset\", `${f * 100}%`)\n    .attr(\"stop-color\", colorScale(minValue + f * (maxValue - minValue)));\n});\n\n// --- Base-plane grid ----------------------------------------------------------\nconst gridGroup = svg.append(\"g\");\nfor (let i = 0; i <= nCols; i++) {\n  const a = iso(i, 0);\n  const b = iso(i, nRows);\n  gridGroup.append(\"line\").attr(\"x1\", a.x).attr(\"y1\", a.y).attr(\"x2\", b.x).attr(\"y2\", b.y);\n}\nfor (let j = 0; j <= nRows; j++) {\n  const a = iso(0, j);\n  const b = iso(nCols, j);\n  gridGroup.append(\"line\").attr(\"x1\", a.x).attr(\"y1\", a.y).attr(\"x2\", b.x).attr(\"y2\", b.y);\n}\ngridGroup.selectAll(\"line\").attr(\"stroke\", t.grid).attr(\"stroke-width\", 1.5);\n\n// --- Bars (painter's algorithm: back-to-front by grid depth) ----------------\nconst barGroup = svg.append(\"g\");\nconst hw = 0.38; // half-width of footprint, leaves spacing between bars\nconst sorted = [...bars].sort((a, b) => a.ix + a.iy - (b.ix + b.iy));\nlet maxBarTopFace = null; // captured for a subtle focal-point highlight below\n\nfor (const d of sorted) {\n  const cx = d.ix + 0.5;\n  const cy = d.iy + 0.5;\n  const baseTL = iso(cx - hw, cy - hw);\n  const baseTR = iso(cx + hw, cy - hw);\n  const baseBR = iso(cx + hw, cy + hw);\n  const baseBL = iso(cx - hw, cy + hw);\n  const barH = d.value * heightScale;\n  const raise = (p) => ({ x: p.x, y: p.y - barH });\n  const topTL = raise(baseTL);\n  const topTR = raise(baseTR);\n  const topBR = raise(baseBR);\n  const topBL = raise(baseBL);\n\n  const base = d3.color(colorScale(d.value));\n  const g = barGroup.append(\"g\");\n  g.append(\"polygon\")\n    .attr(\"points\", pts([baseBL, baseBR, topBR, topBL]))\n    .attr(\"fill\", base.darker(0.9))\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 1.5);\n  g.append(\"polygon\")\n    .attr(\"points\", pts([baseTR, baseBR, topBR, topTR]))\n    .attr(\"fill\", base.darker(0.45))\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 1.5);\n  g.append(\"polygon\")\n    .attr(\"points\", pts([topTL, topTR, topBR, topBL]))\n    .attr(\"fill\", base)\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 1.5);\n\n  const labelY = (topTL.y + topBR.y) / 2 - 6;\n  const labelX = (topTL.x + topBR.x) / 2;\n  g.append(\"text\")\n    .attr(\"x\", labelX)\n    .attr(\"y\", labelY)\n    .attr(\"text-anchor\", \"middle\")\n    .style(\"font-size\", \"14px\")\n    .style(\"font-weight\", \"600\")\n    .style(\"paint-order\", \"stroke\")\n    .style(\"stroke\", t.pageBg)\n    .style(\"stroke-width\", \"3px\")\n    .attr(\"fill\", t.ink)\n    .text(d.value);\n\n  if (d.value === maxValue) {\n    maxBarTopFace = [topTL, topTR, topBR, topBL];\n  }\n}\n\n// --- Focal-point highlight: ring the single highest-value bar so the color\n// ramp is not the only cue for hierarchy. ---------------------------------\nif (maxBarTopFace) {\n  barGroup\n    .append(\"polygon\")\n    .attr(\"points\", pts(maxBarTopFace))\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", t.amber)\n    .attr(\"stroke-width\", 3);\n}\n\n// --- Category axis labels (along the two front grid edges) ------------------\nconst labelGroup = svg.append(\"g\");\nxCategories.forEach((name, ix) => {\n  const p = iso(ix + 0.5, nRows + 0.5);\n  labelGroup\n    .append(\"text\")\n    .attr(\"transform\", `translate(${p.x},${p.y}) rotate(30)`)\n    .attr(\"text-anchor\", \"middle\")\n    .style(\"font-size\", \"15px\")\n    .attr(\"fill\", t.inkSoft)\n    .text(name);\n});\nyCategories.forEach((name, iy) => {\n  const p = iso(nCols + 0.5, iy + 0.5);\n  labelGroup\n    .append(\"text\")\n    .attr(\"transform\", `translate(${p.x},${p.y}) rotate(-30)`)\n    .attr(\"text-anchor\", \"middle\")\n    .style(\"font-size\", \"15px\")\n    .attr(\"fill\", t.inkSoft)\n    .text(name);\n});\n\n// --- Color legend (value magnitude -> Imprint sequential scale) ------------\n// Positioned relative to the fitted grid's right edge (not a fixed offset\n// from the canvas edge) so it stays snug against the composition at any scale.\nconst gridRightX = originX + rawMaxX * cellSize;\nconst legendX = gridRightX + legendGap;\nconst legendW = 26;\nconst legendH = plotAreaHeight * 0.5;\nconst legendY = margin.top + (plotAreaHeight - legendH) / 2;\nsvg\n  .append(\"rect\")\n  .attr(\"x\", legendX)\n  .attr(\"y\", legendY)\n  .attr(\"width\", legendW)\n  .attr(\"height\", legendH)\n  .attr(\"fill\", \"url(#seqGradient)\")\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + legendW / 2)\n  .attr(\"y\", legendY - 16)\n  .attr(\"text-anchor\", \"middle\")\n  .style(\"font-size\", \"14px\")\n  .attr(\"fill\", t.inkSoft)\n  .text(\"Sales ($k)\");\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + legendW + 8)\n  .attr(\"y\", legendY + 5)\n  .style(\"font-size\", \"13px\")\n  .attr(\"fill\", t.inkSoft)\n  .text(maxValue);\nsvg\n  .append(\"text\")\n  .attr(\"x\", legendX + legendW + 8)\n  .attr(\"y\", legendY + legendH)\n  .style(\"font-size\", \"13px\")\n  .attr(\"fill\", t.inkSoft)\n  .text(minValue);\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(\"bar-3d-categorical · javascript · d3 · anyplot.ai\");\n"}