{"spec_id":"bar-3d-categorical","library":"echarts","language":"javascript","code":"// anyplot.ai\n// bar-3d-categorical: 3D Bar Chart for Categorical Comparison\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-09-04\n\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic) ---------------------------------------\n// Customer satisfaction score (0-100) by product line and region.\nconst regions = [\"North\", \"South\", \"East\", \"West\"];\nconst productLines = [\"Electronics\", \"Apparel\", \"Home Goods\", \"Grocery\"];\nconst scores = [\n  [82, 76, 88, 91],\n  [74, 69, 81, 77],\n  [90, 85, 93, 88],\n  [95, 91, 89, 97],\n];\n\nconst cells = [];\nproductLines.forEach((yLabel, row) => {\n  regions.forEach((xLabel, col) => {\n    cells.push({ col, row, xLabel, yLabel, value: scores[row][col] });\n  });\n});\nconst values = cells.map((c) => c.value);\nconst minValue = Math.min(...values);\nconst maxValue = Math.max(...values);\n\n// --- Isometric projection (elevation ~30deg, azimuth ~45deg) ---------------\n// The depth factor is pushed slightly beyond a literal sin(30deg) so that\n// consecutive rows/columns stay clear of each other even at max bar height —\n// a purely 30deg depth step would let a tall back bar visually collide with\n// a short bar one row closer (their screen-space heights overlap).\nconst ISO_ANGLE = (30 * Math.PI) / 180;\nconst COS_A = Math.cos(ISO_ANGLE);\nconst DEPTH_A = 0.574; // ~elevation 35deg equivalent, for readable depth spacing\nconst CELL = 140; // grid spacing along each categorical axis\nconst PAD = 0.32; // half bar footprint (cell units) — leaves a visible gap\nconst MAX_BAR_H = 140; // px for the tallest bar\nconst LABEL_MARGIN = 40; // px beyond the grid's silhouette for axis labels\n\nfunction projectRaw(col, row, z) {\n  const x = (col - row) * COS_A * CELL;\n  const y = (col + row) * DEPTH_A * CELL - z;\n  return [x, y];\n}\n\nfunction barHeight(value) {\n  return (value / 100) * MAX_BAR_H;\n}\n\n// --- Center the isometric grid inside the mount -----------------------------\nconst TOP_MARGIN = 110;\nconst BOTTOM_MARGIN = 70;\nconst LEFT_MARGIN = 210;\nconst RIGHT_MARGIN = 190;\nconst midRow = (productLines.length - 1) / 2;\nconst midCol = (regions.length - 1) / 2;\n\nconst rawPoints = [];\nfor (const rowEdge of [-PAD, productLines.length - 1 + PAD]) {\n  for (const colEdge of [-PAD, regions.length - 1 + PAD]) {\n    rawPoints.push(projectRaw(colEdge, rowEdge, 0));\n  }\n}\ncells.forEach(({ col, row, value }) => {\n  rawPoints.push(projectRaw(col - PAD, row - PAD, barHeight(value)));\n});\n// Axis label lanes: a straight line below (x-axis) and to the left (y-axis)\n// of the grid's silhouette, not the skewed isometric edge.\nconst frontTipYRaw = projectRaw(regions.length - 1 + PAD, productLines.length - 1 + PAD, 0)[1];\nconst leftTipXRaw = projectRaw(-PAD, productLines.length - 1 + PAD, 0)[0];\nconst xLabelYRaw = frontTipYRaw + LABEL_MARGIN;\nconst yLabelXRaw = leftTipXRaw - LABEL_MARGIN - 70;\nregions.forEach((_, col) => rawPoints.push([projectRaw(col, midRow, 0)[0], xLabelYRaw]));\nproductLines.forEach((_, row) => rawPoints.push([yLabelXRaw, projectRaw(midCol, row, 0)[1]]));\n\nconst rawXs = rawPoints.map((p) => p[0]);\nconst rawYs = rawPoints.map((p) => p[1]);\nconst rawMinX = Math.min(...rawXs);\nconst rawMaxX = Math.max(...rawXs);\nconst rawMinY = Math.min(...rawYs);\nconst rawMaxY = Math.max(...rawYs);\n\nconst drawWidth = size.width - LEFT_MARGIN - RIGHT_MARGIN;\nconst drawHeight = size.height - TOP_MARGIN - BOTTOM_MARGIN;\nconst originX = LEFT_MARGIN + (drawWidth - (rawMaxX - rawMinX)) / 2 - rawMinX;\nconst originY = TOP_MARGIN + (drawHeight - (rawMaxY - rawMinY)) / 2 - rawMinY;\n\nfunction project(col, row, z) {\n  const [x, y] = projectRaw(col, row, z);\n  return [x + originX, y + originY];\n}\n\n// --- Floor grid — relates bars to their categorical position ---------------\nfunction gridLine(p1, p2) {\n  return {\n    type: \"line\",\n    shape: { x1: p1[0], y1: p1[1], x2: p2[0], y2: p2[1] },\n    style: { stroke: t.grid, lineWidth: 1.5 },\n    silent: true,\n  };\n}\n\nconst chromeElements = [];\nfor (let row = 0; row <= productLines.length; row++) {\n  chromeElements.push(gridLine(project(-PAD, row - PAD, 0), project(regions.length - 1 + PAD, row - PAD, 0)));\n}\nfor (let col = 0; col <= regions.length; col++) {\n  chromeElements.push(gridLine(project(col - PAD, -PAD, 0), project(col - PAD, productLines.length - 1 + PAD, 0)));\n}\n\n// Axis category labels — straight lanes outside the grid silhouette\nregions.forEach((label, col) => {\n  const x = project(col, midRow, 0)[0];\n  chromeElements.push({\n    type: \"text\",\n    style: { text: label, x, y: frontTipYRaw + originY + LABEL_MARGIN, fill: t.inkSoft, fontSize: 15, align: \"center\", verticalAlign: \"top\" },\n    silent: true,\n  });\n});\nproductLines.forEach((label, row) => {\n  const y = project(midCol, row, 0)[1];\n  chromeElements.push({\n    type: \"text\",\n    style: { text: label, x: leftTipXRaw + originX - LABEL_MARGIN, y, fill: t.inkSoft, fontSize: 15, align: \"right\", verticalAlign: \"middle\" },\n    silent: true,\n  });\n});\n\n// Legend title above the visualMap color bar (visualMap draws the gradient + min/max itself)\nconst legendWidth = 26;\nconst legendTop = TOP_MARGIN + 40;\nconst legendHeight = 340;\nconst legendRight = 84; // = 110 - legendWidth, mirrors the grid's RIGHT_MARGIN framing\nchromeElements.push({\n  type: \"text\",\n  style: {\n    text: \"Satisfaction\\nscore\",\n    x: size.width - legendRight - legendWidth / 2,\n    y: legendTop - 20,\n    fill: t.inkSoft,\n    fontSize: 14,\n    align: \"center\",\n    verticalAlign: \"bottom\",\n  },\n  silent: true,\n});\n\n// --- Custom series: each data item renders one isometric 3D bar ------------\n// Bound to the real dataset (value, col, row) so ECharts drives color via\n// visualMap and keeps tooltip/hover interactivity, instead of static shapes.\nfunction renderItem(params, api) {\n  const value = api.value(0);\n  const col = api.value(1);\n  const row = api.value(2);\n  const h = barHeight(value);\n  const topColor = api.visual(\"color\");\n  const rightColor = echarts.color.lift(topColor, -0.18);\n  const leftColor = echarts.color.lift(topColor, -0.38);\n\n  const baseFront = project(col + PAD, row + PAD, 0);\n  const baseLeft = project(col - PAD, row + PAD, 0);\n  const baseRight = project(col + PAD, row - PAD, 0);\n  const topFront = project(col + PAD, row + PAD, h);\n  const topLeft = project(col - PAD, row + PAD, h);\n  const topRight = project(col + PAD, row - PAD, h);\n  const topBack = project(col - PAD, row - PAD, h);\n  const topCenter = project(col, row, h);\n\n  const face = (points, fill) => ({\n    type: \"polygon\",\n    shape: { points },\n    style: { fill, stroke: t.pageBg, lineWidth: 1.5 },\n  });\n\n  return {\n    type: \"group\",\n    z2: col + row,\n    children: [\n      face([baseLeft, baseFront, topFront, topLeft], leftColor),\n      face([baseFront, baseRight, topRight, topFront], rightColor),\n      face([topBack, topLeft, topFront, topRight], topColor),\n      {\n        // Anchored at the top face's own centroid (not its back edge) so the\n        // label always sits within its own bar's footprint, never drifting\n        // over a taller neighbor drawn at a different depth.\n        type: \"text\",\n        style: {\n          text: String(value),\n          x: topCenter[0],\n          y: topCenter[1],\n          fill: t.ink,\n          fontSize: 14,\n          fontWeight: 600,\n          align: \"center\",\n          verticalAlign: \"middle\",\n          backgroundColor: t.elevatedBg,\n          borderColor: t.grid,\n          borderWidth: 1,\n          borderRadius: 4,\n          padding: [2, 5],\n        },\n      },\n    ],\n  };\n}\n\n// --- Init ---------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"bar-3d-categorical · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    top: 30,\n    textStyle: { color: t.ink, fontSize: 22 },\n  },\n  tooltip: { trigger: \"item\" },\n  visualMap: {\n    dimension: 0,\n    min: minValue,\n    max: maxValue,\n    inRange: { color: t.seq },\n    orient: \"vertical\",\n    right: legendRight,\n    top: legendTop,\n    itemWidth: legendWidth,\n    itemHeight: legendHeight,\n    text: [String(maxValue), String(minValue)],\n    textGap: 10,\n    textStyle: { color: t.inkSoft, fontSize: 14 },\n  },\n  graphic: chromeElements,\n  series: [\n    {\n      type: \"custom\",\n      // No axis-based coordinate system backs this chart (the isometric\n      // projection is computed manually in renderItem); without this,\n      // ECharts' custom series defaults to cartesian2d and throws for\n      // missing xAxis/yAxis components.\n      coordinateSystem: null,\n      renderItem,\n      data: cells.map((c) => ({\n        name: `${c.xLabel} · ${c.yLabel}`,\n        value: [c.value, c.col, c.row],\n      })),\n      tooltip: {\n        formatter: (params) => `${params.name}<br/><b>${params.value[0]}</b> satisfaction score`,\n      },\n    },\n  ],\n});\n"}