{"spec_id":"scatter-3d","library":"echarts","language":"javascript","code":"// anyplot.ai\n// scatter-3d: 3D Scatter Plot\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 85/100 | Created: 2026-09-10\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Deterministic PRNG (LCG + Box-Muller) -----------------------------------\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction gaussian(mean, std) {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  return mean + std * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// --- Data: rock samples in 3D compositional space, density as 4th dimension -\n// SiO2 / Fe2O3 / MgO (wt%) separate three igneous rock groups; density (g/cm3)\n// is layered on top as a continuous color-encoded variable.\nconst clusters = [\n  { n: 40, siO2: 50, fe2O3: 13, mgO: 5, density: 2.95, spread: [3, 0.9, 0.9, 0.05] },\n  { n: 40, siO2: 72, fe2O3: 3, mgO: 1, density: 2.68, spread: [2.5, 0.6, 0.4, 0.03] },\n  { n: 40, siO2: 46, fe2O3: 6, mgO: 13, density: 3.02, spread: [2.5, 0.8, 0.9, 0.04] },\n];\n\nconst samples = [];\nclusters.forEach((c) => {\n  for (let i = 0; i < c.n; i++) {\n    samples.push({\n      siO2: gaussian(c.siO2, c.spread[0]),\n      fe2O3: Math.max(0.2, gaussian(c.fe2O3, c.spread[1])),\n      mgO: Math.max(0.1, gaussian(c.mgO, c.spread[2])),\n      density: gaussian(c.density, c.spread[3]),\n    });\n  }\n});\n\nconst siO2Range = [Math.min(...samples.map((s) => s.siO2)), Math.max(...samples.map((s) => s.siO2))];\nconst fe2O3Range = [Math.min(...samples.map((s) => s.fe2O3)), Math.max(...samples.map((s) => s.fe2O3))];\nconst mgORange = [Math.min(...samples.map((s) => s.mgO)), Math.max(...samples.map((s) => s.mgO))];\nconst densityRange = [Math.min(...samples.map((s) => s.density)), Math.max(...samples.map((s) => s.density))];\n\nfunction norm(v, [lo, hi]) {\n  return ((v - lo) / (hi - lo)) * 10;\n}\n\n// --- Isometric projection: (x, y, z) -> (screenX, screenY) ------------------\n// Standard 30-degree axonometric projection: y is the vertical axis, x runs to\n// the lower-right, z runs to the lower-left — the classic isometric layout.\nconst COS30 = Math.cos(Math.PI / 6);\nconst SIN30 = Math.sin(Math.PI / 6);\nfunction project(nx, ny, nz) {\n  return [(nx - nz) * COS30, (nx + nz) * SIN30 + ny];\n}\n\nconst points = samples.map((s) => {\n  const [px, py] = project(norm(s.siO2, siO2Range), norm(s.fe2O3, fe2O3Range), norm(s.mgO, mgORange));\n  return [px, py, s.density, s.siO2, s.fe2O3, s.mgO];\n});\n\n// Axis guides run past the data extent (13 vs. the normalized max of 10) so\n// the guide lines read as open-ended axes rather than a closed data-bound box.\nconst originP = project(0, 0, 0);\nconst xTip = project(13, 0, 0);\nconst yTip = project(0, 13, 0);\nconst zTip = project(0, 0, 13);\n\n// --- Frame geometry: preserve the isometric angles by locking x/y to a\n// single pixels-per-unit scale (echarts cartesian has no built-in aspect lock)\nconst allX = points.map((p) => p[0]).concat([originP[0], xTip[0], yTip[0], zTip[0]]);\nconst allY = points.map((p) => p[1]).concat([originP[1], xTip[1], yTip[1], zTip[1]]);\nconst padX = (Math.max(...allX) - Math.min(...allX)) * 0.12;\nconst padY = (Math.max(...allY) - Math.min(...allY)) * 0.08;\nconst xAxisMin = Math.min(...allX) - padX;\nconst xAxisMax = Math.max(...allX) + padX;\nconst yAxisMin = Math.min(...allY) - padY;\nconst yAxisMax = Math.max(...allY) + padY;\n\nconst marginTop = 110;\nconst marginBottom = 50;\nconst marginLeft = 130;\nconst marginRight = 210;\nconst availW = size.width - marginLeft - marginRight;\nconst availH = size.height - marginTop - marginBottom;\nconst dataW = xAxisMax - xAxisMin;\nconst dataH = yAxisMax - yAxisMin;\nconst gridScale = Math.min(availW / dataW, availH / dataH);\nconst gridWidth = dataW * gridScale;\nconst gridHeight = dataH * gridScale;\nconst gridLeft = marginLeft + (availW - gridWidth) / 2;\nconst gridTop = marginTop + (availH - gridHeight) / 2;\n\n// --- Title (fontsize scaled to the 67-char baseline) -------------------------\nconst titleText = \"Rock Sample Composition · scatter-3d · javascript · echarts · anyplot.ai\";\nconst titleFontSize = Math.max(15, Math.round(22 * Math.min(1, 67 / titleText.length)));\n\n// --- Init ---------------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Option ---------------------------------------------------------------\nconst option = {\n  animation: false,\n  backgroundColor: \"transparent\",\n  color: t.palette,\n  title: {\n    text: titleText,\n    left: \"center\",\n    top: 30,\n    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: \"medium\" },\n  },\n  tooltip: {\n    trigger: \"item\",\n    formatter: (params) => {\n      if (!Array.isArray(params.data)) return \"\";\n      const [, , density, siO2, fe2O3, mgO] = params.data;\n      return (\n        `SiO₂: ${siO2.toFixed(1)} wt%<br/>` +\n        `Fe₂O₃: ${fe2O3.toFixed(1)} wt%<br/>` +\n        `MgO: ${mgO.toFixed(1)} wt%<br/>` +\n        `Density: ${density.toFixed(2)} g/cm³`\n      );\n    },\n    backgroundColor: t.elevatedBg,\n    borderColor: t.inkSoft,\n    textStyle: { color: t.ink },\n  },\n  visualMap: {\n    dimension: 2,\n    min: densityRange[0],\n    max: densityRange[1],\n    inRange: { color: t.seq },\n    orient: \"vertical\",\n    right: 30,\n    top: \"middle\",\n    text: [\"High density (g/cm³)\", \"Low density (g/cm³)\"],\n    textStyle: { color: t.inkSoft, fontSize: 13 },\n    itemWidth: 14,\n    itemHeight: 140,\n  },\n  grid: { left: gridLeft, top: gridTop, width: gridWidth, height: gridHeight },\n  xAxis: {\n    type: \"value\",\n    min: xAxisMin,\n    max: xAxisMax,\n    show: false,\n  },\n  yAxis: {\n    type: \"value\",\n    min: yAxisMin,\n    max: yAxisMax,\n    show: false,\n  },\n  series: [\n    {\n      // Axis guides\n      type: \"line\",\n      data: [\n        [originP[0], originP[1]],\n        [xTip[0], xTip[1]],\n      ],\n      showSymbol: false,\n      silent: true,\n      lineStyle: { color: t.inkSoft, width: 1.5 },\n      z: 1,\n    },\n    {\n      type: \"line\",\n      data: [\n        [originP[0], originP[1]],\n        [yTip[0], yTip[1]],\n      ],\n      showSymbol: false,\n      silent: true,\n      lineStyle: { color: t.inkSoft, width: 1.5 },\n      z: 1,\n    },\n    {\n      type: \"line\",\n      data: [\n        [originP[0], originP[1]],\n        [zTip[0], zTip[1]],\n      ],\n      showSymbol: false,\n      silent: true,\n      lineStyle: { color: t.inkSoft, width: 1.5 },\n      z: 1,\n    },\n    {\n      // Rock samples, positioned by isometric projection, colored by density\n      type: \"scatter\",\n      data: points,\n      symbolSize: 11,\n      itemStyle: {\n        opacity: 0.75,\n        borderColor: t.pageBg,\n        borderWidth: 1,\n      },\n      z: 2,\n    },\n  ],\n};\n\nchart.setOption(option);\n\n// Axis labels, anchored to each guide's tip and nudged off the line itself —\n// real data coordinates converted to pixels, not a decorative overlay.\nconst toPixel = (dataPoint) => chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, dataPoint);\nconst axisLabels = [\n  { point: xTip, text: \"SiO₂ (wt%)\", dx: 15, dy: -8, font: \"16px sans-serif\" },\n  { point: yTip, text: \"Fe₂O₃ (wt%)\", dx: 85, dy: -8, font: \"16px sans-serif\" },\n  { point: zTip, text: \"MgO (wt%)\", dx: -135, dy: -8, font: \"16px sans-serif\" },\n];\n\n// Numeric tick labels partway along each guide (interpolated from that\n// variable's real min/max) so the composition scale reads directly off the\n// static PNG without needing the interactive tooltip.\nfunction tickValue([lo, hi], frac) {\n  return lo + (frac / 10) * (hi - lo);\n}\nconst tickSpecs = [\n  { axisVec: [1, 0, 0], range: siO2Range, ticks: [{ t: 3, dx: 6, dy: -2 }, { t: 8, dx: 10, dy: -8 }] },\n  { axisVec: [0, 1, 0], range: fe2O3Range, ticks: [{ t: 3, dx: 10, dy: 2 }, { t: 8, dx: 10, dy: 2 }] },\n  { axisVec: [0, 0, 1], range: mgORange, ticks: [{ t: 3, dx: -32, dy: -2 }, { t: 8, dx: -36, dy: -8 }] },\n];\nconst tickLabels = tickSpecs.flatMap((spec) =>\n  spec.ticks.map(({ t: frac, dx, dy }) => ({\n    point: project(spec.axisVec[0] * frac, spec.axisVec[1] * frac, spec.axisVec[2] * frac),\n    text: `${tickValue(spec.range, frac).toFixed(0)}%`,\n    dx,\n    dy,\n    font: \"11px sans-serif\",\n  }))\n);\n\nchart.setOption({\n  graphic: [...axisLabels, ...tickLabels].map((a) => {\n    const pixel = toPixel([a.point[0], a.point[1]]);\n    return {\n      type: \"text\",\n      left: pixel[0] + a.dx,\n      top: pixel[1] + a.dy,\n      silent: true,\n      style: { text: a.text, fill: t.inkSoft, font: a.font },\n    };\n  }),\n});\n"}