{"spec_id":"bar-3d-categorical","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// bar-3d-categorical: 3D Bar Chart for Categorical Comparison\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-04\n//# anyplot-orientation: square\n\n// Chart.js has no native 3D chart type — this hand-builds an isometric bar\n// chart (custom camera basis, true perspective projection, painter's-algorithm\n// depth sorting) inside a single Chart.js plugin, same technique as the\n// wireframe-3d-basic chartjs entry. No external 3D library, no community plugin.\n\nconst t = window.ANYPLOT_TOKENS;\nconst INK = t.ink;\nconst INK_SOFT = t.inkSoft;\nconst GRID = t.grid;\n\n// --- Data: quarterly sales ($k) by product category x sales region ----------\nconst X_CATS = [\"Electronics\", \"Apparel\", \"Home\", \"Sports\", \"Toys\"];\nconst Y_CATS = [\"North\", \"South\", \"East\", \"West\"];\nconst VALUES = [\n  // rows = Y_CATS (regions), cols = X_CATS (products) — 4 x 5 = 20 bars\n  [82, 45, 60, 38, 25], // North\n  [68, 52, 47, 41, 30], // South\n  [90, 38, 55, 60, 22], // East\n  [55, 60, 42, 35, 48], // West\n];\nconst NX = X_CATS.length;\nconst NY = Y_CATS.length;\n\nlet vMin = Infinity, vMax = -Infinity;\nfor (const row of VALUES) for (const v of row) { if (v < vMin) vMin = v; if (v > vMax) vMax = v; }\n\n// --- Layout: category index -> centered grid coordinate ---------------------\nconst BAR_HALF = 0.34; // half-width of each bar footprint — leaves a visible gap\nconst cx = (i) => i - (NX - 1) / 2;\nconst cy = (j) => j - (NY - 1) / 2;\nconst xExtent = NX / 2;\nconst yExtent = NY / 2;\nconst Z_SCALE = 0.85; // vertical exaggeration relative to the xy half-extent\n\n// --- Camera: elevation 30 deg / azimuth 45 deg (per spec), true perspective -\nconst ELEV_DEG = 30, AZIM_DEG = 45;\nconst elev = (ELEV_DEG * Math.PI) / 180;\nconst azim = (AZIM_DEG * Math.PI) / 180;\nconst camDir = [Math.cos(elev) * Math.cos(azim), Math.cos(elev) * Math.sin(azim), Math.sin(elev)];\nconst worldUp = [0, 0, 1];\nconst cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];\nconst normalize = (a) => { const l = Math.hypot(a[0], a[1], a[2]); return [a[0] / l, a[1] / l, a[2] / l]; };\nconst right = normalize(cross(camDir, worldUp));\nconst camUp = cross(right, camDir);\n\nconst CAM_DIST = 5.2, FOCAL = 5.2;\nconst projectNorm = (nx, ny, nz) => {\n  const px = nx * right[0] + ny * right[1] + nz * right[2];\n  const py = nx * camUp[0] + ny * camUp[1] + nz * camUp[2];\n  const pd = nx * camDir[0] + ny * camDir[1] + nz * camDir[2];\n  const depth = CAM_DIST - pd;\n  const scale = FOCAL / depth;\n  return { x: px * scale, y: py * scale, depth };\n};\nconst norm = (x, y, z) => [x / xExtent, y / yExtent, (z / vMax) * Z_SCALE];\nconst project = (x, y, z) => projectNorm(...norm(x, y, z));\n\n// With elevation/azimuth both positive, the +x and +y bar faces point toward\n// the camera — those are the two visible side faces (plus the top).\nconst signX = camDir[0] >= 0 ? 1 : -1;\nconst signY = camDir[1] >= 0 ? 1 : -1;\n\n// --- Value -> Imprint sequential colour (single polarity: sales are magnitudes)\nconst hexToRgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));\nconst seqLo = hexToRgb(t.seq[0]), seqHi = hexToRgb(t.seq[1]);\nconst lerpRgb = (a, b, f) => a.map((v, i) => Math.round(v + (b[i] - v) * f));\nconst clamp01 = (v) => Math.min(1, Math.max(0, v));\nconst clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));\nconst shade = (rgb, f) => rgb.map((v) => clamp(Math.round(v * f), 0, 255));\nconst rgbCss = (rgb) => `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;\nconst colorForValue = (v) => {\n  const f = vMax > vMin ? clamp01((v - vMin) / (vMax - vMin)) : 0.5;\n  return lerpRgb(seqLo, seqHi, f);\n};\n\n// --- Build bar geometry: 3 visible faces per bar (top + two camera-facing sides)\nconst faces = [];\nfor (let j = 0; j < NY; j++) {\n  for (let i = 0; i < NX; i++) {\n    const v = VALUES[j][i];\n    const x0 = cx(i) - BAR_HALF, x1 = cx(i) + BAR_HALF;\n    const y0 = cy(j) - BAR_HALF, y1 = cy(j) + BAR_HALF;\n    const xf = signX > 0 ? x1 : x0;\n    const yf = signY > 0 ? y1 : y0;\n    const base = colorForValue(v);\n\n    const addFace = (corners3d, rgb) => {\n      const pts = corners3d.map((p) => project(...p));\n      const depth = pts.reduce((s, p) => s + p.depth, 0) / pts.length;\n      faces.push({ pts, depth, color: rgbCss(rgb) });\n    };\n\n    addFace([[x0, y0, v], [x1, y0, v], [x1, y1, v], [x0, y1, v]], shade(base, 1.08)); // top\n    addFace([[xf, y0, 0], [xf, y1, 0], [xf, y1, v], [xf, y0, v]], shade(base, 0.72)); // x-facing side\n    addFace([[x0, yf, 0], [x1, yf, 0], [x1, yf, v], [x0, yf, v]], shade(base, 0.55)); // y-facing side\n  }\n}\nfaces.sort((a, b) => b.depth - a.depth); // painter's algorithm: farthest first\n\n// --- Floor grid: cell boundaries on the base plane (z = 0) -------------------\nconst floorLines = [];\nfor (let i = 0; i <= NX; i++) {\n  const bx = i - NX / 2;\n  floorLines.push([project(bx, -yExtent, 0), project(bx, yExtent, 0)]);\n}\nfor (let j = 0; j <= NY; j++) {\n  const by = j - NY / 2;\n  floorLines.push([project(-xExtent, by, 0), project(xExtent, by, 0)]);\n}\n\n// --- Axis box: anchor at the NEAREST floor corner ---------------------------\n// A back-corner gnomon (the usual choice) sits directly behind whichever row\n// happens to hold the tallest bars — with perspective, a tall bar's top can\n// project higher on screen than a farther-but-low tick label, hiding it. The\n// nearest floor corner is never behind a bar (every bar base is farther back\n// or to the side of it), so its category labels stay clear regardless of\n// which cell is tallest.\nlet axisCorner = null, bestDepth = Infinity;\nfor (const sx of [-xExtent, xExtent]) for (const sy of [-yExtent, yExtent]) {\n  const d = project(sx, sy, 0).depth;\n  if (d < bestDepth) { bestDepth = d; axisCorner = [sx, sy]; }\n}\nconst [cAtX, cAtY] = axisCorner;\n\nconst axisEdges = [\n  {\n    from: [-xExtent, cAtY, 0], to: [xExtent, cAtY, 0],\n    ticks: X_CATS.map((_, i) => cx(i)), labels: X_CATS, title: \"Product\",\n  },\n  {\n    from: [cAtX, -yExtent, 0], to: [cAtX, yExtent, 0],\n    ticks: Y_CATS.map((_, j) => cy(j)), labels: Y_CATS, title: \"Region\",\n  },\n  {\n    from: [cAtX, cAtY, 0], to: [cAtX, cAtY, vMax],\n    ticks: [0, vMax / 2, vMax], labels: [0, vMax / 2, vMax].map((v) => `${Math.round(v)}`), title: \"Sales ($k)\",\n  },\n];\n\n// --- Fit chart scales to the projected content (no clipping, no guessing) ---\nlet minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;\nconst consider = (p) => { if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x; if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y; };\nfaces.forEach((f) => f.pts.forEach(consider));\nfloorLines.forEach(([a, b]) => { consider(a); consider(b); });\naxisEdges.forEach((e) => { consider(project(...e.from)); consider(project(...e.to)); });\n\nconst MARGIN = 0.34; // room for tick labels, axis titles, and value labels\nlet halfX = ((maxX - minX) / 2) * (1 + MARGIN);\nlet halfY = ((maxY - minY) / 2) * (1 + MARGIN);\nconst midX = (minX + maxX) / 2, midY = (minY + maxY) / 2;\nconst TARGET_ASPECT = 1.0; // square mount — the perspective box is roughly square\nif (halfX / halfY < TARGET_ASPECT) halfX = halfY * TARGET_ASPECT; else halfY = halfX / TARGET_ASPECT;\n\n// --- Mount --------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Plugin: floor grid, depth-sorted bars, axis box + ticks, colour key ----\nconst bar3dPlugin = {\n  id: \"bar3d\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales: { x, y } } = chart;\n    const toPx = (p) => [x.getPixelForValue(p.x), y.getPixelForValue(p.y)];\n\n    // Floor grid.\n    ctx.save();\n    ctx.strokeStyle = GRID;\n    ctx.lineWidth = 1.2;\n    for (const [a, b] of floorLines) {\n      const [ax, ay] = toPx(a), [bx, by] = toPx(b);\n      ctx.beginPath();\n      ctx.moveTo(ax, ay);\n      ctx.lineTo(bx, by);\n      ctx.stroke();\n    }\n    ctx.restore();\n\n    // Axis box edges + ticks + labels — drawn before the bars so the bars\n    // (opaque, foreground) always paint over any scaffolding line that falls\n    // behind them; the nearest-corner choice already keeps the box clear of\n    // the bars, and this draw order guarantees it never cuts across one anyway.\n    ctx.save();\n    ctx.strokeStyle = INK_SOFT;\n    ctx.fillStyle = INK_SOFT;\n    ctx.font = \"600 13px -apple-system, Segoe UI, Roboto, sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"middle\";\n    // Reference point for the perpendicular sign flip: the scene's screen-space\n    // center (not the shared axis-origin corner — that degenerates for the Z\n    // edge, whose midpoint sits almost exactly above the origin).\n    const centerPx = toPx({ x: midX, y: midY });\n    // All three edges share the same corner (cAtX, cAtY, 0) as one endpoint —\n    // anchor each title at whichever projected endpoint sits farther from that\n    // shared corner, so the three titles fan out instead of piling up on it.\n    const cornerPx = toPx(project(cAtX, cAtY, 0));\n    const distSq = (p, q) => (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2;\n\n    for (const edge of axisEdges) {\n      const pA = project(...edge.from), pB = project(...edge.to);\n      const [ax, ay] = toPx(pA), [bx, by] = toPx(pB);\n      ctx.lineWidth = 2;\n      ctx.beginPath();\n      ctx.moveTo(ax, ay);\n      ctx.lineTo(bx, by);\n      ctx.stroke();\n\n      const dx = bx - ax, dy = by - ay;\n      const len = Math.hypot(dx, dy) || 1;\n      const dirX = dx / len, dirY = dy / len;\n      let perpX = -dirY, perpY = dirX;\n      const midx = (ax + bx) / 2, midy = (ay + by) / 2;\n      if (perpX * (midx - centerPx[0]) + perpY * (midy - centerPx[1]) < 0) { perpX = -perpX; perpY = -perpY; }\n\n      const tickSpan = edge.ticks[edge.ticks.length - 1] - edge.ticks[0];\n      for (let k = 0; k < edge.ticks.length; k++) {\n        const f = tickSpan !== 0 ? (edge.ticks[k] - edge.ticks[0]) / tickSpan : 0.5;\n        const tx3 = edge.from[0] + (edge.to[0] - edge.from[0]) * f;\n        const ty3 = edge.from[1] + (edge.to[1] - edge.from[1]) * f;\n        const tz3 = edge.from[2] + (edge.to[2] - edge.from[2]) * f;\n        const pt = project(tx3, ty3, tz3);\n        const [tx, ty] = toPx(pt);\n        ctx.lineWidth = 1.4;\n        ctx.beginPath();\n        ctx.moveTo(tx, ty);\n        ctx.lineTo(tx + perpX * 9, ty + perpY * 9);\n        ctx.stroke();\n        ctx.fillText(`${edge.labels[k]}`, tx + perpX * 30, ty + perpY * 30);\n      }\n\n      // Title sits beyond whichever endpoint is farther from the shared\n      // corner, extended along the edge's own direction — never beside a\n      // tick label, and never piled onto a sibling axis's title.\n      const bFarther = distSq([bx, by], cornerPx) >= distSq([ax, ay], cornerPx);\n      const [tix, tiy] = bFarther ? [bx, by] : [ax, ay];\n      const tiDirX = bFarther ? dirX : -dirX, tiDirY = bFarther ? dirY : -dirY;\n      ctx.save();\n      ctx.font = \"700 15px -apple-system, Segoe UI, Roboto, sans-serif\";\n      ctx.fillStyle = INK;\n      ctx.fillText(edge.title, tix + tiDirX * 46 + perpX * 22, tiy + tiDirY * 46 + perpY * 22);\n      ctx.restore();\n    }\n    ctx.restore();\n\n    // Bars, back-to-front, each face a filled + lightly outlined quad.\n    ctx.save();\n    ctx.lineJoin = \"round\";\n    for (const f of faces) {\n      const px = f.pts.map(toPx);\n      ctx.beginPath();\n      px.forEach(([fx, fy], k) => (k === 0 ? ctx.moveTo(fx, fy) : ctx.lineTo(fx, fy)));\n      ctx.closePath();\n      ctx.fillStyle = f.color;\n      ctx.fill();\n      ctx.strokeStyle = t.pageBg;\n      ctx.lineWidth = 1.5;\n      ctx.stroke();\n    }\n    ctx.restore();\n\n    // Value labels on top of each bar (grid has 20 bars, under the 25 threshold).\n    ctx.save();\n    ctx.fillStyle = INK;\n    ctx.font = \"600 15px -apple-system, Segoe UI, Roboto, sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.textBaseline = \"bottom\";\n    for (let j = 0; j < NY; j++) {\n      for (let i = 0; i < NX; i++) {\n        const v = VALUES[j][i];\n        const p = project(cx(i), cy(j), v);\n        const [lx, ly] = toPx(p);\n        ctx.fillText(`${v}`, lx, ly - 8);\n      }\n    }\n    ctx.restore();\n  },\n\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea } = chart;\n\n    // Sales colour key (bottom-left), fixed to the chart area in pixels.\n    ctx.save();\n    const keyX = chartArea.left + 24;\n    const keyY = chartArea.bottom - 40;\n    const keyW = 190, keyH = 14;\n    const grad = ctx.createLinearGradient(keyX, 0, keyX + keyW, 0);\n    grad.addColorStop(0, t.seq[0]);\n    grad.addColorStop(1, t.seq[1]);\n    ctx.fillStyle = grad;\n    ctx.fillRect(keyX, keyY, keyW, keyH);\n    ctx.strokeStyle = INK_SOFT;\n    ctx.lineWidth = 1;\n    ctx.strokeRect(keyX, keyY, keyW, keyH);\n\n    ctx.font = \"600 13px -apple-system, Segoe UI, Roboto, sans-serif\";\n    ctx.fillStyle = INK_SOFT;\n    ctx.textBaseline = \"bottom\";\n    ctx.textAlign = \"left\";\n    ctx.fillText(\"Sales ($k)\", keyX, keyY - 6);\n    ctx.textBaseline = \"top\";\n    ctx.fillText(`${vMin}`, keyX, keyY + keyH + 4);\n    ctx.textAlign = \"right\";\n    ctx.fillText(`${vMax}`, keyX + keyW, keyY + keyH + 4);\n    ctx.restore();\n  },\n};\n\n// --- Chart --------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [{ data: [], showLine: false, pointRadius: 0 }] },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: 16 },\n    plugins: {\n      title: {\n        display: true,\n        text: \"bar-3d-categorical · javascript · chartjs · anyplot.ai\",\n        color: INK,\n        font: { size: 22, weight: \"600\" },\n        padding: { top: 4, bottom: 14 },\n      },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: { type: \"linear\", min: midX - halfX, max: midX + halfX, display: false },\n      y: { type: \"linear\", min: midY - halfY, max: midY + halfY, display: false },\n    },\n  },\n  plugins: [bar3dPlugin],\n});\n"}