{"spec_id":"wireframe-3d-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// wireframe-3d-basic: Basic 3D Wireframe Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.1\n// Quality: 88/100 | Created: 2026-08-04\n//# anyplot-orientation: square\n\nconst t = window.ANYPLOT_TOKENS;\nconst INK = t.ink;\nconst INK_SOFT = t.inkSoft;\nconst GRID = t.grid;\n\n// --- Data: two-hill / one-valley terrain z = f(x, y) ------------------------\n// A sum of Gaussian bumps (two positive peaks, one negative dip) gives a\n// terrain-like surface with a meaningful z = 0 baseline — a good fit for the\n// \"exploring terrain / topographical data\" application from the spec.\nconst GRID_N = 28; // grid points per axis (spec recommends 20x20 - 50x50)\nconst X_MIN = -4, X_MAX = 4, Y_MIN = -4, Y_MAX = 4;\n\nconst bump = (x, y, cx, cy, sx, sy, amp) =>\n  amp * Math.exp(-(((x - cx) ** 2) / (2 * sx * sx) + ((y - cy) ** 2) / (2 * sy * sy)));\n\nconst heightFn = (x, y) =>\n  bump(x, y, -1.7, 1.3, 1.05, 1.05, 2.1) +\n  bump(x, y, 1.9, 1.6, 1.15, 1.15, 1.6) -\n  bump(x, y, 0.1, -2.1, 1.3, 1.3, 1.9);\n\nconst xs = Array.from({ length: GRID_N }, (_, i) => X_MIN + ((X_MAX - X_MIN) * i) / (GRID_N - 1));\nconst ys = Array.from({ length: GRID_N }, (_, j) => Y_MIN + ((Y_MAX - Y_MIN) * j) / (GRID_N - 1));\nconst Z = ys.map((y) => xs.map((x) => heightFn(x, y)));\n\nlet zMin = Infinity, zMax = -Infinity;\nfor (const row of Z) for (const v of row) { if (v < zMin) zMin = v; if (v > zMax) zMax = v; }\nconst zAbsMax = Math.max(Math.abs(zMin), Math.abs(zMax));\n\n// --- Normalize data into a symmetric cube for a stable projection -----------\nconst xHalf = (X_MAX - X_MIN) / 2, xMid = (X_MAX + X_MIN) / 2;\nconst yHalf = (Y_MAX - Y_MIN) / 2, yMid = (Y_MAX + Y_MIN) / 2;\nconst Z_SCALE = 0.78; // vertical exaggeration relative to the xy half-extent\nconst norm = (x, y, z) => [(x - xMid) / xHalf, (y - yMid) / yHalf, (z / zAbsMax) * Z_SCALE];\n\n// --- Camera: elevation/azimuth view + true perspective projection -----------\n// Standard axonometric-camera technique: build a right/up/forward basis from\n// elevation + azimuth, then divide by depth-along-view for perspective.\nconst ELEV_DEG = 28, AZIM_DEG = -66; // close to the spec's suggested viewing angle\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.0, FOCAL = 5.0;\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, scale };\n};\nconst project = (x, y, z) => projectNorm(...norm(x, y, z));\n\n// --- Height -> Imprint diverging colour (meaningful midpoint at z = 0) ------\nconst hexToRgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));\nconst divLo = hexToRgb(t.div[0]), divMid = hexToRgb(t.div[1]), divHi = hexToRgb(t.div[2]);\nconst lerpRgb = (a, b, f) => a.map((v, i) => Math.round(v + (b[i] - v) * f));\nconst clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));\nconst heightColor = (z) => {\n  const f = zAbsMax > 0 ? clamp(z / zAbsMax, -1, 1) : 0;\n  const c = f < 0 ? lerpRgb(divLo, divMid, f + 1) : lerpRgb(divMid, divHi, f);\n  return `rgb(${c[0]},${c[1]},${c[2]})`;\n};\n\n// --- Build wireframe segments (row lines + column lines), depth-sortable ----\nconst BASE_LINE_W = 2.6;\nconst segments = [];\nconst addSegment = (x1, y1, z1, x2, y2, z2) => {\n  const p1 = project(x1, y1, z1), p2 = project(x2, y2, z2);\n  segments.push({\n    x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y,\n    depth: (p1.depth + p2.depth) / 2,\n    color: heightColor((z1 + z2) / 2),\n    width: BASE_LINE_W * clamp((p1.scale + p2.scale) / 2, 0.82, 1.4),\n  });\n};\nfor (let j = 0; j < GRID_N; j++) {\n  for (let i = 0; i < GRID_N - 1; i++) addSegment(xs[i], ys[j], Z[j][i], xs[i + 1], ys[j], Z[j][i + 1]);\n}\nfor (let i = 0; i < GRID_N; i++) {\n  for (let j = 0; j < GRID_N - 1; j++) addSegment(xs[i], ys[j], Z[j][i], xs[i], ys[j + 1], Z[j + 1][i]);\n}\nsegments.sort((a, b) => b.depth - a.depth); // painter's algorithm: farthest first\n\n// --- Axis box: pick the farthest corner so axes sit behind the mesh ---------\nlet axisCorner = null, bestDepth = -Infinity;\nfor (const sx of [-1, 1]) for (const sy of [-1, 1]) for (const sz of [-1, 1]) {\n  const d = projectNorm(sx, sy, sz * Z_SCALE).depth;\n  if (d > bestDepth) { bestDepth = d; axisCorner = [sx, sy, sz]; }\n}\nconst [cSignX, cSignY, cSignZ] = axisCorner;\nconst xAtCorner = cSignX > 0 ? X_MAX : X_MIN;\nconst yAtCorner = cSignY > 0 ? Y_MAX : Y_MIN;\nconst zAtCorner = cSignZ > 0 ? zAbsMax : -zAbsMax;\n\nconst axisEdges = [\n  { from: [X_MIN, yAtCorner, zAtCorner], to: [X_MAX, yAtCorner, zAtCorner], ticks: [-4, -2, 0, 2, 4], label: \"X\", fmt: (v) => `${v}` },\n  { from: [xAtCorner, Y_MIN, zAtCorner], to: [xAtCorner, Y_MAX, zAtCorner], ticks: [-4, -2, 0, 2, 4], label: \"Y\", fmt: (v) => `${v}` },\n  { from: [xAtCorner, yAtCorner, -zAbsMax], to: [xAtCorner, yAtCorner, zAbsMax], ticks: [-1, -0.5, 0, 0.5, 1].map((f) => +(f * zAbsMax).toFixed(2)), label: \"Z\", fmt: (v) => (Math.abs(v) < 1e-9 ? \"0\" : v.toFixed(1)) },\n];\n\n// --- Floor reference plane (subtle, grounds the terrain in space) -----------\nconst floorCorners = [\n  [X_MIN, Y_MIN, -zAbsMax], [X_MAX, Y_MIN, -zAbsMax],\n  [X_MAX, Y_MAX, -zAbsMax], [X_MIN, Y_MAX, -zAbsMax],\n].map(([x, y, z]) => project(x, y, z));\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; };\nsegments.forEach((s) => { consider({ x: s.x1, y: s.y1 }); consider({ x: s.x2, y: s.y2 }); });\naxisEdges.forEach((e) => { consider(project(...e.from)); consider(project(...e.to)); });\nfloorCorners.forEach(consider);\n\nconst MARGIN = 0.3; // room for tick labels + axis titles outside the box\nlet halfX = ((maxX - minX) / 2) * (1 + MARGIN);\nlet halfY = ((maxY - minY) / 2) * (1 + MARGIN);\nconst midX = (minX + maxX) / 2, midY = (minY + maxY) / 2;\n// Square mount (1200x1200 CSS) — this camera angle projects the cube into a\n// roughly square bounding box, so a square canvas (vs. the 16:9 default)\n// keeps x/y data units undistorted while letting the wireframe actually fill\n// the frame instead of leaving huge empty bands on a landscape canvas.\nconst TARGET_ASPECT = 1.0;\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, depth-sorted wireframe, axis box + ticks, colour key ----\nconst wireframePlugin = {\n  id: \"wireframe3d\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales: { x, y } } = chart;\n    const toPx = (X, Y) => [x.getPixelForValue(X), y.getPixelForValue(Y)];\n\n    // Floor outline.\n    ctx.save();\n    ctx.strokeStyle = GRID;\n    ctx.lineWidth = 1.2;\n    ctx.beginPath();\n    floorCorners.forEach((p, k) => {\n      const [px, py] = toPx(p.x, p.y);\n      k === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);\n    });\n    ctx.closePath();\n    ctx.stroke();\n    ctx.restore();\n\n    // Wireframe mesh, back-to-front.\n    ctx.save();\n    ctx.lineCap = \"round\";\n    ctx.lineJoin = \"round\";\n    for (const s of segments) {\n      const [ax, ay] = toPx(s.x1, s.y1);\n      const [bx, by] = toPx(s.x2, s.y2);\n      ctx.strokeStyle = s.color;\n      ctx.lineWidth = s.width;\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.\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    const originPx = toPx(0, 0); // data origin always projects to screen (0,0)\n\n    for (const edge of axisEdges) {\n      const pA = project(...edge.from), pB = project(...edge.to);\n      const [ax, ay] = toPx(pA.x, pA.y);\n      const [bx, by] = toPx(pB.x, pB.y);\n      ctx.lineWidth = 2;\n      ctx.beginPath();\n      ctx.moveTo(ax, ay);\n      ctx.lineTo(bx, by);\n      ctx.stroke();\n\n      // Constant tangent direction along the (straight) projected edge.\n      const dx = bx - ax, dy = by - ay;\n      const len = Math.hypot(dx, dy) || 1;\n      let perpX = -dy / len, perpY = dx / len;\n      const midx = (ax + bx) / 2, midy = (ay + by) / 2;\n      if (perpX * (midx - originPx[0]) + perpY * (midy - originPx[1]) < 0) { perpX = -perpX; perpY = -perpY; }\n\n      // Real-space tick positions interpolated along the edge's dominant axis.\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 = (edge.ticks[k] - edge.ticks[0]) / tickSpan;\n        const px3 = edge.from[0] + (edge.to[0] - edge.from[0]) * f;\n        const py3 = edge.from[1] + (edge.to[1] - edge.from[1]) * f;\n        const pz3 = edge.from[2] + (edge.to[2] - edge.from[2]) * f;\n        const pt = project(px3, py3, pz3);\n        const [tx, ty] = toPx(pt.x, pt.y);\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.fmt(edge.ticks[k]), tx + perpX * 24, ty + perpY * 24);\n      }\n\n      // Axis title beyond the far end.\n      ctx.save();\n      ctx.font = \"700 15px -apple-system, Segoe UI, Roboto, sans-serif\";\n      ctx.fillStyle = INK;\n      ctx.fillText(edge.label, bx + perpX * 42, by + perpY * 42);\n      ctx.restore();\n    }\n    ctx.restore();\n  },\n\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea } = chart;\n\n    // Elevation 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.div[0]);\n    grad.addColorStop(0.5, t.div[1]);\n    grad.addColorStop(1, t.div[2]);\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(\"Elevation (z)\", keyX, keyY - 6);\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"top\";\n    ctx.fillText(`${(-zAbsMax).toFixed(1)}`, keyX, keyY + keyH + 4);\n    ctx.textAlign = \"center\";\n    ctx.fillText(\"0\", keyX + keyW / 2, keyY + keyH + 4);\n    ctx.textAlign = \"right\";\n    ctx.fillText(`+${zAbsMax.toFixed(1)}`, 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: \"wireframe-3d-basic · 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: [wireframePlugin],\n});\n"}