Vibe coding a bell curve visualization using Claude and ChatGPT

dataviz
llm
One of my long-term aims has been to be able to easily create animated and interactive visualizations to include in essays and presentation. Here I try to make an explanatory visualization of properties of random data following a bell curve.
Published

June 18, 2025

One of my long-term aims has been to be able to easily create animated and interactive visualizations to include in essays and presentation. I have a specific visualization I want to create for a talk I am doing on Friday. I want to be able to create an animation showing the following:

I wrote a bit about how I am inspired by Stephen Wolfram’s ability to write essays including mathematical visualizations here: Q3 2024 quarterly reflection.
  1. A bell curve (perhaps created with dots representing people)
  2. The bell curve splitting into two slightly on the x-axis.
  3. The right-side bell curve becoming slightly shorter and wider.
  4. Showing a line on the x-axis to the far right and highlighting the areas to the right side of the curves, demonstrating that there are many more values in the right-shifted and flattened curve.

To start, I chatted with ChatGPT 4o a bit to get it to write a better brief for what I want. This is what it came up with:

Animation Brief: Bell Curve Demonstration Using Dots

General Concept This is a multi-stage animation that uses approximately 1,000 dots to represent individuals distributed along a bell curve. The animation will visually demonstrate how a small shift in the average (mean) and a difference in variability (standard deviation) can result in a large difference at the extremes. There should be no text or labels on the animation – it should be entirely visual and self-explanatory with verbal narration.

Stage-by-Stage Instructions

Stage 1: Single Bell Curve

  • Display ~1,000 dots distributed normally along the x-axis, centered around a mean value (e.g. x = 0).
  • The y-axis represents frequency/density, but should not be explicitly shown – simply stack the dots vertically to form the shape of a bell curve.
  • This represents a mixed population before any subgroup distinction.

Stage 2: Split Into Two Groups

  • Gradually split the single bell curve into two groups of 500 dots each, shifting slightly apart along the x-axis.
  • One group (e.g. Group A) remains centered at x = 0.
  • The other group (Group B) shifts slightly to the right, e.g. centered at x = +0.5 standard deviations.
  • Each group forms its own bell curve using 500 dots.

Stage 3: Increase Variability in One Group

  • Keep Group A’s bell curve shape the same.
  • Gradually flatten and widen Group B’s curve to show greater variability:
    • Increase standard deviation while preserving the total number of dots (still 500).
    • The peak becomes lower and the tails extend further in both directions.

Stage 4: Highlight the Extremes

  • Draw a vertical threshold line far on the right side of the x-axis (e.g. at +2.5 or +3 SD).
  • Highlight the dots to the right of this threshold line in both groups:
    • Use a visual emphasis like changing their color, brightness, or opacity.
  • Make it visually clear that many more dots from Group B fall beyond the threshold than from Group A, due to both:
    • Group B’s higher mean.
    • Group B’s wider spread.

Technical/Visual Notes

  • Animation should feel smooth and intuitive.
  • Dots may appear semi-randomized (not perfectly symmetrical) to evoke natural variation.
  • Colors or shapes may differentiate groups after the split, but not before.
  • Viewer should clearly see: 1 A unified population. 2 A small average difference. 3 A variance difference. 4 A large difference in extreme values.

I was originally considering using various libraries to do this (including Manim), but now I think it makes most sense to just use D3 (a wonderful Javascript data visualization library), especially since D3 is one of the libraries mentioned in the Claude system prompt.

I think I will try three ways of vibe-coding this:

Here is the initial prompt I will use (modified from the above to specify D3 and make it better as a prompt):


## D3.js Bell Curve Animation

Write a D3.js animation using SVG to demonstrate how small differences in average and variability affect the extremes of two distributions.

### Canvas and Setup:
* Use an 1920 x 1080 SVG canvas.
* Use D3.js (v6 or later).

### Data Generation:
* Generate 1,000 data points from a standard normal distribution (mean = 0, standard deviation = 1).
* Represent each point as a circle with radius 3px.
* Use stacking to arrange points vertically so they form a bell curve (denser in the center).

### Animation Phases:
Implement these four sequential animation phases, each with a 2-second transition:

Phase 1: Single Bell Curve
* Display all 1,000 dots forming a standard normal bell curve centered at x = 0.
* Use a grey color for all dots.

Phase 2: Split into Two Groups
* Split the 1,000 dots into two equal groups of 500.
* Group A remains centered at mean = 0.
* Group B shifts to mean = +0.5.
* Both groups retain standard deviation = 1.
* Use blue for Group A and orange for Group B.

Phase 3: Increase Variability in Group B
* Keep Group A unchanged.
* Increase the standard deviation of Group B to 1.5.
* Group B’s curve should appear flatter and wider as a result.
Phase 4: Highlight the Extremes
* Draw a vertical threshold line at x = +2.5 (in SVG coordinates).
* Highlight all dots to the right of this line:
* If they belong to Group A, color them dark blue.
* If they belong to Group B, color them dark orange or red.

Other Notes:

* Do not display text, labels, axes, or gridlines.
* Keep the layout visually clean and centered.
* Use d3.scaleLinear() to map the x-domain of [-4, +4] to the SVG width.
* Stacking (y) can be a simple vertical offset based on how many dots occupy a bin around that x-value.

Ask questions if anything is unclear. 

Let’s see what we get. First Claude Sonnet 4:

Claude Sonnet 4 (extended thinking)

Ok there are clearly things wrong with this. The movement of the points — where they completely change position on the x-axis, is just wrong. I don’t really like the way it’s tried to randomly scatter the points either. And it hasn’t really understood the point about separating into two bell curves. But I guess it is not a bad attempt for a one-shot. The problem is mine, for not having been careful enough with the description of what I want.

Now ChatGPT o4 High:

ChatGPT o4 High (extended thinking)

This is slightly better I guess but has similar issues.

I realized I’ve broken one of my own golden rules about vibe coding — you must break down tasks into small steps, and only do one step at a time. And try to describe the first step so well that the LLM gets it right first time. So let’s start again with a better prompt. I’ll write it myself this time. I think something much simpler than previously:


Use D3 to create an animation with a resolution of 1920 x 1080. This will be a multi-step animation. Here is the first step:

Grey dots with 14 pixel diameter fall from the top of the screen forming stacks at the bottom of the screen 48 pixels apart. The fall randomly but with a higher probability of falling in the center, and so as they accumulate they form a bell curve.

I will start fresh chats so we get the context correct right from the start. Let’s see if they can one-shot this:

Claude 4 Sonnet

This is annoying. It seems that Anthropic is having issues, and it is failing to execute my prompt. So I will continue with ChatGPT:

ChatGPT o4 High (extended thinking)

After 10 iterations and a few code tweaks by myself, I got very nearly what I wanted. There was one small and slightly weird problem — the rightmost orange dots here not stacking correctly:

I tried multiple times to get ChatGPT o4 High to fix this, including pointing out where I thought the problem lay, but it failed every time. I’ve found that often if an LLM failed at something, it won’t be able to fix it in the same context (this is partly due to LLMs polluting their own context with incorrect stuff I think). So I pasted the code into a new thread—this time with ChatGPT o3—described the problem and left it to fix the issue. But it didn’t suceeed. So I gave up.

Here’s the result:

Here’s the code:


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Bell Curve Dot Fall Animation</title>
  <script src="https://d3js.org/d3.v7.min.js"></script>
  <style>
    body { margin: 0; overflow: hidden; }
    svg { display: block; }
  </style>
</head>
<body>
<script>
  const width = 1920;
  const height = 1080;
  const spacing = 48;
  const diameter = 14;
  const radius = diameter / 2;
  const buckets = Math.floor(width / spacing);

  const svg = d3.select("body").append("svg")
    .attr("width", width)
    .attr("height", height);

  // Box-Muller transform for a normal distribution
  function sampleNormal(mean, sd) {
    let u = 0, v = 0;
    while (u === 0) u = Math.random();
    while (v === 0) v = Math.random();
    return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v) * sd + mean;
  }

  // Drop a single dot
  function dropOne() {
    let x = sampleNormal(width / 2, width / 6);
    x = Math.max(0, Math.min(width - 1e-6, x));
    const idx = Math.min(Math.max(Math.floor(x / spacing), 0), buckets - 1);
    stackCounts[idx]++;

    const cx = idx * spacing + spacing / 2;
    const finalY = height - stackCounts[idx] * diameter + radius;

    svg.append("circle")
      .attr("cx", cx)
      .attr("cy", -radius)
      .attr("r", radius)
      .attr("fill", "grey")
      .transition()
      .duration(400)
      .ease(d3.easeCubicOut)
      .attr("cy", finalY);
  }

  // Initialize drop
  let stackCounts = new Array(buckets).fill(0);
  let dropCount = 0;
  const maxDrops = 1000;
  let droppingComplete = false;

  const timer = d3.interval(() => {
    if (dropCount >= maxDrops) {
      timer.stop();
      droppingComplete = true;
      console.log('Dropping complete: awaiting first right-arrow press');
    } else {
      dropOne();
      dropCount++;
    }
  }, 20);

  // First key: recolor
  function onFirstKey(event) {
    if (event.code === 'ArrowRight' && droppingComplete) {
      event.preventDefault();
      console.log('First ArrowRight: recoloring');
      svg.selectAll('circle').each(function() {
        const circle = d3.select(this);
        const cx = +circle.attr('cx');
        const normalized = cx / width;
        const pPurple = 1 - normalized;
        const color = Math.random() < pPurple ? 'purple' : 'orange';
        circle.attr('data-color', color)
              .transition()
              .duration(1000)
              .attr('fill', color);
      });
      window.removeEventListener('keydown', onFirstKey);
      window.addEventListener('keydown', onSecondKey);
      console.log('Awaiting second right-arrow press for restacking');
    }
  }

  // Second key: restack
  function onSecondKey(event) {
    if (event.code === 'ArrowRight') {
      event.preventDefault();
      console.log('Second ArrowRight: restacking dots');

      // Orange dots
      console.log('Restacking orange dots');
      const orangeNodes = svg.selectAll('circle')
        .filter(function() { return d3.select(this).attr('data-color') === 'orange'; })
        .nodes();
      const orangeGroups = {};
      orangeNodes.forEach(node => {
        const circle = d3.select(node);
        let newCx = +circle.attr('cx') + 24;
        newCx = Math.min(Math.max(newCx, spacing/2), width - spacing/2);
        circle.attr('_newCx', newCx);
        const idx = Math.floor(newCx / spacing);
        (orangeGroups[idx] = orangeGroups[idx] || []).push(node);
      });
      Object.values(orangeGroups).forEach(nodes => {
        nodes.sort((a, b) => +d3.select(b).attr('cy') - +d3.select(a).attr('cy'));
        nodes.forEach((node, i) => {
          const circle = d3.select(node);
          const newY = height - (i + 1) * diameter + radius;
          circle.transition()
                .duration(400)
                .ease(d3.easeCubicOut)
                .attr('cx', +circle.attr('_newCx'))
                .attr('cy', newY);
        });
      });

      // Purple dots
      console.log('Restacking purple dots');
      const purpleNodes = svg.selectAll('circle')
        .filter(function() { return d3.select(this).attr('data-color') === 'purple'; })
        .nodes();
      const purpleGroups = {};
      purpleNodes.forEach(node => {
        const circle = d3.select(node);
        const cx = +circle.attr('cx');
        const idx = Math.floor(cx / spacing);
        (purpleGroups[idx] = purpleGroups[idx] || []).push(node);
      });
      Object.values(purpleGroups).forEach(nodes => {
        nodes.sort((a, b) => +d3.select(b).attr('cy') - +d3.select(a).attr('cy'));
        nodes.forEach((node, i) => {
          const circle = d3.select(node);
          const newY = height - (i + 1) * diameter + radius;
          circle.transition()
                .duration(400)
                .ease(d3.easeCubicOut)
                .attr('cy', newY);
        });
      });

      window.removeEventListener('keydown', onSecondKey);
      window.addEventListener('keydown', onThirdKey);
      console.log('Awaiting third right-arrow press to draw the red line');
    }
  }

  // Third key: draw red threshold line
  function onThirdKey(event) {
    if (event.code === 'ArrowRight') {
      event.preventDefault();
      console.log('Third ArrowRight: drawing red line');
      const base = width / 2 + (width / 6) * 1.645;
      const thresholdX = Math.min(Math.max(base + 110, spacing/2), width - spacing/2);
      svg.append('line')
        .attr('x1', thresholdX)
        .attr('x2', thresholdX)
        .attr('y1', 0)
        .attr('y2', height)
        .attr('stroke', 'red')
        .attr('stroke-width', 2);
      window.removeEventListener('keydown', onThirdKey);
    }
  }

  window.addEventListener('keydown', onFirstKey);
</script>
</body>
</html>