nathanupchurch.com/content/blog/build-an-svg-circle-grid-with-p5js/build-an-svg-circle-grid-with-p5js.md
2023-07-05 11:06:31 -05:00

18 KiB

title description date tags synopsis imageURL imageAlt
Build an SVG Circle Grid with p5.js Make a configurable SVG graphic of a grid of circles in random colors and sizes with p5.js. 2023-06-18
Processing
p5.js
Code Tutorial
SVG
In this tutorial, we'll learn how to make a configurable SVG graphic of a grid of circles in random colors and sizes with p5.js. /img/terminal.svg A stylized illustration of a terminal prompt.

Processing is a fantastic language for creative programming and learning how to code, allowing programmers of all skill levels to quickly and simply create complex graphics, data visualizations, and generative art. Its Javascript implementation, p5.js, is perfect for those already familiar with Javascript, or who want to use processing to make graphics for the web without the complexity of SVG, or the insanity of using CSS for complex graphics. Today we're going to build a simple but pretty graphic using P5.

Our goal

First, let's define exactly what we're going to be making. We want to make a grid of circles that:

  1. Fills the viewport
  2. Randomly assigns a size to each circle
  3. Randomly assigns a color to each circle
  4. Is flexible and avoids hardcoded values, allowing us to get a variety of different looks depending on our parameters
  5. Allows us to download our generated image as an SVG

With that nailed down, let's go ahead and get set up.

Setup

Processing has a handy IDE, much like Arduino, that we can use to get started quickly. Processing IDE is available as a Flatpak, so it should be simple to install no matter what distro you're running. If, sadly, you're on Windows or MacOS, I'm sure there is also a simple way to install it on your machine that can be worked out with a quick search on the internet.

Once you've installed and launched the IDE, at the top right you'll notice a dropdown that says "Java." This is the Processing IDE's mode selector. Click the dropdown, and choose "Manage Modes." In the new window, install "p5.js Mode," and switch to p5.js using the mode selector.

Getting started

If you've done everything correctly up to now, you should see two tabs in your IDE: a .js file, and index.html. Our .js file should look like this:

function setup() {

}


function draw() {

}

and our index.html should look like this:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- PLEASE NO CHANGES BELOW THIS LINE (UNTIL I SAY SO) -->
  <script language="javascript" type="text/javascript" src="libraries/p5.min.js"></script>
  <script language="javascript" type="text/javascript" src="sketch_230615e.js"></script>
  <!-- OK, YOU CAN MAKE CHANGES BELOW THIS LINE AGAIN -->

  <style>
    body {
      padding: 0;
      margin: 0;
    }
  </style>
</head>

<body>
</body>
</html>

In any processing sketch, there are two main functions, as you can see by looking at our .js file. setup() will run once when the sketch is loaded, and draw() will loop repeatedly. For our purposes, we don't need draw(), so we'll just leave it empty.

Now that we have our boilerplate, the next thing we need to do is set up the canvas for our sketch. As you may recall, we want our sketch to fill the viewport, so let's set up a canvas inside of our setup() function like so:

function setup() {
  createCanvas(window.innerWidth, window.innerHeight);
  background(0);
  noStroke();
}

Now if we click the play icon at the top left of the Processing IDE, a browser window should open and load a page showing our sketch so far. We ought to see a viewport entirely covered with one large black canvas. The LibreWolf web browser opened to localhost. The viewport is entirely black.

Random results

To meet conditions two and three of our goal, we'll need a way to get random numbers. Let's write a quick function inside setup() to provide us with random integers:

  const getRandomInt = (min, max) => {
    min = Math.ceil(min);
    max = Math.floor(max);
    return Math.floor(Math.random() * (max - min) + min);
  }

Let's make some circles

To make a circle in p5, we need to define three parameters: x position, y position, and its diameter. With that in mind, we can define a circle like so:

circle(10,10,10);

Add one to the bottom of setup() and refresh your browser tab if you'd like to test it out. Be sure and delete it afterwards.

We're going to be making a grid of circles, however, so we are going to need a little more information. First, let's start by generating a line of circles. Although we only need to generate a line of circles on one axis for the purpose of making a grid, a row or a column, for the sake of future flexibility, we'll assume that our line might be horizontal, vertical, or diagonal. To do this, we'll need the following information:

  • Line start position x
  • Line start position y
  • Distance between circles on x
  • Distance between circles on y
  • Minimum circle diameter
  • Maximum circle diameter
  • Quantity of circles
  • The axis upon which our line extends
  • An array of colors for our circles

You'll notice that we have min-max values for circle diameter, and an array for our circle colors. This is so that a size and color can be chosen randomly by our program.

With that out of the way, let's start writing a function to generate our line of circles inside setup():

const generateCircleLine = (startX, startY, distX, distY, minD, maxD, qty, axis, fillArr) => {
}

We are going to need to increment our starting coordinates, so let's assign two variables to those parameters:

let x = startX;
let y = startY;

And we can write our loop inside of generateCircleLine():

for (let i = 0; i < qty; i++) {

  const diameter = getRandomInt(minD-1, maxD);

  if (!i) {
     fill(...fillArr[getRandomInt(0, fillArr.length)]);
     circle(x,y,diameter);

     continue;
  }

  switch (axis) {
    case 'x':
      x = !distX ? x + startX : x + distX;
    break;
    case 'y':
      y = !distY ? y + startY : y + distY;
    break;
    case 'xy':
      x = !distX ? x + startX : x + distX;
      y = !distY ? y + startY : y + distY;
    break;
  }

  fill(...fillArr[getRandomInt(0, fillArr.length)]);
  circle(x,y,diameter);
}

In the loop above:

  • We start by assigning diameter to a random integer between minD and maxD
  • Then, if i is false / 0, indicating that we're on our first iteration, we randomly choose a fill color value from fillArr, draw our first circle, and continue on to our next iteration.
  • Now that we're on iteration 1 / circle 2, we need to figure out what axis or axes our line is on, and calculate the new starting coordinates for the circle about to be drawn. For this, we use a switch statement, so if we are operating on the x asis, only the x starting coordinate will be incremented, et cetera. We have used the ternary operator here to indicate that if a distance is not specified along any given axis, the program should increment by a distance equal to its starting coordinate.
  • As we now have our coordinates, we choose a random fill color from fillArr, draw our next circle, and repeat until i < qty.

That was a lot, so let's look at our code and make sure everything is alright. This is what our code should look like at the moment:

function setup() {
  createCanvas(window.innerWidth, window.innerHeight);
  background(0);
  noStroke();

  const getRandomInt = (min, max) => {
    min = Math.ceil(min);
    max = Math.floor(max);
    return Math.floor(Math.random() * (max - min) + min);
  }

  const generateCircleLine = (startX, startY, distX, distY, minD, maxD, qty, axis, fillArr) => {

    let x = startX;
    let y = startY;

    for (let i = 0; i < qty; i++) {

      const diameter = getRandomInt(minD-1, maxD);

      if (!i) {
         fill(...fillArr[getRandomInt(0, fillArr.length)]);
         circle(x,y,diameter);

         continue;
      }

      switch (axis) {
        case 'x':
          x = !distX ? x + startX : x + distX;
        break;
        case 'y':
          y = !distY ? y + startY : y + distY;
        break;
        case 'xy':
          x = !distX ? x + startX : x + distX;
          y = !distY ? y + startY : y + distY;
        break;
      }

      fill(...fillArr[getRandomInt(0, fillArr.length)]);
      circle(x,y,diameter);
    }
  }
}


function draw() {

}

We can check that everything is working by calling our new function at the bottom of setup():

generateCircleLine(10, 10, 20, 0, 5, 10, 10, 'x', [[255,255,255]]);

The LibreWolf web browser opened to localhost. The viewport is black, with a single row of ten white circles in varying sizes in the top-left corner. And look at that! We have a line!

Repeating rows

As we now have a function built to generate lines of circles, all we need to do is repeat the process and we'll have a grid. In setup(), let's go ahead and create generateCircleGrid() to do just that. Because we'll be passing our parameter values from generateCircleGrid() to generateCircleLine(), we'll need all of the same information, plus a few new parameters:

  const generateCircleGrid = (rowStartX, rowStartY, rowDistX, minD, maxD, rowCircleQty, numRows, rowSpacing, fillArr) => {
}

Inside our new function, all we need to do is call generateCircleLine() in a loop and we'll have ourselves a grid:

for (let i = 0; i < numRows; i++) {
      rowYPosition = !i ? rowStartY : rowStartY + rowSpacing*i;
      generateCircleLine(rowStartX, rowYPosition, rowDistX, 0, minD, maxD, rowCircleQty, 'x', fillArr);
}

In the first line of our for loop, we're checking whether we're in the first iteration (i == 0). If so, rowYPosition is assigned to rowStartY; otherwise, rowYPosition is assigned to rowStartY + rowSpacing*i. That means that on our second+ iterations, our row starting coordinate is incremented by our row spacing value multiplied by the current iteration number, ensuring that each iteration draws a row at the correct y coordinate.

We've hardcoded 'x' in the generateCircleLine() parameter, so that we only have to worry about incrementing one axis as we generate our grid: generateCircleLine() will create columns, and generateCircleGrid() loops them to generate rows. To this end, we're also passing 0 to the distY in generateCircleLine()`.

The perfect fit

So, we can create lines of circles, and use those to make a grid, but we have a problem. Right now, our sketch needs to be explicitly told how many circles to draw in each row. We need a way to determine how many circles ought to be drawn in order to fill the canvas on a given axis.

Fortunately, this is an easy fix. If write a function inside setup() to subtract our starting coordinate from the window dimension for a given axis, divide the result by the distance between circles, and return the result, we should wind up with just the right nimber of circles to fit within that axis. We'll also give our function a parameter called axis. Using this parameter, we'll specify whether the operation should use window.innerWidth to return a result for the x axis, or window.innerHeight to return a result for the y axis.

const getMaxCircles = (distance, axis, startDistance)=> {
  return Math.floor(axis == 'x' ? (window.innerWidth - startDistance) / distance :
  (window.innerHeight - startDistance) / distance);
}

Specifying the grid

We're now all set up to begin actually generating grids, so let's make one! To keep things neat, let's specify our grid in an object called myGrid that contains all the parameters we'll need. Here's what mine looks like:

const myGrid = {
  rowStartX: 20,
  rowStartY: 20,
  rowDistX: 20,
  minDiameter: 5,
  maxDiameter: 15,
  rowSpacing: 20,
  rowCircleQty: function() {return getMaxCircles(this.rowDistX, 'x', this.rowStartX)},
  numRows: function() {return getMaxCircles(this.rowSpacing, 'y', this.rowStartY)},
  fillArray: [
    [100, 50, 255],
    [100, 100, 255],
    [100, 150, 255],
    [100, 200, 255],
    [100, 250, 255]
  ]
}

You may notice a couple of things:

  • I've taken advantage of the getMaxCircles() function we wrote earlier to calculate ideal values for myGrid.rowCircleQty, and myGrid.numRows.
  • myGrid.fillArray is using RGB values. Check the p5.js documentation for other color values you can use.

Feel free to customize myGrid to make your ideal circle grid, and call generateCircleGrid() to see the result:

generateCircleGrid(
  myGrid.rowStartX,
  myGrid.rowStartY,
  myGrid.rowDistX,
  myGrid.minDiameter,
  myGrid.maxDiameter,
  myGrid.rowCircleQty(),
  myGrid.numRows(),
  myGrid.rowSpacing,
  myGrid.fillArray
);

The LibreWolf web browser opened to localhost. The viewport is filled with circles of varying sizes in various shades of blue.

SVG export

Looking back on our goals, we've accomplished all but one: SVG export. Happily, there's a library that makes this trivial. All we need to do is link the script in the <head> of index.html,

<script language="javascript" type="text/javascript" src="https://unpkg.com/p5.js-svg@1.5.1"></script>

add a button in <body>,

<button onclick="save()">Save SVG</button>

style it in <style>,

button {
  position: fixed;
  bottom: 3rem;
  right: 3rem;
  width: 7rem;
  height: 3rem;
}

and make one small tweak in our Javascript file. We simply need to modify our createCanvas() function like so:

createCanvas(window.innerWidth, window.innerHeight, SVG);

Our finished code

Here's what everything should look like at this point:

*.js

function setup() {
  createCanvas(window.innerWidth, window.innerHeight, SVG);
  background(0);
  noStroke();

  const getRandomInt = (min, max) => {
    min = Math.ceil(min);
    max = Math.floor(max);
    return Math.floor(Math.random() * (max - min) + min);
  }

  const getMaxCircles = (distance, axis, startDistance)=> {
    return Math.floor(axis == 'x' ? (window.innerWidth - startDistance) / distance :
    (window.innerHeight - startDistance) / distance);
  }

  const generateCircleLine = (startX, startY, distX, distY, minD, maxD, qty, axis, fillArr) => {

    let x = startX;
    let y = startY;

    for (let i = 0; i < qty; i++) {

      const diameter = getRandomInt(minD-1, maxD);

      if (!i) {
         fill(...fillArr[getRandomInt(0, fillArr.length)]);
         circle(x,y,diameter);

         continue;
      }

      switch (axis) {
        case 'x':
          x = !distX ? x + startX : x + distX;
        break;
        case 'y':
          y = !distY ? y + startY : y + distY;
        break;
        case 'xy':
          x = !distX ? x + startX : x + distX;
          y = !distY ? y + startY : y + distY;
        break;
      }

      fill(...fillArr[getRandomInt(0, fillArr.length)]);
      circle(x,y,diameter);
    }
  }
  const generateCircleGrid = (rowStartX, rowStartY, rowDistX, minD, maxD, rowCircleQty, numRows, rowSpacing, fillArr) => {
    for (let i = 0; i < numRows; i++) {
      rowYPosition = !i ? rowStartY : rowStartY + rowSpacing*i;
      generateCircleLine(rowStartX, rowYPosition, rowDistX, 0, minD, maxD, rowCircleQty, 'x', fillArr);
    }
  }

    const myGrid = {
    rowStartX: 20,
    rowStartY: 20,
    rowDistX: 20,
    minDiameter: 5,
    maxDiameter: 15,
    rowSpacing: 20,
    rowCircleQty: function() {return getMaxCircles(this.rowDistX, 'x', this.rowStartX)},
    numRows: function() {return getMaxCircles(this.rowSpacing, 'y', this.rowStartY)},
    fillArray: [
      [100, 50, 255],
      [100, 100, 255],
      [100, 150, 255],
      [100, 200, 255],
      [100, 250, 255]
    ]
  }

  generateCircleGrid(
    myGrid.rowStartX,
    myGrid.rowStartY,
    myGrid.rowDistX,
    myGrid.minDiameter,
    myGrid.maxDiameter,
    myGrid.rowCircleQty(),
    myGrid.numRows(),
    myGrid.rowSpacing,
    myGrid.fillArray
  );
}


function draw() {

}

index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- PLEASE NO CHANGES BELOW THIS LINE (UNTIL I SAY SO) -->
  <script language="javascript" type="text/javascript" src="libraries/p5.min.js"></script>
  <script language="javascript" type="text/javascript" src="sketch_230615e.js"></script>
  <script language="javascript" type="text/javascript" src="https://unpkg.com/p5.js-svg@1.5.1"></script>
  <!-- OK, YOU CAN MAKE CHANGES BELOW THIS LINE AGAIN -->

  <style>
    body {
      padding: 0;
      margin: 0;
    }

    button {
      position: fixed;
      bottom: 3rem;
      right: 3rem;
      width: 7rem;
      height: 3rem;
    }
  </style>
</head>

<body>
<button onclick="save()">Save SVG</button>
</body>
</html>

Et voilà

And we're done! Now you can tweak the parameters and make grids with all sorts of colors and size variations, and even export and edit them in Inkscape! The LibreWolf web browser opened to localhost. The viewport is filled with circles of varying sizes in various shades of blue. There is a button reading "Save SVG" in the bottom right corner. If you'd like to make this project even better, maybe consider implementing a GUI to adjust your grid paramaters, or adding some interactivity.