{"spec_id":"surface-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// surface-basic: Basic 3D Surface Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 86/100 | Created: 2026-09-10\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 100, right: 260, bottom: 60, left: 60 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: standing-wave interference amplitude over a 2D membrane ---------\nconst GRID_N = 38;\nconst EXTENT = 4; // x, y span [-EXTENT, EXTENT]\nconst xs = d3.range(GRID_N).map((i) => -EXTENT + (2 * EXTENT * i) / (GRID_N - 1));\nconst ys = d3.range(GRID_N).map((j) => -EXTENT + (2 * EXTENT * j) / (GRID_N - 1));\nconst zGrid = xs.map((x) => ys.map((y) => Math.sin(x) * Math.cos(y)));\nconst zFlat = zGrid.flat();\nconst zMin = d3.min(zFlat);\nconst zMax = d3.max(zFlat);\n\n// Vertical exaggeration so height variation reads clearly against the x/y span\nconst Z_SCALE = EXTENT * 0.65;\nconst zWorld = (z) => z * Z_SCALE;\n\n// --- 3D projection: azimuth spin around Z, then elevation tilt around X ----\n// Mutable so drag-to-rotate (below) can update the view and re-project.\nlet azimuth = (-35 * Math.PI) / 180;\nlet elevation = (26 * Math.PI) / 180;\nconst MIN_ELEVATION = (6 * Math.PI) / 180;\nconst MAX_ELEVATION = (80 * Math.PI) / 180;\n\nfunction project(x, y, z, az, el) {\n  const cosAz = Math.cos(az);\n  const sinAz = Math.sin(az);\n  const cosEl = Math.cos(el);\n  const sinEl = Math.sin(el);\n  // spin around the vertical (z) axis\n  const x1 = x * cosAz - y * sinAz;\n  const y1 = x * sinAz + y * cosAz;\n  // tilt the spun frame around the (screen-horizontal) x axis\n  const y2 = y1 * cosEl - z * sinEl;\n  const depth = y1 * sinEl + z * cosEl;\n  // A larger z (taller surface) must land at a smaller screen-y (render higher up).\n  return { sx: x1, sy: y2, depth };\n}\n\nconst originX = margin.left + iw / 2;\nconst originY = margin.top + ih / 2;\n\nconst axisCorner = [-EXTENT, -EXTENT, zWorld(zMin)];\nconst axisEnds = {\n  x: [EXTENT, -EXTENT, zWorld(zMin)],\n  y: [-EXTENT, EXTENT, zWorld(zMin)],\n  z: [-EXTENT, -EXTENT, zWorld(zMax)],\n};\n\n// --- SVG mount ---------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst surfaceG = svg.append(\"g\").attr(\"class\", \"surface\");\nconst axisG = svg.append(\"g\").attr(\"class\", \"axes\");\n\n// --- Color scale (diverging: amplitude has a meaningful zero midpoint) ------\nconst absMax = Math.max(Math.abs(zMin), Math.abs(zMax));\nconst color = d3.scaleSequential(d3.interpolateRgbBasis(t.div)).domain([-absMax, absMax]);\n\nconst lineGen = d3.line();\nconst tickCounts = 5;\nconst xTicks = d3.scaleLinear().domain([-EXTENT, EXTENT]).ticks(tickCounts);\nconst yTicks = d3.scaleLinear().domain([-EXTENT, EXTENT]).ticks(tickCounts);\nconst zTicks = d3.scaleLinear().domain([zMin, zMax]).ticks(tickCounts);\n\n// Re-projects and redraws the surface + axis triad for the current\n// azimuth/elevation — called once up front and again on every drag step.\nfunction render() {\n  const proj = (x, y, z) => project(x, y, z, azimuth, elevation);\n\n  // Bounding box in projected model space, used to fit the surface into iw x\n  // ih. x/y use independent scale factors (a stylized isometric-like\n  // projection, not a physical camera) so the surface fills the canvas.\n  const corners = [];\n  for (const x of [-EXTENT, EXTENT]) {\n    for (const y of [-EXTENT, EXTENT]) {\n      for (const z of [zWorld(zMin), zWorld(zMax)]) corners.push(proj(x, y, z));\n    }\n  }\n  const sxExtent = d3.extent(corners, (d) => d.sx);\n  const syExtent = d3.extent(corners, (d) => d.sy);\n  const fitScaleX = 0.92 * (iw / (sxExtent[1] - sxExtent[0]));\n  const fitScaleY = 0.92 * (ih / (syExtent[1] - syExtent[0]));\n  const sxCenter = (sxExtent[0] + sxExtent[1]) / 2;\n  const syCenter = (syExtent[0] + syExtent[1]) / 2;\n  const toScreen = (p) => [originX + (p.sx - sxCenter) * fitScaleX, originY + (p.sy - syCenter) * fitScaleY];\n\n  // --- Surface mesh: one quad per grid cell, painter's algorithm back-to-front\n  const quads = [];\n  for (let i = 0; i < GRID_N - 1; i++) {\n    for (let j = 0; j < GRID_N - 1; j++) {\n      const cellCorners = [\n        [xs[i], ys[j], zGrid[i][j]],\n        [xs[i + 1], ys[j], zGrid[i + 1][j]],\n        [xs[i + 1], ys[j + 1], zGrid[i + 1][j + 1]],\n        [xs[i], ys[j + 1], zGrid[i][j + 1]],\n      ];\n      const projected = cellCorners.map(([x, y, z]) => proj(x, y, zWorld(z)));\n      const avgZ = (zGrid[i][j] + zGrid[i + 1][j] + zGrid[i + 1][j + 1] + zGrid[i][j + 1]) / 4;\n      const avgDepth = d3.mean(projected, (p) => p.depth);\n      quads.push({ points: projected.map(toScreen), value: avgZ, depth: avgDepth });\n    }\n  }\n  quads.sort((a, b) => a.depth - b.depth);\n\n  surfaceG\n    .selectAll(\"path\")\n    .data(quads)\n    .join(\"path\")\n    .attr(\"d\", (d) => lineGen(d.points) + \"Z\")\n    .attr(\"fill\", (d) => color(d.value))\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 0.6)\n    .attr(\"stroke-opacity\", 0.5);\n\n  // --- Axis triad (drawn from the bounding-box corner nearest the viewer) ---\n  axisG.selectAll(\"*\").remove();\n  const originScreen = toScreen(proj(...axisCorner));\n  const axisEndScreen = {};\n  for (const key of [\"x\", \"y\", \"z\"]) {\n    axisEndScreen[key] = toScreen(proj(...axisEnds[key]));\n    axisG\n      .append(\"line\")\n      .attr(\"x1\", originScreen[0])\n      .attr(\"y1\", originScreen[1])\n      .attr(\"x2\", axisEndScreen[key][0])\n      .attr(\"y2\", axisEndScreen[key][1])\n      .attr(\"stroke\", t.inkSoft)\n      .attr(\"stroke-width\", 1.5);\n  }\n\n  // Ticks + labels for x and y (world-plane ticks, below the axis) and z\n  // (height ticks, offset sideways since the z-axis renders near-vertical)\n  function drawTicks(worldToPoint, domainValues, labelFn, { dx = 0, dy = \"1.1em\", anchor = \"middle\" } = {}) {\n    for (const v of domainValues) {\n      const p = toScreen(proj(...worldToPoint(v)));\n      axisG\n        .append(\"text\")\n        .attr(\"x\", p[0])\n        .attr(\"y\", p[1])\n        .attr(\"dx\", dx)\n        .attr(\"dy\", dy)\n        .attr(\"text-anchor\", anchor)\n        .attr(\"fill\", t.inkSoft)\n        .style(\"font-size\", \"13px\")\n        .text(labelFn(v));\n    }\n  }\n  drawTicks((v) => [v, -EXTENT, zWorld(zMin)], xTicks, (v) => v.toFixed(0));\n  drawTicks((v) => [-EXTENT, v, zWorld(zMin)], yTicks, (v) => v.toFixed(0));\n  drawTicks((v) => [-EXTENT, -EXTENT, zWorld(v)], zTicks, (v) => v.toFixed(1), {\n    dx: -10,\n    dy: \"0.35em\",\n    anchor: \"end\",\n  });\n\n  // Axis titles — placed just outside the surface's screen-space bounding\n  // box (the projection is linear in x/y/z, so every surface point provably\n  // projects inside the convex hull of the 8 corner projections, i.e. inside\n  // this box). Each label is pushed along the ray from the box center\n  // through its axis's screen midpoint until it exits the box, then a fixed\n  // padding further — guaranteed to land in empty background space no\n  // matter how the view has been rotated.\n  const screenCorners = corners.map(toScreen);\n  const boxMinX = d3.min(screenCorners, (p) => p[0]);\n  const boxMaxX = d3.max(screenCorners, (p) => p[0]);\n  const boxMinY = d3.min(screenCorners, (p) => p[1]);\n  const boxMaxY = d3.max(screenCorners, (p) => p[1]);\n  const boxCenter = [(boxMinX + boxMaxX) / 2, (boxMinY + boxMaxY) / 2];\n  const midpoint = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];\n  function edgeLabel(mid, padding) {\n    const dx = mid[0] - boxCenter[0];\n    const dy = mid[1] - boxCenter[1];\n    const tx = dx !== 0 ? ((dx > 0 ? boxMaxX : boxMinX) - boxCenter[0]) / dx : Infinity;\n    const ty = dy !== 0 ? ((dy > 0 ? boxMaxY : boxMinY) - boxCenter[1]) / dy : Infinity;\n    const t = Math.min(tx, ty);\n    const len = Math.hypot(dx, dy) || 1;\n    return [boxCenter[0] + t * dx + (dx / len) * padding, boxCenter[1] + t * dy + (dy / len) * padding];\n  }\n  const xLabelScreen = edgeLabel(midpoint(originScreen, axisEndScreen.x), 40);\n  const yLabelScreen = edgeLabel(midpoint(originScreen, axisEndScreen.y), 40);\n  const zLabelScreen = edgeLabel(midpoint(originScreen, axisEndScreen.z), 40);\n  axisG\n    .append(\"text\")\n    .attr(\"x\", xLabelScreen[0])\n    .attr(\"y\", xLabelScreen[1])\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"15px\")\n    .text(\"Position X (m)\");\n  axisG\n    .append(\"text\")\n    .attr(\"x\", yLabelScreen[0])\n    .attr(\"y\", yLabelScreen[1])\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"15px\")\n    .text(\"Position Y (m)\");\n  axisG\n    .append(\"text\")\n    .attr(\"x\", zLabelScreen[0])\n    .attr(\"y\", zLabelScreen[1])\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", t.ink)\n    .style(\"font-size\", \"15px\")\n    .attr(\"transform\", `rotate(-90 ${zLabelScreen[0]} ${zLabelScreen[1]})`)\n    .text(\"Wave Amplitude\");\n}\nrender();\n\n// --- Drag-to-rotate: classic D3 technique for exploring a 3D surface from\n// different angles (the spec explicitly asks interactive libraries for this).\n// The static PNG capture happens before any pointer event fires, so the\n// screenshot is unaffected; only the interactive HTML output responds.\nsvg.style(\"cursor\", \"grab\").call(\n  d3\n    .drag()\n    .on(\"start\", () => svg.style(\"cursor\", \"grabbing\"))\n    .on(\"drag\", (event) => {\n      azimuth += event.dx * 0.008;\n      elevation = Math.max(MIN_ELEVATION, Math.min(MAX_ELEVATION, elevation - event.dy * 0.008));\n      render();\n    })\n    .on(\"end\", () => svg.style(\"cursor\", \"grab\")),\n);\n\n// --- Colorbar (2D legend for the height/color mapping) ----------------------\nconst barX = width - margin.right + 90;\nconst barTop = margin.top + 40;\nconst barHeight = ih - 80;\nconst barWidth = 26;\nconst legendScale = d3.scaleLinear().domain([absMax, -absMax]).range([0, barHeight]);\nconst gradientId = \"surface-basic-colorbar\";\nconst defs = svg.append(\"defs\");\nconst gradient = defs\n  .append(\"linearGradient\")\n  .attr(\"id\", gradientId)\n  .attr(\"x1\", \"0\")\n  .attr(\"x2\", \"0\")\n  .attr(\"y1\", \"0\")\n  .attr(\"y2\", \"1\");\nd3.range(0, 1.001, 0.1).forEach((stop) => {\n  gradient\n    .append(\"stop\")\n    .attr(\"offset\", `${stop * 100}%`)\n    .attr(\"stop-color\", color(absMax - stop * 2 * absMax));\n});\nsvg\n  .append(\"rect\")\n  .attr(\"x\", barX)\n  .attr(\"y\", barTop)\n  .attr(\"width\", barWidth)\n  .attr(\"height\", barHeight)\n  .attr(\"fill\", `url(#${gradientId})`)\n  .attr(\"stroke\", t.inkSoft)\n  .attr(\"stroke-width\", 1);\nconst legendAxis = d3.axisRight(legendScale).ticks(5).tickFormat(d3.format(\".1f\"));\nconst legendG = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${barX + barWidth},${barTop})`)\n  .call(legendAxis);\nlegendG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"13px\");\nlegendG.selectAll(\"line\").attr(\"stroke\", t.grid);\nlegendG.select(\".domain\").attr(\"stroke\", t.inkSoft);\nsvg\n  .append(\"text\")\n  .attr(\"x\", barX + barWidth / 2)\n  .attr(\"y\", barTop - 18)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\"Amplitude\");\n\n// --- Title -------------------------------------------------------------------\nconst title = \"Wave Interference Surface · surface-basic · javascript · d3 · anyplot.ai\";\nconst baselineFontSize = 25;\nconst titleFontSize = title.length > 67 ? Math.round(baselineFontSize * (67 / title.length)) : baselineFontSize;\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 52)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleFontSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(title);\n"}