Iteration

Solandra provides loops for grids, rows, circles and paths. Callbacks receive positions, sizes and centres in canvas coordinates.

forTiling

forTiling divides the canvas into an n-column grid. Each callback receives (point, delta, center, index):

  • point — the top-left corner of the tile
  • delta — the [width, height] of the tile
  • center — the center of the tile
  • index — a sequential counter

Options: margin (space around the edges), type: "square" (force square tiles; the default "proportionate" follows the canvas aspect ratio), and order ("columnFirst" or "rowFirst").

s.background(210, 25, 95)
s.forTiling(
  { n: 8, type: "square", margin: 0.05 },
  ([x, y], [dX], [cX, cY], i) => {
    s.setFillColor(120 + i * 2, 60, 50)
    s.fill(
      new RegularPolygon({
        at: [cX, cY],
        n: 3 + (i % 5),
        r: dX * 0.4,
        a: i * 0.05,
      })
    )
  }
)

forHorizontal and forVertical

Divide the canvas into full-height columns or full-width rows. Same callback signature as forTiling.

s.background(0, 0, 15)
s.forHorizontal({ n: 12, margin: 0.1 }, ([x, y], [dX, dY], _c, i) => {
  const h = 0.3 + 0.6 * Math.abs(Math.sin(i * 0.8))
  s.setFillColor(20 + i * 10, 80, 60)
  s.fill(
    new Rect({ at: [x + dX * 0.15, y + dY * (1 - h)], w: dX * 0.7, h: dY * h })
  )
})

forMargin(margin, callback) gives a single cell inset from the canvas edges.

forGrid

forGrid iterates over integer coordinates. Use it with the hexagonal, triangular and isometric transforms:

s.background(45, 40, 96)
s.forGrid({ minX: 1, maxX: 9, minY: 1, maxY: 9 }, ([x, y], i) => {
  const on = (x * y) % 3 === 0
  s.setFillColor(on ? 340 : 215, 70, 55)
  s.fill(
    new Circle({
      at: [x * 0.1, y * 0.1],
      r: on ? 0.04 : 0.015,
    })
  )
})

times, downFrom and range

times(n, cb) counts from 0 to n − 1. downFrom(n, cb) counts from n to 1. range({ from, to, n }, cb) visits n values across an interval, including both ends by default:

s.background(230, 40, 12)
s.downFrom(9, (n) => {
  s.setFillColor(260 - n * 15, 70, 25 + n * 6)
  s.fill(new Circle({ at: [0.5, 0.5], r: 0.05 * n }))
})
s.setStrokeColor(0, 0, 100, 0.6)
s.lineWidth = 0.002
s.range({ from: 0, to: Math.PI / 3, n: 12, inclusive: false }, (a) => {
  s.draw(new RegularPolygon({ at: [0.5, 0.5], n: 3, r: 0.47, a }))
})

aroundCircle

Places n points evenly around a circle (at defaults to the canvas center, r to 0.25):

s.background(20, 30, 95)
s.aroundCircle({ n: 24, r: 0.35 }, ([x, y], i) => {
  s.setFillColor(i * 15, 70, 55)
  s.fill(new Star({ at: [x, y], n: 5, r: 0.045, a: i * 0.3 }))
})

forRadialTiling

forRadialTiling divides concentric rings into n sectors. Each callback receives a HollowArc, its centre point, its bounds (r, r2, a, a2, ring, sector) and a sequential index.

s.background(0, 0, 8)
s.forRadialTiling(
  { n: 20, rings: 5, r: 0.45 },
  (_at, cell, { ring, sector }) => {
    s.setFillColor(
      (sector % 2 === 0 ? 8 : 22) + ring * 4,
      sector % 2 === 0 ? 75 : 45,
      (ring + sector) % 2 === 0 ? 48 : 24
    )
    s.fill(cell)
  }
)

Set innerRadius to leave a hole in the centre, or from and to to draw a partial circle. Use the midpoint of the cell's angles to orient shapes outwards:

s.background(235, 25, 8)
s.forRadialTiling(
  { n: 12, rings: 3, r: 0.46, innerRadius: 0.09 },
  (at, cell, { ring, a, a2 }) => {
    s.setFillColor(210 + ring * 40, 60, 25 + ring * 8, 0.9)
    s.fill(cell)
 
    s.withTranslation(at, () => {
      s.withRotation((a + a2) / 2, () => {
        s.setFillColor(40 - ring * 10, 80, 60, 0.85)
        s.fill(new Ellipse({ at: [0, 0], w: 0.1 + ring * 0.03, h: 0.035 }))
      })
    })
  }
)

order: "ringFirst" visits each ring in turn. Use "sectorFirst" to visit cells outwards along each sector.

forPoissonDiskPoints

Poisson disk sampling produces random points at least minDist apart. Use it to scatter shapes without tight clusters.

s.background(210, 60, 12)
s.forPoissonDiskPoints({ minDist: 0.08 }, ([x, y], i) => {
  s.setFillColor(180 + y * 100, 70, 60)
  s.fill(new Circle({ at: [x, y], r: 0.025 }))
})

alongPath

alongPath visits n points spaced evenly by distance along a path. The callback receives each point and the path's angle there, so you can position and rotate shapes along the outline.

Pass a SimplePath or an object with a .path, such as Line, Rect, RegularPolygon, Star or Spiral. Set inclusive: false on closed paths to avoid repeating the first point.

s.background(215, 40, 15)
const star = new Star({ at: s.meta.center, n: 5, r: 0.28, r2: 0.13 })
 
s.alongPath({ path: star, n: 60, inclusive: false }, (at, angle, i) => {
  s.setFillColor(45 + i * 3, 85, 60)
  s.withTranslation(at, () => {
    s.withRotation(angle, () => {
      s.fill(
        new Rect({
          at: [0, 0],
          w: 0.006,
          h: 0.03 + 0.03 * (i % 3),
          align: "center",
        })
      )
    })
  })
})

For measurements without a drawing callback, use SimplePath.length, pointAt, tangentAt or pointsAlong; see Paths and Curves.

Collecting results and changing order

Use these helpers with the iteration methods above.

build collects the callback's return values into an array:

// collect tile centers, then connect them in a shuffled tour
s.background(0, 0, 96)
const centers = s.build(
  s.forTiling,
  { n: 6, type: "square" },
  (_pt, _d, c) => c
)
s.shuffle(centers)
s.lineWidth = 0.004
s.setStrokeColor(215, 60, 45)
centers.forEach((from, i) => {
  const to = centers[(i + 1) % centers.length]
  s.draw(new Line(from, to))
})

withRandomOrder runs the callbacks in shuffled order, changing how overlapping shapes are layered:

s.background(40, 30, 95)
s.withRandomOrder(
  s.forTiling,
  { n: 6, type: "square", margin: 0.1 },
  ([x, y], [dX], _c, i) => {
    s.setFillColor(i * 4, 70, 55, 0.95)
    s.fill(new Square({ at: [x, y], s: dX * 1.4 }))
  }
)

Choosing at random: proportionately and doProportion

doProportion(p, cb) runs the callback with probability p. proportionately chooses a callback by weight; the weights do not need to sum to 1:

s.background(220, 30, 14)
s.forTiling({ n: 10, type: "square" }, ([x, y], [dX], [cX, cY]) => {
  s.proportionately([
    [
      3,
      () => {
        s.setFillColor(45, 90, 60)
        s.fill(new Circle({ at: [cX, cY], r: dX * 0.35 }))
      },
    ],
    [
      2,
      () => {
        s.setFillColor(340, 80, 60)
        s.fill(new Square({ at: [cX, cY], s: dX * 0.6, align: "center" }))
      },
    ],
    [1, () => {}], // sometimes do nothing
  ])
})

See Randomness and Noise for more ways to choose values and positions.

Solandra was made by James Porter.

Check out the GitHub page or install with npm i solandra