{"spec_id":"scatter-3d","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// scatter-3d: 3D Scatter Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-10\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: air-quality sensor readings, three continuous dimensions --------\n// Deterministic seeded LCG (no Math.random — reproducible across renders).\nlet seed = 42;\nconst rand = () => {\n  seed = (seed * 1664525 + 1013904223) >>> 0;\n  return seed / 0x100000000;\n};\nconst 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};\nconst clip = (v, lo, hi) => Math.min(hi, Math.max(lo, v));\n\nconst TEMP_MIN = 5, TEMP_MAX = 35; // deg C\nconst HUM_MIN = 20, HUM_MAX = 95; // %\nconst ALT_MIN = 0, ALT_MAX = 2000; // m\n\nconst N = 160;\nconst readings = [];\nfor (let i = 0; i < N; i++) {\n  const temperature = clip(gaussian(20, 7), TEMP_MIN, TEMP_MAX);\n  const humidity = clip(75 - 0.9 * (temperature - 20) + gaussian(0, 9), HUM_MIN, HUM_MAX);\n  const altitude = clip(rand() * ALT_MAX, ALT_MIN, ALT_MAX);\n  const pm25 = clip(52 - altitude * 0.021 + (temperature - 20) * 0.35 + gaussian(0, 5), 2, 80);\n  readings.push({ temperature, humidity, altitude, pm25 });\n}\nconst PM_MIN = Math.min(...readings.map((r) => r.pm25));\nconst PM_MAX = Math.max(...readings.map((r) => r.pm25));\n\n// --- Isometric 3D -> 2D projection ------------------------------------------\n// x = temperature (floor axis), z = humidity (floor axis), y = altitude (vertical).\n// Chart.js has no native 3D scale, so the cube is projected with a standard\n// 30-degree isometric transform and rendered on Chart.js's own linear x/y axes.\n// WORLD_SCALE is enlarged (10 -> 13) so the fixed-pixel chrome (bubble radii,\n// axis overshoot, label offsets) consumes a smaller share of the canvas,\n// raising the cube's effective fill of the 3200x1800 render.\nconst WORLD_SCALE = 13;\nconst AXIS_OVERSHOOT = WORLD_SCALE + 1;\nconst COS30 = Math.cos(Math.PI / 6);\nconst SIN30 = Math.sin(Math.PI / 6);\nconst toWorld = (r) => ({\n  wx: ((r.temperature - TEMP_MIN) / (TEMP_MAX - TEMP_MIN)) * WORLD_SCALE,\n  wy: (r.altitude / ALT_MAX) * WORLD_SCALE,\n  wz: ((r.humidity - HUM_MIN) / (HUM_MAX - HUM_MIN)) * WORLD_SCALE,\n});\nconst project = (wx, wy, wz) => ({ x: (wx - wz) * COS30, y: wy - (wx + wz) * SIN30 });\n\n// --- Imprint sequential colormap for the 4th variable (PM2.5) --------------\nconst hexToRgb = (hex) => [\n  parseInt(hex.slice(1, 3), 16),\n  parseInt(hex.slice(3, 5), 16),\n  parseInt(hex.slice(5, 7), 16),\n];\nconst lerpColor = (hexA, hexB, frac, alpha = 1) => {\n  const a = hexToRgb(hexA);\n  const b = hexToRgb(hexB);\n  const mix = a.map((c, i) => Math.round(c + (b[i] - c) * frac));\n  return `rgba(${mix[0]}, ${mix[1]}, ${mix[2]}, ${alpha})`;\n};\n\n// --- Floor grid (subtle depth cue on the temperature/humidity plane) -------\nconst GRID_DIVS = [0, WORLD_SCALE * 0.25, WORLD_SCALE * 0.5, WORLD_SCALE * 0.75, WORLD_SCALE];\nconst gridDatasets = GRID_DIVS.flatMap((v) => [\n  { type: \"line\", data: [project(0, 0, v), project(WORLD_SCALE, 0, v)], borderColor: t.grid, borderWidth: 1, pointRadius: 0, fill: false, tension: 0 },\n  { type: \"line\", data: [project(v, 0, 0), project(v, 0, WORLD_SCALE)], borderColor: t.grid, borderWidth: 1, pointRadius: 0, fill: false, tension: 0 },\n]);\n\n// --- Axis spines, overshoot beyond the data range to leave room for labels -\nconst axisDatasets = [\n  { type: \"line\", data: [project(0, 0, 0), project(AXIS_OVERSHOOT, 0, 0)], borderColor: t.inkSoft, borderWidth: 2, pointRadius: 0, fill: false, tension: 0 },\n  { type: \"line\", data: [project(0, 0, 0), project(0, 0, AXIS_OVERSHOOT)], borderColor: t.inkSoft, borderWidth: 2, pointRadius: 0, fill: false, tension: 0 },\n  { type: \"line\", data: [project(0, 0, 0), project(0, AXIS_OVERSHOOT, 0)], borderColor: t.inkSoft, borderWidth: 2, pointRadius: 0, fill: false, tension: 0 },\n];\n\n// --- Data points, painter's-algorithm sorted back-to-front ------------------\n// Alpha (~0.8) on the bubble fill reduces overplotting in the densest cluster\n// while keeping the border for separation between overlapping bubbles.\nconst BUBBLE_ALPHA = 0.8;\nconst points = readings\n  .map((r) => {\n    const { wx, wy, wz } = toWorld(r);\n    const { x, y } = project(wx, wy, wz);\n    const frac = (r.pm25 - PM_MIN) / (PM_MAX - PM_MIN);\n    return { x, y, r: 4 + frac * 10, depth: wx + wz, color: lerpColor(t.seq[0], t.seq[1], frac, BUBBLE_ALPHA), raw: r };\n  })\n  .sort((a, b) => a.depth - b.depth);\n\nconst pointsDataset = {\n  type: \"bubble\",\n  label: \"Sensor readings\",\n  data: points,\n  backgroundColor: points.map((p) => p.color),\n  borderColor: t.pageBg,\n  borderWidth: 1,\n};\n\n// --- Custom plugins (canvas-native, no external libraries) -----------------\nconst axisLabelPlugin = {\n  id: \"axisLabels3d\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const toPx = (wx, wy, wz) => {\n      const p = project(wx, wy, wz);\n      return { px: scales.x.getPixelForValue(p.x), py: scales.y.getPixelForValue(p.y) };\n    };\n    const label = (main, sub, x, y, align) => {\n      ctx.textAlign = align;\n      ctx.font = \"600 15px sans-serif\";\n      ctx.fillStyle = t.ink;\n      ctx.fillText(main, x, y);\n      ctx.font = \"12px sans-serif\";\n      ctx.fillStyle = t.inkSoft;\n      ctx.fillText(sub, x, y + 18);\n    };\n\n    ctx.save();\n    ctx.textBaseline = \"middle\";\n\n    // Extra pixel clearance (vs. the other two labels) because PM2.5 -- and\n    // therefore bubble size -- rises with temperature, so the largest bubbles\n    // sit closest to this edge of the cube.\n    const tempTip = toPx(AXIS_OVERSHOOT, 0, 0);\n    label(\"Temperature (°C)\", `${TEMP_MIN}–${TEMP_MAX}`, tempTip.px + 34, tempTip.py, \"left\");\n\n    const humTip = toPx(0, 0, AXIS_OVERSHOOT);\n    label(\"Humidity (%)\", `${HUM_MIN}–${HUM_MAX}`, humTip.px - 10, humTip.py, \"right\");\n\n    const altTip = toPx(0, AXIS_OVERSHOOT, 0);\n    label(\"Altitude (m)\", `${ALT_MIN}–${ALT_MAX}`, altTip.px, altTip.py - 32, \"center\");\n\n    ctx.restore();\n  },\n};\n\nconst colorbarPlugin = {\n  id: \"colorbar\",\n  afterDraw(chart) {\n    const { ctx, chartArea } = chart;\n    const barWidth = 16;\n    const barX = chartArea.right + 44;\n    const barTop = chartArea.top + 6;\n    const barHeight = chartArea.height - 12;\n\n    ctx.save();\n    const gradient = ctx.createLinearGradient(0, barTop, 0, barTop + barHeight);\n    gradient.addColorStop(0, t.seq[1]);\n    gradient.addColorStop(1, t.seq[0]);\n    ctx.fillStyle = gradient;\n    ctx.fillRect(barX, barTop, barWidth, barHeight);\n    ctx.strokeStyle = t.grid;\n    ctx.strokeRect(barX, barTop, barWidth, barHeight);\n\n    ctx.fillStyle = t.inkSoft;\n    ctx.font = \"12px sans-serif\";\n    ctx.textAlign = \"left\";\n    ctx.textBaseline = \"middle\";\n    ctx.fillText(`${Math.round(PM_MAX)}`, barX + barWidth + 6, barTop);\n    ctx.fillText(`${Math.round(PM_MIN)}`, barX + barWidth + 6, barTop + barHeight);\n\n    ctx.translate(barX + barWidth + 48, barTop + barHeight / 2);\n    ctx.rotate(-Math.PI / 2);\n    ctx.textAlign = \"center\";\n    ctx.fillStyle = t.ink;\n    ctx.font = \"600 13px sans-serif\";\n    ctx.fillText(\"PM2.5 (µg/m³)\", 0, 0);\n    ctx.restore();\n  },\n};\n\n// --- Mount + chart -----------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\nconst TITLE = \"scatter-3d · javascript · chartjs · anyplot.ai\";\nconst TITLE_FONT = TITLE.length > 67 ? Math.max(15, Math.round(22 * (67 / TITLE.length))) : 22;\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [...gridDatasets, ...axisDatasets, pointsDataset] },\n  plugins: [axisLabelPlugin, colorbarPlugin],\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 10, right: 160, bottom: 24, left: 130 } },\n    interaction: { mode: \"nearest\", intersect: true },\n    scales: {\n      x: { display: false, grid: { display: false } },\n      y: { display: false, grid: { display: false } },\n    },\n    plugins: {\n      title: { display: true, text: TITLE, color: t.ink, font: { size: TITLE_FONT, weight: \"500\" } },\n      legend: { display: false },\n      tooltip: {\n        filter: (item) => item.dataset.type === \"bubble\",\n        callbacks: {\n          title: () => \"\",\n          label: (ctx) => {\n            const raw = ctx.raw.raw;\n            return [\n              `Temperature: ${raw.temperature.toFixed(1)} °C`,\n              `Humidity: ${raw.humidity.toFixed(0)} %`,\n              `Altitude: ${raw.altitude.toFixed(0)} m`,\n              `PM2.5: ${raw.pm25.toFixed(1)} µg/m³`,\n            ];\n          },\n        },\n      },\n    },\n  },\n});\n"}