{"spec_id":"contour-3d","library":"echarts","language":"javascript","code":"// anyplot.ai\n// contour-3d: 3D Contour Plot\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-09-10\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Data: synthetic terrain elevation from three overlapping hills --------\nconst GRID_N = 36;\nconst RANGE = 5; // km, both x and y span [-RANGE, RANGE]\nconst step = (2 * RANGE) / (GRID_N - 1);\nconst xs = Array.from({ length: GRID_N }, (_, i) => -RANGE + i * step);\nconst ys = Array.from({ length: GRID_N }, (_, i) => -RANGE + i * step);\n\nconst HILLS = [\n  { cx: -2.2, cy: 1.6, height: 560, spread: 2.4 },\n  { cx: 2.0, cy: -1.8, height: 430, spread: 1.8 },\n  { cx: -1.0, cy: -2.9, height: 300, spread: 2.2 },\n];\nconst BASE_ELEVATION = 140;\n\nfunction elevation(x, y) {\n  return HILLS.reduce((sum, h) => {\n    const dx = x - h.cx;\n    const dy = y - h.cy;\n    return (\n      sum +\n      h.height * Math.exp(-(dx * dx + dy * dy) / (2 * h.spread * h.spread))\n    );\n  }, BASE_ELEVATION);\n}\n\nconst zGrid = xs.map((x) => ys.map((y) => elevation(x, y)));\nconst zFlat = zGrid.flat();\nconst zMin = Math.min(...zFlat);\nconst zMax = Math.max(...zFlat);\nconst zSpan = zMax - zMin;\nconst zMid = (zMin + zMax) / 2;\n\n// --- Contour levels: 5 interior thresholds -> 6 filled elevation bands -----\nconst N_LEVELS = 5;\nconst N_BANDS = N_LEVELS + 1;\nconst levels = Array.from(\n  { length: N_LEVELS },\n  (_, k) => zMin + (zSpan * (k + 1)) / (N_LEVELS + 1),\n);\nconst bandBoundaries = [zMin, ...levels, zMax];\n\nfunction hexToRgb(hex) {\n  const n = parseInt(hex.slice(1), 16);\n  return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };\n}\nfunction lerpColor(hexA, hexB, frac) {\n  const a = hexToRgb(hexA);\n  const b = hexToRgb(hexB);\n  const mix = (u, v) => Math.round(u + (v - u) * frac);\n  return `rgb(${mix(a.r, b.r)}, ${mix(a.g, b.g)}, ${mix(a.b, b.b)})`;\n}\n// Imprint sequential ramp (green -> blue), sampled at each band's midpoint\n// so the filled bands read as discrete steps rather than a smooth gradient.\nconst bandColors = Array.from({ length: N_BANDS }, (_, b) =>\n  lerpColor(t.seq[0], t.seq[1], (b + 0.5) / N_BANDS),\n);\nconst pieces = bandColors.map((color, b) => ({\n  min: bandBoundaries[b],\n  max: bandBoundaries[b + 1],\n  color,\n  label: `${Math.round(bandBoundaries[b])}–${Math.round(bandBoundaries[b + 1])} m`,\n}));\n\n// --- Camera: orthographic axonometric projection (elevation + azimuth) -----\n// A flatter elevation (24 deg, vs. a more common 30) keeps the projected\n// footprint closer to the mount's own 16:9 proportions — a steeper camera\n// left much of the canvas empty on either side of a comparatively small,\n// tall silhouette. ELEVATION/AZIMUTH are mutable (not const): dragging the\n// mount re-orbits the camera, which is the spec's \"enable rotation for\n// interactive libraries\" applied to a custom (non-echarts-gl) projection.\nlet ELEVATION = (24 * Math.PI) / 180;\nlet AZIMUTH = (45 * Math.PI) / 180;\nlet sinAz, cosAz, sinEl, cosEl;\nfunction updateTrig() {\n  sinAz = Math.sin(AZIMUTH);\n  cosAz = Math.cos(AZIMUTH);\n  sinEl = Math.sin(ELEVATION);\n  cosEl = Math.cos(ELEVATION);\n}\nupdateTrig();\nconst ZSCALE = 0.6; // compresses height relative to the x/y footprint\n// The base plane sits below the lowest terrain point so its projected\n// footprint never overlaps the terrain's screen area, regardless of paint\n// order — a \"floor\" the contour lines can be projected onto for reference.\nconst FLOOR_Z = zMin - zSpan * 0.08;\n\nfunction projectRaw(x, y, zData) {\n  const xn = x / RANGE;\n  const yn = y / RANGE;\n  const zn = ((zData - zMid) / zSpan) * 2 * ZSCALE;\n  const screenX = -xn * sinAz + yn * cosAz;\n  const screenY = -xn * cosAz * sinEl - yn * sinAz * sinEl + zn * cosEl;\n  return { screenX, screenY };\n}\n\n// --- Fit the projected bounding box (terrain + floor plane) into the mount -\n// LABEL_MARGIN extends the fitted box beyond the data cube so the axis\n// titles (anchored past the last tick, see below) still land inside the\n// canvas instead of being clipped at the mount edge. Re-run after every\n// camera change since rotating shifts the projected bounding box.\nconst LABEL_MARGIN = 2.4;\nconst TOP_MARGIN = 65;\nconst BOTTOM_MARGIN = 35;\nconst LEFT_MARGIN = 90;\nconst RIGHT_MARGIN = 220; // room for the piecewise elevation legend\nconst drawW = size.width - LEFT_MARGIN - RIGHT_MARGIN;\nconst drawH = size.height - TOP_MARGIN - BOTTOM_MARGIN;\nconst PAD = 1.05; // small extra breathing room now that LABEL_MARGIN covers the titles\n\nlet scale, originX, originY;\nfunction fitProjection() {\n  const corners = [];\n  for (const x of [-RANGE - LABEL_MARGIN, RANGE + LABEL_MARGIN]) {\n    for (const y of [-RANGE - LABEL_MARGIN, RANGE + LABEL_MARGIN]) {\n      for (const z of [FLOOR_Z, zMax]) corners.push(projectRaw(x, y, z));\n    }\n  }\n  const sxs = corners.map((c) => c.screenX);\n  const sys = corners.map((c) => c.screenY);\n  const boxW = Math.max(...sxs) - Math.min(...sxs);\n  const boxH = Math.max(...sys) - Math.min(...sys);\n  const boxCx = (Math.max(...sxs) + Math.min(...sxs)) / 2;\n  const boxCy = (Math.max(...sys) + Math.min(...sys)) / 2;\n  scale = Math.min(drawW / (boxW * PAD), drawH / (boxH * PAD));\n  originX = LEFT_MARGIN + drawW / 2 - boxCx * scale;\n  originY = TOP_MARGIN + drawH / 2 + boxCy * scale;\n}\nfitProjection();\n\nfunction toPixel(x, y, zData) {\n  const { screenX, screenY } = projectRaw(x, y, zData);\n  return [originX + screenX * scale, originY - screenY * scale];\n}\nfunction projectIdx(i, j, zData) {\n  return toPixel(xs[i], ys[j], zData);\n}\n\n// --- Marching squares: extract isolines at a given elevation level ---------\n// Operates entirely in data space (x, y, z) — independent of the camera, so\n// it only needs to run once regardless of how the scene is later rotated.\nfunction marchingSquares(level) {\n  const segments = [];\n  for (let i = 0; i < GRID_N - 1; i += 1) {\n    for (let j = 0; j < GRID_N - 1; j += 1) {\n      const x0 = xs[i];\n      const x1 = xs[i + 1];\n      const y0 = ys[j];\n      const y1 = ys[j + 1];\n      const zBL = zGrid[i][j];\n      const zBR = zGrid[i + 1][j];\n      const zTR = zGrid[i + 1][j + 1];\n      const zTL = zGrid[i][j + 1];\n      let caseIndex = 0;\n      if (zBL > level) caseIndex |= 1;\n      if (zBR > level) caseIndex |= 2;\n      if (zTR > level) caseIndex |= 4;\n      if (zTL > level) caseIndex |= 8;\n      if (caseIndex === 0 || caseIndex === 15) continue;\n\n      const lerp = (xa, ya, za, xb, yb, zb) => {\n        const frac = (level - za) / (zb - za);\n        return [xa + frac * (xb - xa), ya + frac * (yb - ya)];\n      };\n      const bottom = () => lerp(x0, y0, zBL, x1, y0, zBR);\n      const right = () => lerp(x1, y0, zBR, x1, y1, zTR);\n      const top = () => lerp(x1, y1, zTR, x0, y1, zTL);\n      const left = () => lerp(x0, y1, zTL, x0, y0, zBL);\n\n      // Standard marching-squares edge table; cases 5 and 10 are the\n      // ambiguous saddle configurations, resolved with a fixed pairing.\n      const edgeTable = {\n        1: [[left, bottom]],\n        2: [[bottom, right]],\n        3: [[left, right]],\n        4: [[right, top]],\n        5: [\n          [left, bottom],\n          [right, top],\n        ],\n        6: [[bottom, top]],\n        7: [[left, top]],\n        8: [[top, left]],\n        9: [[top, bottom]],\n        10: [\n          [bottom, left],\n          [top, right],\n        ],\n        11: [[top, right]],\n        12: [[right, left]],\n        13: [[right, bottom]],\n        14: [[bottom, left]],\n      };\n      edgeTable[caseIndex].forEach(([edgeA, edgeB]) => {\n        const [px1, py1] = edgeA();\n        const [px2, py2] = edgeB();\n        segments.push({ i, j, x1: px1, y1: py1, x2: px2, y2: py2 });\n      });\n    }\n  }\n  return segments;\n}\nconst contoursByLevel = levels.map((level) => ({\n  level,\n  segments: marchingSquares(level),\n}));\n\n// --- Draw items: terrain quads (visualMap-colored) + surface contour lines -\n// One custom series so both share a single z2 stacking order: each isoline\n// segment is keyed to the grid cell it crosses, drawn just above that cell's\n// quad, which keeps lines readable on top of their own patch of terrain\n// while neighboring cells still paint in roughly back-to-front order.\nconst drawItems = [];\nfor (let i = 0; i < GRID_N - 1; i += 1) {\n  for (let j = 0; j < GRID_N - 1; j += 1) {\n    const avg =\n      (zGrid[i][j] + zGrid[i + 1][j] + zGrid[i + 1][j + 1] + zGrid[i][j + 1]) /\n      4;\n    drawItems.push({ kind: \"quad\", i, j, value: avg });\n  }\n}\ncontoursByLevel.forEach(({ level, segments }) => {\n  segments.forEach((seg) =>\n    drawItems.push({ kind: \"line\", level, value: level, ...seg }),\n  );\n});\n\nconst LINE_COLOR = t.ink;\n\nfunction renderItem(params, api) {\n  const item = drawItems[params.dataIndex];\n  if (item.kind === \"quad\") {\n    const points = [\n      projectIdx(item.i, item.j, zGrid[item.i][item.j]),\n      projectIdx(item.i + 1, item.j, zGrid[item.i + 1][item.j]),\n      projectIdx(item.i + 1, item.j + 1, zGrid[item.i + 1][item.j + 1]),\n      projectIdx(item.i, item.j + 1, zGrid[item.i][item.j + 1]),\n    ];\n    const color = api.visual(\"color\");\n    return {\n      type: \"polygon\",\n      z2: 1000 + item.i + item.j,\n      shape: { points },\n      // Stroking each quad with its own fill color (instead of leaving it\n      // bare) blends the antialiasing seam between adjacent same-color\n      // quads into a smooth surface instead of a faint crosshatch texture.\n      style: { fill: color, stroke: color, lineWidth: 1 },\n    };\n  }\n  const p1 = toPixel(item.x1, item.y1, item.level);\n  const p2 = toPixel(item.x2, item.y2, item.level);\n  return {\n    type: \"line\",\n    z2: 1000 + item.i + item.j + 0.5,\n    shape: { x1: p1[0], y1: p1[1], x2: p2[0], y2: p2[1] },\n    style: { stroke: LINE_COLOR, lineWidth: 1.6, opacity: 0.55 },\n    silent: true,\n  };\n}\n\n// --- Floor plane: the same isolines projected down, for orientation -------\nfunction buildFloorElements() {\n  const floorElements = [\n    {\n      type: \"polygon\",\n      shape: {\n        points: [\n          toPixel(-RANGE, -RANGE, FLOOR_Z),\n          toPixel(RANGE, -RANGE, FLOOR_Z),\n          toPixel(RANGE, RANGE, FLOOR_Z),\n          toPixel(-RANGE, RANGE, FLOOR_Z),\n        ],\n      },\n      style: { fill: t.elevatedBg, stroke: t.grid, lineWidth: 1.5 },\n      silent: true,\n    },\n  ];\n  contoursByLevel.forEach(({ segments }) => {\n    segments.forEach((seg) => {\n      const p1 = toPixel(seg.x1, seg.y1, FLOOR_Z);\n      const p2 = toPixel(seg.x2, seg.y2, FLOOR_Z);\n      floorElements.push({\n        type: \"line\",\n        shape: { x1: p1[0], y1: p1[1], x2: p2[0], y2: p2[1] },\n        style: {\n          stroke: t.inkSoft,\n          lineWidth: 1.2,\n          opacity: 0.55,\n          lineDash: [4, 4],\n        },\n        silent: true,\n      });\n    });\n  });\n  return floorElements;\n}\n\n// --- Axis frame: ground (X, Y) + elevation (Z) edges, ticks + labels -------\n// Camera-facing corner selection matches the elevation-24/azimuth-45 camera:\n// X/Y ticks sit on the far-bottom corner (+RANGE, +RANGE) so they trail\n// behind the terrain instead of crossing it; Z sits on (+RANGE, -RANGE),\n// which the azimuth collapses to a single vertical screen line clear of\n// the hills. Four generic drawing primitives (line/tick/label/title) feed a\n// single `drawAxis` composer so the three axis frames below are declarative\n// config objects rather than three repeated call sequences.\nconst AXIS_COLOR = t.inkSoft;\nconst TICK_LEN = RANGE * 0.07;\n\nfunction axisLine(out, p1, p2) {\n  const points = [p1, p2].map(([x, y, z]) => toPixel(x, y, z));\n  out.push({\n    type: \"polyline\",\n    shape: { points },\n    style: { stroke: AXIS_COLOR, lineWidth: 2, fill: \"none\" },\n    silent: true,\n  });\n}\nfunction tickLabel(out, pos, text, align) {\n  const [px, py] = toPixel(...pos);\n  out.push({\n    type: \"text\",\n    style: {\n      text,\n      x: px,\n      y: py,\n      fill: t.inkSoft,\n      fontSize: 13,\n      align: align || \"center\",\n      verticalAlign: \"middle\",\n    },\n    silent: true,\n  });\n}\nfunction axisTitle(out, pos, text, offset) {\n  const [px, py] = toPixel(...pos);\n  out.push({\n    type: \"text\",\n    style: {\n      text,\n      x: px + offset[0],\n      y: py + offset[1],\n      fill: t.ink,\n      fontSize: 17,\n      fontWeight: \"bold\",\n      align: \"center\",\n      verticalAlign: \"middle\",\n    },\n    silent: true,\n  });\n}\nfunction drawAxis(out, cfg) {\n  axisLine(out, cfg.line[0], cfg.line[1]);\n  cfg.ticks.forEach((v) => {\n    const { from, to } = cfg.tickPos(v);\n    axisLine(out, from, to);\n    tickLabel(out, cfg.labelPos(v), cfg.tickText(v), cfg.align);\n  });\n  axisTitle(out, cfg.titlePos, cfg.title, cfg.titleOffset);\n}\n\nconst CORNER_X = RANGE;\nconst CORNER_Y = RANGE;\nconst groundZ = FLOOR_Z;\nconst Z_CORNER_X = RANGE;\nconst Z_CORNER_Y = -RANGE;\nconst axisTicksXY = [-5, -2.5, 0, 2.5, 5];\nconst zTicks = [zMin, zMid, zMax].map((v) => Math.round(v / 10) * 10);\n\nfunction buildAxisElements() {\n  const out = [];\n  // Titles stay centered at v=0 (the axis midpoint) but sit at a larger\n  // outward distance than the tick labels — a farther \"row\" rather than the\n  // same point, which is what caused the title to collide with the \"0\" tick\n  // label. (Anchoring titles past the last tick instead was tried and\n  // rejected: at this camera's azimuth the two ground axes share a corner,\n  // so both titles converged on nearly the same screen position beyond it.)\n  drawAxis(out, {\n    line: [\n      [-RANGE, CORNER_Y, groundZ],\n      [RANGE, CORNER_Y, groundZ],\n    ],\n    ticks: axisTicksXY,\n    tickPos: (v) => ({\n      from: [v, CORNER_Y, groundZ],\n      to: [v, CORNER_Y + TICK_LEN, groundZ],\n    }),\n    labelPos: (v) => [v, CORNER_Y + TICK_LEN * 2.4, groundZ],\n    tickText: (v) => String(v),\n    title: \"Easting (km)\",\n    titlePos: [0, CORNER_Y + TICK_LEN * 4.8, groundZ],\n    titleOffset: [0, 0],\n  });\n\n  drawAxis(out, {\n    line: [\n      [CORNER_X, -RANGE, groundZ],\n      [CORNER_X, RANGE, groundZ],\n    ],\n    ticks: axisTicksXY,\n    tickPos: (v) => ({\n      from: [CORNER_X, v, groundZ],\n      to: [CORNER_X + TICK_LEN, v, groundZ],\n    }),\n    labelPos: (v) => [CORNER_X + TICK_LEN * 2.4, v, groundZ],\n    tickText: (v) => String(v),\n    title: \"Northing (km)\",\n    titlePos: [CORNER_X + TICK_LEN * 4.8, 0, groundZ],\n    titleOffset: [0, 0],\n  });\n\n  drawAxis(out, {\n    line: [\n      [Z_CORNER_X, Z_CORNER_Y, FLOOR_Z],\n      [Z_CORNER_X, Z_CORNER_Y, zMax],\n    ],\n    ticks: zTicks,\n    tickPos: (v) => ({\n      from: [Z_CORNER_X, Z_CORNER_Y, v],\n      to: [Z_CORNER_X, Z_CORNER_Y - TICK_LEN, v],\n    }),\n    labelPos: (v) => [Z_CORNER_X, Z_CORNER_Y - TICK_LEN * 2.4, v],\n    tickText: (v) => String(Math.round(v)),\n    align: \"center\",\n    title: \"Elevation (m)\",\n    titlePos: [Z_CORNER_X, Z_CORNER_Y, zMax],\n    titleOffset: [0, -40],\n  });\n\n  return out;\n}\n\n// --- Init + option -----------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"Terrain Elevation · contour-3d · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    top: 24,\n    textStyle: { color: t.ink, fontSize: 22, fontWeight: 500 },\n  },\n  tooltip: {\n    trigger: \"item\",\n    formatter: (params) => {\n      const item = drawItems[params.dataIndex];\n      return item.kind === \"quad\"\n        ? `Elevation: <b>${Math.round(item.value)} m</b>`\n        : null;\n    },\n  },\n  visualMap: {\n    type: \"piecewise\",\n    dimension: 0,\n    seriesIndex: 0,\n    pieces,\n    orient: \"vertical\",\n    right: 60,\n    top: TOP_MARGIN + 20,\n    itemWidth: 26,\n    itemHeight: 26,\n    itemGap: 8,\n    textStyle: { color: t.inkSoft, fontSize: 13 },\n  },\n  graphic: { elements: [...buildFloorElements(), ...buildAxisElements()] },\n  series: [\n    {\n      type: \"custom\",\n      coordinateSystem: null,\n      renderItem,\n      data: drawItems.map((item) => ({ value: [item.value] })),\n    },\n  ],\n});\n\n// --- Drag-to-rotate: the spec asks interactive libraries to enable rotation\n// of the 3D structure. echarts-gl (true grid3D) isn't installed, so this\n// custom-canvas projection re-orbits the camera by hand: a drag updates\n// AZIMUTH/ELEVATION, re-fits the projection, and re-projects every element.\n// Inert for the static PNG capture (no pointer events fire in headless\n// screenshotting), but live in the shipped interactive HTML page.\nconst zr = chart.getZr();\nzr.setCursorStyle(\"grab\");\nlet dragging = false;\nlet lastX = 0;\nlet lastY = 0;\nconst MIN_ELEVATION = (4 * Math.PI) / 180;\nconst MAX_ELEVATION = (80 * Math.PI) / 180;\n\nfunction rerender() {\n  updateTrig();\n  fitProjection();\n  chart.setOption({\n    graphic: { elements: [...buildFloorElements(), ...buildAxisElements()] },\n    series: [{ data: drawItems.map((item) => ({ value: [item.value] })) }],\n  });\n}\n\nzr.on(\"mousedown\", (e) => {\n  dragging = true;\n  lastX = e.offsetX;\n  lastY = e.offsetY;\n  zr.setCursorStyle(\"grabbing\");\n});\nzr.on(\"mousemove\", (e) => {\n  if (!dragging) return;\n  AZIMUTH += (e.offsetX - lastX) * 0.006;\n  ELEVATION = Math.min(\n    MAX_ELEVATION,\n    Math.max(MIN_ELEVATION, ELEVATION - (e.offsetY - lastY) * 0.006),\n  );\n  lastX = e.offsetX;\n  lastY = e.offsetY;\n  rerender();\n});\nzr.on(\"mouseup\", () => {\n  dragging = false;\n  zr.setCursorStyle(\"grab\");\n});\nzr.on(\"globalout\", () => {\n  dragging = false;\n  zr.setCursorStyle(\"grab\");\n});\n"}