3D Scatter Plot

ScatterPlot3D uses instanced Three.js/Threlte points and supports multiple series, color_values, and optional surfaces. Legend clicks update hidden_series, an array of series IDs (or indices for series without IDs). Bind it to control visibility; input series stay unchanged. Per-series visible provides the initial state when hidden_series is omitted.

Axis ranges accept independently automatic bounds: { range: [0, null] } fixes the minimum while the maximum follows the data. Clear a range input to restore automatic sizing; its placeholder shows the computed bound. “Restore axes to initial values” restores the original labels and ranges, including authored limits. Standalone ScatterPlot3DControls takes auto_ranges={{ x, y, z }}; the low-level ScatterPlot3DScene takes final ranges in the same shape.

Basic 3D Scatter Plot

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Generate helical data
  const n_points = 100
  const helix_1 = {
    x: Array.from({ length: n_points }, (_, idx) => Math.cos(idx * 0.2)),
    y: Array.from({ length: n_points }, (_, idx) => idx * 0.1),
    z: Array.from({ length: n_points }, (_, idx) => Math.sin(idx * 0.2)),
    point_style: { fill: `steelblue` },
    label: `Helix 1`,
  }

  const helix_2 = {
    x: Array.from({ length: n_points }, (_, idx) => Math.cos(idx * 0.2 + Math.PI)),
    y: Array.from({ length: n_points }, (_, idx) => idx * 0.1),
    z: Array.from({ length: n_points }, (_, idx) => Math.sin(idx * 0.2 + Math.PI)),
    point_style: { fill: `orangered` },
    label: `Helix 2`,
  }
</script>

<ScatterPlot3D
  series={[helix_1, helix_2]}
  x_axis={{ label: `X` }}
  y_axis={{ label: `Height` }}
  z_axis={{ label: `Z` }}
  style="height: 450px"
/>

Color-Coded Points

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Generate spherical shell of points with color based on z-coordinate
  const n_points = 500
  const sphere_data = {
    x: [],
    y: [],
    z: [],
    color_values: [],
  }

  for (let idx = 0; idx < n_points; idx++) {
    // Random point on unit sphere
    const theta = Math.random() * Math.PI * 2
    const phi = Math.acos(2 * Math.random() - 1)
    const radius = 0.8 + Math.random() * 0.4 // Slight thickness

    const x_val = radius * Math.sin(phi) * Math.cos(theta)
    const y_val = radius * Math.sin(phi) * Math.sin(theta)
    const z_val = radius * Math.cos(phi)

    sphere_data.x.push(x_val)
    sphere_data.y.push(y_val)
    sphere_data.z.push(z_val)
    sphere_data.color_values.push(z_val) // Color by z-coordinate
  }
</script>

<ScatterPlot3D
  series={[{ ...sphere_data, label: `Sphere` }]}
  x_axis={{ label: `X` }}
  y_axis={{ label: `Y` }}
  z_axis={{ label: `Z` }}
  color_scale={{ scheme: `interpolateViridis` }}
  color_bar={{ title: `Z-coordinate` }}
  style="height: 450px"
/>

Grid Surface

Add a surface defined by a z = f(x, y) function. The surface is colored by the z-value by default:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Define a saddle surface: z = x^2 - y^2
  const saddle_surface = {
    type: `grid`,
    x_range: [-2, 2],
    y_range: [-2, 2],
    resolution: 30,
    z_fn: (coord_x, coord_y) => coord_x * coord_x - coord_y * coord_y,
    opacity: 0.8,
    wireframe: true,
    wireframe_color: `#444`,
  }

  // Scatter points on the surface
  const n_points = 50
  const points_on_surface = {
    x: Array.from({ length: n_points }, () => Math.random() * 4 - 2),
    y: Array.from({ length: n_points }, () => Math.random() * 4 - 2),
    z: [],
    color_values: [],
    point_style: { fill: `white`, radius: 6 },
    label: `Sample Points`,
  }

  // Calculate z values using the surface function
  for (let idx = 0; idx < n_points; idx++) {
    const coord_x = points_on_surface.x[idx]
    const coord_y = points_on_surface.y[idx]
    const coord_z = coord_x * coord_x - coord_y * coord_y
    points_on_surface.z.push(coord_z)
    points_on_surface.color_values.push(coord_z)
  }
</script>

<ScatterPlot3D
  series={[points_on_surface]}
  surfaces={[saddle_surface]}
  x_axis={{ label: `X` }}
  y_axis={{ label: `Y` }}
  z_axis={{ label: `Z = X² - Y²` }}
  color_scale={{ scheme: `interpolateCool` }}
  color_bar={{ title: `Height` }}
  style="height: 500px"
/>

Parametric Surface

A torus built from parametric equations:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  const [major_radius, minor_radius] = [0.4, 0.15]

  const torus_surface = {
    // Parametric torus surface
    type: `parametric`,
    u_range: [0, Math.PI * 2],
    v_range: [0, Math.PI * 2],
    resolution: [40, 20],
    parametric_fn: (param_u, value) => ({
      x: (major_radius + minor_radius * Math.cos(value)) * Math.cos(param_u),
      y: (major_radius + minor_radius * Math.cos(value)) * Math.sin(param_u),
      z: minor_radius * Math.sin(value),
    }),
    color_fn: (coord_x, coord_y, coord_z) => {
      // Color by angle around the tube
      const hue =
        (Math.atan2(coord_z, Math.sqrt(coord_x * coord_x + coord_y * coord_y) - major_radius) +
          Math.PI) /
        (2 * Math.PI)
      return `hsl(${hue * 360}, 70%, 50%)`
    },
    opacity: 0.85,
  }
</script>

<ScatterPlot3D
  surfaces={[torus_surface]}
  x_axis={{ label: `X` }}
  y_axis={{ label: `Y` }}
  z_axis={{ label: `Z` }}
  style="height: 500px"
  legend={null}
/>

Lines with Markers

Display 3D trajectories as connected lines with markers at each data point. Each series can have its own color and style:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Generate Lissajous curves - parametric 3D curves
  const n_points = 60

  const curve_1 = {
    x: Array.from({ length: n_points }, (_, idx) => {
      const angle = (idx / n_points) * Math.PI * 2
      return Math.sin(3 * angle)
    }),
    y: Array.from({ length: n_points }, (_, idx) => {
      const angle = (idx / n_points) * Math.PI * 2
      return Math.sin(4 * angle)
    }),
    z: Array.from({ length: n_points }, (_, idx) => {
      const angle = (idx / n_points) * Math.PI * 2
      return Math.sin(5 * angle)
    }),
    point_style: { fill: `#e74c3c`, radius: 4 },
    line_style: { stroke: `#e74c3c`, stroke_width: 3 },
    label: `Lissajous (3:4:5)`,
  }

  const curve_2 = {
    x: Array.from({ length: n_points }, (_, idx) => {
      const angle = (idx / n_points) * Math.PI * 2
      return Math.sin(2 * angle + Math.PI / 4)
    }),
    y: Array.from({ length: n_points }, (_, idx) => {
      const angle = (idx / n_points) * Math.PI * 2
      return Math.sin(3 * angle)
    }),
    z: Array.from({ length: n_points }, (_, idx) => {
      const angle = (idx / n_points) * Math.PI * 2
      return Math.cos(2 * angle)
    }),
    point_style: { fill: `#3498db`, radius: 4 },
    line_style: { stroke: `#3498db`, stroke_width: 3 },
    label: `Lissajous (2:3:2)`,
  }

  // Spring/helix trajectory
  const curve_3 = {
    x: Array.from({ length: n_points }, (_, idx) => {
      const angle = (idx / n_points) * Math.PI * 4
      return 0.7 * Math.cos(angle)
    }),
    y: Array.from({ length: n_points }, (_, idx) => {
      const angle = (idx / n_points) * Math.PI * 4
      return 0.7 * Math.sin(angle)
    }),
    z: Array.from({ length: n_points }, (_, idx) => {
      return (idx / n_points) * 2 - 1
    }),
    point_style: { fill: `#2ecc71`, radius: 3 },
    line_style: { stroke: `#2ecc71`, stroke_width: 3, line_dash: `3 2` },
    label: `Helix`,
  }
</script>

<ScatterPlot3D
  series={[curve_1, curve_2, curve_3]}
  x_axis={{ label: `X` }}
  y_axis={{ label: `Y` }}
  z_axis={{ label: `Z` }}
  style="height: 500px"
/>

Size-Scaled Points

Points can be sized based on data values using the size_scale prop:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Generate random 3D data with varying sizes
  const n_points = 200
  const random_data = {
    x: Array.from({ length: n_points }, () => Math.random() * 10 - 5),
    y: Array.from({ length: n_points }, () => Math.random() * 10 - 5),
    z: Array.from({ length: n_points }, () => Math.random() * 10 - 5),
    // Size based on distance from origin
    size_values: [],
    color_values: [],
  }

  for (let idx = 0; idx < n_points; idx++) {
    const coord_x = random_data.x[idx]
    const coord_y = random_data.y[idx]
    const coord_z = random_data.z[idx]
    const distance = Math.sqrt(coord_x * coord_x + coord_y * coord_y + coord_z * coord_z)
    random_data.size_values.push(distance)
    random_data.color_values.push(distance)
  }
</script>

<ScatterPlot3D
  series={[{ ...random_data, label: `Random Points` }]}
  x_axis={{ label: `X` }}
  y_axis={{ label: `Y` }}
  z_axis={{ label: `Z` }}
  size_scale={{ radius_range: [0.05, 0.25] }}
  color_scale={{ scheme: `interpolatePlasma` }}
  color_bar={{ title: `Distance from Origin` }}
  style="height: 500px"
/>

Auto-Rotating View

Enable automatic rotation with the auto_rotate prop. Use the controls pane (gear icon) to adjust rotation speed:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Generate a spiral galaxy-like structure
  const n_arms = 3
  const points_per_arm = 200
  const galaxy_data = { x: [], y: [], z: [], color_values: [] }

  for (let arm_idx = 0; arm_idx < n_arms; arm_idx++) {
    const arm_offset = (arm_idx / n_arms) * Math.PI * 2
    for (let point_idx = 0; point_idx < points_per_arm; point_idx++) {
      const fraction = point_idx / points_per_arm
      const radius = fraction * 4 + 0.5
      const angle = fraction * 4 + arm_offset
      const spread = (1 - fraction) * 0.5 // More spread at center

      const coord_x = radius * Math.cos(angle) + (Math.random() - 0.5) * spread
      const coord_y = radius * Math.sin(angle) + (Math.random() - 0.5) * spread
      const coord_z = (Math.random() - 0.5) * spread * 0.5 // Thin disk

      galaxy_data.x.push(coord_x)
      galaxy_data.y.push(coord_y)
      galaxy_data.z.push(coord_z)
      galaxy_data.color_values.push(fraction) // Color by distance from center
    }
  }

  let auto_rotate = $state(1)
</script>

<label style="display: block; margin-bottom: 1em">
  Rotation Speed: {auto_rotate.toFixed(1)}
  <input type="range" min="0" max="5" step="0.1" bind:value={auto_rotate} />
</label>

<ScatterPlot3D
  series={[{ ...galaxy_data, label: `Galaxy` }]}
  x_axis={{ label: `X` }}
  y_axis={{ label: `Y` }}
  z_axis={{ label: `Z` }}
  color_scale={{ scheme: `interpolateYlOrRd` }}
  {auto_rotate}
  camera_position={[0, 8, 4]}
  style="height: 500px"
  legend={null}
/>

Multiple Surfaces

Combine multiple surfaces in the same plot:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Paraboloid surface
  const paraboloid = {
    type: `grid`,
    x_range: [-1, 1],
    y_range: [-1, 1],
    resolution: 25,
    z_fn: (coord_x, coord_y) => coord_x * coord_x + coord_y * coord_y - 0.5,
    color: `#3498db`,
    opacity: 0.6,
  }

  // Plane cutting through
  const plane = {
    type: `grid`,
    x_range: [-1, 1],
    y_range: [-1, 1],
    resolution: 5,
    z_fn: () => 0.25,
    color: `#e74c3c`,
    opacity: 0.5,
    wireframe: true,
    wireframe_color: `#c0392b`,
  }
</script>

<ScatterPlot3D
  surfaces={[paraboloid, plane]}
  x_axis={{ label: `X` }}
  y_axis={{ label: `Y` }}
  z_axis={{ label: `Z` }}
  camera_position={[4, 3, 3]}
  style="height: 500px"
  legend={null}
/>

Performance with Many Points

The component uses instanced rendering with per-instance colors for efficient handling of large datasets:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Generate many random points
  const n_points = 3_000
  const large_dataset = {
    x: Array.from({ length: n_points }, () => (Math.random() - 0.5) * 10),
    y: Array.from({ length: n_points }, () => (Math.random() - 0.5) * 10),
    z: Array.from({ length: n_points }, () => (Math.random() - 0.5) * 10),
    color_values: Array.from({ length: n_points }, () => Math.random()),
    point_style: { radius: 3 },
    label: `${n_points.toLocaleString()} Points`,
  }
</script>

<p style="margin-bottom: 0.5em">
  Rendering {n_points.toLocaleString()} points with per-instance colors
</p>

<ScatterPlot3D
  series={[large_dataset]}
  x_axis={{ label: `X` }}
  y_axis={{ label: `Y` }}
  z_axis={{ label: `Z` }}
  color_scale={{ scheme: `interpolateRainbow` }}
  sphere_segments={12}
  style="height: 500px"
/>

3D Reference Lines

Add reference lines in 3D space to highlight axes, thresholds, or specific values. Lines can be parallel to any axis or defined as segments between points:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Generate random 3D data
  const n_points = 100
  const scatter_data = {
    x: Array.from({ length: n_points }, () => Math.random() * 4 - 2),
    y: Array.from({ length: n_points }, () => Math.random() * 4 - 2),
    z: Array.from({ length: n_points }, () => Math.random() * 4 - 2),
    color_values: Array.from({ length: n_points }, (_, idx) => idx / n_points),
    point_style: { radius: 4 },
    label: `Data Points`,
  }

  // Reference lines parallel to axes
  const ref_lines = [
    // Line parallel to X-axis at y=0, z=0 (the X-axis itself)
    {
      type: `x-axis`,
      y: 0,
      z: 0,
      label: `X-axis`,
      style: { color: `#e74c3c`, width: 3 },
    },
    // Line parallel to Y-axis at x=0, z=0 (the Y-axis itself)
    {
      type: `y-axis`,
      x: 0,
      z: 0,
      label: `Y-axis`,
      style: { color: `#2ecc71`, width: 3 },
    },
    // Line parallel to Z-axis at x=0, y=0 (the Z-axis itself)
    {
      type: `z-axis`,
      x: 0,
      y: 0,
      label: `Z-axis`,
      style: { color: `#3498db`, width: 3 },
    },
    // Threshold line parallel to X-axis
    {
      type: `x-axis`,
      y: 1.5,
      z: 1.5,
      label: `Threshold`,
      style: { color: `#f39c12`, width: 2, dash: `4 2` },
    },
    // Segment between two points
    {
      type: `segment`,
      p1: [-2, -2, -2],
      p2: [2, 2, 2],
      label: `Diagonal`,
      style: { color: `#9b59b6`, width: 2 },
    },
  ]
</script>

<ScatterPlot3D
  series={[scatter_data]}
  {ref_lines}
  x_axis={{ label: `X`, range: [-2.5, 2.5] }}
  y_axis={{ label: `Y`, range: [-2.5, 2.5] }}
  z_axis={{ label: `Z`, range: [-2.5, 2.5] }}
  color_scale={{ scheme: `interpolateViridis` }}
  style="height: 500px"
/>

3D Reference Planes

Add reference planes in 3D space. Planes can be aligned to axis pairs (XY, XZ, YZ), defined by a normal vector and point, or through three points:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Generate data clustered above and below a plane
  const n_points = 80
  const above_plane = {
    x: Array.from({ length: n_points / 2 }, () => Math.random() * 3 - 1.5),
    y: Array.from({ length: n_points / 2 }, () => Math.random() * 3 - 1.5),
    z: Array.from({ length: n_points / 2 }, () => 0.3 + Math.random() * 1.5),
    point_style: { fill: `#2ecc71`, radius: 5 },
    label: `Class A (above)`,
  }

  const below_plane = {
    x: Array.from({ length: n_points / 2 }, () => Math.random() * 3 - 1.5),
    y: Array.from({ length: n_points / 2 }, () => Math.random() * 3 - 1.5),
    z: Array.from({ length: n_points / 2 }, () => -0.3 - Math.random() * 1.5),
    point_style: { fill: `#e74c3c`, radius: 5 },
    label: `Class B (below)`,
  }

  // Reference planes
  const ref_planes = [
    // XY plane at z=0 (decision boundary)
    {
      type: `xy`,
      z: 0,
      label: `Decision Boundary`,
      style: {
        color: `#3498db`,
        opacity: 0.3,
        wireframe: true,
        wireframe_color: `#2980b9`,
      },
    },
    // YZ plane at x=0 (vertical slice)
    {
      type: `yz`,
      x: 0,
      label: `YZ Slice`,
      style: {
        color: `#f39c12`,
        opacity: 0.2,
      },
    },
  ]
</script>

<ScatterPlot3D
  series={[above_plane, below_plane]}
  {ref_planes}
  x_axis={{ label: `Feature 1`, range: [-2, 2] }}
  y_axis={{ label: `Feature 2`, range: [-2, 2] }}
  z_axis={{ label: `Feature 3`, range: [-2, 2] }}
  camera_position={[5, 4, 3]}
  style="height: 500px"
/>

Combining Lines, Planes, and Surfaces

Create complex 3D visualizations by combining reference lines, planes, and surfaces:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Generate points on a paraboloid z = x² + y²
  const n_points = 60
  const paraboloid_points = {
    x: [],
    y: [],
    z: [],
    color_values: [],
    point_style: { radius: 4 },
    label: `z = x² + y²`,
  }

  for (let idx = 0; idx < n_points; idx++) {
    const theta = Math.random() * 2 * Math.PI
    const radius = Math.sqrt(Math.random()) * 1.5
    const coord_x = radius * Math.cos(theta)
    const coord_y = radius * Math.sin(theta)
    const coord_z = coord_x * coord_x + coord_y * coord_y
    paraboloid_points.x.push(coord_x)
    paraboloid_points.y.push(coord_y)
    paraboloid_points.z.push(coord_z)
    paraboloid_points.color_values.push(coord_z)
  }

  // Surface definition
  const paraboloid_surface = {
    type: `grid`,
    x_range: [-1.5, 1.5],
    y_range: [-1.5, 1.5],
    resolution: 25,
    z_fn: (coord_x, coord_y) => coord_x * coord_x + coord_y * coord_y,
    opacity: 0.5,
    wireframe: true,
    wireframe_color: `#666`,
  }

  // Reference lines showing axis intercepts and key values
  const ref_lines = [
    // Vertical line at origin
    {
      type: `z-axis`,
      x: 0,
      y: 0,
      label: `Z-axis`,
      style: { color: `#e74c3c`, width: 3 },
    },
    // Circle at z = 1 (projected down)
    {
      type: `segment`,
      p1: [1, 0, 1],
      p2: [0, 1, 1],
      style: { color: `#f39c12`, width: 2, dash: `4 2` },
    },
    {
      type: `segment`,
      p1: [0, 1, 1],
      p2: [-1, 0, 1],
      style: { color: `#f39c12`, width: 2, dash: `4 2` },
    },
    {
      type: `segment`,
      p1: [-1, 0, 1],
      p2: [0, -1, 1],
      style: { color: `#f39c12`, width: 2, dash: `4 2` },
    },
    {
      type: `segment`,
      p1: [0, -1, 1],
      p2: [1, 0, 1],
      style: { color: `#f39c12`, width: 2, dash: `4 2` },
    },
  ]

  // Reference plane at z = 1
  const ref_planes = [
    {
      type: `xy`,
      z: 1,
      label: `z = 1`,
      style: { color: `#2ecc71`, opacity: 0.15 },
    },
  ]
</script>

<ScatterPlot3D
  series={[paraboloid_points]}
  surfaces={[paraboloid_surface]}
  {ref_lines}
  {ref_planes}
  x_axis={{ label: `X` }}
  y_axis={{ label: `Y` }}
  z_axis={{ label: `Z = X² + Y²` }}
  color_scale={{ scheme: `interpolatePlasma` }}
  color_bar={{ title: `Height` }}
  camera_position={[4, 4, 3]}
  style="height: 550px"
/>

Plane Through Three Points

Define a plane by specifying three non-collinear points:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Three points defining a plane
  const point_1 = [1, 0, 0]
  const point = [0, 1, 0]
  const point_3 = [0, 0, 1]

  // Generate random points near the plane
  const n_points = 50
  const plane_normal = { x: 1, y: 1, z: 1 }
  const plane_points = {
    x: [],
    y: [],
    z: [],
    point_style: { fill: `#3498db`, radius: 5 },
    label: `Near Plane`,
  }

  for (let idx = 0; idx < n_points; idx++) {
    const param_1 = Math.random()
    const param_2 = Math.random() * (1 - param_1)
    const param_3 = 1 - param_1 - param_2
    const noise = (Math.random() - 0.5) * 0.3
    plane_points.x.push(
      point_1[0] * param_1 + point[0] * param_2 + point_3[0] * param_3 + noise,
    )
    plane_points.y.push(
      point_1[1] * param_1 + point[1] * param_2 + point_3[1] * param_3 + noise,
    )
    plane_points.z.push(
      point_1[2] * param_1 + point[2] * param_2 + point_3[2] * param_3 + noise,
    )
  }

  // Reference plane through the three points
  const ref_planes = [
    {
      type: `points`,
      p1: point_1,
      p2: point,
      p3: point_3,
      label: `Fitted Plane`,
      style: {
        color: `#9b59b6`,
        opacity: 0.4,
        wireframe: true,
        wireframe_color: `#7d3c98`,
        double_sided: true,
      },
    },
  ]

  // Reference lines from origin to corner points
  const ref_lines = [
    {
      type: `segment`,
      p1: [0, 0, 0],
      p2: point_1,
      style: { color: `#e74c3c`, width: 2 },
      label: `To P1`,
    },
    {
      type: `segment`,
      p1: [0, 0, 0],
      p2: point,
      style: { color: `#2ecc71`, width: 2 },
      label: `To P2`,
    },
    {
      type: `segment`,
      p1: [0, 0, 0],
      p2: point_3,
      style: { color: `#f39c12`, width: 2 },
      label: `To P3`,
    },
    // Triangle edges
    { type: `segment`, p1: point_1, p2: point, style: { color: `#3498db`, width: 3 } },
    { type: `segment`, p1: point, p2: point_3, style: { color: `#3498db`, width: 3 } },
    { type: `segment`, p1: point_3, p2: point_1, style: { color: `#3498db`, width: 3 } },
  ]
</script>

<ScatterPlot3D
  series={[plane_points]}
  {ref_planes}
  {ref_lines}
  x_axis={{ label: `X`, range: [-0.5, 1.5] }}
  y_axis={{ label: `Y`, range: [-0.5, 1.5] }}
  z_axis={{ label: `Z`, range: [-0.5, 1.5] }}
  camera_position={[3, 3, 3]}
  style="height: 500px"
/>

Custom Surface Colors

Surfaces can be colored using a custom color function that receives x, y, z coordinates:

Interactive example

svelte<script lang="ts">
  import { ScatterPlot3D } from 'matterviz'

  // Ripple surface with custom coloring
  const ripple_surface = {
    type: `grid`,
    x_range: [-1, 1],
    y_range: [-1, 1],
    resolution: 40,
    z_fn: (coord_x, coord_y) => {
      const radius = Math.sqrt(coord_x * coord_x + coord_y * coord_y)
      return Math.sin(radius * 4) * Math.exp(-radius * 0.8) * 0.5
    },
    color_fn: (coord_x, coord_y, coord_z) => {
      // Color based on angle and height
      const angle = (Math.atan2(coord_y, coord_x) + Math.PI) / (2 * Math.PI)
      const height = (coord_z + 0.5) / 1
      return `hsl(${angle * 360}, ${50 + height * 50}%, ${40 + height * 30}%)`
    },
    opacity: 0.7,
    double_sided: true,
  }
</script>

<ScatterPlot3D
  surfaces={[ripple_surface]}
  x_axis={{ label: `X` }}
  y_axis={{ label: `Y` }}
  z_axis={{ label: `Z` }}
  camera_position={[4, 3, 3]}
  style="height: 500px"
  legend={null}
/>