@salamivg/core
TypeScript icon, indicating that this package has built-in type declarations

1.0.0 • Public • Published

SalamiVG ("Salami Vector Graphics")

A place to play with SVGs.

SalamiVG is a creative coding framework for JavaScript with a single render target: SVG.

Why?

I love OPENRNDR and wanted to see if I could make a generative art framework that ran in an interpretted language. I've never been a JVM guy, and even though I like Kotlin, it sounded appealing to me to be able to write generative art in a language I used every day: JavaScript.

Of course you may (reasonably) ask why I'm not just using p5.js, the dominant JavaScript framework for writing generative art. Well, I don't have a good answer to that. I suppose this is really "just for fun" ¯\_(ツ)_/¯. (There is a more detailed comparison with p5.js in the Wiki.)

Installation

npm i --save @salamivg/core

If you use yarn and you can't automatically convert the above to the correct yarn command, then that's on you 😏

Examples

There is a Gallery page in the Wiki with some example renders and links to the code used to create them.

If you're the clone-n-run type, you can use the examples from the /examples directory in this repo:

git clone git@github.com:ericyd/salamivg
cd salamivg
node examples/oscillator-noise.js

Here are some simple SVGs generated with SalamiVG

Concentric rings perturbated by a sine wave
import { renderSvg, circle, hypot, vec2, map } from '@salamivg/core'

const config = {
  width: 100,
  height: 100,
  scale: 2,
  loopCount: 1,
}

renderSvg(config, (svg) => {
  // set basic SVG props
  svg.setBackground('#fff')
  svg.fill = null
  svg.stroke = '#000'
  svg.numericPrecision = 3

  // draw circle in middle of viewport
  svg.circle(
    circle({
      x: svg.center.x,
      y: svg.center.y,
      radius: hypot(svg.width, svg.height) * 0.04,
      'stroke-width': 1,
    }),
  )

  // draw 14 concentric rings around the center. (14 is arbitrary)
  const nRings = 14
  for (let i = 1; i <= nRings; i++) {
    // use `map` to linearly interpolate the radius on a log scale
    const baseRadius = map(
      0,
      Math.log(nRings),
      hypot(svg.width, svg.height) * 0.09,
      hypot(svg.width, svg.height) * 0.3,
      Math.log(i),
    )

    // as the rings get further from the center,
    // the path is increasingly perturbated by the sine wave.
    const sineInfluence = map(
      0,
      Math.log(nRings),
      baseRadius * 0.01,
      baseRadius * 0.1,
      Math.log(i),
    )

    svg.path((p) => {
      // the stroke width gets thinner as the rings get closer to the edge
      p.strokeWidth = map(1, nRings, 0.8, 0.1, i)

      // the radius varies because the path is perturbated by a sine wave
      const radius = (angle) => baseRadius + Math.sin(angle * 6) * sineInfluence
      p.moveTo(
        vec2(Math.cos(0) * radius(0), Math.sin(0) * radius(0)).add(svg.center),
      )

      // move our way around a circle to draw a smooth path
      for (let angle = 0; angle <= Math.PI * 2; angle += 0.05) {
        p.lineTo(
          vec2(
            Math.cos(angle) * radius(angle),
            Math.sin(angle) * radius(angle),
          ).add(svg.center),
        )
      }
      p.close()
    })
  }
})

Concentric circles example. 14 concentric circles are drawn around the center of the image. As the circle radius increases, the circles becomes increasingly perturbated by a sine wave, making the circle somewhat wavy.

Oscillator noise

SalamiVG ships with a bespoke noise function called "oscillator noise".

import {
  renderSvg,
  map,
  vec2,
  randomSeed,
  createRng,
  Vector2,
  random,
  ColorRgb,
  PI,
  cos,
  sin,
  ColorSequence,
  shuffle,
  createOscNoise,
} from '@salamivg/core'

const config = {
  width: 100,
  height: 100,
  scale: 3,
  loopCount: 1,
}

const colors = ['#B2D0DE', '#E0A0A5', '#9BB3E7', '#F1D1B8', '#D9A9D6']

renderSvg(config, (svg) => {
  // filenameMetadata will be added to the filename that is written to disk;
  // this makes it easy to recall which seeds were used in a particular sketch
  svg.filenameMetadata = { seed }

  // a seeded pseudo-random number generator provides controlled randomness for our sketch
  const rng = createRng(seed)

  // black background 😎
  svg.setBackground('#000')

  // set some basic SVG props
  svg.fill = null
  svg.stroke = ColorRgb.Black
  svg.strokeWidth = 0.25
  svg.numericPrecision = 3

  // create a 2D noise function using the built-in "oscillator noise"
  const noiseFn = createOscNoise(seed)

  // create a bunch of random start points within the svg boundaries
  const nPoints = 200
  const points = new Array(nPoints)
    .fill(0)
    .map(() => Vector2.random(0, svg.width, 0, svg.height, rng))

  // define a color spectrum that can be indexed randomly for line colors
  const spectrum = ColorSequence.fromColors(shuffle(colors, rng))

  // noise functions usually require some type of scaling;
  // here we randomize slightly to get the amount of "flowiness" that we want.
  const scale = random(0.05, 0.13, rng)

  // each start point gets a line
  for (const point of points) {
    svg.path((path) => {
      // choose a random stroke color for the line
      path.stroke = spectrum.at(random(0, 1, rng))

      // move along the vector field defined by the 2D noise function.
      // the line length is "100", which is totally arbitrary.
      path.moveTo(point)
      for (let i = 0; i < 100; i++) {
        let noise = noiseFn(path.cursor.x * scale, path.cursor.y * scale)
        let angle = map(-1, 1, -PI, PI, noise)
        path.lineTo(path.cursor.add(vec2(cos(angle), sin(angle))))
      }
    })
  }

  // when loopCount > 1, this will randomize the seed on each iteration
  return () => {
    seed = randomSeed()
  }
})

Oscillator noise example. Wavy multi-colored lines defined by a noisy vector field weave through the canvas.

Recursive triangle subdivision
/*
Rules

1. Draw an equilateral triangle in the center of the viewBox
2. Subdivide the triangle into 4 equal-sized smaller triangles
3. If less than max depth and <chance>, continue recursively subdividing
4. Each triangle gets a different fun-colored fill, and a slightly-opacified stroke
*/
import {
  renderSvg,
  vec2,
  randomSeed,
  createRng,
  Vector2,
  random,
  randomInt,
  PI,
  ColorSequence,
  shuffle,
  TAU,
  ColorRgb,
} from '@salamivg/core'

const config = {
  width: 100,
  height: 100,
  scale: 3,
  loopCount: 1,
}

let seed = 8852037180828291 // or, randomSeed()

const colors = [
  '#974F7A',
  '#D093C2',
  '#6F9EB3',
  '#E5AD5A',
  '#EEDA76',
  '#B5CE8D',
  '#DAE7E8',
  '#2E4163',
]

const bg = '#2E4163'
const stroke = ColorRgb.fromHex('#DAE7E8')

renderSvg(config, (svg) => {
  const rng = createRng(seed)
  const maxDepth = randomInt(5, 7, rng)
  svg.filenameMetadata = { seed, maxDepth }
  svg.setBackground(bg)
  svg.numericPrecision = 3
  svg.fill = bg
  svg.stroke = stroke
  svg.strokeWidth = 0.25
  const spectrum = ColorSequence.fromColors(shuffle(colors, rng))

  function drawTriangle(a, b, c, depth = 0) {
    // always draw the first triangle; then, draw about half of the triangles
    if (depth === 0 || random(0, 1, rng) < 0.5) {
      // offset amount increases with depth
      const offsetAmount = depth / 2
      const offset = vec2(
        random(-offsetAmount, offsetAmount, rng),
        random(-offsetAmount, offsetAmount, rng),
      )
      // draw the triangle with some offset
      svg.polygon({
        points: [a.add(offset), b.add(offset), c.add(offset)],
        fill: spectrum.at(random(0, 1, rng)).opacify(0.4).toHex(),
        stroke: stroke.opacify(1 / (depth / 4 + 1)).toHex(),
      })
    }
    // recurse if we're above maxDepth and "lady chance allows it"
    if (depth < maxDepth && (depth < 2 || random(0, 1, rng) < 0.75)) {
      const ab = Vector2.mix(a, b, 0.5)
      const ac = Vector2.mix(a, c, 0.5)
      const bc = Vector2.mix(b, c, 0.5)
      drawTriangle(ab, ac, bc, depth + 1)
      drawTriangle(a, ab, ac, depth + 1)
      drawTriangle(b, bc, ab, depth + 1)
      drawTriangle(c, bc, ac, depth + 1)
    }
  }

  // construct an equilateral triangle from the center of the canvas with a random rotation
  const angle = random(0, TAU, rng)
  const a = svg.center.add(Vector2.fromAngle(angle).scale(45))
  const b = svg.center.add(Vector2.fromAngle(angle + (PI * 2) / 3).scale(45))
  const c = svg.center.add(Vector2.fromAngle(angle + (PI * 4) / 3).scale(45))
  drawTriangle(a, b, c)

  // when loopCount > 1, this will randomize the seed on each iteration
  return () => {
    seed = randomSeed()
  }
})

Recursive triangles example. A large equilateral triangle is drawn in the middle of the screen. The triangle is equally subdivided into 4 smaller triangles. Each triangle gets a random color. The subdivision continues for 6 iterations.

Getting Started, Documentation, and FAQ

Please see the project Wiki

Design Philosophy

  1. Imperative works better than declarative for art. Though declarative/functional programming is awesome for production apps, imperative patterns work better for generative art. This is because a lot of sketches build up designs iteratively, and being able to easily modify the logic makes it easier to iterate on ideas and create art quickly. SalamiVG is heavily inspired by OPENRNDR, and likewise utilizes the builder pattern extensively.
  2. Type hints lead to better developer experience. TypeScript is used for typechecking the library, and generating type declaration files for all classes and functions. This improves the developer experience by providing type hints as documentation. Note: although TypeScript is used for typechecking the lib, the actual code is written in JavaScript with JSDocs. This was inspired by the SvelteKit team (source 1, source 2) and it provides a lot of benefits such as reducing build tooling and speeding up test execution. In addition, shipping human-readable JS files means that it's much easier for a library user to modify the files locally, which can be a great way to lead to experimentation and PRs!
  3. Creative coding should be fun. Don't take yourself too seriously.

Internal Development

Install dependencies:

npm i

Before committing:

npm run check:all

Publishing

npm version minor
git push --tags && git push
./scripts/changelog.sh
npm login --registry https://registry.npmjs.org --scope=@salamivg
npm publish --access public

NodeJS Version Compatibility

SalamiVG was developed with Node 20 but it probably works back to Node 14 or so.

This library has been tested against

  • Node 20.8.0
  • Node 18.19.0
  • Node 16.20.2
  • Attempted to test against Node 14 but asdf wouldn't install it on our M1 Mac. Please open an issue if this is causing you problems.

Deno / Bun Support?

Both Deno and Bun work out of the box, with the exception of the renderSvg() function.

Please see the FAQ for a more detailed answer and examples of using SalamiVG with Deno and Bun.

ES Modules Only

SalamiVG ships ES Modules, and does not include CommonJS builds.

Is this a problem? Feel free to open an issue if you need CommonJS. It would probably be trivial to set up Rollup or similar to bundle into a CommonJS package and include it in the exports, but it isn't clear if it is necessary for anyone.

Package Sidebar

Install

npm i @salamivg/core

Weekly Downloads

1

Version

1.0.0

License

The Unlicense

Unpacked Size

160 kB

Total Files

58

Last publish

Collaborators

  • ericyd