├── .npmrc ├── .tool-versions ├── lib ├── constants.ts ├── components │ ├── index.ts │ ├── defs.ts │ ├── hexagon.test.ts │ ├── rectangle.test.ts │ ├── polyline.test.ts │ ├── svg.test.ts │ ├── circle.test.ts │ ├── linear-gradient.test.ts │ ├── hexagon.ts │ ├── linear-gradient.ts │ ├── path.test.ts │ ├── polygon.ts │ ├── polyline.ts │ ├── tag.test.ts │ ├── polygon.test.ts │ ├── rectangle.ts │ ├── tag.ts │ ├── svg.ts │ ├── circle.ts │ └── path.ts ├── internal.ts ├── random.test.ts ├── types.ts ├── index.ts ├── noise │ ├── oscillator-noise.test.ts │ ├── compressor.ts │ ├── compressor.test.ts │ ├── oscillator-noise.ts │ └── oscillator.ts ├── data-structures │ ├── fractalized-line.test.ts │ ├── fractalized-line.ts │ ├── grid.ts │ └── grid.test.ts ├── render.ts ├── math.ts ├── color │ ├── color-sequence.ts │ ├── rgb.test.ts │ ├── color-sequence.test.ts │ ├── rgb.ts │ ├── hsl.test.ts │ └── hsl.ts ├── vector3.ts ├── util.test.ts ├── random.ts ├── math.test.ts ├── vector3.test.ts ├── util.ts ├── vector2.ts └── algorithms │ └── walking-triangles.ts ├── tsconfig.json ├── biome.json ├── scripts └── changelog.sh ├── LICENSE ├── package.json ├── examples ├── concentric-circles.js ├── oscillator-noise.js └── recursive-triangles.js ├── .gitignore └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 23.9.0 2 | deno 2.2.3 3 | bun 1.2.4 4 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | // some "useful" constants 2 | export const PI = Math.PI 3 | export const E = Math.E 4 | // from `0.5 + Math.pow(5, 0.5) * 0.5` or `Math.pow(Math.E, Math.asinh(0.5))`, credit https://www.goldennumber.net/math/ 5 | export const PHI = 1.618033988749895 6 | export const TAU = 2 * PI 7 | -------------------------------------------------------------------------------- /lib/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './circle.js' 2 | export * from './defs.js' 3 | export * from './hexagon.js' 4 | export * from './path.js' 5 | export * from './polygon.js' 6 | export * from './polyline.js' 7 | export * from './rectangle.js' 8 | export * from './svg.js' 9 | export * from './tag.js' 10 | -------------------------------------------------------------------------------- /lib/components/defs.ts: -------------------------------------------------------------------------------- 1 | import { Tag } from './tag.js' 2 | 3 | export class Defs extends Tag { 4 | constructor() { 5 | super('defs') 6 | } 7 | 8 | addDefinition(child: Tag) { 9 | return super.addChild(child) 10 | } 11 | } 12 | 13 | export function defs() { 14 | return new Defs() 15 | } 16 | -------------------------------------------------------------------------------- /lib/internal.ts: -------------------------------------------------------------------------------- 1 | // utility functions intended only for internal use 2 | 3 | export function warnWithDefault(message: string, defaultValue: T): T { 4 | console.warn(message) 5 | return defaultValue 6 | } 7 | 8 | /** 9 | * more useful than an IIFE 10 | */ 11 | export function error(message: string): never { 12 | throw new Error(message) 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "declaration": true, 10 | "outDir": "./dist" 11 | }, 12 | "include": ["lib/**/*"], 13 | "exclude": ["lib/**/*.test.ts"] 14 | } -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.4.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "suspicious": { 11 | "noExplicitAny": "off" 12 | } 13 | } 14 | }, 15 | "formatter": { 16 | "indentStyle": "space" 17 | }, 18 | "javascript": { 19 | "formatter": { 20 | "quoteStyle": "single", 21 | "semicolons": "asNeeded" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/random.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { describe, it } from 'node:test' 3 | import { randomFromObject, randomFromArray } from './random' 4 | 5 | describe('randomFromObject', () => { 6 | it('returns a value from the object', () => { 7 | const obj = { a: 1, b: 2, c: 3 } 8 | const rng = () => 0.5 9 | assert.strictEqual(randomFromObject(obj, rng), 2) 10 | }) 11 | }) 12 | 13 | describe('randomFromArray', () => { 14 | it('returns a value from the object', () => { 15 | const arr = [1, 2, 3] 16 | const rng = () => 0.5 17 | assert.strictEqual(randomFromArray(arr, rng), 2) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | // convenience types to be explicit about what types are expected 2 | export type Radians = number 3 | export type Degrees = number 4 | export type Integer = number 5 | export type Decimal = number 6 | // Maybe TS already has a way to define a range of numbers, 7 | // but until then these are just for documentation purposes. 8 | /** 9 | * A number in range (i, j], i.e. greater than i and less than or equal to j. 10 | */ 11 | export type HalfOpenInterval = number 12 | /** 13 | * A number in range [i, j], i.e. greater than or equal to i and less than or equal to j. 14 | */ 15 | export type ClosedInterval = number 16 | /** 17 | * A number in range (i, j), i.e. greater than i and less than j. 18 | */ 19 | export type OpenInterval = number 20 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export { ColorHsl, hsl } from './color/hsl.js' 2 | export * from './color/color-sequence.js' 3 | export * from './color/rgb.js' 4 | export * from './components/index.js' 5 | export * from './constants.js' 6 | export * from './data-structures/fractalized-line.js' 7 | export * from './data-structures/grid.js' 8 | export * from './math.js' 9 | export * from './noise/oscillator-noise.js' 10 | export * from './noise/oscillator.js' 11 | export * from './random.js' 12 | export * from './render.js' 13 | export * from './util.js' 14 | export { Vector2, vec2 } from './vector2.js' 15 | export { Vector3, vec3 } from './vector3.js' 16 | export { 17 | contoursFromTIN, 18 | type IntersectingLine, 19 | type Triangle3, 20 | type TIN, 21 | type Contour, 22 | type ContourParams, 23 | } from './algorithms/walking-triangles.js' 24 | -------------------------------------------------------------------------------- /lib/components/hexagon.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { describe, it } from 'node:test' 3 | import { Hexagon } from './hexagon' 4 | import { vec2 } from '../vector2' 5 | 6 | describe('Hexagon', () => { 7 | it('renders a hexagonal polygon', () => { 8 | const hex = new Hexagon({ 9 | center: vec2(10, 10), 10 | circumradius: 1, 11 | rotation: Math.PI / 6, 12 | }) 13 | hex.numericPrecision = 2 14 | assert.strictEqual( 15 | hex.render(), 16 | '', 17 | ) 18 | }) 19 | 20 | describe('.neighbors', () => { 21 | it('returns the correct number of neighbors', () => { 22 | const hex = new Hexagon({ 23 | center: vec2(10, 10), 24 | circumradius: 1, 25 | rotation: Math.PI / 6, 26 | }) 27 | const neighbors = hex.neighbors() 28 | assert.strictEqual(neighbors.length, 6) 29 | }) 30 | 31 | it.todo('returns the correct neighbors') 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /scripts/changelog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | from=$1 4 | to=$2 5 | 6 | if [ "$from" == "" ]; then 7 | from=$(git describe --abbrev=0 $(git describe --abbrev=0)^) 8 | echo "No 'from' positional arg provided. Defaulting to '$from'" 9 | fi 10 | 11 | if [ "$to" == "" ]; then 12 | to=$(git describe --abbrev=0) 13 | echo "No 'to' positional arg provided. Defaulting to '$to'" 14 | fi 15 | 16 | echo "\nChangelog from $from to $to\n" 17 | 18 | # Some formatting explanations: 19 | # %h: short sha 20 | # %ad: author date, using --date option for format 21 | # %s: "subject" (probably the first line of the commit message, but haven't tested extensively) 22 | # %an: author name 23 | # %d: ref name; prints stuff like "(HEAD -> main, tag: v0.4.0, origin/main, origin/HEAD)" 24 | git log \ 25 | --abbrev-commit \ 26 | --decorate \ 27 | --date=short \ 28 | --format=format:'* %s (%ad, [%h](https://github.com/ericyd/salamivg/commit/%H))' \ 29 | "$from"..."$to" 30 | 31 | # a nice one-liner that shows most of the useful info 32 | # git log --abbrev-commit --decorate --date=short --format=format:'%h, %aI - %s %d' --all "$from"..."$to" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /lib/components/rectangle.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { Rectangle, rect } from './rectangle' 4 | 5 | describe('rect', () => { 6 | it('returns a Rectangle', () => { 7 | const r = rect({}) 8 | assert(r instanceof Rectangle) 9 | }) 10 | 11 | it('can accept attributes', () => { 12 | const r = rect({ 13 | x: 10, 14 | y: 11, 15 | width: 12, 16 | height: 13, 17 | }) 18 | assert.strictEqual(r.x, 10) 19 | assert.strictEqual(r.y, 11) 20 | assert.strictEqual(r.width, 12) 21 | assert.strictEqual(r.height, 13) 22 | }) 23 | 24 | it('can accept a builder', () => { 25 | const r = rect((r) => { 26 | r.x = 10 27 | r.y = 11 28 | r.width = 12 29 | r.height = 13 30 | }) 31 | assert.strictEqual(r.x, 10) 32 | assert.strictEqual(r.y, 11) 33 | assert.strictEqual(r.width, 12) 34 | assert.strictEqual(r.height, 13) 35 | }) 36 | }) 37 | 38 | describe('Rectangle', () => { 39 | it('has x, y, width, height props', () => { 40 | const r = new Rectangle() 41 | assert.notStrictEqual(r.x, undefined) 42 | assert.notStrictEqual(r.y, undefined) 43 | assert.notStrictEqual(r.width, undefined) 44 | assert.notStrictEqual(r.height, undefined) 45 | }) 46 | 47 | it('has a center', () => { 48 | const r = new Rectangle({ x: 2, y: 2, width: 2, height: 2 }) 49 | assert.strictEqual(r.center.x, 3) 50 | assert.strictEqual(r.center.y, 3) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /lib/noise/oscillator-noise.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import { createOscNoise } from './oscillator-noise' 3 | import { randomSeed } from '../random' 4 | import assert from 'node:assert' 5 | 6 | describe('OscillatorNoise', () => { 7 | it('returns consistent values for the same seed/x/y inputs', () => { 8 | const seed = randomSeed() 9 | const noiseFn1 = createOscNoise(seed) 10 | const actual1 = noiseFn1(100, 100) 11 | 12 | const noiseFn2 = createOscNoise(seed) 13 | const actual2 = noiseFn2(100, 100) 14 | 15 | assert.strictEqual( 16 | actual1, 17 | actual2, 18 | `Seed ${seed} gave 2 different values: ${actual1} and ${actual2}`, 19 | ) 20 | }) 21 | 22 | describe('createOscNoise', () => { 23 | // if I'm being honest, I don't know if this test is accurately testing this feature. 24 | it('can scale in the xy plane', () => { 25 | const noiseFn1 = createOscNoise('100', 1) 26 | const actual1 = noiseFn1(0, 0) 27 | const actual2 = noiseFn1(0.1, 0.1) 28 | const diff1 = actual2 - actual1 29 | 30 | const noiseFn2 = createOscNoise('100', 0.1) 31 | const actual3 = noiseFn2(0, 0) 32 | const actual4 = noiseFn2(0.1, 0.1) 33 | const diff2 = actual3 - actual4 34 | 35 | assert(diff2 < diff1) 36 | }) 37 | 38 | it('can scale the output', () => { 39 | const outputScale = 10 40 | 41 | const noiseFn1 = createOscNoise('100', 1) 42 | const actual1 = noiseFn1(0, 0) 43 | 44 | const noiseFn2 = createOscNoise('100', 1, outputScale) 45 | const actual2 = noiseFn2(0, 0) 46 | 47 | assert(actual2 === actual1 * outputScale) 48 | }) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@salamivg/core", 3 | "type": "module", 4 | "version": "1.1.0", 5 | "description": "A creative coding framework for generating SVGs", 6 | "main": "dist/index.js", 7 | "module": "dist/index.js", 8 | "homepage": "https://github.com/ericyd/salamivg#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/ericyd/salamivg.git" 12 | }, 13 | "author": "Eric Dauenhauer", 14 | "license": "The Unlicense", 15 | "exports": { 16 | ".": { 17 | "import": "./dist/index.js", 18 | "types": "./dist/index.d.ts" 19 | } 20 | }, 21 | "directories": { 22 | "lib": "lib" 23 | }, 24 | "files": [ 25 | "dist/**/*" 26 | ], 27 | "scripts": { 28 | "prepublishOnly": "npm run build && npm run check:all", 29 | "preversion": "npm run build && npm run check:all", 30 | "format": "npx @biomejs/biome format lib/*.ts lib/**/*.ts examples/*.js --write", 31 | "lint": "npx @biomejs/biome lint lib/*.ts lib/**/*.ts", 32 | "lint:fix": "npx @biomejs/biome lint --apply-unsafe lib/*.ts lib/**/*.ts", 33 | "test": "node --import tsx --test", 34 | "check:types": "tsc --noEmit true --emitDeclarationOnly false", 35 | "check:format": "npx @biomejs/biome format lib/*.ts lib/**/*.ts", 36 | "check:all": "npm run check:format && npm run check:types && npm run lint && npm test", 37 | "build": "tsc" 38 | }, 39 | "keywords": [ 40 | "svg", 41 | "creative", 42 | "generative", 43 | "art", 44 | "draw", 45 | "drawing", 46 | "procedural" 47 | ], 48 | "devDependencies": { 49 | "@biomejs/biome": "1.4.1", 50 | "@types/node": "^20.10.5", 51 | "tsx": "^4.19.3", 52 | "typescript": "^5.3.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/data-structures/fractalized-line.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { FractalizedLine } from './fractalized-line' 4 | import { vec2 } from '../vector2' 5 | 6 | /** 7 | * 8 | * @param {number} defaultValue 9 | * @param {number[]} setValues 10 | * @returns 11 | */ 12 | function fakeRng(defaultValue = 0.5, setValues = []) { 13 | let i = 0 14 | return () => { 15 | i++ 16 | return setValues[i] ?? defaultValue 17 | } 18 | } 19 | 20 | describe('FractalizedLine', () => { 21 | it('generates one midpoint for every existing point one every subdivision', () => { 22 | const rng = fakeRng() 23 | const line = new FractalizedLine([vec2(0, 0), vec2(1, 1)], rng) 24 | line.perpendicularSubdivide(1) 25 | assert.strictEqual(line.points.length, 3) 26 | line.perpendicularSubdivide(1) 27 | assert.strictEqual(line.points.length, 5) 28 | line.perpendicularSubdivide(1) 29 | assert.strictEqual(line.points.length, 9) 30 | }) 31 | 32 | it('returns a new line with points generated from a fractal subdivision pattern', () => { 33 | const rng = fakeRng() 34 | const line = new FractalizedLine([vec2(0, 0), vec2(1, 1)], rng) 35 | line.perpendicularSubdivide(3) 36 | // these points are all exactly in the middle because the fakeRng returns 0.5. Other test values are hard to mentally grok so just going with simple for now. 37 | assert.deepStrictEqual(line.points, [ 38 | vec2(0, 0), 39 | vec2(0.125, 0.125), 40 | vec2(0.25, 0.25), 41 | vec2(0.375, 0.375), 42 | vec2(0.5, 0.5), 43 | vec2(0.625, 0.625), 44 | vec2(0.75, 0.75), 45 | vec2(0.875, 0.875), 46 | vec2(1, 1), 47 | ]) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /lib/components/polyline.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { Polyline, lineSegment } from './polyline' 4 | import { vec2 } from '../vector2' 5 | 6 | describe('Polyline', () => { 7 | describe('render', () => { 8 | it('formats `points` correctly', () => { 9 | const poly = new Polyline({ points: [vec2(0, 0), vec2(100, 100)] }) 10 | const actual = poly.render() 11 | assert.strictEqual(actual, '') 12 | }) 13 | 14 | it('includes other properties', () => { 15 | const poly = new Polyline({ 16 | points: [vec2(0, 0), vec2(100, 100)], 17 | fill: '#000', 18 | stroke: '#432', 19 | }) 20 | const actual = poly.render() 21 | assert.strictEqual( 22 | actual, 23 | '', 24 | ) 25 | }) 26 | 27 | it('uses correct precision', () => { 28 | const poly = new Polyline({ 29 | points: [vec2(0.1234, 0.1234), vec2(100.1234, 100.1234)], 30 | }) 31 | poly.numericPrecision = 2 32 | const actual = poly.render() 33 | assert.strictEqual( 34 | actual, 35 | '', 36 | ) 37 | }) 38 | 39 | it('throws if points is empty', () => { 40 | const poly = new Polyline() 41 | assert.throws(() => poly.render()) 42 | }) 43 | }) 44 | }) 45 | 46 | describe('LineSegment', () => { 47 | it('renders a polyline tag', () => { 48 | const actual = lineSegment(vec2(0, 0), vec2(100, 100)).render() 49 | assert.strictEqual(actual, '') 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /lib/components/svg.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import { Svg } from './svg' 3 | import { LinearGradient } from './linear-gradient' 4 | import assert from 'node:assert' 5 | 6 | describe('Svg', () => { 7 | describe('defineLinearGradient', () => { 8 | it('renders defs', () => { 9 | const svg = new Svg({}) 10 | svg.defineLinearGradient({ colors: ['#000', '#fff'], id: '123' }) 11 | svg.rect({ x: 0, y: 0, width: 10, height: 10 }) 12 | const actual = svg.render() 13 | const expected = [ 14 | '', 15 | '', 16 | '', 17 | '', 18 | '', 19 | '', 20 | '', 21 | '', 22 | '', 23 | ].join('') 24 | assert.strictEqual(actual, expected) 25 | }) 26 | 27 | it('renders linear gradient IDs when fill is a LinearGradient', () => { 28 | const grad = new LinearGradient({ 29 | id: 'grad-id', 30 | colors: ['#000', '#fff'], 31 | }) 32 | const t = new Svg({ fill: grad }) 33 | assert.strictEqual( 34 | t.render(), 35 | '', 36 | ) 37 | }) 38 | }) 39 | 40 | describe('center', () => { 41 | it('is a Vector2 at the center of the viewport', () => { 42 | const center = new Svg({ width: 50, height: 50 }).center 43 | assert.strictEqual(center.x, 25) 44 | assert.strictEqual(center.y, 25) 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /lib/components/circle.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { describe, it } from 'node:test' 3 | import { Circle } from './circle' 4 | import { vec2 } from '../vector2' 5 | 6 | describe('Circle', () => { 7 | describe('constructor', () => { 8 | it('can be constructed with x, y, radius', () => { 9 | const c = new Circle({ x: 10, y: 12, radius: 5 }) 10 | assert.strictEqual(c.x, 10) 11 | assert.strictEqual(c.y, 12) 12 | assert.strictEqual(c.radius, 5) 13 | assert.strictEqual(c.attributes.cx, 10) 14 | assert.strictEqual(c.attributes.cy, 12) 15 | }) 16 | 17 | it('can be constructed with center, radius', () => { 18 | const c = new Circle({ center: vec2(10, 12), radius: 5 }) 19 | assert.strictEqual(c.x, 10) 20 | assert.strictEqual(c.y, 12) 21 | assert.strictEqual(c.radius, 5) 22 | assert.strictEqual(c.attributes.cx, 10) 23 | assert.strictEqual(c.attributes.cy, 12) 24 | }) 25 | 26 | it('will throw error without either (x,y) or center', () => { 27 | assert.throws(() => new Circle({ radius: 10 })) 28 | }) 29 | }) 30 | 31 | describe('center', () => { 32 | it('returns the center', () => { 33 | const c = new Circle({ x: 10, y: 12, radius: 5 }) 34 | assert.strictEqual(c.center.x, c.x) 35 | assert.strictEqual(c.center.x, 10) 36 | assert.strictEqual(c.center.y, c.y) 37 | assert.strictEqual(c.center.y, 12) 38 | }) 39 | }) 40 | 41 | describe('contains', () => { 42 | const circle = new Circle({ x: 10, y: 10, radius: 5 }) 43 | const tests = [ 44 | [vec2(10, 10), true], 45 | [vec2(5, 10), true], 46 | [vec2(10, 5), true], 47 | [vec2(10, 15), true], 48 | [vec2(15, 10), true], 49 | [vec2(5, 5), false], 50 | [vec2(15, 15), false], 51 | ] 52 | 53 | for (const [point, expected] of tests) { 54 | it(`returns ${expected} for ${point}`, () => { 55 | assert.strictEqual(circle.contains(point), expected) 56 | }) 57 | } 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /examples/concentric-circles.js: -------------------------------------------------------------------------------- 1 | import { renderSvg, circle, hypot, vec2, map, Vector2 } from '../dist/index.js' 2 | 3 | const config = { 4 | width: 100, 5 | height: 100, 6 | scale: 2, 7 | loopCount: 1, 8 | } 9 | 10 | renderSvg(config, (svg) => { 11 | // set basic SVG props 12 | svg.setBackground('#fff') 13 | svg.fill = null 14 | svg.stroke = '#000' 15 | svg.numericPrecision = 3 16 | 17 | // draw circle in middle of viewport 18 | svg.circle( 19 | circle({ 20 | center: svg.center, 21 | radius: hypot(svg.width, svg.height) * 0.04, 22 | 'stroke-width': 1, 23 | }), 24 | ) 25 | 26 | // draw 14 concentric rings around the center. (14 is arbitrary) 27 | const nRings = 14 28 | for (let i = 1; i <= nRings; i++) { 29 | // use `map` to linearly interpolate the radius on a log scale 30 | const baseRadius = map( 31 | 0, 32 | Math.log(nRings), 33 | hypot(svg.width, svg.height) * 0.09, 34 | hypot(svg.width, svg.height) * 0.3, 35 | Math.log(i), 36 | ) 37 | 38 | // as the rings get further from the center, 39 | // the path is increasingly perturbated by the sine wave. 40 | const sineInfluence = map( 41 | 0, 42 | Math.log(nRings), 43 | baseRadius * 0.01, 44 | baseRadius * 0.1, 45 | Math.log(i), 46 | ) 47 | 48 | svg.path((p) => { 49 | // the stroke width gets thinner as the rings get closer to the edge 50 | p.strokeWidth = map(1, nRings, 0.8, 0.1, i) 51 | 52 | // the radius varies because the path is perturbated by a sine wave 53 | const radius = (angle) => baseRadius + Math.sin(angle * 6) * sineInfluence 54 | const start = Vector2.fromAngle(0).scale(radius(0)).add(svg.center) 55 | p.moveTo(start) 56 | 57 | // move our way around a circle to draw a smooth path 58 | for (let angle = 0; angle <= Math.PI * 2; angle += 0.05) { 59 | const next = Vector2.fromAngle(angle) 60 | .scale(radius(angle)) 61 | .add(svg.center) 62 | p.lineTo(next) 63 | } 64 | p.close() 65 | }) 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /lib/components/linear-gradient.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import { LinearGradient } from './linear-gradient' 3 | import { ColorRgb } from '../color/rgb' 4 | import { hsl } from '../color/hsl' 5 | import assert from 'node:assert' 6 | 7 | describe('LinearGradient', () => { 8 | describe('render', () => { 9 | it('renders stops and direction attributes', () => { 10 | const stops = [ 11 | [10, new ColorRgb(0.2, 0.3, 0.4, 1)], 12 | [45, '#45f986'], 13 | [75, hsl(45, 0.3, 0.9)], 14 | [100, '#fe8945'], 15 | ] 16 | const actual = new LinearGradient({ stops, id: '18d0a2538b4' }).render() 17 | const expected = [ 18 | '', 19 | '', 20 | '', 21 | '', 22 | '', 23 | '', 24 | ].join('') 25 | assert.strictEqual(actual, expected) 26 | }) 27 | 28 | it('automatically creates stops from a list of colors', () => { 29 | const colors = [ 30 | new ColorRgb(0.2, 0.3, 0.4, 1), 31 | '#45f986', 32 | hsl(45, 0.3, 0.9), 33 | '#fe8945', 34 | ] 35 | const actual = new LinearGradient({ 36 | colors, 37 | id: '18d0a2538b4', 38 | numericPrecision: 2, 39 | }).render() 40 | const expected = [ 41 | '', 42 | '', 43 | '', 44 | '', 45 | '', 46 | '', 47 | ].join('') 48 | assert.strictEqual(actual, expected) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /lib/components/hexagon.ts: -------------------------------------------------------------------------------- 1 | import { Radians } from '../types.js' 2 | import { vec2, Vector2 } from '../vector2.js' 3 | import { Polygon } from './polygon.js' 4 | 5 | type HexagonAttributes = { 6 | center: Vector2 7 | /** 8 | * the radius of the circumscribed circle. Either the apothem or the circumradius must be defined. 9 | */ 10 | circumradius?: number 11 | /** 12 | * the radius of the inscribed circle. Either the apothem or the circumradius must be defined. 13 | */ 14 | apothem?: number 15 | /** 16 | * @default 0 17 | */ 18 | rotation?: Radians 19 | } 20 | 21 | export class Hexagon extends Polygon { 22 | #rotation = 0 23 | #circumradius: number | undefined 24 | #apothem: number | undefined 25 | center: Vector2 26 | 27 | constructor({ 28 | center, 29 | circumradius, 30 | apothem, 31 | rotation = 0, 32 | ...attributes 33 | }: HexagonAttributes) { 34 | if (typeof circumradius !== 'number' && typeof apothem !== 'number') { 35 | throw new Error('Must provide either circumradius or apothem') 36 | } 37 | 38 | // @ts-expect-error either circumradius or apothem is defined at this point 39 | const cr = circumradius ?? (apothem * 2) / Math.sqrt(3) 40 | const points: Vector2[] = [] 41 | for (let i = 0; i < 6; i++) { 42 | const angle = (Math.PI / 3) * i + rotation 43 | points[i] = center.add( 44 | vec2(Math.cos(angle), Math.sin(angle)).multiply(cr), 45 | ) 46 | } 47 | 48 | super({ ...attributes, points }) 49 | this.center = center 50 | this.#circumradius = cr 51 | this.#apothem = apothem ?? (cr * Math.sqrt(3)) / 2 52 | } 53 | 54 | /** 55 | * @returns {Hexagon[]} the list of neighboring hexagons, assuming a hexagonal grid 56 | */ 57 | neighbors(): Hexagon[] { 58 | const hexagons: Hexagon[] = new Array(6) 59 | for (let i = 0; i < 6; i++) { 60 | const angle = (Math.PI / 3) * i + this.#rotation + Math.PI / 6 61 | const center = this.center.add( 62 | vec2(Math.cos(angle), Math.sin(angle)).multiply( 63 | (this.#apothem ?? 1) * 2, 64 | ), 65 | ) 66 | hexagons[i] = new Hexagon({ 67 | center, 68 | circumradius: this.#circumradius ?? 1, 69 | rotation: this.#rotation, 70 | ...this.attributes, 71 | }) 72 | } 73 | return hexagons 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /lib/components/linear-gradient.ts: -------------------------------------------------------------------------------- 1 | import { type ColorStop } from '../color/color-sequence.js' 2 | import { ColorHsl } from '../color/hsl.js' 3 | import { ColorRgb } from '../color/rgb.js' 4 | import { Vector2, vec2 } from '../vector2.js' 5 | import { Tag } from './tag.js' 6 | 7 | export type LinearGradientAttributes = { 8 | stops?: ColorStop[] 9 | colors?: Array 10 | /** 11 | * @default new Vector2(0, 0) 12 | */ 13 | start?: Vector2 14 | /** 15 | * @default new Vector2(0, 1) 16 | */ 17 | end?: Vector2 18 | /** 19 | * @default Math.random().toString(16).replace(/^0\./, '') 20 | */ 21 | id?: string 22 | /** 23 | * @default Infinity 24 | */ 25 | numericPrecision?: number 26 | } 27 | 28 | export class LinearGradient extends Tag { 29 | constructor({ 30 | stops, 31 | colors, 32 | start = vec2(0, 0), 33 | end = vec2(0, 1), 34 | id = Math.random().toString(16).replace(/^0\./, ''), 35 | numericPrecision = Infinity, 36 | }: LinearGradientAttributes = {}) { 37 | if ((stops?.length ?? 0) === 0 && (colors?.length ?? 0) === 0) { 38 | throw new Error( 39 | 'Cannot create linear gradient without at least one stop or color', 40 | ) 41 | } 42 | 43 | super('linearGradient', { 44 | x1: start.x, 45 | x2: end.x, 46 | y1: start.y, 47 | y2: end.y, 48 | id, 49 | }) 50 | this.numericPrecision = numericPrecision 51 | const colorStops: [number, ColorHsl | ColorRgb][] = 52 | stops?.map(([num, color]) => [ 53 | num, 54 | typeof color === 'string' ? ColorRgb.fromHex(color) : color, 55 | ]) ?? 56 | colors?.map((color, i, array) => [ 57 | (i / (array.length - 1)) * 100, 58 | typeof color === 'string' ? ColorRgb.fromHex(color) : color, 59 | ]) ?? 60 | [] 61 | this.children = 62 | colorStops?.map( 63 | (stop) => new LinearGradientStop(stop, this.numericPrecision), 64 | ) ?? [] 65 | } 66 | 67 | get id(): string { 68 | return this.attributes.id 69 | } 70 | } 71 | 72 | class LinearGradientStop extends Tag { 73 | /** @param {ColorStop} stop */ 74 | constructor(stop: ColorStop, numericPrecision = Infinity) { 75 | super('stop', { 76 | offset: stop[0], 77 | 'stop-color': stop[1], 78 | }) 79 | this.numericPrecision = numericPrecision 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/noise/compressor.ts: -------------------------------------------------------------------------------- 1 | import { ClosedInterval } from '../types.js' 2 | 3 | export type CompressorOptions = { 4 | /** 5 | * the knee. Should be proportionate to the signal being compressed, and greater than 0. 6 | */ 7 | W: number 8 | /** 9 | * the threshold. Should be proportionate to the signal being compressed, and greater than 0. 10 | */ 11 | T: number 12 | /** 13 | * the compression ratio. Should be in range [1, Infinity] 14 | */ 15 | R: ClosedInterval<1, typeof Infinity> 16 | } 17 | 18 | // based on https://dsp.stackexchange.com/questions/73619/how-to-derive-equation-for-second-order-interpolation-of-soft-knee-cutoff-in-a-c 19 | export class Compressor { 20 | W: number 21 | T: number 22 | R: ClosedInterval<1, typeof Infinity> 23 | 24 | constructor({ W, T, R }: CompressorOptions) { 25 | this.W = W 26 | this.T = T 27 | if (this.T < 0) { 28 | console.warn( 29 | `T is '${T}', but was expected to be in range [0, Infinity]. Wonkiness may ensue.`, 30 | ) 31 | } 32 | this.R = R 33 | } 34 | /** 35 | * This should be the only public function, 36 | * but while developing I need to have the other functions available for testing. 37 | */ 38 | compress(input: number): number { 39 | if (this.belowKnee(input)) { 40 | return input 41 | } 42 | if (this.insideKnee(input)) { 43 | return this.compressInsideKnee(input) 44 | } 45 | return this.compressAboveKnee(input) 46 | } 47 | 48 | belowKnee(input: number): boolean { 49 | // original formula 50 | // return 2 * (input - this.T) < -this.W 51 | return Math.abs(input) < this.T && 2 * (Math.abs(input) - this.T) < -this.W 52 | } 53 | 54 | insideKnee(input: number): boolean { 55 | return 2 * Math.abs(Math.abs(input) - this.T) <= this.W 56 | } 57 | 58 | compressInsideKnee(input: number): number { 59 | const sign = input < 0 ? -1 : 1 60 | return ( 61 | sign * 62 | (Math.abs(input) + 63 | ((1 / this.R - 1) * (Math.abs(input) - this.T + this.W / 2) ** 2) / 64 | (2 * this.W)) 65 | ) 66 | } 67 | 68 | /** 69 | * Above the knee, compression is same as "hard knee" compression formula 70 | */ 71 | compressAboveKnee(input: number): number { 72 | const sign = input < 0 ? -1 : 1 73 | return sign * (this.T + (Math.abs(input) - this.T) / this.R) 74 | } 75 | 76 | /** 77 | * Returns current compressor options. 78 | */ 79 | options(): CompressorOptions { 80 | return { W: this.W, T: this.T, R: this.R } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /examples/oscillator-noise.js: -------------------------------------------------------------------------------- 1 | import { 2 | renderSvg, 3 | map, 4 | vec2, 5 | randomSeed, 6 | createRng, 7 | Vector2, 8 | random, 9 | ColorRgb, 10 | PI, 11 | cos, 12 | sin, 13 | ColorSequence, 14 | shuffle, 15 | createOscNoise, 16 | } from '../dist/index.js' 17 | 18 | const config = { 19 | width: 100, 20 | height: 100, 21 | scale: 3, 22 | loopCount: 1, 23 | } 24 | 25 | let seed = 5318189853830211 // randomSeed() 26 | 27 | const colors = ['#B2D0DE', '#E0A0A5', '#9BB3E7', '#F1D1B8', '#D9A9D6'] 28 | 29 | renderSvg(config, (svg) => { 30 | // filenameMetadata will be added to the filename that is written to disk; 31 | // this makes it easy to recall which seeds were used in a particular sketch 32 | svg.filenameMetadata = { seed } 33 | 34 | // a seeded pseudo-random number generator provides controlled randomness for our sketch 35 | const rng = createRng(seed) 36 | 37 | // black background 😎 38 | svg.setBackground('#000') 39 | 40 | // set some basic SVG props 41 | svg.fill = null 42 | svg.stroke = ColorRgb.Black 43 | svg.strokeWidth = 0.25 44 | svg.numericPrecision = 3 45 | 46 | // create a 2D noise function using the built-in "oscillator noise" 47 | const noiseFn = createOscNoise(seed) 48 | 49 | // create a bunch of random start points within the svg boundaries 50 | const nPoints = 200 51 | const points = new Array(nPoints) 52 | .fill(0) 53 | .map(() => Vector2.random(0, svg.width, 0, svg.height, rng)) 54 | 55 | // define a color spectrum that can be indexed randomly for line colors 56 | const spectrum = ColorSequence.fromColors(shuffle(colors, rng)) 57 | 58 | // noise functions usually require some type of scaling; 59 | // here we randomize slightly to get the amount of "flowiness" that we want. 60 | const scale = random(0.05, 0.13, rng) 61 | 62 | // each start point gets a line 63 | for (const point of points) { 64 | svg.path((path) => { 65 | // choose a random stroke color for the line 66 | path.stroke = spectrum.at(random(0, 1, rng)) 67 | 68 | // move along the vector field defined by the 2D noise function. 69 | // the line length is "100", which is totally arbitrary. 70 | path.moveTo(point) 71 | for (let i = 0; i < 100; i++) { 72 | let noise = noiseFn(path.cursor.x * scale, path.cursor.y * scale) 73 | let angle = map(-1, 1, -PI, PI, noise) 74 | path.lineTo(path.cursor.add(vec2(cos(angle), sin(angle)))) 75 | } 76 | }) 77 | } 78 | 79 | // when loopCount > 1, this will randomize the seed on each iteration 80 | return () => { 81 | seed = randomSeed() 82 | } 83 | }) 84 | -------------------------------------------------------------------------------- /lib/render.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper functions for running this framework locally as an art platform. 3 | * 4 | * This file is exported separately from the rest of the lib to isolate Node.js dependencies. 5 | */ 6 | 7 | import { mkdirSync, writeFileSync } from 'node:fs' 8 | import { execSync } from 'node:child_process' 9 | import { basename, extname, join } from 'node:path' 10 | import { Svg, SvgAttributes, SvgBuilder } from './components/index.js' 11 | 12 | const NOOP = () => {} 13 | 14 | /** 15 | * RenderLoopOptions defines the options for the render loop. 16 | */ 17 | export type RenderLoopOptions = { 18 | /** 19 | * Number of times the render loop will run. Each loop will write the SVG to a file and open it if `open` is true. 20 | * @default 1 21 | */ 22 | loopCount?: number 23 | /** 24 | * Opens the rendered SVG after every frame using the system `open` command. 25 | * @default true 26 | */ 27 | openEveryFrame?: boolean 28 | /** 29 | * Logs the filename to "console.log" after every frame. 30 | * @default true 31 | */ 32 | logFilename?: boolean 33 | /** 34 | * The directory to write the rendered SVGs to. 35 | * @default 'screenshots' 36 | */ 37 | renderDirectory?: string 38 | } 39 | 40 | export function timestamp(d = new Date()): string { 41 | return d.toISOString().replace(/[^a-zA-Z0-9]/g, '-') 42 | } 43 | 44 | /** 45 | * @returns the most recent rendered SVG 46 | */ 47 | export function renderSvg( 48 | { 49 | loopCount = 1, 50 | openEveryFrame = true, 51 | logFilename = true, 52 | renderDirectory = 'screenshots', 53 | ...svgAttributes 54 | }: SvgAttributes & RenderLoopOptions, 55 | builder: SvgBuilder, 56 | ): string { 57 | let loops = 0 58 | let rendered = '' 59 | while (loops < loopCount) { 60 | const svg = new Svg(svgAttributes) 61 | loops++ 62 | const sketchFilename = basename(process.argv[1], extname(process.argv[1])) 63 | mkdirSync(join(renderDirectory, sketchFilename), { recursive: true }) 64 | const postLoop = builder(svg) ?? NOOP 65 | const filename = join( 66 | renderDirectory, 67 | sketchFilename, 68 | `${timestamp()}-${svg.formatFilenameMetadata()}.svg`, 69 | ) 70 | rendered = svg.render() 71 | writeFileSync(filename, rendered) 72 | if (openEveryFrame) { 73 | const command = process.platform === 'win32' ? 'start' : 'open' 74 | execSync(`${command} "${filename}"`) 75 | } 76 | if (logFilename) { 77 | console.log(filename) 78 | } 79 | postLoop() 80 | } 81 | return rendered 82 | } 83 | 84 | /** 85 | * Turns the `render` function into a NOOP. 86 | * Useful if you want to explore multiple algorithms in the same sketch 87 | */ 88 | renderSvg.skip = NOOP 89 | -------------------------------------------------------------------------------- /lib/components/path.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import { path } from './path' 3 | import assert from 'node:assert' 4 | import { vec2 } from '../vector2' 5 | 6 | describe('Path', () => { 7 | it('moveTo should add the correct path instruction', () => { 8 | const p = path((p) => { 9 | p.moveTo(vec2(5, 5)) 10 | p.moveTo(vec2(10, 10)) 11 | }) 12 | assert.strictEqual(p.render(), '') 13 | }) 14 | 15 | it('lineTo should add the correct path instruction', () => { 16 | const p = path((p) => { 17 | p.moveTo(vec2(5, 5)) 18 | p.lineTo(vec2(10, 10)) 19 | }) 20 | assert.strictEqual(p.render(), '') 21 | }) 22 | 23 | it('cubicBezier should add the correct path instruction', () => { 24 | const p = path((p) => { 25 | p.moveTo(vec2(5, 5)) 26 | p.cubicBezier(vec2(10, 10), vec2(15, 15), vec2(20, 20)) 27 | }) 28 | assert.strictEqual( 29 | p.render(), 30 | '', 31 | ) 32 | }) 33 | 34 | it('smoothBezier should add the correct path instruction', () => { 35 | const p = path((p) => { 36 | p.moveTo(vec2(5, 5)) 37 | p.smoothBezier(vec2(10, 10), vec2(15, 15)) 38 | }) 39 | assert.strictEqual(p.render(), '') 40 | }) 41 | 42 | it('close should add the correct path instruction', () => { 43 | const p = path((p) => { 44 | p.moveTo(vec2(80, 80)) 45 | p.close() 46 | }) 47 | assert.strictEqual(p.render(), '') 48 | }) 49 | 50 | it('should render an arc correctly', () => { 51 | const p = path((p) => { 52 | p.moveTo(vec2(80, 80)) 53 | p.arc({ 54 | rx: 45, 55 | ry: 45, 56 | xAxisRotation: 0, 57 | largeArcFlag: false, 58 | sweepFlag: false, 59 | end: vec2(125, 125), 60 | }) 61 | p.lineTo(vec2(125, 80)) 62 | p.close() 63 | }) 64 | assert.strictEqual( 65 | p.render(), 66 | '', 67 | ) 68 | }) 69 | 70 | it('should render a quadratic bezier correctly', () => { 71 | const p = path((p) => { 72 | p.moveTo(vec2(5, 5)) 73 | p.quadraticBezier(vec2(10, 10), vec2(15, 15)) 74 | }) 75 | assert.strictEqual(p.render(), '') 76 | }) 77 | 78 | it('should render a smooth quadratic bezier correctly', () => { 79 | const p = path((p) => { 80 | p.moveTo(vec2(5, 5)) 81 | p.smoothQuadraticBezier(vec2(10, 10), vec2(15, 15)) 82 | }) 83 | assert.strictEqual(p.render(), '') 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | lib/**/*.d.ts 133 | !lib/types.d.ts 134 | package-lock.json 135 | .DS_Store 136 | .vscode 137 | screenshots -------------------------------------------------------------------------------- /lib/math.ts: -------------------------------------------------------------------------------- 1 | import { Radians } from './types.js' 2 | import { Vector2 } from './vector2.js' 3 | 4 | // some helpers to avoid the `Math.` namespace everywhere 5 | export const cos = Math.cos 6 | export const sin = Math.sin 7 | export const tan = Math.tan 8 | export const atan2 = Math.atan2 9 | 10 | /** 11 | * Hypotenuse of a right triangle 12 | */ 13 | export function hypot(x: number, y: number): number { 14 | return Math.sqrt(x * x + y * y) 15 | } 16 | 17 | /** 18 | * Returns if a number is in a range 19 | */ 20 | export function isWithin(min: number, max: number, value: number): boolean { 21 | return value > min && value < max 22 | } 23 | 24 | /** 25 | * Returns if a number is in a range [target-error, target+error] 26 | */ 27 | export function isWithinError( 28 | target: number, 29 | error: number, 30 | value: number, 31 | ): boolean { 32 | return value > target - error && value < target + error 33 | } 34 | 35 | /** 36 | * Returns the angle (directionally-aware) of the smallest angular difference between them. 37 | * The result will always assume traveling from `angle1` to `angle2`; 38 | * that is, if angle1 is anti-clockwise of angle2, the result will be positive (traveling clockwise to reach angle2), 39 | * and if angle1 is clockwise of angle2, the result will be negative (traveling anti-clockwise to reach angle2). 40 | */ 41 | export function smallestAngularDifference( 42 | angle1: Radians, 43 | angle2: Radians, 44 | ): Radians { 45 | let diff = angle2 - angle1 46 | 47 | // the smallest angular rotation should always be in range [-π, π] 48 | while (diff > Math.PI) { 49 | diff -= Math.PI * 2 50 | } 51 | while (diff < -Math.PI) { 52 | diff += Math.PI * 2 53 | } 54 | 55 | return diff 56 | } 57 | 58 | /** 59 | * Three vertices define an angle. 60 | * Param `point2` is the vertex. 61 | * Params `point1` and `point3` are the two end points 62 | * 63 | * Two formula for the same thing 64 | * cos-1 ( (a · b) / (|a| |b|) ) 65 | * sin-1 ( |a × b| / (|a| |b|) ) 66 | */ 67 | export function angleOfVertex( 68 | point1: Vector2, 69 | point2: Vector2, 70 | point3: Vector2, 71 | ): number { 72 | const a = point1.subtract(point2) 73 | const b = point3.subtract(point2) 74 | const lengthProduct = a.length() * b.length() 75 | return Math.acos(a.dot(b) / lengthProduct) 76 | } 77 | 78 | export function haveSameSign(number1: number, number2: number): boolean { 79 | return number1 < 0 === number2 < 0 80 | } 81 | 82 | /** 83 | * Sets a value to a fixed precision. 84 | * @example toFixedPrecision(1.2345, 2) // 1.23 85 | * @param {number} value the value to be set to a fixed precision 86 | * @param {Integer} precision the number of decimal places to keep 87 | * @returns {number} 88 | */ 89 | export function toFixedPrecision(value: number, precision: number): number { 90 | if (precision === Infinity || precision < 0) { 91 | return value 92 | } 93 | return Math.round(value * 10 ** precision) / 10 ** precision 94 | } 95 | -------------------------------------------------------------------------------- /lib/color/color-sequence.ts: -------------------------------------------------------------------------------- 1 | import { ColorRgb } from './rgb.js' 2 | import { ColorHsl } from './hsl.js' 3 | import { ClosedInterval } from '../types.js' 4 | 5 | export type Color = string | ColorRgb | ColorHsl 6 | 7 | /** 8 | * `number` attribute is value in range [0, 1] that identifies the part of the spectrum where the color is present. 9 | */ 10 | export type ColorStop = [ClosedInterval<0, 1>, Color] 11 | 12 | /** 13 | * The public interface can accept any type of color (string, rgb, hsl), but internally it is stored as Hsl 14 | */ 15 | type InternalColorStop = [ClosedInterval<0, 1>, ColorHsl] 16 | 17 | /** 18 | * @description A sequence of colors that can be linearly interpolated together. 19 | * @example 20 | * const spectrum = ColorSequence.fromColors(['#ff0000', '#00ff00', '#0000ff']) 21 | * spectrum.at(0) // ColorHsl ColorHsl { h: 0, s: 1, l: 0.5, a: 1 } 22 | * spectrum.at(0.5) // ColorHsl { h: 120, s: 1, l: 0.5, a: 1 } 23 | * spectrum.at(1) // ColorHsl { h: 240, s: 1, l: 0.5, a: 1 } 24 | * @example 25 | * // You may customize the "stops" as needed 26 | * const spectrum = new ColorSequence([ 27 | * [0, '#ff0000'], 28 | * [0.7, '#00ff00'], 29 | * [1, '#0000ff'] 30 | * ]) 31 | * spectrum.at(0) // ColorHsl { h: 0, s: 1, l: 0.5, a: 1 } 32 | * spectrum.at(0.5) // ColorHsl { h: 85.71428571428572, s: 1, l: 0.5, a: 1 } 33 | * spectrum.at(0.7) // ColorHsl { h: 120, s: 1, l: 0.5, a: 1 } 34 | * spectrum.at(1) // ColorHsl { h: 240, s: 1, l: 0.5, a: 1 } 35 | */ 36 | export class ColorSequence { 37 | #pairs: InternalColorStop[] 38 | 39 | constructor(pairs: ColorStop[]) { 40 | this.#pairs = pairs.map(([stopVal, color]) => [stopVal, colorToHsl(color)]) 41 | } 42 | 43 | /** 44 | * @param {Color[]} colors list of colors (hex strings, ColorRgb instances, or ColorHsl instances) 45 | */ 46 | static fromColors(colors: Color[]): ColorSequence { 47 | return new ColorSequence( 48 | colors.map((color, i, array) => [ 49 | i / (array.length - 1), 50 | colorToHsl(color), 51 | ]), 52 | ) 53 | } 54 | 55 | /** 56 | * Returns a linearly interpolated color from the color sequence based on the `t` value. 57 | */ 58 | at(t: number): ColorHsl { 59 | const stopB = this.#pairs.findIndex(([stopVal]) => stopVal >= t) 60 | if (stopB === 0 || this.#pairs.length === 1) { 61 | return this.#pairs[stopB][1] 62 | } 63 | if (stopB === -1) { 64 | return this.#pairs[this.#pairs.length - 1][1] 65 | } 66 | const stopA = stopB - 1 67 | const range = this.#pairs[stopB][0] - this.#pairs[stopA][0] 68 | const percentage = (t - this.#pairs[stopA][0]) / range 69 | return this.#pairs[stopA][1].mix(this.#pairs[stopB][1], percentage) 70 | } 71 | } 72 | 73 | function colorToHsl(color: Color): ColorHsl { 74 | if (typeof color === 'string') { 75 | return ColorRgb.fromHex(color).toHsl() 76 | } 77 | if (color instanceof ColorRgb) { 78 | return color.toHsl() 79 | } 80 | if (color instanceof ColorHsl) { 81 | return color 82 | } 83 | throw new Error(`Invalid color type: ${color}`) 84 | } 85 | -------------------------------------------------------------------------------- /examples/recursive-triangles.js: -------------------------------------------------------------------------------- 1 | /* 2 | Rules 3 | 4 | 1. Draw an equilateral triangle in the center of the viewBox 5 | 2. Subdivide the triangle into 4 equal-sized smaller triangles 6 | 3. If less than max depth and , continue recursively subdividing 7 | 4. Each triangle gets a different fun-colored fill, and a slightly-opacified stroke 8 | */ 9 | import { 10 | renderSvg, 11 | vec2, 12 | randomSeed, 13 | createRng, 14 | Vector2, 15 | random, 16 | randomInt, 17 | PI, 18 | ColorSequence, 19 | shuffle, 20 | TAU, 21 | ColorRgb, 22 | } from '../dist/index.js' 23 | 24 | const config = { 25 | width: 100, 26 | height: 100, 27 | scale: 3, 28 | loopCount: 1, 29 | } 30 | 31 | let seed = 8852037180828291 // or, randomSeed() 32 | 33 | const colors = [ 34 | '#974F7A', 35 | '#D093C2', 36 | '#6F9EB3', 37 | '#E5AD5A', 38 | '#EEDA76', 39 | '#B5CE8D', 40 | '#DAE7E8', 41 | '#2E4163', 42 | ] 43 | 44 | const bg = '#2E4163' 45 | const stroke = ColorRgb.fromHex('#DAE7E8') 46 | 47 | renderSvg(config, (svg) => { 48 | const rng = createRng(seed) 49 | const maxDepth = randomInt(5, 7, rng) 50 | svg.filenameMetadata = { seed, maxDepth } 51 | svg.setBackground(bg) 52 | svg.numericPrecision = 3 53 | svg.fill = bg 54 | svg.stroke = stroke 55 | svg.strokeWidth = 0.25 56 | const spectrum = ColorSequence.fromColors(shuffle(colors, rng)) 57 | 58 | function drawTriangle(a, b, c, depth = 0) { 59 | // always draw the first triangle; then, draw about half of the triangles 60 | if (depth === 0 || random(0, 1, rng) < 0.5) { 61 | // offset amount increases with depth 62 | const offsetAmount = depth / 2 63 | const offset = vec2( 64 | random(-offsetAmount, offsetAmount, rng), 65 | random(-offsetAmount, offsetAmount, rng), 66 | ) 67 | // draw the triangle with some offset 68 | svg.polygon({ 69 | points: [a.add(offset), b.add(offset), c.add(offset)], 70 | fill: spectrum.at(random(0, 1, rng)).opacify(0.4).toHex(), 71 | stroke: stroke.opacify(1 / (depth / 4 + 1)).toHex(), 72 | }) 73 | } 74 | // recurse if we're above maxDepth and "lady chance allows it" 75 | if (depth < maxDepth && (depth < 2 || random(0, 1, rng) < 0.75)) { 76 | const ab = Vector2.mix(a, b, 0.5) 77 | const ac = Vector2.mix(a, c, 0.5) 78 | const bc = Vector2.mix(b, c, 0.5) 79 | drawTriangle(ab, ac, bc, depth + 1) 80 | drawTriangle(a, ab, ac, depth + 1) 81 | drawTriangle(b, bc, ab, depth + 1) 82 | drawTriangle(c, bc, ac, depth + 1) 83 | } 84 | } 85 | 86 | // construct an equilateral triangle from the center of the canvas with a random rotation 87 | const angle = random(0, TAU, rng) 88 | const a = svg.center.add(Vector2.fromAngle(angle).scale(45)) 89 | const b = svg.center.add(Vector2.fromAngle(angle + (PI * 2) / 3).scale(45)) 90 | const c = svg.center.add(Vector2.fromAngle(angle + (PI * 4) / 3).scale(45)) 91 | drawTriangle(a, b, c) 92 | 93 | // when loopCount > 1, this will randomize the seed on each iteration 94 | return () => { 95 | seed = randomSeed() 96 | } 97 | }) 98 | -------------------------------------------------------------------------------- /lib/components/polygon.ts: -------------------------------------------------------------------------------- 1 | import { toFixedPrecision } from '../math.js' 2 | import { Decimal } from '../types.js' 3 | import { Vector2 } from '../vector2.js' 4 | import { Rectangle } from './rectangle.js' 5 | import { Tag } from './tag.js' 6 | 7 | type PolygonAttributes = { 8 | points?: Vector2[] 9 | } 10 | 11 | export class Polygon extends Tag { 12 | points: Vector2[] = [] 13 | /** 14 | * Initialize to "empty" rectangle 15 | */ 16 | #boundingBox: Rectangle = new Rectangle({ x: 0, y: 0, width: 0, height: 0 }) 17 | #xs: Decimal[] = [] 18 | #ys: Decimal[] = [] 19 | 20 | constructor( 21 | { points = [], ...attributes }: PolygonAttributes = { points: [] }, 22 | ) { 23 | super('polygon', attributes) 24 | this.points = points 25 | this.#xs = points.map(({ x }) => x) 26 | this.#ys = points.map(({ y }) => y) 27 | } 28 | 29 | /** @returns {Rectangle} */ 30 | get boundingBox(): Rectangle { 31 | if (this.#boundingBox.empty()) { 32 | const minX = Math.min(...this.#xs) 33 | const maxX = Math.max(...this.#xs) 34 | const minY = Math.min(...this.#ys) 35 | const maxY = Math.max(...this.#ys) 36 | this.#boundingBox = new Rectangle({ 37 | x: minX, 38 | y: minY, 39 | width: maxX - minX, 40 | height: maxY - minY, 41 | }) 42 | } 43 | return this.#boundingBox 44 | } 45 | 46 | /** 47 | * Returns true when the given point is inside the polygon, and false when outside. 48 | * If the point lies on the edge of the polygon, the results might not be predictable. 49 | * Credit: https://stackoverflow.com/a/2922778/3991555 50 | * Original: https://wrf.ecse.rpi.edu/Research/Short_Notes/pnpoly.html 51 | * @param {Vector2} point 52 | */ 53 | contains(point: Vector2): boolean { 54 | let i 55 | let j 56 | let c = false 57 | for (i = 0, j = this.points.length - 1; i < this.points.length; j = i++) { 58 | if ( 59 | this.#ys[i] > point.y !== this.#ys[j] > point.y && 60 | point.x < 61 | ((this.#xs[j] - this.#xs[i]) * (point.y - this.#ys[i])) / 62 | (this.#ys[j] - this.#ys[i]) + 63 | this.#xs[i] 64 | ) { 65 | c = !c 66 | } 67 | } 68 | return c 69 | } 70 | 71 | render(): string { 72 | if (!Array.isArray(this.points) || this.points.length === 0) { 73 | throw new Error('Cannot render a Polygon without points') 74 | } 75 | this.setAttributes({ 76 | points: this.points 77 | .map((vec) => 78 | [ 79 | toFixedPrecision(vec.x, this.numericPrecision), 80 | toFixedPrecision(vec.y, this.numericPrecision), 81 | ].join(','), 82 | ) 83 | .join(' '), 84 | }) 85 | return super.render() 86 | } 87 | } 88 | 89 | export function polygon(atts: PolygonAttributes): Polygon 90 | export function polygon(builder: (p: Polygon) => void): Polygon 91 | export function polygon( 92 | attrsOrBuilder: PolygonAttributes | ((poly: Polygon) => void), 93 | ): Polygon { 94 | if (typeof attrsOrBuilder === 'function') { 95 | const poly = new Polygon() 96 | attrsOrBuilder(poly) 97 | return poly 98 | } 99 | return new Polygon(attrsOrBuilder) 100 | } 101 | -------------------------------------------------------------------------------- /lib/components/polyline.ts: -------------------------------------------------------------------------------- 1 | import { toFixedPrecision } from '../math.js' 2 | import { Vector2 } from '../vector2.js' 3 | import { Rectangle } from './rectangle.js' 4 | import { Tag } from './tag.js' 5 | 6 | type PolylineAttributes = { 7 | points?: Vector2[] 8 | } 9 | 10 | export class Polyline extends Tag { 11 | cursor = new Vector2(0, 0) 12 | points: Vector2[] = [] 13 | 14 | /** 15 | * Initialize to "empty" rectangle 16 | */ 17 | #boundingBox = new Rectangle({ x: 0, y: 0, width: 0, height: 0 }) 18 | 19 | constructor({ points = [], ...attributes }: PolylineAttributes = {}) { 20 | super('polyline', attributes) 21 | this.points = points 22 | this.cursor = points[points.length - 1] ?? new Vector2(0, 0) 23 | } 24 | 25 | /** @returns {Rectangle} */ 26 | get boundingBox(): Rectangle { 27 | if (this.#boundingBox.empty()) { 28 | const xs = this.points.map(({ x }) => x) 29 | const ys = this.points.map(({ y }) => y) 30 | const minX = Math.min(...xs) 31 | const maxX = Math.max(...xs) 32 | const minY = Math.min(...ys) 33 | const maxY = Math.max(...ys) 34 | this.#boundingBox = new Rectangle({ 35 | x: minX, 36 | y: minY, 37 | width: maxX - minX, 38 | height: maxY - minY, 39 | }) 40 | } 41 | return this.#boundingBox 42 | } 43 | 44 | /** 45 | * Adds a point to the points of the polyline. 46 | * @param {Vector2} point 47 | */ 48 | push(point: Vector2) { 49 | this.points.push(point) 50 | this.cursor = point 51 | } 52 | 53 | /** 54 | * @returns {boolean} 55 | */ 56 | empty(): boolean { 57 | return this.points.length === 0 58 | } 59 | 60 | render(): string { 61 | if (!Array.isArray(this.points) || this.points.length === 0) { 62 | throw new Error('Cannot render a Polyline without points') 63 | } 64 | this.setAttributes({ 65 | points: this.points 66 | .map((vec) => 67 | [ 68 | toFixedPrecision(vec.x, this.numericPrecision), 69 | toFixedPrecision(vec.y, this.numericPrecision), 70 | ].join(','), 71 | ) 72 | .join(' '), 73 | }) 74 | return super.render() 75 | } 76 | } 77 | 78 | export function polyline(atts: PolylineAttributes): Polyline 79 | export function polyline(builder: (p: Polyline) => void): Polyline 80 | export function polyline( 81 | attrsOrBuilder: PolylineAttributes | ((poly: Polyline) => void), 82 | ): Polyline { 83 | if (typeof attrsOrBuilder === 'function') { 84 | const poly = new Polyline() 85 | attrsOrBuilder(poly) 86 | return poly 87 | } 88 | return new Polyline(attrsOrBuilder) 89 | } 90 | 91 | // I would prefer this to live in it's own file but it creates a circular dependency. oh well. 92 | export class LineSegment extends Polyline { 93 | /** 94 | * @param {Vector2} start 95 | * @param {Vector2} end 96 | */ 97 | constructor(start: Vector2, end: Vector2) { 98 | super({ 99 | points: [start, end], 100 | }) 101 | } 102 | } 103 | 104 | /** 105 | * @param {Vector2} start 106 | * @param {Vector2} end 107 | * @returns {LineSegment} 108 | */ 109 | export function lineSegment(start: Vector2, end: Vector2): LineSegment { 110 | return new LineSegment(start, end) 111 | } 112 | -------------------------------------------------------------------------------- /lib/color/rgb.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { ColorRgb, rgb } from './rgb' 4 | 5 | describe('rgb', () => { 6 | it('returns instanceof ColorRgb', () => { 7 | assert(rgb(1, 1, 1) instanceof ColorRgb) 8 | }) 9 | }) 10 | 11 | describe('ColorRgb', () => { 12 | it('correctly stringifies with interpolation', () => { 13 | const actual = `${new ColorRgb(0.1, 0.2, 0.4, 1)}` 14 | assert.strictEqual(actual, 'rgb(25.5, 51, 102, 1)') 15 | }) 16 | 17 | it('correctly stringifies with `toString()`', () => { 18 | const actual = new ColorRgb(0.1, 0.2, 0.4, 1).toString() 19 | assert.strictEqual(actual, 'rgb(25.5, 51, 102, 1)') 20 | }) 21 | 22 | describe('fromHex', () => { 23 | /** @type {[string, {r: number, g: number, b: number, a: number }][]} */ 24 | const tests = [ 25 | ['#000', { r: 0, g: 0, b: 0, a: 1 }], 26 | ['#fff', { r: 1, g: 1, b: 1, a: 1 }], 27 | [ 28 | '#888', 29 | { r: (17 * 8) / 255, g: (17 * 8) / 255, b: (17 * 8) / 255, a: 1 }, 30 | ], 31 | [ 32 | '#999', 33 | { r: (17 * 9) / 255, g: (17 * 9) / 255, b: (17 * 9) / 255, a: 1 }, 34 | ], 35 | ['#ffffff', { r: 1, g: 1, b: 1, a: 1 }], 36 | ['#ffffff00', { r: 1, g: 1, b: 1, a: 0 }], 37 | ['#000000', { r: 0, g: 0, b: 0, a: 1 }], 38 | ['#00000000', { r: 0, g: 0, b: 0, a: 0 }], 39 | ['#000', { r: 0, g: 0, b: 0, a: 1 }], 40 | ['#fff', { r: 1, g: 1, b: 1, a: 1 }], 41 | ['#000000', { r: 0, g: 0, b: 0, a: 1 }], 42 | ['#ffffff', { r: 1, g: 1, b: 1, a: 1 }], 43 | ['0x000', { r: 0, g: 0, b: 0, a: 1 }], 44 | ['0xfff', { r: 1, g: 1, b: 1, a: 1 }], 45 | [ 46 | '0x888', 47 | { r: (17 * 8) / 255, g: (17 * 8) / 255, b: (17 * 8) / 255, a: 1 }, 48 | ], 49 | [ 50 | '0x999', 51 | { r: (17 * 9) / 255, g: (17 * 9) / 255, b: (17 * 9) / 255, a: 1 }, 52 | ], 53 | ['0xffffff', { r: 1, g: 1, b: 1, a: 1 }], 54 | ['0xffffff00', { r: 1, g: 1, b: 1, a: 0 }], 55 | ['0x000000', { r: 0, g: 0, b: 0, a: 1 }], 56 | ['0x00000000', { r: 0, g: 0, b: 0, a: 0 }], 57 | ] 58 | 59 | for (const [hex, { r, g, b, a }] of tests) { 60 | const actual = ColorRgb.fromHex(hex) 61 | 62 | it(`converts ${hex} red ${r}>`, () => { 63 | assert.strictEqual(actual.r, r) 64 | }) 65 | 66 | it(`converts ${hex} green ${g}>`, () => { 67 | assert.strictEqual(actual.g, g) 68 | }) 69 | 70 | it(`converts ${hex} blue ${b}>`, () => { 71 | assert.strictEqual(actual.b, b) 72 | }) 73 | 74 | it(`converts ${hex} alpha ${a}>`, () => { 75 | assert.strictEqual(actual.a, a) 76 | }) 77 | } 78 | }) 79 | 80 | describe('mix', () => { 81 | it('mixes correctly', () => { 82 | const a = new ColorRgb(0.0, 0.0, 0.0) 83 | const b = new ColorRgb(1, 1, 1) 84 | const actual = a.mix(b) 85 | assert.strictEqual(actual.toString(), 'rgb(127.5, 127.5, 127.5, 1)') 86 | }) 87 | }) 88 | 89 | describe('toHex', () => { 90 | const hexes = [ 91 | '#000000ff', 92 | '#ffffffff', 93 | '#010101ff', 94 | '#fefefeff', 95 | '#ab84f5ff', 96 | ] 97 | for (const hex of hexes) { 98 | it(`converts to and from ${hex}`, () => { 99 | assert.strictEqual(hex, ColorRgb.fromHex(hex).toHex()) 100 | }) 101 | } 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /lib/components/tag.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { Tag } from './tag' 4 | import { LinearGradient } from './linear-gradient' 5 | 6 | describe('Tag', () => { 7 | describe('render', () => { 8 | it('should return a string', () => { 9 | const t = new Tag('test') 10 | assert.strictEqual(typeof t.render(), 'string') 11 | }) 12 | 13 | it('should include all attributes', () => { 14 | const t = new Tag('test', { fill: 'red' }) 15 | assert.strictEqual(t.render(), '') 16 | }) 17 | 18 | it('should round numeric attributes to the specified precision', () => { 19 | const t = new Tag('test', { x: 1.23456 }) 20 | t.numericPrecision = 2 21 | assert.strictEqual(t.render(), '') 22 | }) 23 | 24 | it('should omit undefined attributes', () => { 25 | const t = new Tag('test', { 26 | fill: 'red', 27 | stroke: undefined, 28 | wonky: true, 29 | undef: undefined, 30 | }) 31 | assert.strictEqual(t.render(), '') 32 | }) 33 | }) 34 | 35 | describe('setVisualAttributes', () => { 36 | it('should use incoming attributes when they are not set on the target instance', () => { 37 | const t = new Tag('test') 38 | // @ts-expect-error this is either a lint error or a TS error. It's a test so it's fine 39 | t.setVisualAttributes({ fill: '#fff', stroke: '#000' }) 40 | assert.strictEqual(t.attributes.fill, '#fff') 41 | assert.strictEqual(t.attributes.stroke, '#000') 42 | }) 43 | 44 | it('should use target attributes when they are set on the target instance', () => { 45 | const t = new Tag('test') 46 | t.fill = '#0f0' 47 | t.strokeWidth = 2 48 | // @ts-expect-error this is either a lint error or a TS error. It's a test so it's fine 49 | t.setVisualAttributes({ 50 | fill: '#fff', 51 | 'stroke-width': 5, 52 | stroke: '#000', 53 | }) 54 | assert.strictEqual(t.attributes.fill, '#0f0') 55 | assert.strictEqual(t.attributes['stroke-width'], 2) 56 | assert.strictEqual(t.attributes.stroke, '#000') 57 | }) 58 | 59 | it('should omit attributes that are not defined in either incomine or target', () => { 60 | const t = new Tag('test') 61 | t.fill = '#0f0' 62 | // @ts-expect-error this is either a lint error or a TS error. It's a test so it's fine 63 | t.setVisualAttributes({ 'stroke-width': 5 }) 64 | assert.strictEqual(t.attributes.stroke, undefined) 65 | }) 66 | }) 67 | 68 | describe('addChild', () => { 69 | it('should set visual attributes on the child', () => { 70 | const t = new Tag('test') 71 | t.fill = '#0f0' 72 | const child = new Tag('test') 73 | // @ts-expect-error this is either a lint error or a TS error. It's a test so it's fine 74 | t.addChild(child) 75 | assert.strictEqual(child.attributes.fill, '#0f0') 76 | assert.strictEqual(child.attributes.stroke, undefined) 77 | }) 78 | 79 | it('should set numericPrecision', () => { 80 | const t = new Tag('test') 81 | t.numericPrecision = 2 82 | const child = new Tag('test') 83 | assert.strictEqual(child.numericPrecision, Infinity) 84 | // @ts-expect-error this is either a lint error or a TS error. It's a test so it's fine 85 | t.addChild(child) 86 | assert.strictEqual(child.numericPrecision, 2) 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /lib/color/color-sequence.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { ColorSequence } from './color-sequence' 4 | import { ColorRgb, rgb } from './rgb' 5 | import { ColorHsl, hsl } from './hsl' 6 | 7 | describe('ColorSequence', () => { 8 | describe('constructor', () => { 9 | it('can accept mixed types of colors', () => { 10 | const spectrum = new ColorSequence([ 11 | [0, '010101'], 12 | [0.5, rgb(0.5, 0.9, 0.2)], 13 | [1, hsl(150, 0.5, 0.5)], 14 | ]) 15 | assert( 16 | spectrum.at(1) instanceof ColorHsl, 17 | `spectrum.at(1) is not instance of ColorHsl. Got: ${spectrum.at(1)}`, 18 | ) 19 | assert( 20 | spectrum.at(0) instanceof ColorHsl, 21 | `spectrum.at(0) is not instance of ColorHsl. Got: ${spectrum.at(0)}`, 22 | ) 23 | assert( 24 | spectrum.at(0.5) instanceof ColorHsl, 25 | `spectrum.at(0. is not instance of ColorHsl. Got: ${spectrum.at(0.5)}`, 26 | ) 27 | }) 28 | }) 29 | 30 | describe('fromColors', () => { 31 | it('with two colors, creates a color sequence with color stops at 0 and 1', () => { 32 | const spectrum = ColorSequence.fromColors(['010101', 'fefefe']) 33 | assert.strictEqual(spectrum.at(0).toRgb().toHex(), '#010101ff') 34 | assert.notStrictEqual(spectrum.at(0.01).toRgb().toHex(), '#010101ff') 35 | assert.strictEqual(spectrum.at(1).toRgb().toHex(), '#fefefeff') 36 | assert.notStrictEqual(spectrum.at(0.94).toRgb().toHex(), '#fefefeff') 37 | assert.strictEqual(spectrum.at(0.5).toRgb().toHex(), '#808080ff') 38 | }) 39 | 40 | it('can accept mixed types of colors', () => { 41 | const spectrum = ColorSequence.fromColors([ 42 | '010101', 43 | rgb(0.5, 0.9, 0.2), 44 | hsl(150, 0.5, 0.5), 45 | ]) 46 | assert( 47 | spectrum.at(1) instanceof ColorHsl, 48 | `spectrum.at(1) is not instance of ColorHsl. Got: ${spectrum.at(1)}`, 49 | ) 50 | assert( 51 | spectrum.at(0) instanceof ColorHsl, 52 | `spectrum.at(0) is not instance of ColorHsl. Got: ${spectrum.at(0)}`, 53 | ) 54 | assert( 55 | spectrum.at(0.5) instanceof ColorHsl, 56 | `spectrum.at(0. is not instance of ColorHsl. Got: ${spectrum.at(0.5)}`, 57 | ) 58 | }) 59 | }) 60 | 61 | describe('at', () => { 62 | it('returns lowest stop when `t` is lower than lowest stop is requested', () => { 63 | const spectrum = new ColorSequence([ 64 | [0.3, ColorRgb.fromHex('#ab85a9ff')], 65 | [0.7, ColorRgb.fromHex('#97febcff')], 66 | ]) 67 | assert.strictEqual(spectrum.at(0.3).toRgb().toHex(), '#ab85a9ff') 68 | assert.strictEqual(spectrum.at(0).toRgb().toHex(), '#ab85a9ff') 69 | assert.strictEqual(spectrum.at(-100).toRgb().toHex(), '#ab85a9ff') 70 | }) 71 | 72 | it('returns highest stop when `t` is higher than highest stop is requested', () => { 73 | const spectrum = new ColorSequence([ 74 | [0.3, ColorRgb.fromHex('#ab85a9ff')], 75 | [0.7, ColorRgb.fromHex('#97febcff')], 76 | ]) 77 | assert.strictEqual(spectrum.at(0.7).toRgb().toHex(), '#97febcff') 78 | assert.strictEqual(spectrum.at(1).toRgb().toHex(), '#97febcff') 79 | assert.strictEqual(spectrum.at(100).toRgb().toHex(), '#97febcff') 80 | }) 81 | 82 | it('mixes colors together', () => { 83 | const spectrum = ColorSequence.fromColors(['010101', 'fefefe']) 84 | const actual = spectrum.at(0.5) 85 | assert.strictEqual(actual.toRgb().toHex(), '#808080ff') 86 | }) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /lib/vector3.ts: -------------------------------------------------------------------------------- 1 | import { jitter, random, Rng } from './random.js' 2 | import { ClosedInterval } from './types.js' 3 | 4 | export class Vector3 { 5 | x: number 6 | y: number 7 | z: number 8 | /** 9 | * @param {number} x coordinate 10 | * @param {number} [y] coordinate defaults to `x` if omitted. 11 | * @param {number} [z] coordinate defaults to `y` if omitted. 12 | */ 13 | constructor(x: number, y?: number, z?: number) { 14 | if (typeof x !== 'number') { 15 | throw new Error( 16 | `Vector3 constructor requires a number for x, got ${typeof x}`, 17 | ) 18 | } 19 | this.x = x 20 | this.y = y ?? x 21 | this.z = z ?? this.y 22 | } 23 | 24 | add(other: Vector3): Vector3 { 25 | return vec3(this.x + other.x, this.y + other.y, this.z + other.z) 26 | } 27 | 28 | subtract(other: Vector3): Vector3 { 29 | return vec3(this.x - other.x, this.y - other.y, this.z - other.z) 30 | } 31 | 32 | divide(n: number): Vector3 { 33 | return vec3(this.x / n, this.y / n, this.z / n) 34 | } 35 | 36 | multiply(n: number): Vector3 { 37 | return vec3(this.x * n, this.y * n, this.z * n) 38 | } 39 | 40 | /** 41 | * Alias for `multiply` 42 | */ 43 | scale(n: number): Vector3 { 44 | return this.multiply(n) 45 | } 46 | 47 | /** 48 | * Returns a Vector3 that is a mix 49 | * @param {Vector3} a 50 | * @param {Vector3} b 51 | * @param {number} mix when 0, returns a; when 1, returns b 52 | * @returns {Vector3} 53 | */ 54 | static mix(a: Vector3, b: Vector3, mix: ClosedInterval<0, 1>) { 55 | return a.multiply(1 - mix).add(b.multiply(mix)) 56 | } 57 | 58 | distanceTo(other: Vector3): number { 59 | return Math.sqrt( 60 | (other.x - this.x) ** 2 + 61 | (other.y - this.y) ** 2 + 62 | (other.z - this.z) ** 2, 63 | ) 64 | } 65 | 66 | /** 67 | * The euclidean length of the vector 68 | */ 69 | length(): number { 70 | return Math.sqrt(this.x ** 2 + this.y ** 2 + this.z ** 2) 71 | } 72 | 73 | /** 74 | * Dot product 75 | */ 76 | dot(other: Vector3): number { 77 | return this.x * other.x + this.y * other.y + this.z * other.z 78 | } 79 | 80 | static midpoint(a: Vector3, b: Vector3): Vector3 { 81 | return vec3((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2) 82 | } 83 | 84 | /** 85 | * Returns a random point in the given bounds. 86 | */ 87 | static random( 88 | xMin: number, 89 | xMax: number, 90 | yMin: number, 91 | yMax: number, 92 | zMin: number, 93 | zMax: number, 94 | rng: Rng, 95 | ): Vector3 { 96 | return vec3( 97 | random(xMin, xMax, rng), 98 | random(yMin, yMax, rng), 99 | random(zMin, zMax, rng), 100 | ) 101 | } 102 | 103 | /** 104 | * Returns a new Vector3, randomly offset by a maximum of `amount` 105 | */ 106 | jitter(amount: number, rng: Rng): Vector3 { 107 | return vec3( 108 | jitter(amount, this.x, rng), 109 | jitter(amount, this.y, rng), 110 | jitter(amount, this.z, rng), 111 | ) 112 | } 113 | 114 | /** 115 | * Value equality check 116 | */ 117 | eq(other: Vector3): boolean { 118 | return this.x === other.x && this.y === other.y && this.z === other.z 119 | } 120 | 121 | toString(): string { 122 | return `Vector3 { x: ${this.x}, y: ${this.y}, z: ${this.z} }` 123 | } 124 | } 125 | 126 | /** 127 | * @param {number} x 128 | * @param {number} [y] defaults to `x` if omitted. 129 | * @param {number} [z] defaults to `y` if omitted. 130 | */ 131 | export function vec3(x: number, y?: number, z?: number): Vector3 { 132 | return new Vector3(x, y ?? x, z ?? y ?? x) 133 | } 134 | -------------------------------------------------------------------------------- /lib/noise/compressor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import { Compressor } from './compressor' 3 | import assert from 'node:assert' 4 | 5 | describe('Compressor', () => { 6 | const compressor = new Compressor({ W: 0.1, T: 0.5, R: 2 }) 7 | 8 | describe('belowKnee', () => { 9 | const tests = [ 10 | [-2.0, false], 11 | [-1.0, false], 12 | [-0.7, false], 13 | [-0.6, false], 14 | [-0.5, false], 15 | [-0.45, false], 16 | // this is the cutoff because the difference between value and threshold is greater than half of "knee" (W) 17 | [-0.449, true], 18 | [-0.4, true], 19 | [-0.3, true], 20 | [-0.2, true], 21 | [-0.1, true], 22 | [0.0, true], 23 | [0.1, true], 24 | [0.2, true], 25 | [0.3, true], 26 | [0.4, true], 27 | [0.449, true], 28 | // cutoff 29 | [0.45, false], 30 | [0.5, false], 31 | [0.6, false], 32 | [0.7, false], 33 | [1.0, false], 34 | [2.0, false], 35 | ] 36 | 37 | for (const test of tests) { 38 | it(`returns "${test[1]}" for input ${test[0]} with threshold ${compressor.T} and knee ${compressor.W}`, () => { 39 | assert.strictEqual(compressor.belowKnee(test[0]), test[1]) 40 | }) 41 | } 42 | }) 43 | 44 | describe('insideKnee', () => { 45 | const tests = [ 46 | [-2.0, false], 47 | [-1.0, false], 48 | [-0.7, false], 49 | [-0.6, false], 50 | // cutoff 51 | [-0.5499, true], 52 | [-0.5, true], 53 | [-0.45, true], 54 | // cutoff 55 | [-0.449, false], 56 | [-0.4, false], 57 | [0.0, false], 58 | [0.4, false], 59 | [0.449, false], 60 | // cutoff 61 | [0.45, true], 62 | [0.5, true], 63 | [0.5499, true], 64 | // cutoff 65 | [0.55, false], 66 | [0.7, false], 67 | [1.0, false], 68 | [2.0, false], 69 | ] 70 | 71 | for (const test of tests) { 72 | it(`returns "${test[1]}" for input ${test[0]} with threshold ${compressor.T} and knee ${compressor.W}`, () => { 73 | assert.strictEqual(compressor.insideKnee(test[0]), test[1]) 74 | }) 75 | } 76 | }) 77 | 78 | describe('compressInsideKnee', () => { 79 | const tests = [ 80 | [-0.5499, -0.5249499750000001], 81 | [-0.525, -0.5109375], 82 | [-0.5, -0.49375], 83 | [-0.475, -0.47343749999999996], 84 | [-0.45, -0.45], 85 | [0.45, 0.45], 86 | [0.475, 0.47343749999999996], 87 | [0.5, 0.49375], 88 | [0.525, 0.5109375], 89 | [0.5499, 0.5249499750000001], 90 | ] 91 | 92 | for (const test of tests) { 93 | it(`returns "${test[1]}" for input ${test[0]} with threshold ${compressor.T} and knee ${compressor.W} and ratio ${compressor.R}`, () => { 94 | assert.strictEqual(compressor.compressInsideKnee(test[0]), test[1]) 95 | }) 96 | } 97 | }) 98 | 99 | describe('compressAboveKnee', () => { 100 | const tests = [ 101 | [-2.0, -1.25], 102 | [-1.0, -0.75], 103 | [-0.7, -0.6], 104 | [-0.6, -0.55], 105 | [-0.55, -0.525], 106 | // note: the output value is just above the upper end of the "compressInsideKnee" function, which is expected 107 | [0.55, 0.525], 108 | [0.6, 0.55], 109 | [0.7, 0.6], 110 | [1.0, 0.75], 111 | [2.0, 1.25], 112 | ] 113 | 114 | for (const test of tests) { 115 | it(`returns "${test[1]}" for input ${test[0]} with threshold ${compressor.T} and knee ${compressor.W} and ratio ${compressor.R}`, () => { 116 | assert.strictEqual(compressor.compressAboveKnee(test[0]), test[1]) 117 | }) 118 | } 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /lib/util.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { 4 | array, 5 | range, 6 | rangeWithIndex, 7 | degToRad, 8 | map, 9 | clamp, 10 | quantize, 11 | pickBy, 12 | } from './util' 13 | 14 | describe('array', () => { 15 | it('returns an array of size n filled with the index', () => { 16 | const actual = array(5) 17 | assert.deepStrictEqual(actual, [0, 1, 2, 3, 4]) 18 | }) 19 | }) 20 | 21 | describe('range', () => { 22 | it('returns an array in range [min, max)', () => { 23 | const actual = range(2, 6) 24 | assert.deepStrictEqual(actual, [2, 3, 4, 5]) 25 | }) 26 | 27 | // probably could be more graceful about this... 28 | it('throws error if min is greater than max', () => { 29 | assert.throws(() => range(3, 2)) 30 | }) 31 | }) 32 | 33 | describe('rangeWithIndex', () => { 34 | it('returns an array in range [min, max) with index attached', () => { 35 | const actual = rangeWithIndex(2, 6) 36 | assert.deepStrictEqual(actual, [ 37 | [2, 0], 38 | [3, 1], 39 | [4, 2], 40 | [5, 3], 41 | ]) 42 | }) 43 | 44 | // probably could be more graceful about this... 45 | it('throws error if min is greater than max', () => { 46 | assert.throws(() => rangeWithIndex(3, 2)) 47 | }) 48 | }) 49 | 50 | describe('degToRad', () => { 51 | const tests = [ 52 | [90, Math.PI / 2], 53 | [180, Math.PI], 54 | [270, (Math.PI * 3) / 2], 55 | [360, Math.PI * 2], 56 | ] 57 | for (const [degrees, radians] of tests) { 58 | it(`converts ${degrees} degrees to ${radians} radians`, () => { 59 | assert.strictEqual(degToRad(degrees), radians) 60 | }) 61 | } 62 | }) 63 | 64 | describe('map', () => { 65 | it('linearly maps values from before range to after range', () => { 66 | const tests = [ 67 | [0, 1, 10, 20, 0.5, 15], 68 | [0, 10, 10, 20, 0, 10], 69 | [0, 10, 10, 20, 1, 11], 70 | [0, 10, 10, 20, 2, 12], 71 | [0, 10, 10, 20, 3, 13], 72 | [0, 10, 10, 20, 4, 14], 73 | [0, 10, 10, 20, 5, 15], 74 | [0, 10, 10, 20, 6, 16], 75 | [0, 10, 10, 20, 7, 17], 76 | [0, 10, 10, 20, 8, 18], 77 | [0, 10, 10, 20, 9, 19], 78 | [0, 10, 10, 20, 10, 20], 79 | ] 80 | 81 | for (const [bL, bR, aL, aR, x, expected] of tests) { 82 | const actual = map(bL, bR, aL, aR, x) 83 | assert.strictEqual(actual, expected) 84 | } 85 | }) 86 | }) 87 | 88 | describe('clamp', () => { 89 | it('clamps to max when value is greater than max', () => { 90 | const actual = clamp(0, 100, 101) 91 | assert.strictEqual(actual, 100) 92 | }) 93 | 94 | it('clamps to min when value is less than min', () => { 95 | const actual = clamp(0, 100, -1) 96 | assert.strictEqual(actual, 0) 97 | }) 98 | 99 | it('does not clamp when value is in range', () => { 100 | const actual = clamp(0, 100, 50) 101 | assert.strictEqual(actual, 50) 102 | }) 103 | }) 104 | 105 | describe('quantize', () => { 106 | it('rounds value up to nearest quantum', () => { 107 | const actual = quantize(5, 4.5) 108 | assert.strictEqual(actual, 5) 109 | }) 110 | 111 | it('rounds value down to nearest quantum', () => { 112 | const actual = quantize(5, 5.5) 113 | assert.strictEqual(actual, 5) 114 | }) 115 | 116 | it('works for arbitrarily large values', () => { 117 | const actual = quantize(5, 5449093209.5) 118 | assert.strictEqual(actual, 5449093210) 119 | }) 120 | }) 121 | 122 | describe('pickBy', () => { 123 | it('returns object of same type that matches predicate', () => { 124 | const obj = { a: 1, b: 2, c: 3 } 125 | // @ts-ignore 126 | const predicate = (val, key) => val < 3 && /[ac]/.test(key) 127 | const actual = pickBy(predicate, obj) 128 | assert.deepStrictEqual(actual, { a: 1 }) 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /lib/random.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The seedable PRNG functions are taken from this SO question: 3 | * https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript 4 | * And specifically this insanely thorough answer: 5 | * https://stackoverflow.com/a/47593316/3991555 6 | */ 7 | 8 | import { ClosedInterval, Integer } from './types.js' 9 | 10 | export type Rng = () => number 11 | 12 | /** 13 | * Credit: https://stackoverflow.com/a/47593316/3991555 14 | */ 15 | export function cyrb128(str: string): [number, number, number, number] { 16 | let h1 = 1779033703 17 | let h2 = 3144134277 18 | let h3 = 1013904242 19 | let h4 = 2773480762 20 | for (let i = 0, k; i < str.length; i++) { 21 | k = str.charCodeAt(i) 22 | h1 = h2 ^ Math.imul(h1 ^ k, 597399067) 23 | h2 = h3 ^ Math.imul(h2 ^ k, 2869860233) 24 | h3 = h4 ^ Math.imul(h3 ^ k, 951274213) 25 | h4 = h1 ^ Math.imul(h4 ^ k, 2716044179) 26 | } 27 | h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067) 28 | h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233) 29 | h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213) 30 | h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179) 31 | return [ 32 | (h1 ^ h2 ^ h3 ^ h4) >>> 0, 33 | (h2 ^ h1) >>> 0, 34 | (h3 ^ h1) >>> 0, 35 | (h4 ^ h1) >>> 0, 36 | ] 37 | } 38 | 39 | /** 40 | * Credit: https://stackoverflow.com/a/47593316/3991555 41 | */ 42 | export function sfc32(i: number, j: number, k: number, l: number): Rng { 43 | let a = i 44 | let b = j 45 | let c = k 46 | let d = l 47 | return () => { 48 | a >>>= 0 49 | b >>>= 0 50 | c >>>= 0 51 | d >>>= 0 52 | let t = (a + b) | 0 53 | a = b ^ (b >>> 9) 54 | b = (c + (c << 3)) | 0 55 | c = (c << 21) | (c >>> 11) 56 | d = (d + 1) | 0 57 | t = (t + d) | 0 58 | c = (c + t) | 0 59 | return (t >>> 0) / 4294967296 60 | } 61 | } 62 | 63 | export function createRng(seed?: string | number): Rng { 64 | const cyrb128seed = cyrb128(String(seed ?? Date.now())) 65 | // Four 32-bit component hashes provide the seed for sfc32. 66 | return sfc32(cyrb128seed[0], cyrb128seed[1], cyrb128seed[2], cyrb128seed[3]) 67 | } 68 | 69 | export function random( 70 | min: number, 71 | max: number, 72 | rng: Rng = Math.random, 73 | ): number { 74 | const [low, high] = min < max ? [min, max] : [max, min] 75 | return low + rng() * (high - low) 76 | } 77 | 78 | export function randomInt( 79 | min: number, 80 | max: number, 81 | rng: Rng = Math.random, 82 | ): Integer { 83 | return Math.floor(random(min, max, rng)) 84 | } 85 | 86 | /** 87 | * @returns {number} an integer in range [0, Number.MAX_SAFE_INTEGER] 88 | */ 89 | export function randomSeed( 90 | rng: Rng = Math.random, 91 | ): ClosedInterval<0, typeof Number.MAX_SAFE_INTEGER> { 92 | return Math.floor(random(0, Number.MAX_SAFE_INTEGER, rng)) 93 | } 94 | 95 | /** 96 | * Returns a random number in range [`value` - `amount`, `value` + `amount`] 97 | */ 98 | export function jitter(amount: number, value: number, rng: Rng): number { 99 | return random(value - amount, value + amount, rng) 100 | } 101 | 102 | /** 103 | * Shuffle an array. 104 | * Returns a new array, does *not* modify in place. 105 | */ 106 | export function shuffle(arr: Array, rng: Rng = Math.random): Array { 107 | const copy = [...arr] // create a copy of original array 108 | for (let i = copy.length - 1; i; i--) { 109 | const randomIndex = Math.floor(random(0, i + 1, rng)) 110 | ;[copy[i], copy[randomIndex]] = [copy[randomIndex], copy[i]] // swap 111 | } 112 | return copy 113 | } 114 | 115 | /** 116 | * Returns a random value from an array 117 | */ 118 | export function randomFromArray(array: Array, rng: Rng = Math.random): T { 119 | const index = randomInt(0, array.length, rng) 120 | return array[index] 121 | } 122 | 123 | /** 124 | * Returns a random value from an object 125 | */ 126 | export function randomFromObject( 127 | obj: Record, 128 | rng: Rng = Math.random, 129 | ): T { 130 | return obj[randomFromArray(Object.keys(obj), rng)] 131 | } 132 | -------------------------------------------------------------------------------- /lib/components/polygon.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import { Polygon } from './polygon' 3 | import assert from 'node:assert' 4 | import { vec2 } from '../vector2' 5 | 6 | describe('Polygon', () => { 7 | describe('render', () => { 8 | it('formats `points` correctly', () => { 9 | const poly = new Polygon({ points: [vec2(0, 0), vec2(100, 100)] }) 10 | const actual = poly.render() 11 | assert.strictEqual(actual, '') 12 | }) 13 | 14 | it('includes other properties', () => { 15 | const poly = new Polygon({ 16 | points: [vec2(0, 0), vec2(100, 100)], 17 | fill: '#000', 18 | stroke: '#432', 19 | }) 20 | const actual = poly.render() 21 | assert.strictEqual( 22 | actual, 23 | '', 24 | ) 25 | }) 26 | 27 | it('uses correct precision', () => { 28 | const poly = new Polygon({ 29 | points: [vec2(0.1234, 0.1234), vec2(100.1234, 100.1234)], 30 | }) 31 | poly.numericPrecision = 2 32 | const actual = poly.render() 33 | assert.strictEqual( 34 | actual, 35 | '', 36 | ) 37 | }) 38 | 39 | it('throws if points is empty', () => { 40 | const poly = new Polygon() 41 | assert.throws(() => poly.render()) 42 | }) 43 | }) 44 | 45 | describe('contains', () => { 46 | describe('square', () => { 47 | const poly = new Polygon({ 48 | points: [vec2(0, 0), vec2(100, 0), vec2(100, 100), vec2(0, 100)], 49 | }) 50 | 51 | const tests = [ 52 | [vec2(50, 50), true], 53 | [vec2(99, 99), true], 54 | [vec2(99, 1), true], 55 | [vec2(1, 1), true], 56 | [vec2(1, 99), true], 57 | [vec2(-1, -1), false], 58 | [vec2(-1, 99), false], 59 | [vec2(1, -1), false], 60 | [vec2(101, 101), false], 61 | // edges; sometimes unexpected results 62 | [vec2(100, 50), false], 63 | [vec2(50, 100), false], 64 | [vec2(0, 50), true], 65 | [vec2(50, 0), true], 66 | ] 67 | 68 | for (const [point, expected] of tests) { 69 | it(`returns ${expected} for ${point}`, () => { 70 | assert.strictEqual(poly.contains(point), expected) 71 | }) 72 | } 73 | }) 74 | 75 | describe('diamond', () => { 76 | const poly = new Polygon({ 77 | points: [vec2(5, 0), vec2(10, 5), vec2(5, 10), vec2(0, 5)], 78 | }) 79 | 80 | const tests = [ 81 | [vec2(5, 1), true], 82 | [vec2(5, 9), true], 83 | [vec2(1, 5), true], 84 | [vec2(9, 5), true], 85 | [vec2(5, 5), true], 86 | [vec2(4, 0.5), false], 87 | [vec2(6, 0.5), false], 88 | [vec2(9.5, 4), false], 89 | [vec2(9.5, 6), false], 90 | [vec2(4, 9.5), false], 91 | [vec2(6, 9.5), false], 92 | [vec2(0.5, 4), false], 93 | [vec2(0.5, 6), false], 94 | ] 95 | 96 | for (const [point, expected] of tests) { 97 | it(`returns ${expected} for ${point}`, () => { 98 | assert.strictEqual(poly.contains(point), expected) 99 | }) 100 | } 101 | }) 102 | 103 | describe('bowtie', () => { 104 | const poly = new Polygon({ 105 | points: [ 106 | vec2(0, 0), 107 | vec2(5, 2), 108 | vec2(10, 0), 109 | vec2(10, 8), 110 | vec2(5, 6), 111 | vec2(0, 8), 112 | ], 113 | }) 114 | 115 | const tests = [ 116 | [vec2(1, 1), true], 117 | [vec2(1, 7), true], 118 | [vec2(5, 5), true], 119 | [vec2(9, 1), true], 120 | [vec2(9, 7), true], 121 | [vec2(5, 1), false], 122 | [vec2(5, 7), false], 123 | ] 124 | 125 | for (const [point, expected] of tests) { 126 | it(`returns ${expected} for ${point}`, () => { 127 | assert.strictEqual(poly.contains(point), expected) 128 | }) 129 | } 130 | }) 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /lib/math.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { vec2 } from './vector2' 3 | import { 4 | smallestAngularDifference, 5 | angleOfVertex, 6 | isWithin, 7 | toFixedPrecision, 8 | } from './math' 9 | import { describe, it } from 'node:test' 10 | 11 | // Note: due to floating point calculations, some tests need to test a range 12 | 13 | describe('smallestAngularDifference', () => { 14 | const tests = [ 15 | { angle1: 0, angle2: 0, expected: 0 }, 16 | // "9-o-clock" to "12-o-clock" = quarter turn clockwise 17 | { angle1: -Math.PI / 2, angle2: Math.PI, expected: -Math.PI / 2 }, 18 | // "12-o-clock" to "9-o-clock" = quarter turn anti-clockwise 19 | // argument order matters!!! 20 | { angle1: Math.PI, angle2: -Math.PI / 2, expected: Math.PI / 2 }, 21 | // outside of [-π, π] gets corrected 22 | { angle1: Math.PI * 3, angle2: -Math.PI / 2, expected: Math.PI / 2 }, 23 | // same angle, different sign = 0 24 | { angle1: -Math.PI, angle2: Math.PI, expected: 0 }, 25 | { angle1: -Math.PI * 9, angle2: Math.PI * 9, expected: 0 }, 26 | { angle1: Math.PI, angle2: -Math.PI, expected: 0 }, 27 | { angle1: Math.PI * 9, angle2: -Math.PI * 9, expected: 0 }, 28 | { angle1: -Math.PI / 2, angle2: (Math.PI * 3) / 2, expected: 0 }, 29 | { angle1: (-Math.PI * 9) / 2, angle2: (Math.PI * 3) / 2, expected: 0 }, 30 | { angle1: (Math.PI * 3) / 2, angle2: -Math.PI / 2, expected: 0 }, 31 | { angle1: (Math.PI * 3) / 2, angle2: (-Math.PI * 9) / 2, expected: 0 }, 32 | { angle1: 0.5, angle2: 2.75, expected: 2.25 }, 33 | ] 34 | 35 | for (const { angle1, angle2, expected } of tests) { 36 | it(`should return ${expected} for ${angle1} and ${angle2}`, () => { 37 | assert.equal(smallestAngularDifference(angle1, angle2), expected) 38 | }) 39 | } 40 | 41 | // floating point is hard so some tests need to test a small range 42 | const approximateTests = [ 43 | { 44 | angle1: -Math.PI * 0.99, 45 | angle2: Math.PI * 0.99, 46 | expected: -Math.PI * 0.02, 47 | }, 48 | { angle1: Math.PI * 2 - 0.05, angle2: 0.05, expected: 0.1 }, 49 | { angle1: -0.05, angle2: 0.05, expected: 0.1 }, 50 | { angle1: Math.PI / 2 - 0.05, angle2: Math.PI / 2 + 0.05, expected: 0.1 }, 51 | { angle1: Math.PI - 0.05, angle2: -Math.PI + 0.05, expected: 0.1 }, 52 | { angle1: 100.5, angle2: 102.75, expected: 2.25 }, 53 | // outside of [-π, π] gets corrected 54 | { angle1: Math.PI * 5, angle2: (-Math.PI * 9) / 2, expected: Math.PI / 2 }, 55 | { angle1: (-Math.PI * 9) / 2, angle2: Math.PI * 5, expected: -Math.PI / 2 }, 56 | ] 57 | 58 | for (const { angle1, angle2, expected } of approximateTests) { 59 | it(`should return ${expected} for ${angle1} and ${angle2}`, () => { 60 | const actual = smallestAngularDifference(angle1, angle2) 61 | assert( 62 | isWithin(expected - 0.001, expected + 0.001, actual), 63 | `Actual: ${actual}, Expected: ${expected}`, 64 | ) 65 | }) 66 | } 67 | }) 68 | 69 | describe('angleOfVertex', () => { 70 | const tests = [ 71 | { a: vec2(2, 0), b: vec2(0, 0), c: vec2(4, 4), expected: Math.PI / 4 }, 72 | ] 73 | 74 | for (const { a, b, c, expected } of tests) { 75 | it(`should return ${expected} for ${a}, ${b}, ${c}`, () => { 76 | const actual = angleOfVertex(a, b, c) 77 | assert(isWithin(expected - 0.001, expected + 0.001, actual)) 78 | }) 79 | } 80 | }) 81 | 82 | describe('toFixedPrecision', () => { 83 | const tests = [ 84 | [1.123456789, 0, 1], 85 | [1.123456789, 1, 1.1], 86 | [1.123456789, 2, 1.12], 87 | [1.123456789, 3, 1.123], 88 | // this function rounds, but perhaps "floor" is more intuitive? 89 | [1.123456789, 4, 1.1235], 90 | [1.123456789, 5, 1.12346], 91 | [1.123456789, 9, 1.123456789], 92 | ] 93 | for (const [input, precision, expected] of tests) { 94 | assert.strictEqual(toFixedPrecision(input, precision), expected) 95 | } 96 | 97 | it('returns value when precision is Infinity', () => { 98 | assert.strictEqual(toFixedPrecision(1.123456789, Infinity), 1.123456789) 99 | }) 100 | 101 | it('returns value when precision is <0', () => { 102 | assert.strictEqual(toFixedPrecision(1.123456789, -1), 1.123456789) 103 | }) 104 | }) 105 | -------------------------------------------------------------------------------- /lib/color/rgb.ts: -------------------------------------------------------------------------------- 1 | import { warnWithDefault } from '../internal.js' 2 | import { ClosedInterval } from '../types.js' 3 | import { clamp } from '../util.js' 4 | import { ColorHsl } from './hsl.js' 5 | 6 | export class ColorRgb { 7 | r: ClosedInterval<0, 1> 8 | g: ClosedInterval<0, 1> 9 | b: ClosedInterval<0, 1> 10 | a: ClosedInterval<0, 1> 11 | 12 | constructor( 13 | red: ClosedInterval<0, 1>, 14 | green: ClosedInterval<0, 1>, 15 | blue: ClosedInterval<0, 1>, 16 | alpha: ClosedInterval<0, 1> = 1, 17 | ) { 18 | this.r = 19 | red > 1 || red < 0 20 | ? warnWithDefault(`clamping r '${red}' to [0, 1]`, clamp(0, 1, red)) 21 | : red 22 | this.g = 23 | green > 1 || green < 0 24 | ? warnWithDefault(`clamping g '${green}' to [0, 1]`, clamp(0, 1, green)) 25 | : green 26 | this.b = 27 | blue > 1 || blue < 0 28 | ? warnWithDefault(`clamping b '${blue}' to [0, 1]`, clamp(0, 1, blue)) 29 | : blue 30 | this.a = 31 | alpha > 1 || alpha < 0 32 | ? warnWithDefault(`clamping a '${alpha}' to [0, 1]`, clamp(0, 1, alpha)) 33 | : alpha 34 | } 35 | 36 | static Black = new ColorRgb(0, 0, 0) 37 | static White = new ColorRgb(1, 1, 1) 38 | 39 | /** 40 | * credit: https://github.com/openrndr/openrndr/blob/d184fed22e191df2860ed47f9f9354a142ad52b6/openrndr-color/src/commonMain/kotlin/org/openrndr/color/ColorRGBa.kt#L84-L131 41 | * @param {string} hex color hex string, e.g. '#000' 42 | */ 43 | static fromHex(hex: string): ColorRgb { 44 | const raw = hex.replace(/^0x|#/, '') 45 | /** 46 | * @param {string} str 47 | * @param {number} start 48 | * @param {number} end 49 | * @returns number 50 | */ 51 | const fromHex = ( 52 | str: string, 53 | start: number, 54 | end: number, 55 | multiplier = 1, 56 | ): number => (multiplier * parseInt(str.slice(start, end), 16)) / 255 57 | 58 | switch (raw.length) { 59 | case 3: 60 | return new ColorRgb( 61 | fromHex(raw, 0, 1, 17), 62 | fromHex(raw, 1, 2, 17), 63 | fromHex(raw, 2, 3, 17), 64 | ) 65 | case 6: 66 | return new ColorRgb( 67 | fromHex(raw, 0, 2), 68 | fromHex(raw, 2, 4), 69 | fromHex(raw, 4, 6), 70 | ) 71 | case 8: 72 | return new ColorRgb( 73 | fromHex(raw, 0, 2), 74 | fromHex(raw, 2, 4), 75 | fromHex(raw, 4, 6), 76 | fromHex(raw, 6, 8), 77 | ) 78 | default: 79 | throw new Error(`Cannot construct ColorRgb from value ${hex}`) 80 | } 81 | } 82 | 83 | /** 84 | * This is very homespun, I imagine there are serious optimizations available 85 | * @param {ColorRgb} other 86 | * @param {number} [mix=0.5] the mix of the two colors. When 0, returns `this`. When 1, returns `other` 87 | * @returns {ColorRgb} 88 | */ 89 | mix(other: ColorRgb, mix = 0.5): ColorRgb { 90 | return ColorHsl.fromRgb(this).mix(ColorHsl.fromRgb(other), mix).toRgb() 91 | } 92 | 93 | toString(): string { 94 | return `rgb(${this.r * 255}, ${this.g * 255}, ${this.b * 255}, ${this.a})` 95 | } 96 | 97 | toHex(): string { 98 | return [ 99 | '#', 100 | Math.round(this.r * 255) 101 | .toString(16) 102 | .padStart(2, '0'), 103 | Math.round(this.g * 255) 104 | .toString(16) 105 | .padStart(2, '0'), 106 | Math.round(this.b * 255) 107 | .toString(16) 108 | .padStart(2, '0'), 109 | Math.round(this.a * 255) 110 | .toString(16) 111 | .padStart(2, '0'), 112 | ].join('') 113 | } 114 | 115 | /** 116 | * Converts to HSL color space 117 | * @returns {ColorHsl} 118 | */ 119 | toHsl(): ColorHsl { 120 | return ColorHsl.fromRgb(this) 121 | } 122 | 123 | /** 124 | * @param {number} a new alpha amount 125 | * @returns {ColorRgb} 126 | */ 127 | opacify(a: number): ColorRgb { 128 | return new ColorRgb(this.r, this.g, this.b, a) 129 | } 130 | } 131 | 132 | export function rgb( 133 | r: ClosedInterval<0, 1>, 134 | g: ClosedInterval<0, 1>, 135 | b: ClosedInterval<0, 1>, 136 | a: ClosedInterval<0, 1> = 1, 137 | ): ColorRgb { 138 | return new ColorRgb(r, g, b, a) 139 | } 140 | -------------------------------------------------------------------------------- /lib/noise/oscillator-noise.ts: -------------------------------------------------------------------------------- 1 | import { PI } from '../constants.js' 2 | import { createRng, random, randomInt } from '../random.js' 3 | import { ClosedInterval } from '../types.js' 4 | import { Vector2 } from '../vector2.js' 5 | import { Oscillator } from './oscillator.js' 6 | 7 | /** 8 | * When `y` is omitted, it defaults to `x` 9 | */ 10 | export type OscillatorNoiseFn = (x: number, y?: number) => ClosedInterval<-1, 1> 11 | 12 | // Straight from slide 29 of this: 13 | // https://raw.githubusercontent.com/petewerner/misc/master/Curl%20Noise%20Slides.pdf 14 | // Originally implemented here: https://github.com/ericyd/generative-art/blob/b9a1bc25ec60fb99e7e9a2bd9ba32c55da40da67/openrndr/src/main/kotlin/noise/curl.kt#L21 15 | /** 16 | * Creates a noise function based on oscillators 17 | * @param {string | number} seed 18 | * @param {number} [xyScale=1] 19 | * When < 1, causes noise to oscillate over larger distances in the xy plane. 20 | * When > 1, causes noise to oscillate over smaller distances in the xy plane. 21 | * @param {number} [outputScale=1] Scales the output value 22 | */ 23 | export function createOscNoise( 24 | seed: string | number, 25 | xyScale = 1, 26 | outputScale = 1, 27 | ): OscillatorNoiseFn { 28 | const rng = createRng(seed) 29 | const osc = new Oscillator({ 30 | amplitude: 1, 31 | period: random(PI, 4 * PI, rng), 32 | phase: random(-PI, PI, rng), 33 | compress: true, 34 | }) 35 | 36 | const ampModulatorCount = randomInt(2, 5, rng) 37 | for (let i = 0; i < ampModulatorCount; i++) { 38 | const period = random(2.1, 9.2, rng) 39 | const modulator = new Oscillator({ 40 | period, 41 | amplitude: random(0.1, 0.5, rng), 42 | phase: random(-period / 2, period / 2, rng), 43 | compress: true, 44 | }) 45 | osc.modulateAmplitude(modulator) 46 | } 47 | 48 | const phaseModulatorCount = randomInt(1, 2, rng) 49 | for (let i = 0; i < phaseModulatorCount; i++) { 50 | const period = random(8.1, 20.2, rng) 51 | const modulator = new Oscillator({ 52 | period, 53 | amplitude: random(0.1, 2.5, rng), 54 | phase: random(-period / 2, period / 2, rng), 55 | compress: true, 56 | }) 57 | osc.modulatePhase(modulator) 58 | } 59 | 60 | return (x: number, y = 0): number => 61 | osc.output(x * xyScale, y * xyScale) * outputScale 62 | } 63 | 64 | /** 65 | * When `y` is omitted, it defaults to `x` 66 | * @returns A vector representing the curl of the noise field at the given point. 67 | */ 68 | export type OscillatorCurlFn = (x: number, y?: number) => Vector2 69 | 70 | /** 71 | * Creates a curl noise function based on oscillators. 72 | * 73 | * epsilon is the length of the differential (might be using wrong terminology there) 74 | * I think there is some room for experimentation here... I'm not sure what the "right" epsilonilon is 75 | * @param {string} seed - The seed for the random number generator. 76 | * @param {number} [epsilon=0.1] - The length of the differential. 77 | * @returns {OscillatorCurlFn} The created curl noise function. 78 | */ 79 | export function createOscCurl(seed: string, epsilon = 0.1): OscillatorCurlFn { 80 | const noiseFn = createOscNoise(seed) 81 | return (x: number, y = 0): Vector2 => { 82 | // a is the partial derivative of our field at point (x, y) in y direction 83 | // ∂ / ∂y 84 | // The slides describe this as "∂x1/∂y" but personally I understand it better as ∂/∂y 85 | // 86 | // More readably, this is 87 | // const a1 = noiseFn(x, y + epsilon) 88 | // const a2 = noiseFn(x, y - epsilon) 89 | // const a = (a1 - a2) / (2.0 * epsilon) 90 | // but this is simplified for MAXIMUM SPEED 😂 91 | const a = 92 | (noiseFn(x, y + epsilon) - noiseFn(x, y - epsilon)) / (2.0 * epsilon) 93 | 94 | // b is the partial derivative of our field at point (x, y) in x direction 95 | // ∂ / ∂x 96 | // The slides describe this as "∂y1/∂x" but personally I understand it better as ∂/∂x 97 | // 98 | // Expanded: 99 | // const b1 = noiseFn(x + epsilon, y) 100 | // const b2 = noiseFn(x - epsilon, y) 101 | // const b = (b1 - b2) / (2.0 * epsilon) 102 | const b = 103 | (noiseFn(x + epsilon, y) - noiseFn(x - epsilon, y)) / (2.0 * epsilon) 104 | 105 | return new Vector2(a, -b) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /lib/noise/oscillator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Oscillator noise 3 | * 4 | * Kinda similar to this (https://piterpasma.nl/articles/wobbly) although I had the idea independently 5 | */ 6 | 7 | import { Compressor, CompressorOptions } from './compressor.js' 8 | 9 | export type OscillatorAttributes = { 10 | period: number 11 | amplitude: number 12 | /** 13 | * @default Math.sin 14 | */ 15 | wave?(t: number): number 16 | /** 17 | * @default 0 18 | */ 19 | phase: number 20 | /** 21 | * When `compress` is `true`, the compressor will default to { W: amplitude * 0.3, T: amplitude * 0.7, R: 2 } 22 | * @default false 23 | */ 24 | compress: boolean | CompressorOptions 25 | } 26 | 27 | export class Oscillator { 28 | #frequencyModulators: Oscillator[] = [] 29 | #amplitudeModulators: Oscillator[] = [] 30 | #phaseModulators: Oscillator[] = [] 31 | 32 | readonly #period: number 33 | readonly #frequency: number 34 | readonly #amplitude: number 35 | readonly #wave: (t: number) => number 36 | readonly #phase: number 37 | #compressor: Compressor | null 38 | 39 | /** 40 | * @param {OscillatorAttributes} attributes 41 | */ 42 | constructor({ 43 | phase = 0, 44 | period, 45 | amplitude, 46 | wave = Math.sin, 47 | compress = false, 48 | }: OscillatorAttributes) { 49 | this.#phase = phase 50 | this.#period = period 51 | this.#frequency = (2 * Math.PI) / this.#period 52 | this.#amplitude = amplitude 53 | this.#wave = wave 54 | const compressorEnabled = compress === true || typeof compress === 'object' 55 | const compressorOptions = 56 | typeof compress === 'object' 57 | ? compress 58 | : { W: amplitude * 0.3, T: amplitude * 0.7, R: 2 } 59 | this.#compressor = compressorEnabled 60 | ? new Compressor(compressorOptions) 61 | : null 62 | } 63 | 64 | toString() { 65 | return `Oscillator { period: ${this.#period}, amplitude: ${ 66 | this.#amplitude 67 | }, phase: ${this.#phase}, compress: ${!!this.#compressor} }` 68 | } 69 | 70 | set compressor(options: CompressorOptions) { 71 | this.#compressor = new Compressor(options) 72 | } 73 | 74 | frequency(x: number, y = 0): number { 75 | const modulated = this.#frequencyModulators.reduce( 76 | (sum, curr) => sum + curr.output(x, y), 77 | 0, 78 | ) 79 | return this.#frequency + modulated 80 | } 81 | 82 | amplitude(x: number, y = 0): number { 83 | const modulated = this.#amplitudeModulators.reduce( 84 | (sum, curr) => sum + curr.output(x, y), 85 | 0, 86 | ) 87 | 88 | // yModulation oscillates between [-1, 1] on a period of this.#amplitude 89 | // the purpose of yModulation is to ensure that waves vary over both the x axis and y axis 90 | const yModulation = Math.sin(y / this.#amplitude) 91 | return this.#amplitude * yModulation + modulated 92 | } 93 | 94 | phase(x: number, y = 0): number { 95 | const modulated = this.#phaseModulators.reduce( 96 | (sum, curr) => sum + curr.output(x, y), 97 | 0, 98 | ) 99 | return this.#phase + modulated 100 | } 101 | 102 | modulateFrequency(osc: Oscillator): Oscillator { 103 | this.#frequencyModulators.push(osc) 104 | return this 105 | } 106 | 107 | modulateAmplitude(osc: Oscillator): Oscillator { 108 | this.#amplitudeModulators.push(osc) 109 | return this 110 | } 111 | 112 | modulatePhase(osc: Oscillator): Oscillator { 113 | this.#phaseModulators.push(osc) 114 | return this 115 | } 116 | 117 | compress(input: number): number { 118 | return this.#compressor === null ? input : this.#compressor.compress(input) 119 | } 120 | 121 | output(x: number, y = 0): number { 122 | return this.compress( 123 | this.#wave(x * this.frequency(x, y) + this.phase(x, y)) * 124 | this.amplitude(x, y), 125 | ) 126 | } 127 | 128 | clone(attributes: Partial): Oscillator { 129 | const newOsc = new Oscillator({ 130 | phase: this.#phase, 131 | amplitude: this.#amplitude, 132 | wave: this.#wave, 133 | compress: this.#compressor ? this.#compressor.options() : false, 134 | period: this.#period, 135 | ...attributes, 136 | }) 137 | for (const o of this.#amplitudeModulators) { 138 | newOsc.modulateAmplitude(o) 139 | } 140 | for (const o of this.#frequencyModulators) { 141 | newOsc.modulateFrequency(o) 142 | } 143 | for (const o of this.#phaseModulators) { 144 | newOsc.modulatePhase(o) 145 | } 146 | return newOsc 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /lib/vector3.test.ts: -------------------------------------------------------------------------------- 1 | import { Vector3, vec3 } from './vector3' 2 | import assert from 'node:assert' 3 | import { describe, it } from 'node:test' 4 | 5 | describe('Vector3', () => { 6 | const rng = () => 0.5 7 | 8 | describe('constructor', () => { 9 | it('should create vector with default values if y and z are omitted', () => { 10 | const v = new Vector3(1) 11 | assert.strictEqual(v.x, 1) 12 | assert.strictEqual(v.y, 1) 13 | assert.strictEqual(v.z, 1) 14 | }) 15 | 16 | it('should throw error if x is not a number', () => { 17 | assert.throws( 18 | // @ts-expect-error test data 19 | () => new Vector3('a'), 20 | /Vector3 constructor requires a number for x, got string/, 21 | ) 22 | }) 23 | }) 24 | 25 | describe('add', () => { 26 | it('should add vectors correctly', () => { 27 | const v1 = vec3(1, 2, 3) 28 | const v2 = vec3(4, 5, 6) 29 | const result = v1.add(v2) 30 | assert.strictEqual(result.x, 5) 31 | assert.strictEqual(result.y, 7) 32 | assert.strictEqual(result.z, 9) 33 | }) 34 | }) 35 | 36 | describe('subtract', () => { 37 | it('should subtract vectors correctly', () => { 38 | const v1 = vec3(1, 2, 3) 39 | const v2 = vec3(4, 5, 6) 40 | const result = v1.subtract(v2) 41 | assert.strictEqual(result.x, -3) 42 | assert.strictEqual(result.y, -3) 43 | assert.strictEqual(result.z, -3) 44 | }) 45 | }) 46 | 47 | describe('multiply', () => { 48 | it('should multiply vector by scalar correctly', () => { 49 | const v = vec3(1, 2, 3) 50 | const result = v.multiply(2) 51 | assert.strictEqual(result.x, 2) 52 | assert.strictEqual(result.y, 4) 53 | assert.strictEqual(result.z, 6) 54 | }) 55 | }) 56 | 57 | describe('divide', () => { 58 | it('should divide vector by scalar correctly', () => { 59 | const v = vec3(1, 2, 3) 60 | const result = v.divide(2) 61 | assert.strictEqual(result.x, 0.5) 62 | assert.strictEqual(result.y, 1) 63 | assert.strictEqual(result.z, 1.5) 64 | }) 65 | }) 66 | 67 | describe('distanceTo', () => { 68 | it('should calculate distance between two vectors correctly', () => { 69 | const v1 = vec3(1, 2, 3) 70 | const v2 = vec3(4, 5, 6) 71 | const result = v1.distanceTo(v2) 72 | assert.strictEqual(result, Math.sqrt(9 + 9 + 9)) 73 | }) 74 | }) 75 | 76 | describe('length', () => { 77 | it('should calculate the length of a vector correctly', () => { 78 | const v = vec3(3, 4, 12) 79 | const result = v.length() 80 | assert.strictEqual(result, 13) 81 | }) 82 | }) 83 | 84 | describe('dot', () => { 85 | it('should calculate the dot product of two vectors correctly', () => { 86 | const v1 = vec3(1, 2, 3) 87 | const v2 = vec3(4, 5, 6) 88 | const result = v1.dot(v2) 89 | assert.strictEqual(result, 32) 90 | }) 91 | }) 92 | 93 | describe('static midpoint', () => { 94 | it('should calculate the midpoint between two vectors correctly', () => { 95 | const v1 = vec3(0, 0, 0) 96 | const v2 = vec3(4, 5, 6) 97 | const result = Vector3.midpoint(v1, v2) 98 | assert.strictEqual(result.x, 2) 99 | assert.strictEqual(result.y, 2.5) 100 | assert.strictEqual(result.z, 3) 101 | }) 102 | }) 103 | 104 | describe('static random', () => { 105 | it('should generate a random vector within specified bounds', () => { 106 | const v = Vector3.random(0, 1, 0, 1, 0, 1, rng) 107 | assert.strictEqual(v.x, 0.5) 108 | assert.strictEqual(v.y, 0.5) 109 | assert.strictEqual(v.z, 0.5) 110 | }) 111 | }) 112 | 113 | describe('jitter', () => { 114 | it('should jitter a vector by the specified amount', () => { 115 | const v = vec3(5, 5, 5) 116 | const result = v.jitter(1, () => 0.75) 117 | assert.strictEqual(result.x, 5.5) 118 | assert.strictEqual(result.y, 5.5) 119 | assert.strictEqual(result.z, 5.5) 120 | }) 121 | }) 122 | 123 | describe('eq', () => { 124 | it('should check for value equality correctly', () => { 125 | const v1 = vec3(1, 2, 3) 126 | const v2 = vec3(1, 2, 3) 127 | assert.strictEqual(v1.eq(v2), true) 128 | 129 | const v3 = vec3(4, 5, 6) 130 | assert.strictEqual(v1.eq(v3), false) 131 | }) 132 | }) 133 | 134 | describe('toString', () => { 135 | it('should return a string representation of the vector', () => { 136 | const v = vec3(1, 2, 3) 137 | assert.strictEqual(v.toString(), 'Vector3 { x: 1, y: 2, z: 3 }') 138 | }) 139 | }) 140 | }) 141 | -------------------------------------------------------------------------------- /lib/util.ts: -------------------------------------------------------------------------------- 1 | // resources: https://observablehq.com/@makio135/utilities 2 | 3 | /** 4 | * @param {number} n length of array 5 | * @returns {number[]} 6 | */ 7 | export function array(n: number): number[] { 8 | try { 9 | return new Array(n).fill(0).map((_zero, i) => i) 10 | } catch (e) { 11 | const error = new Error( 12 | `Could not create an array with n: ${n}. Original error: ${ 13 | (e as Error).message 14 | }`, 15 | ) 16 | throw error 17 | } 18 | } 19 | 20 | /** 21 | * @param {number} min 22 | * @param {number} max 23 | * @param {number} step 24 | * @returns {number[]} 25 | */ 26 | export function range(min: number, max: number, step = 1): number[] { 27 | try { 28 | return new Array((max - min) / step).fill(0).map((_, i) => min + i * step) 29 | } catch (e) { 30 | const error = new Error( 31 | `Could not create an array with min: ${min}, max: ${max}, step: ${step}. Original error: ${ 32 | (e as Error).message 33 | }`, 34 | ) 35 | throw error 36 | } 37 | } 38 | 39 | /** 40 | * @param {number} min 41 | * @param {number} max 42 | * @param {number} step 43 | * @returns {[number, number][]} 44 | */ 45 | export function rangeWithIndex( 46 | min: number, 47 | max: number, 48 | step = 1, 49 | ): [number, number][] { 50 | try { 51 | return new Array((max - min) / step) 52 | .fill(0) 53 | .map((_, i) => [min + i * step, i]) 54 | } catch (e) { 55 | const error = new Error( 56 | `Could not create an array with min: ${min}, max: ${max}, step: ${step}. Original error: ${ 57 | (e as Error).message 58 | }`, 59 | ) 60 | throw error 61 | } 62 | } 63 | 64 | /** 65 | * 66 | * @param {Degrees} degrees 67 | * @returns {Radians} 68 | */ 69 | export function degToRad(degrees: number): number { 70 | return (degrees * Math.PI) / 180 71 | } 72 | 73 | /** 74 | * https://github.com/openrndr/openrndr/blob/2ca048076f6999cd79aee0d5b3db471152f59063/openrndr-math/src/commonMain/kotlin/org/openrndr/math/Mapping.kt#L8-L33 75 | * Linearly maps a value, which is given in the before domain to a value in the after domain. 76 | * @param {number} beforeLeft the lowest value of the before range 77 | * @param {number} beforeRight the highest value of the before range 78 | * @param {number} afterLeft the lowest value of the after range 79 | * @param {number} afterRight the highest value of the after range 80 | * @param {number} value the value to be mapped 81 | * @param {boolean} shouldClamp constrain the result to the after range 82 | * @return {number} a value in the after range 83 | */ 84 | export function map( 85 | beforeLeft: number, 86 | beforeRight: number, 87 | afterLeft: number, 88 | afterRight: number, 89 | value: number, 90 | shouldClamp?: boolean, 91 | ): number { 92 | const db = beforeRight - beforeLeft 93 | const da = afterRight - afterLeft 94 | 95 | if (db !== 0.0) { 96 | const n = (value - beforeLeft) / db 97 | return afterLeft + (shouldClamp ? clamp(0.0, 1.0, n) : n) * da 98 | } 99 | const n = value - beforeLeft 100 | return afterLeft + (shouldClamp ? clamp(0.0, 1.0, n) : n) * da 101 | } 102 | 103 | /** 104 | * @param {number} min 105 | * @param {number} max 106 | * @param {number} x 107 | * @returns number 108 | */ 109 | export const clamp = (min: number, max: number, x: number): number => 110 | Math.max(min, Math.min(max, x)) 111 | 112 | /** 113 | * 114 | * @param {number} quantum 115 | * @param {number} value 116 | * @returns {number} 117 | */ 118 | export const quantize = (quantum: number, value: number): number => 119 | Math.round(value / quantum) * quantum 120 | 121 | // maybe I should just import ramda? https://github.com/ramda/ramda/blob/96d601016b562e887e15efd894ec401672f73757/source/pickBy.js 122 | /** 123 | * Returns a partial copy of an object containing only the keys that satisfy 124 | * the supplied predicate. 125 | * @template T 126 | * @param {(value: T, key: string, obj: Record): boolean} test A predicate to determine whether or not a key 127 | * should be included on the output object. 128 | * @param {Record} obj The object to copy from} test 129 | * @return {Record} A new object with only properties that satisfy `pred` 130 | * on it. 131 | */ 132 | export function pickBy( 133 | test: (value: T, key: string, obj: Record) => boolean, 134 | obj: Record, 135 | ): Record { 136 | const result: Record = {} 137 | for (const prop in obj) { 138 | if (test(obj[prop], prop, obj)) { 139 | result[prop] = obj[prop] 140 | } 141 | } 142 | return result 143 | } 144 | -------------------------------------------------------------------------------- /lib/data-structures/fractalized-line.ts: -------------------------------------------------------------------------------- 1 | import { LineSegment } from '../components/polyline.js' 2 | import { Path } from '../components/path.js' 3 | import { random, type Rng } from '../random.js' 4 | import { Vector2 } from '../vector2.js' 5 | 6 | /** 7 | * Based on the algorithm used here: 8 | * http://rectangleworld.com/blog/archives/462 9 | * and introduced here: 10 | * http://rectangleworld.com/blog/archives/413 11 | * 12 | * Note: since the original algorithm has been removed: 13 | * It used a linked-list type structure which, yes, is more efficient, but 14 | * I chose to use a normal array because it is much, much easier to work with. 15 | * That said, I could potentially implement a custom iterator if I wanted to use a linked list in the future. 16 | */ 17 | 18 | /** 19 | * Represents a fractalized line based on a given set of points. 20 | */ 21 | export class FractalizedLine { 22 | /** 23 | * The initial set of points. 24 | */ 25 | private points: Vector2[] 26 | 27 | /** 28 | * The random number generator. 29 | */ 30 | private rng: Rng 31 | 32 | /** 33 | * @param {Vector2[]} points - The initial set of points. 34 | * @param {Rng} [rng] - The random number generator. Defaults to the default Random instance. 35 | */ 36 | constructor(points: Vector2[], rng: Rng = Math.random) { 37 | this.points = points 38 | this.rng = rng 39 | } 40 | 41 | /** 42 | * The segments formed by connecting consecutive points. 43 | * @returns {LineSegment[]} - The array of LineSegments. 44 | */ 45 | get segments(): LineSegment[] { 46 | const segments: LineSegment[] = [] 47 | for (let i = 0; i < this.points.length - 1; i++) { 48 | segments.push(new LineSegment(this.points[i], this.points[i + 1])) 49 | } 50 | return segments 51 | } 52 | 53 | /** 54 | * Creates a Path from the points. 55 | * @param {boolean} closed - Whether to close the path. 56 | * @returns {Path} - The created Path instance. 57 | */ 58 | path(closed = true): Path { 59 | return Path.fromPoints(this.points, closed) 60 | } 61 | 62 | /** 63 | * Recursively subdivide the points using perpendicular offset. 64 | * @param {number} subdivisions - The number of times to subdivide. 65 | * @param {number} [offsetPct=0.5] - The percentage of the offset. 66 | * @returns {FractalizedLine} - The updated FractalizedLine instance. 67 | */ 68 | perpendicularSubdivide( 69 | subdivisions: number, 70 | offsetPct = 0.5, 71 | ): FractalizedLine { 72 | return this.subdivide( 73 | subdivisions, 74 | offsetPct, 75 | this.perpendicularOffset.bind(this), 76 | ) 77 | } 78 | 79 | /** 80 | * Recursively subdivide the points. 81 | * @private 82 | * @param {number} subdivisions - The number of times to subdivide. 83 | * @param {number} [offsetPct=0.5] - The percentage of the offset. 84 | * @param {(v1: Vector2, v2: Vector2, n: number) => Vector2} [offsetFn=this.perpendicularOffset] - The offset function. 85 | * @returns {FractalizedLine} - The updated FractalizedLine instance. 86 | */ 87 | subdivide( 88 | subdivisions: number, 89 | offsetPct = 0.5, 90 | offsetFn: ( 91 | v1: Vector2, 92 | v2: Vector2, 93 | n: number, 94 | ) => Vector2 = this.perpendicularOffset.bind(this), 95 | ): FractalizedLine { 96 | for (let i = 0; i < subdivisions; i++) { 97 | const newPoints: Vector2[] = [] 98 | 99 | for (let j = 0; j < this.points.length - 1; j++) { 100 | const current = this.points[j] 101 | const next = this.points[j + 1] 102 | const mid = offsetFn(current, next, offsetPct) 103 | newPoints.push(current, mid) 104 | } 105 | newPoints.push(this.points[this.points.length - 1]) 106 | 107 | this.points = newPoints 108 | } 109 | return this 110 | } 111 | 112 | /** 113 | * Calculate the perpendicular offset between two points. 114 | * @private 115 | * @param {Vector2} start - The starting point. 116 | * @param {Vector2} end - The ending point. 117 | * @param {number} offsetPct - The percentage of the offset. 118 | * @returns {Vector2} - The calculated offset point. 119 | */ 120 | perpendicularOffset( 121 | start: Vector2, 122 | end: Vector2, 123 | offsetPct: number, 124 | ): Vector2 { 125 | const perpendicular = 126 | Math.atan2(end.y - start.y, end.x - start.x) - Math.PI / 2.0 127 | const maxDeviation = (start.subtract(end).length() / 2.0) * offsetPct 128 | const mid = start.add(end).divide(2) 129 | const offset = random(-maxDeviation, maxDeviation, this.rng) 130 | return new Vector2( 131 | mid.x + Math.cos(perpendicular) * offset, 132 | mid.y + Math.sin(perpendicular) * offset, 133 | ) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /lib/vector2.ts: -------------------------------------------------------------------------------- 1 | import { Circle } from './components/circle.js' 2 | import { jitter, random, Rng } from './random.js' 3 | 4 | export class Vector2 { 5 | x: number 6 | y: number 7 | /** 8 | * @param {number} x coordinate 9 | * @param {number} [y] coordinate defaults to `x` if omitted. 10 | */ 11 | constructor(x: number, y?: number) { 12 | if (typeof x !== 'number') { 13 | throw new Error( 14 | `Vector2 constructor requires a number for x, got ${typeof x}`, 15 | ) 16 | } 17 | this.x = x 18 | this.y = y ?? x 19 | } 20 | 21 | /** 22 | * @param {Vector2} other 23 | * @returns {Vector2} 24 | */ 25 | add(other: Vector2): Vector2 { 26 | return vec2(this.x + other.x, this.y + other.y) 27 | } 28 | 29 | /** 30 | * @param {Vector2} other 31 | * @returns {Vector2} 32 | */ 33 | subtract(other: Vector2): Vector2 { 34 | return vec2(this.x - other.x, this.y - other.y) 35 | } 36 | 37 | /** 38 | * @param {number} n 39 | * @returns {Vector2} 40 | */ 41 | divide(n: number): Vector2 { 42 | return vec2(this.x / n, this.y / n) 43 | } 44 | 45 | /** 46 | * @param {number} n 47 | * @returns {Vector2} 48 | */ 49 | multiply(n: number): Vector2 { 50 | return vec2(this.x * n, this.y * n) 51 | } 52 | 53 | /** 54 | * Alias for `multiply` 55 | * @param {number} n 56 | * @returns {Vector2} 57 | */ 58 | scale(n: number): Vector2 { 59 | return this.multiply(n) 60 | } 61 | 62 | /** 63 | * Returns a Vector2 that is a mix 64 | * @param {Vector2} a 65 | * @param {Vector2} b 66 | * @param {number} mix a mix percentage in range [0, 1] where 0 returns a and 1 returns b 67 | * @returns {Vector2} 68 | */ 69 | static mix(a: Vector2, b: Vector2, mix: number): Vector2 { 70 | return a.multiply(1 - mix).add(b.multiply(mix)) 71 | } 72 | 73 | /** 74 | * @param {Vector2} other 75 | * @returns {number} 76 | */ 77 | distanceTo(other: Vector2): number { 78 | return Math.sqrt((other.x - this.x) ** 2 + (other.y - this.y) ** 2) 79 | } 80 | 81 | /** 82 | * The euclidean length of the vector 83 | * @returns {number} 84 | */ 85 | length(): number { 86 | return Math.sqrt(this.x ** 2 + this.y ** 2) 87 | } 88 | 89 | /** 90 | * Dot product 91 | * @param {Vector2} other 92 | */ 93 | dot(other: Vector2): number { 94 | return this.x * other.x + this.y * other.y 95 | } 96 | 97 | /** 98 | * @param {Vector2} other 99 | * @returns {number} 100 | */ 101 | angleTo(other: Vector2): number { 102 | return Math.atan2(other.y - this.y, other.x - this.x) 103 | } 104 | 105 | /** 106 | * @param {Vector2} a 107 | * @param {Vector2} b 108 | * @returns {Vector2} 109 | */ 110 | static midpoint(a: Vector2, b: Vector2): Vector2 { 111 | return vec2((a.x + b.x) / 2, (a.y + b.y) / 2) 112 | } 113 | 114 | /** 115 | * Returns a random point in the given bounds. 116 | * @param {number} xMin 117 | * @param {number} xMax 118 | * @param {number} yMin 119 | * @param {number} yMax 120 | * @param {Rng} rng 121 | * @returns {Vector2} 122 | */ 123 | static random( 124 | xMin: number, 125 | xMax: number, 126 | yMin: number, 127 | yMax: number, 128 | rng: Rng, 129 | ): Vector2 { 130 | return vec2(random(xMin, xMax, rng), random(yMin, yMax, rng)) 131 | } 132 | 133 | /** 134 | * Returns a random point within the given circle. 135 | * @param {Circle} circle 136 | * @param {Rng} rng 137 | * @returns {Vector2} 138 | */ 139 | static randomInCircle(circle: Circle, rng: Rng): Vector2 { 140 | const angle = random(0, Math.PI * 2, rng) 141 | const radius = random(0, circle.radius, rng) 142 | return circle.center.add(Vector2.fromAngle(angle).scale(radius)) 143 | } 144 | 145 | /** 146 | * Constructs a Vector2 instance from the given angle, in Radians. 147 | * @param {Radians} angle 148 | * @returns {Vector2} 149 | */ 150 | static fromAngle(angle: number): Vector2 { 151 | return vec2(Math.cos(angle), Math.sin(angle)) 152 | } 153 | 154 | /** 155 | * Returns a new Vector2, randomly offset by a maximum of `amount` 156 | * @param {number} amount 157 | * @param {Rng} rng 158 | * @returns {Vector2} 159 | */ 160 | jitter(amount: number, rng: Rng): Vector2 { 161 | return vec2(jitter(amount, this.x, rng), jitter(amount, this.y, rng)) 162 | } 163 | 164 | /** 165 | * Value equality check 166 | * @param {Vector2} other 167 | * @returns {boolean} 168 | */ 169 | eq(other: Vector2): boolean { 170 | return this.x === other.x && this.y === other.y 171 | } 172 | 173 | toString(): string { 174 | return `Vector2 { x: ${this.x}, y: ${this.y} }` 175 | } 176 | } 177 | 178 | /** 179 | * @param {number} x 180 | * @param {number} [y] defaults to `x` if omitted. 181 | * @returns Vector2 182 | */ 183 | export function vec2(x: number, y?: number): Vector2 { 184 | return new Vector2(x, y ?? x) 185 | } 186 | -------------------------------------------------------------------------------- /lib/components/rectangle.ts: -------------------------------------------------------------------------------- 1 | import { Vector2, vec2 } from '../vector2.js' 2 | import { LineSegment } from './polyline.js' 3 | import { Tag } from './tag.js' 4 | 5 | type RectangleAttributes = { 6 | /** 7 | * @default 0 8 | */ 9 | x?: number 10 | /** 11 | * @default 0 12 | */ 13 | y?: number 14 | /** 15 | * @default 1 16 | */ 17 | width?: number 18 | /** 19 | * @default 1 20 | */ 21 | height?: number 22 | /** 23 | * If provided, sets the `rx` property of the SVG rect tag. 24 | * Takes precedence over `rx` property if both are passed in the constructor 25 | */ 26 | borderRadius?: number 27 | /** 28 | * border radius in the x direction 29 | */ 30 | rx?: number 31 | /** 32 | * border radius in the y direction. Defaults to rx if not provided. 33 | */ 34 | ry?: number 35 | } 36 | 37 | /** 38 | * @example 39 | * const r = rect(r => { 40 | * r.fill = '#000' 41 | * r.stroke = '#055' 42 | * r.x = 1 43 | * r.y = 10 44 | * r.width = 100 45 | * r.height = 15 46 | * r.borderRadius = 1.4 47 | * }) 48 | * @example 49 | * const r = rect({ x: 1, y: 10, width: 100, height: 15, borderRadius: 1.4 }) 50 | */ 51 | export class Rectangle extends Tag { 52 | constructor(attributes: RectangleAttributes = {}) { 53 | super('rect', { 54 | x: attributes.x ?? 0, 55 | y: attributes.y ?? 0, 56 | width: attributes.width ?? 1, 57 | height: attributes.height ?? 1, 58 | rx: attributes.borderRadius ?? attributes.rx, 59 | ...attributes, 60 | }) 61 | } 62 | 63 | set x(value: number) { 64 | this.setAttributes({ x: value }) 65 | } 66 | 67 | get x(): number { 68 | return this.attributes.x as number 69 | } 70 | 71 | set y(value: number) { 72 | this.setAttributes({ y: value }) 73 | } 74 | 75 | get y(): number { 76 | return this.attributes.y as number 77 | } 78 | 79 | set width(value: number) { 80 | this.setAttributes({ width: value }) 81 | } 82 | 83 | get width(): number { 84 | return this.attributes.width as number 85 | } 86 | 87 | set height(value: number) { 88 | this.setAttributes({ height: value }) 89 | } 90 | 91 | get height(): number { 92 | return this.attributes.height as number 93 | } 94 | 95 | set borderRadius(value: number) { 96 | this.setAttributes({ rx: value }) 97 | } 98 | 99 | get center(): Vector2 { 100 | return vec2(this.x + this.width / 2, this.y + this.height / 2) 101 | } 102 | 103 | get corner(): Vector2 { 104 | return vec2(this.x, this.y) 105 | } 106 | 107 | vertices(): Vector2[] { 108 | return [ 109 | vec2(this.x, this.y), 110 | vec2(this.x, this.y + this.height), 111 | vec2(this.x + this.width, this.y + this.height), 112 | vec2(this.x + this.width, this.y), 113 | ] 114 | } 115 | 116 | sides(): LineSegment[] { 117 | return [ 118 | new LineSegment(vec2(this.x, this.y), vec2(this.x, this.y + this.height)), 119 | new LineSegment( 120 | vec2(this.x, this.y + this.height), 121 | vec2(this.x + this.width, this.y + this.height), 122 | ), 123 | new LineSegment( 124 | vec2(this.x + this.width, this.y + this.height), 125 | vec2(this.x + this.width, this.y), 126 | ), 127 | new LineSegment(vec2(this.x + this.width, this.y), vec2(this.x, this.y)), 128 | ] 129 | } 130 | 131 | static fromCenter(center: Vector2, width: number, height: number): Rectangle { 132 | return new Rectangle({ 133 | x: center.x - width / 2, 134 | y: center.y - height / 2, 135 | width, 136 | height, 137 | }) 138 | } 139 | 140 | empty(): boolean { 141 | return this.x === 0 && this.y === 0 && this.width === 0 && this.height === 0 142 | } 143 | } 144 | 145 | type RectFunction = (rect: Rectangle) => void 146 | 147 | // TODO: add proper overloads 148 | /** 149 | * @param {RectangleAttributes | number | RectFunction} attrsOrBuilderOrX 150 | * @param {number} [y] 151 | * @param {number} [width] 152 | * @param {number} [height] 153 | */ 154 | export function rect( 155 | attrsOrBuilderOrX: RectangleAttributes | number | RectFunction, 156 | y?: number, 157 | width?: number, 158 | height?: number, 159 | ): Rectangle { 160 | if (typeof attrsOrBuilderOrX === 'function') { 161 | const c = new Rectangle() 162 | attrsOrBuilderOrX(c) 163 | return c 164 | } 165 | if (typeof attrsOrBuilderOrX === 'object') { 166 | return new Rectangle(attrsOrBuilderOrX as RectangleAttributes) 167 | } 168 | if ( 169 | typeof attrsOrBuilderOrX === 'number' && 170 | (typeof y === 'number' || y === undefined) && 171 | (typeof width === 'number' || width === undefined) && 172 | (typeof height === 'number' || height === undefined) 173 | ) { 174 | return new Rectangle({ x: attrsOrBuilderOrX, y, width, height }) 175 | } 176 | throw new Error( 177 | `Unable to construct circle from "${attrsOrBuilderOrX}, ${y}, ${width}, ${height}"`, 178 | ) 179 | } 180 | -------------------------------------------------------------------------------- /lib/data-structures/grid.ts: -------------------------------------------------------------------------------- 1 | import { error } from '../internal.js' 2 | import { Vector2, vec2 } from '../vector2.js' 3 | 4 | export type GridAttributes = { 5 | /** 6 | * the minimum x value (inclusive), when used as an iterator 7 | * @default 0 8 | */ 9 | xMin?: number 10 | /** 11 | * the maximum x value (exclusive), when used as an iterator 12 | * @default 1 13 | */ 14 | xMax?: number 15 | /** 16 | * the minimum y value (inclusive), when used as an iterator 17 | * @default 0 18 | */ 19 | yMin?: number 20 | /** 21 | * the maximum y value (exclusive), when used as an iterator 22 | * @default 1 23 | */ 24 | yMax?: number 25 | /** 26 | * the step size in the x direction 27 | * @default 1 28 | */ 29 | xStep?: number 30 | /** 31 | * the step size in the x direction 32 | * @default 1 33 | */ 34 | yStep?: number 35 | /** 36 | * the number of columnCount in the grid. This is more commonly defined when using the grid as a data store, but if `columnCount` is defined it will override `xMax` when used as an iterator. 37 | */ 38 | columnCount?: number 39 | /** 40 | * the number of rowCount in the grid. This is more commonly defined when using the grid as a data store, but if `rowCount` is defined it will override `yMax` when used as an iterator. 41 | */ 42 | rowCount?: number 43 | /** 44 | * order of the grid. This is rarely necessary to define, but since the internal representation of the grid is a 1-D array, this defines the layout of the cells. 45 | * @default 'row major' 46 | */ 47 | order?: 'row major' | 'column major' 48 | /** 49 | * the value to fill the grid with. This is only used when the grid is used as a data store. 50 | */ 51 | fill?: any 52 | } 53 | 54 | export class Grid { 55 | #xMin: number 56 | #xMax: number 57 | #yMin: number 58 | #yMax: number 59 | #xStep: number 60 | #yStep: number 61 | #order: 'row major' | 'column major' 62 | #grid: T[] 63 | columnCount: number 64 | rowCount: number 65 | length: number 66 | 67 | constructor({ 68 | xMin = 0, 69 | xMax = 1, 70 | yMin = 0, 71 | yMax = 1, 72 | xStep = 1, 73 | yStep = 1, 74 | order = 'row major', 75 | columnCount, 76 | rowCount, 77 | fill, 78 | }: GridAttributes = {}) { 79 | this.#xMin = xMin 80 | this.#xMax = columnCount ? this.#xMin + columnCount : xMax 81 | this.#yMin = yMin 82 | this.#yMax = rowCount ? this.#yMin + rowCount : yMax 83 | this.#xStep = xStep 84 | this.#yStep = yStep 85 | this.columnCount = 86 | columnCount ?? Math.ceil((this.#xMax - this.#xMin) / this.#xStep) 87 | this.rowCount = 88 | rowCount ?? Math.ceil((this.#yMax - this.#yMin) / this.#yStep) 89 | this.#grid = new Array(this.columnCount * this.rowCount) 90 | if (fill !== undefined) { 91 | this.#grid.fill(fill) 92 | } 93 | this.#order = order 94 | this.length = this.#grid.length 95 | } 96 | 97 | get xMin(): number { 98 | return this.#xMin 99 | } 100 | get xMax(): number { 101 | return this.#xMax 102 | } 103 | get yMin(): number { 104 | return this.#yMin 105 | } 106 | get yMax(): number { 107 | return this.#yMax 108 | } 109 | get xStep(): number { 110 | return this.#xStep 111 | } 112 | get yStep(): number { 113 | return this.#yStep 114 | } 115 | get order(): 'row major' | 'column major' { 116 | return this.#order 117 | } 118 | 119 | #index(x: Vector2 | number, y?: number): number { 120 | const [i, j] = 121 | x instanceof Vector2 122 | ? [Math.round(x.x / this.#xStep), Math.round(x.y / this.#yStep)] 123 | : y !== undefined 124 | ? [Math.round(x / this.#xStep), Math.round(y / this.#yStep)] 125 | : error(`invalid arguments ${x}, ${y}`) 126 | if (this.#order === 'row major') { 127 | return this.columnCount * j + i 128 | } 129 | return this.rowCount * i + j 130 | } 131 | 132 | get(x: Vector2 | number, y?: number): T { 133 | return this.#grid[this.#index(x, y)] 134 | } 135 | 136 | // TODO: use proper overloads to avoid `any` types (should be `T`) 137 | set(...args: [Vector2, any] | [number, number, any]): void { 138 | const [x, y, value] = 139 | args[0] instanceof Vector2 ? [args[0].x, args[0].y, args[1]] : args 140 | this.#grid[this.#index(x, y)] = value 141 | } 142 | 143 | *[Symbol.iterator](): Generator<[Vector2, any, number], void> { 144 | if (this.#order === 'row major') { 145 | for (let y = this.#yMin; y < this.#yMax; y += this.#yStep) { 146 | for (let x = this.#xMin; x < this.#xMax; x += this.#xStep) { 147 | yield [vec2(x, y), this.get(x, y), this.#index(x, y)] 148 | } 149 | } 150 | } else { 151 | for (let x = this.#xMin; x < this.#xMax; x += this.#xStep) { 152 | for (let y = this.#yMin; y < this.#yMax; y += this.#yStep) { 153 | yield [vec2(x, y), this.get(x, y), this.#index(x, y)] 154 | } 155 | } 156 | } 157 | } 158 | } 159 | 160 | export function grid(attributes: GridAttributes = {}): Grid { 161 | return new Grid(attributes) 162 | } 163 | -------------------------------------------------------------------------------- /lib/components/tag.ts: -------------------------------------------------------------------------------- 1 | import { ColorHsl } from '../color/hsl.js' 2 | import { ColorRgb } from '../color/rgb.js' 3 | import { toFixedPrecision } from '../math.js' 4 | import { pickBy } from '../util.js' 5 | import { LinearGradient } from './linear-gradient.js' 6 | 7 | export type SvgColor = 8 | | 'none' 9 | | string 10 | | null 11 | | ColorRgb 12 | | ColorHsl 13 | | LinearGradient 14 | 15 | export class Tag { 16 | tagName: string 17 | attributes: Record 18 | children: Tag[] 19 | /** 20 | * When < Infinity, will drop decimal values beyond this precision. 21 | * For example, when numericPrecision = 3, 12.34567 will be rounded to 12.345 22 | * @type {number} 23 | */ 24 | numericPrecision: number = Infinity 25 | 26 | /** 27 | * @param {string} tagName 28 | * @param {Record} attributes 29 | */ 30 | constructor(tagName: string, attributes: Record = {}) { 31 | this.tagName = tagName 32 | this.attributes = attributes 33 | /** @type {Array} */ 34 | this.children = [] 35 | } 36 | 37 | /** 38 | * @param {Record} attributes 39 | */ 40 | setAttributes(attributes: Record): void { 41 | this.attributes = { 42 | ...this.attributes, 43 | ...attributes, 44 | } 45 | } 46 | 47 | set fill(value: SvgColor) { 48 | const fill = value === null ? 'none' : value 49 | this.setAttributes({ fill }) 50 | } 51 | 52 | set stroke(value: SvgColor) { 53 | const stroke = value === null ? 'none' : value 54 | this.setAttributes({ stroke }) 55 | } 56 | 57 | set strokeWidth(value: number) { 58 | this.setAttributes({ 'stroke-width': value }) 59 | } 60 | 61 | /** 62 | * @param {*} value 63 | * @param {*} key 64 | * @returns {boolean} 65 | */ 66 | #visualAttributesTestFn(value: unknown, key: string): boolean { 67 | return ( 68 | ['fill', 'stroke', 'stroke-width'].includes(key) && value !== undefined 69 | ) 70 | } 71 | 72 | /** 73 | * @protected 74 | * Returns an object containing the core "visual styles" that should be inherited 75 | * as children are added to the document. 76 | * @returns {Record} 77 | */ 78 | visualAttributes(): Record { 79 | return pickBy(this.#visualAttributesTestFn, { 80 | fill: this.attributes.fill, 81 | stroke: this.attributes.stroke, 82 | 'stroke-width': this.attributes['stroke-width'], 83 | }) 84 | } 85 | 86 | /** 87 | * @protected 88 | * Sets visual attributes on the current tag, favoring any values that have been set explicitly 89 | * @param {Record} incoming 90 | * @returns {void} 91 | */ 92 | setVisualAttributes(incoming: Record = {}): void { 93 | this.setAttributes({ 94 | ...pickBy(this.#visualAttributesTestFn, incoming), 95 | ...this.visualAttributes(), 96 | }) 97 | } 98 | 99 | /** 100 | * @protected 101 | * @param {Tag} child 102 | */ 103 | addChild(child: Tag): Tag { 104 | child.setVisualAttributes(this.visualAttributes()) 105 | // Future enhancement: There should be a more generalized concept of "inheritable attributes". 106 | // The idea here is if the parent's precision has never been set, then use the child's precision, else use the parent's precision. 107 | child.numericPrecision = 108 | this.numericPrecision === Infinity 109 | ? child.numericPrecision 110 | : this.numericPrecision 111 | this.children.push(child) 112 | return child 113 | } 114 | 115 | #formatAttributes(): string { 116 | return Object.entries(pickBy((v) => v !== undefined, this.attributes)) 117 | .map(([key, value]) => { 118 | if (typeof value === 'number') { 119 | return `${key}="${toFixedPrecision(value, this.numericPrecision)}"` 120 | } 121 | return `${key}="${value}"` 122 | }) 123 | .join(' ') 124 | } 125 | 126 | /** 127 | * @returns {string} 128 | */ 129 | render(): string { 130 | // this would be much more elegant as `this.attributes.fill instanceof LinearGradient`, 131 | // but doing so would result in a circular dependency that I don't want to resolve 132 | // with additional abstractions 133 | if ( 134 | this.attributes.fill instanceof Tag && 135 | this.attributes.fill.tagName === 'linearGradient' && 136 | 'id' in this.attributes.fill 137 | ) { 138 | this.attributes.fill = `url(#${this.attributes.fill.id})` 139 | } 140 | if ( 141 | this.attributes.stroke instanceof Tag && 142 | this.attributes.stroke.tagName === 'linearGradient' && 143 | 'id' in this.attributes.stroke 144 | ) { 145 | this.attributes.stroke = `url(#${this.attributes.stroke.id})` 146 | } 147 | return [ 148 | `<${this.tagName} ${this.#formatAttributes()}>`, 149 | this.children.map((child) => child.render()).join(''), 150 | ``, 151 | ].join('') 152 | } 153 | } 154 | 155 | /** 156 | * @param {string} tagName 157 | * @param {(tag: Tag) => void} builder 158 | * @returns {Tag} 159 | */ 160 | export function tag(tagName: string, builder: (tag: Tag) => void): Tag { 161 | const t = new Tag(tagName) 162 | builder(t) 163 | return t 164 | } 165 | -------------------------------------------------------------------------------- /lib/color/hsl.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { ColorHsl, mixColorComponent } from './hsl' 4 | import { ColorRgb } from './rgb' 5 | import { isWithinError } from '../math' 6 | 7 | describe('ColorHsl', () => { 8 | describe('fromRgb', () => { 9 | const exactTests = [ 10 | ['#000000', { h: 0, s: 0, l: 0 }], 11 | ['#ffffff', { h: 0, s: 0, l: 1 }], 12 | ] 13 | 14 | for (const [hex, hsl] of exactTests) { 15 | it(`converts ${hex} to ${JSON.stringify(hsl)}`, () => { 16 | const actual = ColorHsl.fromRgb(ColorRgb.fromHex(hex)) 17 | assert.strictEqual(actual.h, hsl.h) 18 | assert.strictEqual(actual.s, hsl.s) 19 | assert.strictEqual(actual.l, hsl.l) 20 | }) 21 | } 22 | 23 | const inexactTests = [ 24 | ['#555a99', { h: 236, s: 0.29, l: 0.47 }], 25 | ['#4F1349', { h: 306, s: 0.61, l: 0.19 }], 26 | ] 27 | 28 | for (const [hex, hsl] of inexactTests) { 29 | it(`converts ${hex} to ${JSON.stringify(hsl)}`, () => { 30 | const actual = ColorHsl.fromRgb(ColorRgb.fromHex(hex)) 31 | // Not sure why there is so much drift in hue 32 | assert( 33 | isWithinError(hsl.h, 0.5, actual.h), 34 | `actual.h ${actual.h} is not within 0.5 of ${hsl.h}`, 35 | ) 36 | assert( 37 | isWithinError(hsl.s, 0.01, actual.s), 38 | `actual.s ${actual.s} is not within 0.05 of ${hsl.s}`, 39 | ) 40 | assert( 41 | isWithinError(hsl.l, 0.01, actual.l), 42 | `actual.l ${actual.l} is not within 0.05 of ${hsl.l}`, 43 | ) 44 | }) 45 | } 46 | }) 47 | 48 | describe('mix', () => { 49 | it('mixes simple colors correctly', () => { 50 | const a = new ColorHsl(0, 0, 0) 51 | const b = new ColorHsl(180, 1, 1) 52 | const actual = a.mix(b) 53 | assert.strictEqual(actual.toString(), 'hsl(90, 50%, 50%, 1)') 54 | }) 55 | 56 | it('mixes correctly across the 0/360 threshold [test 1]', () => { 57 | const a = new ColorHsl(1, 0.5, 0.5) 58 | const b = new ColorHsl(359, 0.5, 0.5) 59 | const actual = a.mix(b) 60 | assert.strictEqual(actual.toString(), 'hsl(360, 50%, 50%, 1)') 61 | }) 62 | 63 | it('mixes correctly across the 0/360 threshold [test 2]', () => { 64 | const a = new ColorHsl(10, 0.5, 0.5) 65 | const b = new ColorHsl(350, 0.5, 0.5) 66 | const actual = a.mix(b, 0.25) 67 | assert.strictEqual(actual.toString(), 'hsl(5, 50%, 50%, 1)') 68 | }) 69 | 70 | it('mixes correctly inside 0-255 range [test 1]', () => { 71 | const a = new ColorHsl(200, 0.7, 0.7) 72 | const b = new ColorHsl(100, 0.3, 0.3) 73 | const actual = a.mix(b) 74 | assert.strictEqual(actual.toString(), 'hsl(150, 50%, 50%, 1)') 75 | }) 76 | 77 | it('mixes correctly inside 0-255 range [test 2]', () => { 78 | const a = new ColorHsl(150, 0.6, 0.6) 79 | const b = new ColorHsl(250, 0.2, 0.2) 80 | const actual = a.mix(b) 81 | assert.strictEqual(actual.toString(), 'hsl(200, 40%, 40%, 1)') 82 | }) 83 | 84 | it('returns input when mixing the same color', () => { 85 | const a = new ColorHsl(60, 0.6, 0.6) 86 | const b = new ColorHsl(60, 0.6, 0.6) 87 | const actual = a.mix(b) 88 | assert.strictEqual(actual.toString(), 'hsl(60, 60%, 60%, 1)') 89 | }) 90 | 91 | it('returns input components when mixing the partial same color', () => { 92 | const a = new ColorHsl(60, 0.6, 0.4) 93 | const b = new ColorHsl(60, 0.6, 0.6) 94 | const actual = a.mix(b) 95 | // l is the only non-equal component, and it mixes to 0.5 96 | assert.strictEqual(actual.toString(), 'hsl(60, 60%, 50%, 1)') 97 | }) 98 | 99 | // if there wasn't a correction for range [0, 360], the hue would be >360 100 | it('handles positive overflow', () => { 101 | const a = new ColorHsl(359, 1, 1) 102 | const b = new ColorHsl(20, 1, 1) 103 | const acutal = a.mix(b, 0.4) 104 | assert.strictEqual(Math.round(acutal.h), 7) 105 | }) 106 | 107 | // if there wasn't a correction for range [0, 360], the hue would be <0 108 | it('handles negative overflow', () => { 109 | const a = ColorRgb.fromHex('#874059ff').toHsl() 110 | const b = ColorRgb.fromHex('#55598aff').toHsl() 111 | const actual = a.mix(b, 0.2) 112 | assert.strictEqual(Math.round(actual.h), 318) 113 | }) 114 | }) 115 | }) 116 | 117 | describe('mixColorComponent', () => { 118 | const tests = [ 119 | // "wrap" backwards from a to b 120 | { a: 1, b: 359, mix: 0.5, output: 360 }, 121 | { a: 1, b: 359, mix: 0.25, output: 0.5 }, 122 | { a: 1, b: 359, mix: 0.75, output: 359.5 }, 123 | { a: 1, b: 359, mix: 0.0, output: 1 }, 124 | { a: 1, b: 359, mix: 1.0, output: 359 }, 125 | // wrap" forwards from b to a 126 | { a: 359, b: 1, mix: 0.5, output: 360 }, 127 | { a: 359, b: 1, mix: 0.25, output: 359.5 }, 128 | { a: 359, b: 1, mix: 0.75, output: 0.5 }, 129 | { a: 359, b: 1, mix: 0.0, output: 359 }, 130 | { a: 359, b: 1, mix: 1.0, output: 1 }, 131 | // a < b, normal lerp 132 | { a: 100, b: 200, mix: 0.5, output: 150 }, 133 | { a: 100, b: 200, mix: 0.25, output: 125 }, 134 | { a: 100, b: 200, mix: 0.75, output: 175 }, 135 | { a: 100, b: 200, mix: 0.0, output: 100 }, 136 | { a: 100, b: 200, mix: 1.0, output: 200 }, 137 | // a > b, normal lerp 138 | { a: 200, b: 100, mix: 0.5, output: 150 }, 139 | { a: 200, b: 100, mix: 0.25, output: 175 }, 140 | { a: 200, b: 100, mix: 0.75, output: 125 }, 141 | { a: 200, b: 100, mix: 0.0, output: 200 }, 142 | { a: 200, b: 100, mix: 1.0, output: 100 }, 143 | ] 144 | 145 | for (const { a, b, mix, output } of tests) { 146 | it(`mixColorComponent(${a}, ${b}, ${mix}) === ${output}`, () => { 147 | // floating point math is annoying... 148 | const actual = mixColorComponent(a, b, mix) 149 | const result = isWithinError(output, 0.000001, actual) 150 | assert(result, `actual '${actual}' is not within 0.000001 of ${output}`) 151 | }) 152 | } 153 | }) 154 | -------------------------------------------------------------------------------- /lib/color/hsl.ts: -------------------------------------------------------------------------------- 1 | import { warnWithDefault } from '../internal.js' 2 | import { ClosedInterval } from '../types.js' 3 | import { clamp } from '../util.js' 4 | import { ColorRgb } from './rgb.js' 5 | 6 | export class ColorHsl { 7 | h: ClosedInterval<0, 360> 8 | s: ClosedInterval<0, 1> 9 | l: ClosedInterval<0, 1> 10 | a: ClosedInterval<0, 1> 11 | 12 | constructor( 13 | hue: ClosedInterval<0, 360>, 14 | saturation: ClosedInterval<0, 1>, 15 | luminocity: ClosedInterval<0, 1>, 16 | alpha: ClosedInterval<0, 1> = 1, 17 | ) { 18 | this.h = 19 | hue > 360 || hue < 0 20 | ? warnWithDefault(`clamping h '${hue}' to [0, 360]`, clamp(0, 360, hue)) 21 | : hue 22 | this.s = 23 | saturation > 1 || saturation < 0 24 | ? warnWithDefault( 25 | `clamping s '${saturation}' to [0, 1]`, 26 | clamp(0, 1, saturation), 27 | ) 28 | : saturation 29 | this.l = 30 | luminocity > 1 || luminocity < 0 31 | ? warnWithDefault( 32 | `clamping l '${luminocity}' to [0, 1]`, 33 | clamp(0, 1, luminocity), 34 | ) 35 | : luminocity 36 | this.a = 37 | alpha > 1 || alpha < 0 38 | ? warnWithDefault(`clamping a '${alpha}' to [0, 1]`, clamp(0, 1, alpha)) 39 | : alpha 40 | return 41 | } 42 | 43 | /** 44 | * Converts a ColorRgb to ColorHsl. 45 | * Thank you OPENRNDR: https://github.com/openrndr/openrndr/blob/71f233075e01ced7670963194e8730bc5c35c67c/openrndr-color/src/commonMain/kotlin/org/openrndr/color/ColorHSLa.kt#L28 46 | * And SO: https://stackoverflow.com/questions/39118528/rgb-to-hsl-conversion 47 | * @param {ColorRgb} rgb 48 | * @returns {ColorHsl} 49 | */ 50 | static fromRgb(rgb: ColorRgb): ColorHsl { 51 | const min = Math.min(rgb.r, rgb.g, rgb.b) 52 | const max = Math.max(rgb.r, rgb.g, rgb.b) 53 | const component = max === rgb.r ? 'r' : max === rgb.g ? 'g' : 'b' 54 | 55 | // In the case r == g == b 56 | if (min === max) { 57 | return new ColorHsl(0, 0, max, rgb.a) 58 | } 59 | const delta = max - min 60 | const l = (max + min) / 2 61 | const s = delta / (1 - Math.abs(2 * l - 1)) 62 | // cheap pattern matching ¯\_(ツ)_/¯ 63 | const componentHueMap: Record = { 64 | r: (rgb.g - rgb.b) / delta + (rgb.g < rgb.b ? 6 : 0), 65 | g: (rgb.b - rgb.r) / delta + 2, 66 | b: (rgb.r - rgb.g) / delta + 4, 67 | } 68 | const h = 69 | 60 * 70 | (componentHueMap[component] ?? 71 | warnWithDefault( 72 | `Unable to successfully convert value ${rgb} to HSL space. Defaulting hue to 0.`, 73 | 0, 74 | )) 75 | return new ColorHsl(h, s, l, rgb.a) 76 | } 77 | 78 | /** 79 | * @returns {ColorRgb} 80 | */ 81 | toRgb(): ColorRgb { 82 | if (this.s === 0.0) { 83 | return new ColorRgb(this.l, this.l, this.l, this.a) 84 | } 85 | const q = 86 | this.l < 0.5 ? this.l * (1 + this.s) : this.l + this.s - this.l * this.s 87 | const p = 2 * this.l - q 88 | const r = hue2rgb(p, q, this.h / 360.0 + 1.0 / 3) 89 | const g = hue2rgb(p, q, this.h / 360.0) 90 | const b = hue2rgb(p, q, this.h / 360.0 - 1.0 / 3) 91 | return new ColorRgb(r, g, b, this.a) 92 | } 93 | 94 | toString(): string { 95 | return `hsl(${this.h}, ${this.s * 100}%, ${this.l * 100}%, ${this.a})` 96 | } 97 | 98 | /** 99 | * Mix two colors together. 100 | * This is very homespun, I imagine there are optimizations available 101 | * @param {ColorHsl} other 102 | * @param {number} [mix=0.5] The mix of colors. When 0, returns `this`. When 1, returns `other` 103 | * @returns {ColorHsl} 104 | */ 105 | mix(other: ColorHsl, mix = 0.5): ColorHsl { 106 | const h = mixColorComponent(this.h, other.h, mix) 107 | const s = lerp(this.s, other.s, mix) 108 | const l = lerp(this.l, other.l, mix) 109 | const a = lerp(this.a, other.a, mix) 110 | return new ColorHsl(h, s, l, a) 111 | } 112 | 113 | /** 114 | * @returns {string} hex representation of the color 115 | */ 116 | toHex(): string { 117 | return this.toRgb().toHex() 118 | } 119 | 120 | /** 121 | * @param {number} a new alpha amount 122 | * @returns {ColorHsl} 123 | */ 124 | opacify(a: number): ColorHsl { 125 | return new ColorHsl(this.h, this.s, this.l, a) 126 | } 127 | } 128 | 129 | /** 130 | * @param {number} h hue, in range [0, 360] 131 | * @param {number} s saturation, in range [0, 1] 132 | * @param {number} l luminocity, in range [0, 1] 133 | * @param {number} [a=1] alpha, in range [0, 1] 134 | * @returns {ColorHsl} color in hsl format 135 | */ 136 | export function hsl(h: number, s: number, l: number, a = 1): ColorHsl { 137 | return new ColorHsl(h, s, l, a) 138 | } 139 | 140 | /** 141 | * Mixes two color components (in range [0, 1]) using linear interpolation. 142 | * Color components mix in an interesting way because the values cycle, 143 | * i.e. 0.001 is "close" to 0.999 on a color wheel. 144 | * @param {number} a in range [0, 1] 145 | * @param {number} b in range [0, 1] 146 | * @param {number} mix in range [0, 1]. When 0, returns `a`. When 1, returns `b`. 147 | * @returns {number} 148 | */ 149 | export function mixColorComponent(a: number, b: number, mix: number): number { 150 | const aVal = a < b && b - a > 180 ? a + 360 : a 151 | const bVal = b < a && a - b > 180 ? b + 360 : b 152 | const aPct = 1 - mix 153 | const bPct = mix 154 | const result = aVal * aPct + bVal * bPct 155 | return result > 360 ? result - 360 : result < 0 ? result + 360 : result 156 | } 157 | 158 | // this should probably go in a utility file 159 | function lerp( 160 | a: ClosedInterval<0, 1>, 161 | b: ClosedInterval<0, 1>, 162 | mix: ClosedInterval<0, 1>, 163 | ): number { 164 | const aPct = 1 - mix 165 | const bPct = mix 166 | const result = a * aPct + b * bPct 167 | return result 168 | } 169 | 170 | /** 171 | * Honestly not sure what this does 172 | * https://github.com/openrndr/openrndr/blob/71f233075e01ced7670963194e8730bc5c35c67c/openrndr-color/src/commonMain/kotlin/org/openrndr/color/ColorHSLa.kt#L123C10-L130C2 173 | */ 174 | function hue2rgb(p: number, q: number, ut: number): number { 175 | let t = ut 176 | while (t < 0) t += 1.0 177 | while (t > 1) t -= 1.0 178 | if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t 179 | if (t < 1.0 / 2.0) return q 180 | return t < 2.0 / 3.0 ? p + (q - p) * (2.0 / 3.0 - t) * 6.0 : p 181 | } 182 | -------------------------------------------------------------------------------- /lib/components/svg.ts: -------------------------------------------------------------------------------- 1 | import { SvgColor, Tag } from './tag.js' 2 | import { Circle, circle } from './circle.js' 3 | import { Path, path } from './path.js' 4 | import { Rectangle, rect } from './rectangle.js' 5 | import { Polyline, LineSegment, polyline } from './polyline.js' 6 | import { polygon, Polygon } from './polygon.js' 7 | import { LinearGradient, LinearGradientAttributes } from './linear-gradient.js' 8 | import { Defs } from './defs.js' 9 | import { Vector2, vec2 } from '../vector2.js' 10 | 11 | export type SvgAttributes = { 12 | /** 13 | * @default 100 14 | */ 15 | width?: number 16 | /** 17 | * @default 100 18 | */ 19 | height?: number 20 | /** 21 | * Allows the resulting SVG to have larger dimensions, which still keeping the viewBox the same as the `width` and `height` attributes 22 | * @default 1 23 | */ 24 | scale?: number 25 | /** 26 | * @default '0 0 width height' 27 | */ 28 | viewBox?: string 29 | /** 30 | * @default 'xMidYMid meet' 31 | */ 32 | preserveAspectRatio?: string 33 | } 34 | 35 | /** 36 | * The root of any SVG document. 37 | * Although you can construct this class manually, it's much nicer to the the `svg` builder function, 38 | * or the `renderSvg` function if you're running this locally or on a server. 39 | * 40 | * @example 41 | * import { svg, vec2 } from '@salamivg/core' 42 | * 43 | * const document = svg({ width: 100, height: 100, scale: 5 }, (doc) => { 44 | * doc.fill = null; 45 | * doc.strokeWidth = 1; 46 | * doc.path((p) => { 47 | * p.fill = '#ab9342'; 48 | * p.stroke = '#000'; 49 | * p.strokeWidth = 2; 50 | * p.moveTo(vec2(0, 0)); 51 | * p.lineTo(vec2(doc.width, doc.height)); 52 | * p.lineTo(vec2(doc.width, 0)); 53 | * p.close(); 54 | * }); 55 | * }); 56 | * 57 | * console.log(document.render()); 58 | */ 59 | export class Svg extends Tag { 60 | #defs: LinearGradient[] = [] 61 | width: number 62 | height: number 63 | filenameMetadata: Record | null 64 | 65 | constructor({ 66 | width = 100, 67 | height = 100, 68 | scale = 1, 69 | ...attributes 70 | }: SvgAttributes = {}) { 71 | super('svg', { 72 | viewBox: attributes.viewBox ?? `0 0 ${width} ${height}`, 73 | preserveAspectRatio: attributes.preserveAspectRatio ?? 'xMidYMid meet', 74 | xmlns: 'http://www.w3.org/2000/svg', 75 | 'xmlns:xlink': 'http://www.w3.org/1999/xlink', 76 | width: width * scale, 77 | height: height * scale, 78 | ...attributes, 79 | }) 80 | this.width = width 81 | this.height = height 82 | this.filenameMetadata = null 83 | } 84 | 85 | get center(): Vector2 { 86 | return vec2(this.width / 2, this.height / 2) 87 | } 88 | 89 | path(instanceOrBuilder: Path | ((path: Path) => void)): Tag { 90 | return instanceOrBuilder instanceof Path 91 | ? this.addChild(instanceOrBuilder) 92 | : this.addChild(path(instanceOrBuilder)) 93 | } 94 | 95 | paths(ps: Path[]): void { 96 | for (const p of ps) { 97 | this.path(p) 98 | } 99 | } 100 | 101 | lineSegment(lineSegment: LineSegment): Tag { 102 | return this.addChild(lineSegment) 103 | } 104 | 105 | lineSegments(ls: LineSegment[]): void { 106 | for (const l of ls) { 107 | this.lineSegment(l) 108 | } 109 | } 110 | 111 | circle(instanceOrBuilder: Circle | ((circle: Circle) => void)): Tag { 112 | return instanceOrBuilder instanceof Circle 113 | ? this.addChild(instanceOrBuilder) 114 | : this.addChild(circle(instanceOrBuilder)) 115 | } 116 | 117 | circles(cs: Circle[]): void { 118 | for (const c of cs) { 119 | this.circle(c) 120 | } 121 | } 122 | 123 | rect(instanceOrBuilder: Rectangle | ((rect: Rectangle) => void)): Tag { 124 | return instanceOrBuilder instanceof Rectangle 125 | ? this.addChild(instanceOrBuilder) 126 | : this.addChild(rect(instanceOrBuilder)) 127 | } 128 | 129 | rects(rs: Rectangle[]): void { 130 | for (const r of rs) { 131 | this.rect(r) 132 | } 133 | } 134 | 135 | polygon(instanceOrBuilder: Polygon | ((polygon: Polygon) => void)): Tag { 136 | return instanceOrBuilder instanceof Polygon 137 | ? this.addChild(instanceOrBuilder) 138 | : this.addChild(polygon(instanceOrBuilder)) 139 | } 140 | 141 | polygons(ps: Array void)>): void { 142 | for (const p of ps) { 143 | this.polygon(p) 144 | } 145 | } 146 | 147 | polyline(instanceOrBuilder: Polyline | ((polyline: Polyline) => void)): Tag { 148 | return instanceOrBuilder instanceof Polyline 149 | ? this.addChild(instanceOrBuilder) 150 | : this.addChild(polyline(instanceOrBuilder)) 151 | } 152 | 153 | polylines(ps: Polyline[]): void { 154 | for (const p of ps) { 155 | this.polyline(p) 156 | } 157 | } 158 | 159 | formatFilenameMetadata(): string { 160 | return Object.entries(this.filenameMetadata ?? {}) 161 | .map(([key, value]) => `${key}-${value}`) 162 | .join('-') 163 | } 164 | 165 | setBackground(color: SvgColor): void { 166 | const rect = new Rectangle({ 167 | x: 0, 168 | y: 0, 169 | width: this.width, 170 | height: this.height, 171 | }) 172 | rect.stroke = null 173 | rect.fill = color 174 | this.children.unshift(rect) 175 | } 176 | 177 | defineLinearGradient( 178 | props: Omit, 179 | ): LinearGradient { 180 | const grad = new LinearGradient({ 181 | ...props, 182 | numericPrecision: this.numericPrecision, 183 | }) 184 | this.#defs.push(grad) 185 | return grad 186 | } 187 | 188 | contains(point: Vector2): boolean { 189 | if ( 190 | typeof this.attributes.viewBox !== 'string' || 191 | !this.attributes.viewBox.startsWith('0 0') 192 | ) { 193 | console.warn( 194 | `Svg#contains is only guaranteed to work when the viewBox starts with "0 0". Current viewbox is ${this.attributes.viewBox}`, 195 | ) 196 | } 197 | return ( 198 | point.x >= 0 && 199 | point.x <= this.width && 200 | point.y >= 0 && 201 | point.y <= this.height 202 | ) 203 | } 204 | 205 | render(): string { 206 | if (this.#defs.length > 0) { 207 | const defs = new Defs() 208 | for (const def of this.#defs) { 209 | defs.addDefinition(def) 210 | } 211 | this.children.unshift(defs) 212 | } 213 | return super.render() 214 | } 215 | } 216 | 217 | export type SvgBuilder = (svg: Svg) => undefined | SvgBuilderPostLoop 218 | 219 | export type SvgBuilderPostLoop = () => void 220 | 221 | /** 222 | * @param {SvgAttributes} attributes 223 | * @param {SvgBuilder} builder 224 | */ 225 | export function svg(attributes: SvgAttributes, builder: SvgBuilder): Svg { 226 | const s = new Svg(attributes) 227 | builder(s) 228 | return s 229 | } 230 | -------------------------------------------------------------------------------- /lib/data-structures/grid.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { Grid, grid } from './grid' 4 | import { vec2 } from '../vector2' 5 | 6 | describe('grid', () => { 7 | it('should return an instance of Grid', () => { 8 | const g = grid() 9 | assert(g instanceof Grid) 10 | }) 11 | }) 12 | 13 | /** 14 | * helper to simplify comparing vector2 outputs 15 | * @param {Grid} grid 16 | */ 17 | function gridToPojo(grid) { 18 | return [...grid].map(([{ x, y }]) => ({ x, y })) 19 | } 20 | 21 | describe('Grid', () => { 22 | describe('iterator', () => { 23 | it('should default to range x:[0, 1), y:[0, 1)', () => { 24 | const g = new Grid() 25 | const expected = [{ x: 0, y: 0 }] 26 | const actual = gridToPojo(g) 27 | assert.deepStrictEqual(actual, expected) 28 | }) 29 | 30 | it('should step x by xStep', () => { 31 | const g = new Grid({ xMax: 11, xStep: 5 }) 32 | const expected = [ 33 | { x: 0, y: 0 }, 34 | { x: 5, y: 0 }, 35 | { x: 10, y: 0 }, 36 | ] 37 | const actual = gridToPojo(g) 38 | assert.deepStrictEqual(actual, expected) 39 | }) 40 | 41 | it('should step y by yStep', () => { 42 | const g = new Grid({ yMax: 11, yStep: 5 }) 43 | const expected = [ 44 | { x: 0, y: 0 }, 45 | { x: 0, y: 5 }, 46 | { x: 0, y: 10 }, 47 | ] 48 | const actual = gridToPojo(g) 49 | assert.deepStrictEqual(actual, expected) 50 | }) 51 | 52 | it('should yield x range of [xMin, xMax)', () => { 53 | const g = new Grid({ xMin: 2, xMax: 5 }) 54 | const expected = [ 55 | { x: 2, y: 0 }, 56 | { x: 3, y: 0 }, 57 | { x: 4, y: 0 }, 58 | ] 59 | const actual = gridToPojo(g) 60 | assert.deepStrictEqual(actual, expected) 61 | }) 62 | 63 | it('should yield y range of [yMin, yMax)', () => { 64 | const g = new Grid({ yMin: 2, yMax: 5 }) 65 | const expected = [ 66 | { x: 0, y: 2 }, 67 | { x: 0, y: 3 }, 68 | { x: 0, y: 4 }, 69 | ] 70 | const actual = gridToPojo(g) 71 | assert.deepStrictEqual(actual, expected) 72 | }) 73 | 74 | it('when columns is set, should yield x range of [xMin, xMin + columns)', () => { 75 | const g = new Grid({ xMin: 2, columnCount: 3 }) 76 | const expected = [ 77 | { x: 2, y: 0 }, 78 | { x: 3, y: 0 }, 79 | { x: 4, y: 0 }, 80 | ] 81 | const actual = gridToPojo(g) 82 | assert.deepStrictEqual(actual, expected) 83 | }) 84 | 85 | it('when rows is set, should yield y range of [yMin, yMin + rows)', () => { 86 | const g = new Grid({ yMin: 2, rowCount: 3 }) 87 | const expected = [ 88 | { x: 0, y: 2 }, 89 | { x: 0, y: 3 }, 90 | { x: 0, y: 4 }, 91 | ] 92 | const actual = gridToPojo(g) 93 | assert.deepStrictEqual(actual, expected) 94 | }) 95 | 96 | it('should yield in row major order by default', () => { 97 | const g = new Grid({ xMin: 2, xMax: 4, yMin: 2, yMax: 4 }) 98 | const expected = [ 99 | { x: 2, y: 2 }, 100 | { x: 3, y: 2 }, 101 | { x: 2, y: 3 }, 102 | { x: 3, y: 3 }, 103 | ] 104 | const actual = gridToPojo(g) 105 | assert.deepStrictEqual(actual, expected) 106 | }) 107 | 108 | it('should yield in column major order when specified in constructor', () => { 109 | const g = new Grid({ 110 | xMin: 2, 111 | xMax: 4, 112 | yMin: 2, 113 | yMax: 4, 114 | order: 'column major', 115 | }) 116 | const expected = [ 117 | { x: 2, y: 2 }, 118 | { x: 2, y: 3 }, 119 | { x: 3, y: 2 }, 120 | { x: 3, y: 3 }, 121 | ] 122 | const actual = gridToPojo(g) 123 | assert.deepStrictEqual(actual, expected) 124 | }) 125 | 126 | it('should return correct indices when grid is constructed from non-square floats in row major order', () => { 127 | const g = new Grid({ 128 | xMin: 0, 129 | xMax: 5, 130 | yMin: 0, 131 | yMax: 5, 132 | xStep: 1.48492424049175, 133 | yStep: 1.7146428199482247, 134 | }) 135 | const indices = Array.from(g).map(([_, __, i]) => i) 136 | const expected = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] 137 | assert.strictEqual(g.rowCount, 3) 138 | assert.strictEqual(g.columnCount, 4) 139 | assert.deepStrictEqual(indices, expected) 140 | }) 141 | 142 | it('should return correct indices when grid is constructed from non-square floats in column major order', () => { 143 | const g = new Grid({ 144 | xMin: 0, 145 | xMax: 5, 146 | yMin: 0, 147 | yMax: 5, 148 | xStep: 1.48492424049175, 149 | yStep: 1.7146428199482247, 150 | order: 'column major', 151 | }) 152 | const indices = Array.from(g).map(([_, __, i]) => i) 153 | const expected = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] 154 | assert.strictEqual(g.rowCount, 3) 155 | assert.strictEqual(g.columnCount, 4) 156 | assert.deepStrictEqual(indices, expected) 157 | }) 158 | }) 159 | 160 | it('should fill data store when fill is specified', () => { 161 | const g = new Grid({ fill: 'testeroo magoo' }) 162 | const actual = g.get(0, 0) 163 | assert.strictEqual(actual, 'testeroo magoo') 164 | }) 165 | 166 | it('should create data store of correct size when columns and rows are specified', () => { 167 | const g = new Grid({ columnCount: 10, rowCount: 10, fill: 'test' }) 168 | for (let i = 0; i < 10; i++) { 169 | for (let j = 0; j < 10; j++) { 170 | assert.strictEqual( 171 | g.get(i, j), 172 | 'test', 173 | `grid.get(${i}, ${j}) is '${g.get(i, j)}', expected 'test'`, 174 | ) 175 | } 176 | } 177 | }) 178 | 179 | // This is a pretty confusing API, definitely not the recommended usage, but this is probably the least bad way to represent this. 180 | it('should create data store of correct size when xMin,xMax,yMin,yMax are specified', () => { 181 | const g = new Grid({ xMin: 10, xMax: 20, yMin: 15, yMax: 25, fill: 'test' }) 182 | for (let i = 0; i < 10; i++) { 183 | for (let j = 0; j < 10; j++) { 184 | assert.strictEqual( 185 | g.get(i, j), 186 | 'test', 187 | `grid.get(${i}, ${j}) is '${g.get(i, j)}', expected 'test'`, 188 | ) 189 | } 190 | } 191 | }) 192 | 193 | it('should ignore xStep and yStep when creating data store', () => { 194 | const g = new Grid({ 195 | columnCount: 10, 196 | rowCount: 10, 197 | xStep: 10, 198 | yStep: 10, 199 | fill: 'test', 200 | }) 201 | assert.strictEqual(g.get(9, 9), 'test') 202 | }) 203 | 204 | describe('.get', () => { 205 | it('should get values from discrete x/y args', () => { 206 | const g = new Grid({ columnCount: 10, rowCount: 10, fill: 'test' }) 207 | assert.strictEqual(g.get(9, 9), 'test') 208 | }) 209 | 210 | it('should get values from Vector2 arg', () => { 211 | const g = new Grid({ columnCount: 10, rowCount: 10, fill: 'test' }) 212 | assert.strictEqual(g.get(vec2(9, 9)), 'test') 213 | }) 214 | }) 215 | 216 | describe('.set', () => { 217 | it('should set values from discrete x/y args', () => { 218 | const g = new Grid({ columnCount: 10, rowCount: 10, fill: 'test' }) 219 | g.set(8, 8, 'test2') 220 | assert.strictEqual(g.get(8, 8), 'test2') 221 | }) 222 | 223 | it('should set values from Vector2 arg', () => { 224 | const g = new Grid({ columnCount: 10, rowCount: 10, fill: 'test' }) 225 | g.set(vec2(8, 8), 'test2') 226 | assert.strictEqual(g.get(8, 8), 'test2') 227 | }) 228 | }) 229 | }) 230 | -------------------------------------------------------------------------------- /lib/components/circle.ts: -------------------------------------------------------------------------------- 1 | import { error } from '../internal.js' 2 | import { Vector2, vec2 } from '../vector2.js' 3 | import { Tag } from './tag.js' 4 | 5 | export type CircleAttributes = { 6 | x?: number 7 | y?: number 8 | center?: Vector2 9 | /** 10 | * @default 1 11 | */ 12 | radius?: number 13 | } 14 | 15 | type Bitangent = [Vector2, Vector2, number, 'inner' | 'outer'] 16 | type InnerBitangent = [Vector2, Vector2, number, 'inner'] 17 | type OuterBitangent = [Vector2, Vector2, number, 'outer'] 18 | 19 | /** 20 | * @class Circle 21 | */ 22 | export class Circle extends Tag { 23 | #x: number 24 | #y: number 25 | #radius: number 26 | #center: Vector2 27 | 28 | constructor({ 29 | x, 30 | y, 31 | radius = 1, 32 | center, 33 | ...attributes 34 | }: CircleAttributes = {}) { 35 | const [i, j] = 36 | x !== undefined && y !== undefined 37 | ? [x, y] 38 | : center !== undefined 39 | ? [center.x, center.y] 40 | : error( 41 | 'Must pass either `x` and `y` or `center` arguments to Circle constructor', 42 | ) 43 | super('circle', { 44 | cx: i, 45 | cy: j, 46 | r: radius, 47 | ...attributes, 48 | }) 49 | this.#x = i 50 | this.#y = j 51 | this.#center = vec2(i, j) 52 | this.#radius = radius 53 | } 54 | 55 | /** 56 | * @param {number} value 57 | */ 58 | set x(value: number) { 59 | this.setAttributes({ cx: value }) 60 | this.#x = value 61 | } 62 | get x(): number { 63 | return this.#x 64 | } 65 | 66 | /** 67 | * @param {number} value 68 | */ 69 | set y(value: number) { 70 | this.setAttributes({ cy: value }) 71 | this.#y = value 72 | } 73 | get y(): number { 74 | return this.#y 75 | } 76 | 77 | /** 78 | * @param {number} value 79 | */ 80 | set radius(value: number) { 81 | this.setAttributes({ r: value }) 82 | this.#radius = value 83 | } 84 | get radius(): number { 85 | return this.#radius 86 | } 87 | 88 | /** 89 | * @returns {Vector2} 90 | */ 91 | get center(): Vector2 { 92 | return this.#center 93 | } 94 | 95 | /** 96 | * Check if the circle contains a point 97 | * @param {Vector2} point 98 | * @returns {boolean} 99 | */ 100 | contains(point: Vector2): boolean { 101 | return point.distanceTo(this.#center) <= this.#radius 102 | } 103 | 104 | /** 105 | * @param {Circle} other 106 | * @parram {number} [padding=0] optional padding; allows using this method to check for "close to circle" instead of strict intersections 107 | * @returns {boolean} 108 | */ 109 | intersectsCircle(other: Circle, padding = 0): boolean { 110 | return ( 111 | this.center.distanceTo(other.center) < 112 | this.radius + other.radius + padding 113 | ) 114 | } 115 | 116 | /** 117 | * Returns a list of all bitangents, i.e. lines that are tangent to both circles. 118 | * Thanks SO! https://math.stackexchange.com/questions/719758/inner-tangent-between-two-circles-formula 119 | * @param {Circle} other 120 | * @returns {Bitangent[]} a list of tangents, 121 | * where the first value is the point on `small` and the second value is the point on `large`, 122 | * and the third value is the angle of the tangent points relative to 0 radians 123 | */ 124 | bitangents(other: Circle): Bitangent[] { 125 | // there is some duplicated calculations in outer and inner tangents; consider refactoring 126 | return (this.outerTangents(other)).concat( 127 | this.innerTangents(other), 128 | ) 129 | } 130 | 131 | /** 132 | * @param {Circle} other 133 | * @returns {OuterBitangent[]} outer tangent lines 134 | * where the first value is the point on `small` and the second value is the point on `large`, 135 | * and the third value is the angle of the tangent points relative to 0 radians 136 | */ 137 | outerTangents(other: Circle): OuterBitangent[] { 138 | const small = this.radius > other.radius ? other : this 139 | const large = this.radius > other.radius ? this : other 140 | const hypotenuse = small.center.distanceTo(large.center) 141 | const short = large.radius - small.radius 142 | const angleBetweenCenters = Math.atan2(small.y - large.y, small.x - large.x) 143 | const phi = angleBetweenCenters + Math.acos(short / hypotenuse) 144 | const phi2 = angleBetweenCenters - Math.acos(short / hypotenuse) 145 | 146 | return [ 147 | [ 148 | vec2( 149 | small.x + small.radius * Math.cos(phi), 150 | small.y + small.radius * Math.sin(phi), 151 | ), 152 | vec2( 153 | large.x + large.radius * Math.cos(phi), 154 | large.y + large.radius * Math.sin(phi), 155 | ), 156 | phi, 157 | 'outer', 158 | ], 159 | [ 160 | vec2( 161 | small.x + small.radius * Math.cos(phi2), 162 | small.y + small.radius * Math.sin(phi2), 163 | ), 164 | vec2( 165 | large.x + large.radius * Math.cos(phi2), 166 | large.y + large.radius * Math.sin(phi2), 167 | ), 168 | phi2, 169 | 'outer', 170 | ], 171 | ] 172 | } 173 | 174 | /** 175 | * @param {Circle} other 176 | * @returns {InnerBitangent[]} inner tangent lines, 177 | * where the first value is the point on `small` and the second value is the point on `large`, 178 | * and the third value is the angle of the tangent points relative to 0 radians 179 | */ 180 | innerTangents(other: Circle): InnerBitangent[] { 181 | if (this.intersectsCircle(other)) { 182 | return [] 183 | } 184 | const small = this.radius > other.radius ? other : this 185 | const large = this.radius > other.radius ? this : other 186 | const hypotenuse = small.center.distanceTo(large.center) 187 | const short = large.radius + small.radius 188 | const angleBetweenCenters = Math.atan2(small.y - large.y, small.x - large.x) 189 | const phi = 190 | angleBetweenCenters + Math.asin(short / hypotenuse) - Math.PI / 2 191 | const phi2 = 192 | angleBetweenCenters - Math.asin(short / hypotenuse) - Math.PI / 2 193 | 194 | return [ 195 | [ 196 | vec2( 197 | small.x - small.radius * Math.cos(phi), 198 | small.y - small.radius * Math.sin(phi), 199 | ), 200 | vec2( 201 | large.x + large.radius * Math.cos(phi), 202 | large.y + large.radius * Math.sin(phi), 203 | ), 204 | phi, 205 | 'inner', 206 | ], 207 | [ 208 | vec2( 209 | small.x + small.radius * Math.cos(phi2), 210 | small.y + small.radius * Math.sin(phi2), 211 | ), 212 | vec2( 213 | large.x - large.radius * Math.cos(phi2), 214 | large.y - large.radius * Math.sin(phi2), 215 | ), 216 | phi2, 217 | 'inner', 218 | ], 219 | ] 220 | } 221 | 222 | toString(): string { 223 | return `Circle { x: ${this.#x}, y: ${this.#y}, radius: ${this.#radius} }` 224 | } 225 | } 226 | 227 | export function circle(attrs: CircleAttributes): Circle 228 | export function circle(x: number, y: number, radius: number): Circle 229 | export function circle(builder: (c: Circle) => void): Circle 230 | export function circle( 231 | attrsOrBuilderOrX: CircleAttributes | number | ((circle: Circle) => void), 232 | y?: number, 233 | radius?: number, 234 | ): Circle { 235 | if (typeof attrsOrBuilderOrX === 'function') { 236 | const c = new Circle() 237 | attrsOrBuilderOrX(c) 238 | return c 239 | } 240 | if (typeof attrsOrBuilderOrX === 'object') { 241 | return new Circle(attrsOrBuilderOrX as CircleAttributes) 242 | } 243 | if ( 244 | typeof attrsOrBuilderOrX === 'number' && 245 | typeof y === 'number' && 246 | typeof radius === 'number' 247 | ) { 248 | return new Circle({ x: attrsOrBuilderOrX, y, radius }) 249 | } 250 | throw new Error( 251 | `Unable to construct circle from "${attrsOrBuilderOrX}, ${y}, ${radius}"`, 252 | ) 253 | } 254 | -------------------------------------------------------------------------------- /lib/components/path.ts: -------------------------------------------------------------------------------- 1 | // utilities for svg path tags 2 | // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#path_commands 3 | // 4 | // MoveTo: M, m 5 | // LineTo: L, l, H, h, V, v 6 | // Cubic Bézier Curve: C, c, S, s 7 | // Quadratic Bézier Curve: Q, q, T, t 8 | // Elliptical Arc Curve: A, a 9 | // ClosePath: Z, z 10 | // 11 | // Note: Commands are case-sensitive. 12 | // An upper-case command specifies absolute coordinates, 13 | // while a lower-case command specifies coordinates relative to the current position. 14 | // 15 | // It is always possible to specify a negative value as an argument to a command: 16 | // negative angles will be anti-clockwise; 17 | // absolute negative x and y values are interpreted as negative coordinates; 18 | // relative negative x values move to the left, and relative negative y values move upwards. 19 | 20 | import { Tag } from './tag.js' 21 | import { vec2, Vector2 } from '../vector2.js' 22 | import { toFixedPrecision } from '../math.js' 23 | 24 | export type CoordinateType = 'absolute' | 'relative' 25 | // TODO: consider adding definitions for shared attributes like fill, stroke, etc 26 | export type PathAttributes = Record 27 | export type ArcProps = { 28 | rx: number 29 | ry: number 30 | /** 31 | * @default 0 32 | */ 33 | xAxisRotation?: number 34 | /** 35 | * @default false 36 | */ 37 | largeArcFlag?: boolean 38 | /** 39 | * @default false 40 | */ 41 | sweepFlag?: boolean 42 | end: Vector2 43 | } 44 | 45 | export class Path extends Tag { 46 | #d: PathInstruction[] = [] 47 | cursor: Vector2 48 | 49 | constructor(attributes: PathAttributes = {}) { 50 | super('path', attributes) 51 | this.cursor = vec2(0, 0) 52 | } 53 | 54 | /** 55 | * 56 | * @param {Vector2} endPoint 57 | * @param {CoordinateType} coordinateType 58 | */ 59 | moveTo(endPoint: Vector2, coordinateType: CoordinateType = 'absolute'): void { 60 | this.#d.push( 61 | new PathInstruction(coordinateType === 'absolute' ? 'M' : 'm', [ 62 | endPoint, 63 | ]), 64 | ) 65 | this.cursor = endPoint 66 | } 67 | 68 | /** 69 | * 70 | * @param {Vector2} endPoint 71 | * @param {CoordinateType} coordinateType 72 | */ 73 | lineTo(endPoint: Vector2, coordinateType: CoordinateType = 'absolute'): void { 74 | this.#d.push( 75 | new PathInstruction(coordinateType === 'absolute' ? 'L' : 'l', [ 76 | endPoint, 77 | ]), 78 | ) 79 | this.cursor = endPoint 80 | } 81 | 82 | /** 83 | * * C 84 | * (x1,y1, x2,y2, x,y) 85 | * Draw a cubic Bézier curve from the current point to the end point specified by x,y. 86 | * The start control point is specified by x1,y1 and the end control point is specified by x2,y2 87 | * @param {Vector2} controlPoint1 88 | * @param {Vector2} controlPoint2 89 | * @param {Vector2} endPoint 90 | * @param {CoordinateType} coordinateType 91 | */ 92 | cubicBezier( 93 | controlPoint1: Vector2, 94 | controlPoint2: Vector2, 95 | endPoint: Vector2, 96 | coordinateType: CoordinateType = 'absolute', 97 | ): void { 98 | this.#d.push( 99 | new PathInstruction(coordinateType === 'absolute' ? 'C' : 'c', [ 100 | controlPoint1, 101 | controlPoint2, 102 | endPoint, 103 | ]), 104 | ) 105 | this.cursor = endPoint 106 | } 107 | 108 | /** 109 | * S 110 | * Draw a smooth cubic Bézier curve from the current point to the end point specified by x,y. 111 | * The end control point is specified by x2,y2. 112 | * The start control point is a reflection of the end control point of the previous curve command 113 | * @param {Vector2} controlPoint 114 | * @param {Vector2} endPoint 115 | * @param {'absolute' | 'relative'} coordinateType 116 | */ 117 | smoothBezier( 118 | controlPoint: Vector2, 119 | endPoint: Vector2, 120 | coordinateType: CoordinateType = 'absolute', 121 | ): void { 122 | this.#d.push( 123 | new PathInstruction(coordinateType === 'absolute' ? 'S' : 's', [ 124 | controlPoint, 125 | endPoint, 126 | ]), 127 | ) 128 | this.cursor = endPoint 129 | } 130 | 131 | /** 132 | * Draw a quadratic curve controlled by `controlPoint` to the `endPoint` 133 | * @param {Vector2} controlPoint 134 | * @param {Vector2} endPoint 135 | * @param {'absolute' | 'relative'} coordinateType 136 | */ 137 | quadraticBezier( 138 | controlPoint: Vector2, 139 | endPoint: Vector2, 140 | coordinateType: CoordinateType = 'absolute', 141 | ): void { 142 | this.#d.push( 143 | new PathInstruction(coordinateType === 'absolute' ? 'Q' : 'q', [ 144 | controlPoint, 145 | endPoint, 146 | ]), 147 | ) 148 | this.cursor = endPoint 149 | } 150 | 151 | /** 152 | * Draw a quadratic curve controlled by `controlPoint` to the `endPoint` 153 | * @param {Vector2} controlPoint 154 | * @param {Vector2} endPoint 155 | * @param {'absolute' | 'relative'} coordinateType 156 | */ 157 | smoothQuadraticBezier( 158 | controlPoint: Vector2, 159 | endPoint: Vector2, 160 | coordinateType: CoordinateType = 'absolute', 161 | ): void { 162 | this.#d.push( 163 | new PathInstruction(coordinateType === 'absolute' ? 'T' : 't', [ 164 | controlPoint, 165 | endPoint, 166 | ]), 167 | ) 168 | this.cursor = endPoint 169 | } 170 | 171 | arc( 172 | { 173 | rx, 174 | ry, 175 | xAxisRotation = 0, 176 | largeArcFlag = false, 177 | sweepFlag = false, 178 | end, 179 | }: ArcProps, 180 | coordinateType: CoordinateType = 'absolute', 181 | ): void { 182 | this.#d.push( 183 | new PathInstruction(coordinateType === 'absolute' ? 'A' : 'a', [ 184 | vec2(rx, ry), 185 | xAxisRotation, 186 | Number(largeArcFlag), 187 | Number(sweepFlag), 188 | end, 189 | ]), 190 | ) 191 | } 192 | 193 | close(): void { 194 | this.#d.push(new PathInstruction('Z', [])) 195 | this.cursor = this.#d[0].endPoint 196 | } 197 | 198 | /** 199 | * @param {Vector2[]} points 200 | * @param {boolean} [closed=true] 201 | * @param {CoordinateType} [coordinateType='absolute'] 202 | */ 203 | static fromPoints( 204 | points: Vector2[], 205 | closed = true, 206 | coordinateType: CoordinateType = 'absolute', 207 | ): Path { 208 | return path((p) => { 209 | p.moveTo(points[0], coordinateType) 210 | for (let i = 1; i < points.length; i++) { 211 | p.lineTo(points[i], coordinateType) 212 | } 213 | if (closed) { 214 | p.close() 215 | } 216 | }) 217 | } 218 | 219 | render(): string { 220 | this.setAttributes({ 221 | d: this.#d.map((p) => p.render(this.numericPrecision)).join(' '), 222 | }) 223 | return super.render() 224 | } 225 | } 226 | 227 | export type PathAttributesOrBuilder = PathAttributes | ((Path: Path) => void) 228 | 229 | export function path( 230 | attrsOrBuilder: PathAttributes, 231 | attributes?: PathAttributes, 232 | ): Path 233 | export function path( 234 | attrsOrBuilder: (Path: Path) => void, 235 | attributes?: PathAttributes, 236 | ): Path 237 | export function path( 238 | attrsOrBuilder: PathAttributesOrBuilder, 239 | attributes: PathAttributes = {}, 240 | ): Path { 241 | if (typeof attrsOrBuilder === 'function') { 242 | const c = new Path(attributes) 243 | attrsOrBuilder(c) 244 | return c 245 | } 246 | if (typeof attrsOrBuilder === 'object') { 247 | return new Path(attrsOrBuilder) 248 | } 249 | throw new Error(`Unable to construct Path from "${attrsOrBuilder}"`) 250 | } 251 | 252 | type PathCommand = 253 | | 'l' 254 | | 'L' 255 | | 'm' 256 | | 'M' 257 | | 'c' 258 | | 'C' 259 | | 's' 260 | | 'S' 261 | | 'Z' 262 | | 'A' 263 | | 'a' 264 | | 'Q' 265 | | 'q' 266 | | 'T' 267 | | 't' 268 | 269 | class PathInstruction { 270 | endPoint: Vector2 271 | points: (Vector2 | number)[] 272 | commandType: PathCommand 273 | constructor(commandType: PathCommand, points: (Vector2 | number)[]) { 274 | // This isn't super resilient, but this class should never be instantiated manually so it should be fine 275 | this.endPoint = typeof points?.[0] === 'number' ? vec2(0, 0) : points[0] 276 | this.points = points 277 | this.commandType = commandType 278 | } 279 | 280 | /** 281 | * @param {number} [precision=Infinity] 282 | * @returns {string} 283 | */ 284 | render(precision: number = Infinity): string { 285 | return [ 286 | this.commandType, 287 | ...this.points.map((pt) => 288 | pt instanceof Vector2 289 | ? [ 290 | toFixedPrecision(pt.x, precision), 291 | toFixedPrecision(pt.y, precision), 292 | ].join(' ') 293 | : toFixedPrecision(pt as number, precision).toString(), 294 | ), 295 | ].join(' ') 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /lib/algorithms/walking-triangles.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is an implementation of the "Meandering Triangles" contour algorithm, 3 | * described by Bruce Hill here: https://blog.bruce-hill.com/meandering-triangles 4 | * A few changes were made to taste. 5 | * For a point-in-time reference, here are some benchmarks from my M1 Max MacBook Pro 6 | * 7 | * | TIN size | Threshold | Time | 8 | * | | count | | 9 | * |----------|-----------|-------| 10 | * | 414,534 | 10 | 0.7s | 11 | * | 414,534 | 50 | 5.5s | 12 | * | 414,534 | 100 | 15.3s | 13 | * | 45,968 | 10 | 0.1s | 14 | * | 45,968 | 100 | 1.6s | 15 | */ 16 | import { array, map } from '../util.js' 17 | import { vec2, type Vector2 } from '../vector2.js' 18 | import { type Vector3 } from '../vector3.js' 19 | 20 | export type IntersectingLine = [Vector2, Vector2] 21 | export type Triangle3 = [Vector3, Vector3, Vector3] 22 | export type TIN = Triangle3[] 23 | export type Contour = { 24 | line: IntersectingLine 25 | threshold: number 26 | } 27 | export type ContourParams = { 28 | /** 29 | * The TIN (Triangulated Irregular Network) to calculate contours for. 30 | */ 31 | tin: TIN 32 | /** 33 | * How many thresholds to calculate contours for. 34 | * @default 10 35 | */ 36 | thresholdCount?: number 37 | /** 38 | * Minimum "height" (z property of Vector3). 39 | * Thresholds will be evenly spaced between zMin and zMax. 40 | * @default -1 41 | */ 42 | zMin?: number 43 | /** 44 | * Maximum "height" (z property of Vector3). 45 | * Thresholds will be evenly spaced between zMin and zMax. 46 | * @default -1 47 | */ 48 | zMax?: number 49 | /** 50 | * When connecting contour segments, this is the maximum distance between two points for them to be considered the same point. 51 | * This might vary depending on the construction of your TIN, but generally it should remain fairly low. 52 | * @default 1 53 | */ 54 | nearnessThreshold?: number 55 | } 56 | 57 | /** 58 | * Returns contour lines for the given TIN. 59 | * The return value is a map of threshold values to a list of contours. 60 | * This structure is useful if you want to color the contours based on the threshold value. 61 | * 62 | * @example 63 | * // Render contours from a TIN, and color them based on their height. 64 | * const spectrum = ColorSequence.fromColors([hsl(0, 0, 1), hsl(0, 0, 0)]) 65 | * const contourMap = contoursFromTIN({ 66 | * thresholdCount: 50, 67 | * zMin: -1000, 68 | * zMax: 1000, 69 | * tin: [...], 70 | * nearnessThreshold: 1, 71 | * }) 72 | * 73 | * for (const [threshold, contourList] of contourMap.entries()) { 74 | * for (const contours of contourList) { 75 | * const contour = Path.fromPoints(contours, false, 'absolute') 76 | * contour.fill = null 77 | * contour.stroke = spectrum.at(map(zMin, zMax, 0, 1, threshold)) 78 | * contour.strokeWidth = 50 79 | * svg.path(contour) 80 | * } 81 | * } 82 | * 83 | * @example 84 | * // If you don't care about organizing segments by threshold, you can flatten the values into a single list 85 | * const spectrum = ColorSequence.fromColors([hsl(0, 0, 1), hsl(0, 0, 0)]) 86 | * const contourMap = contoursFromTIN({ 87 | * thresholdCount: 50, 88 | * zMin: -1000, 89 | * zMax: 1000, 90 | * tin: [...], 91 | * nearnessThreshold: 1, 92 | * }) 93 | * const contours = Array.from(contourMap.values()).flat(1) 94 | * 95 | * for (const points of contours) { 96 | * const contour = Path.fromPoints(points, false, 'absolute') 97 | * contour.fill = null 98 | * contour.stroke = spectrum.at(map(zMin, zMax, 0, 1, threshold)) 99 | * contour.strokeWidth = 50 100 | * svg.path(contour) 101 | * } 102 | */ 103 | export function contoursFromTIN({ 104 | tin, 105 | thresholdCount = 10, 106 | zMin = -1, 107 | zMax = 1, 108 | nearnessThreshold = 1, 109 | }: ContourParams): Map { 110 | const thresholds = array(thresholdCount).map((i) => 111 | map(0, thresholdCount - 1, zMin, zMax, i), 112 | ) 113 | 114 | // this MUST be flatMapped to ensure we get an unordered list of contour segments for the given threshold 115 | // each segments represents an intersection of a single triangle. 116 | const segments = thresholds.flatMap((threshold) => 117 | calcContour(threshold, tin), 118 | ) 119 | 120 | return connectContourSegments(segments, nearnessThreshold) 121 | } 122 | 123 | function contourLine(vertices: Triangle3, threshold: number): Contour | null { 124 | const below = vertices.filter((v) => v.z < threshold) 125 | const above = vertices.filter((v) => v.z >= threshold) 126 | 127 | if (above.length === 0 || below.length === 0) { 128 | return null 129 | } 130 | 131 | const minority = below.length < above.length ? below : above 132 | const majority = below.length > above.length ? below : above 133 | 134 | // @ts-expect-error the array is initialized empty, 135 | // but visual inspection tells us it will contain IntersectingLine by the time it is returned. 136 | const contourPoints: IntersectingLine = [] 137 | for (const [vMin, vMax] of [ 138 | [minority[0], majority[0]], 139 | [minority[0], majority[1]], 140 | ]) { 141 | const howFar = (threshold - vMax.z) / (vMin.z - vMax.z) 142 | const crossingPoint = vec2( 143 | howFar * vMin.x + (1.0 - howFar) * vMax.x, 144 | howFar * vMin.y + (1.0 - howFar) * vMax.y, 145 | ) 146 | contourPoints.push(crossingPoint) 147 | } 148 | return { line: contourPoints, threshold } 149 | } 150 | 151 | function calcContour(threshold: number, tin: TIN): Contour[] { 152 | return tin 153 | .map((triangle) => contourLine(triangle, threshold)) 154 | .filter(Boolean) as Contour[] 155 | } 156 | 157 | function isNear(p1: Vector2, p2: Vector2, nearness = 1): boolean { 158 | return p1.distanceTo(p2) < nearness 159 | } 160 | 161 | function connectContourSegments( 162 | contours: Contour[], 163 | nearness?: number, 164 | ): Map { 165 | const contourLines = new Map() 166 | 167 | // partition the contour segments by threshold. 168 | // justification: we only want to connect segments which belong to the same threshold, 169 | // (i.e. avoid connecting segments which are spatially close but do not belong to the same threshold). 170 | // Also, this massively speeds up processing because it dramatically reduces the number of segments to search 171 | // through when looking for connecting segments. 172 | const contourMap = new Map() 173 | for (let i = 0; i < contours.length; i++) { 174 | if (contourMap.has(contours[i].threshold)) { 175 | contourMap.get(contours[i].threshold)?.push(contours[i].line) 176 | } else { 177 | contourMap.set(contours[i].threshold, [contours[i].line]) 178 | } 179 | } 180 | 181 | for (const [threshold, segments] of contourMap.entries()) { 182 | while (segments.length > 0) { 183 | // grab arbitrary segment 184 | const line: Vector2[] | undefined = segments.pop() 185 | if (!line) break 186 | 187 | while (true) { 188 | // find segments that join at the head of the line. 189 | // if we find a point that matches, we must take the other point from the segment, so 190 | // we continue to build a line moving forwards with no duplicate points. 191 | const firstMatchingSegmentIndex = segments.findIndex( 192 | (segment) => 193 | isNear(line[0], segment[0], nearness) || 194 | isNear(line[0], segment[1], nearness) || 195 | isNear(line[line.length - 1], segment[0], nearness) || 196 | isNear(line[line.length - 1], segment[1], nearness), 197 | ) 198 | 199 | // when no matching segment exists, the line is complete. 200 | if (firstMatchingSegmentIndex === -1) { 201 | if (line.length > 5) { 202 | if (contourLines.has(threshold)) { 203 | contourLines.get(threshold)?.push(line) 204 | } else { 205 | contourLines.set(threshold, [line]) 206 | } 207 | } 208 | break 209 | } 210 | 211 | const match = segments[firstMatchingSegmentIndex] 212 | 213 | // Note: in all "cases" below, the phrase "connects to" means the points are functionally equivalent, 214 | // and therefore do not need to be duplicated in the resulting contour. 215 | // case: match[0] connects to line[0]; unshift match[1] 216 | if (isNear(match[0], line[0], nearness)) { 217 | line.unshift(match[1]) 218 | } 219 | // case: match[1] connects to line[0]; unshift match[0] 220 | else if (isNear(match[1], line[0], nearness)) { 221 | line.unshift(match[0]) 222 | } 223 | // case: match[0] connects to line[-1]; push match[1] 224 | else if (isNear(match[0], line[line.length - 1], nearness)) { 225 | line.push(match[1]) 226 | } 227 | // case: match[1] connects to line[-1]; push match[0] 228 | else if (isNear(match[1], line[line.length - 1], nearness)) { 229 | line.push(match[0]) 230 | } 231 | 232 | // removing the matching segment from the list to prevent duplicate connections 233 | segments.splice(firstMatchingSegmentIndex, 1) 234 | } 235 | } 236 | } 237 | 238 | return contourLines 239 | } 240 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SalamiVG ("Salami Vector Graphics") 2 | 3 | A place to play with SVGs. 4 | 5 | SalamiVG is a creative coding framework for JavaScript with a single render target: SVG. 6 | 7 | ## Why? 8 | 9 | I love [OPENRNDR](https://openrndr.org/) 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. 10 | 11 | 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](https://github.com/ericyd/salamivg/wiki/FAQ#why-not-p5js).) 12 | 13 | ## Installation 14 | 15 | ``` 16 | npm i --save @salamivg/core 17 | ``` 18 | 19 | If you use yarn and you can't automatically convert the above to the correct yarn command, then that's on you 😏 20 | 21 | ## Examples 22 | 23 | There is [a Gallery page in the Wiki](https://github.com/ericyd/salamivg/wiki/Gallery) with some example renders and links to the code used to create them. 24 | 25 | If you're the clone-n-run type, you can use the examples from the [`/examples` directory](./examples/) in this repo: 26 | 27 | ```js 28 | git clone git@github.com:ericyd/salamivg 29 | cd salamivg 30 | npm i 31 | npm build 32 | node examples/oscillator-noise.js 33 | ``` 34 | 35 | Here are some simple SVGs generated with SalamiVG 36 | 37 |
38 | 39 | Concentric rings perturbated by a sine wave 40 | 41 | ```js 42 | import { renderSvg, circle, hypot, vec2, map } from '@salamivg/core' 43 | 44 | const config = { 45 | width: 100, 46 | height: 100, 47 | scale: 2, 48 | loopCount: 1, 49 | } 50 | 51 | renderSvg(config, (svg) => { 52 | // set basic SVG props 53 | svg.setBackground('#fff') 54 | svg.fill = null 55 | svg.stroke = '#000' 56 | svg.numericPrecision = 3 57 | 58 | // draw circle in middle of viewport 59 | svg.circle( 60 | circle({ 61 | center: svg.center, 62 | radius: hypot(svg.width, svg.height) * 0.04, 63 | 'stroke-width': 1, 64 | }), 65 | ) 66 | 67 | // draw 14 concentric rings around the center. (14 is arbitrary) 68 | const nRings = 14 69 | for (let i = 1; i <= nRings; i++) { 70 | // use `map` to linearly interpolate the radius on a log scale 71 | const baseRadius = map( 72 | 0, 73 | Math.log(nRings), 74 | hypot(svg.width, svg.height) * 0.09, 75 | hypot(svg.width, svg.height) * 0.3, 76 | Math.log(i), 77 | ) 78 | 79 | // as the rings get further from the center, 80 | // the path is increasingly perturbated by the sine wave. 81 | const sineInfluence = map( 82 | 0, 83 | Math.log(nRings), 84 | baseRadius * 0.01, 85 | baseRadius * 0.1, 86 | Math.log(i), 87 | ) 88 | 89 | svg.path((p) => { 90 | // the stroke width gets thinner as the rings get closer to the edge 91 | p.strokeWidth = map(1, nRings, 0.8, 0.1, i) 92 | 93 | // the radius varies because the path is perturbated by a sine wave 94 | const radius = (angle) => baseRadius + Math.sin(angle * 6) * sineInfluence 95 | const start = Vector2.fromAngle(0).scale(radius(0)).add(svg.center) 96 | p.moveTo(start) 97 | 98 | // move our way around a circle to draw a smooth path 99 | for (let angle = 0; angle <= Math.PI * 2; angle += 0.05) { 100 | const next = Vector2.fromAngle(angle) 101 | .scale(radius(angle)) 102 | .add(svg.center) 103 | p.lineTo(next) 104 | } 105 | p.close() 106 | }) 107 | } 108 | }) 109 | ``` 110 | 111 |
112 | 113 | ![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.](./examples/concentric-circles.svg) 114 | 115 |
116 | 117 | Oscillator noise 118 | 119 | SalamiVG ships with a bespoke noise function called "oscillator noise". 120 | 121 | ```js 122 | import { 123 | renderSvg, 124 | map, 125 | vec2, 126 | randomSeed, 127 | createRng, 128 | Vector2, 129 | random, 130 | ColorRgb, 131 | PI, 132 | cos, 133 | sin, 134 | ColorSequence, 135 | shuffle, 136 | createOscNoise, 137 | } from '@salamivg/core' 138 | 139 | const config = { 140 | width: 100, 141 | height: 100, 142 | scale: 3, 143 | loopCount: 1, 144 | } 145 | 146 | const colors = ['#B2D0DE', '#E0A0A5', '#9BB3E7', '#F1D1B8', '#D9A9D6'] 147 | 148 | renderSvg(config, (svg) => { 149 | // filenameMetadata will be added to the filename that is written to disk; 150 | // this makes it easy to recall which seeds were used in a particular sketch 151 | svg.filenameMetadata = { seed } 152 | 153 | // a seeded pseudo-random number generator provides controlled randomness for our sketch 154 | const rng = createRng(seed) 155 | 156 | // black background 😎 157 | svg.setBackground('#000') 158 | 159 | // set some basic SVG props 160 | svg.fill = null 161 | svg.stroke = ColorRgb.Black 162 | svg.strokeWidth = 0.25 163 | svg.numericPrecision = 3 164 | 165 | // create a 2D noise function using the built-in "oscillator noise" 166 | const noiseFn = createOscNoise(seed) 167 | 168 | // create a bunch of random start points within the svg boundaries 169 | const nPoints = 200 170 | const points = new Array(nPoints) 171 | .fill(0) 172 | .map(() => Vector2.random(0, svg.width, 0, svg.height, rng)) 173 | 174 | // define a color spectrum that can be indexed randomly for line colors 175 | const spectrum = ColorSequence.fromColors(shuffle(colors, rng)) 176 | 177 | // noise functions usually require some type of scaling; 178 | // here we randomize slightly to get the amount of "flowiness" that we want. 179 | const scale = random(0.05, 0.13, rng) 180 | 181 | // each start point gets a line 182 | for (const point of points) { 183 | svg.path((path) => { 184 | // choose a random stroke color for the line 185 | path.stroke = spectrum.at(random(0, 1, rng)) 186 | 187 | // move along the vector field defined by the 2D noise function. 188 | // the line length is "100", which is totally arbitrary. 189 | path.moveTo(point) 190 | for (let i = 0; i < 100; i++) { 191 | let noise = noiseFn(path.cursor.x * scale, path.cursor.y * scale) 192 | let angle = map(-1, 1, -PI, PI, noise) 193 | path.lineTo(path.cursor.add(vec2(cos(angle), sin(angle)))) 194 | } 195 | }) 196 | } 197 | 198 | // when loopCount > 1, this will randomize the seed on each iteration 199 | return () => { 200 | seed = randomSeed() 201 | } 202 | }) 203 | ``` 204 | 205 |
206 | 207 | ![Oscillator noise example. Wavy multi-colored lines defined by a noisy vector field weave through the canvas.](./examples/oscillator-noise.svg) 208 | 209 |
210 | 211 | Recursive triangle subdivision 212 | 213 | ```js 214 | /* 215 | Rules 216 | 217 | 1. Draw an equilateral triangle in the center of the viewBox 218 | 2. Subdivide the triangle into 4 equal-sized smaller triangles 219 | 3. If less than max depth and , continue recursively subdividing 220 | 4. Each triangle gets a different fun-colored fill, and a slightly-opacified stroke 221 | */ 222 | import { 223 | renderSvg, 224 | vec2, 225 | randomSeed, 226 | createRng, 227 | Vector2, 228 | random, 229 | randomInt, 230 | PI, 231 | ColorSequence, 232 | shuffle, 233 | TAU, 234 | ColorRgb, 235 | } from '@salamivg/core' 236 | 237 | const config = { 238 | width: 100, 239 | height: 100, 240 | scale: 3, 241 | loopCount: 1, 242 | } 243 | 244 | let seed = 8852037180828291 // or, randomSeed() 245 | 246 | const colors = [ 247 | '#974F7A', 248 | '#D093C2', 249 | '#6F9EB3', 250 | '#E5AD5A', 251 | '#EEDA76', 252 | '#B5CE8D', 253 | '#DAE7E8', 254 | '#2E4163', 255 | ] 256 | 257 | const bg = '#2E4163' 258 | const stroke = ColorRgb.fromHex('#DAE7E8') 259 | 260 | renderSvg(config, (svg) => { 261 | const rng = createRng(seed) 262 | const maxDepth = randomInt(5, 7, rng) 263 | svg.filenameMetadata = { seed, maxDepth } 264 | svg.setBackground(bg) 265 | svg.numericPrecision = 3 266 | svg.fill = bg 267 | svg.stroke = stroke 268 | svg.strokeWidth = 0.25 269 | const spectrum = ColorSequence.fromColors(shuffle(colors, rng)) 270 | 271 | function drawTriangle(a, b, c, depth = 0) { 272 | // always draw the first triangle; then, draw about half of the triangles 273 | if (depth === 0 || random(0, 1, rng) < 0.5) { 274 | // offset amount increases with depth 275 | const offsetAmount = depth / 2 276 | const offset = vec2( 277 | random(-offsetAmount, offsetAmount, rng), 278 | random(-offsetAmount, offsetAmount, rng), 279 | ) 280 | // draw the triangle with some offset 281 | svg.polygon({ 282 | points: [a.add(offset), b.add(offset), c.add(offset)], 283 | fill: spectrum.at(random(0, 1, rng)).opacify(0.4).toHex(), 284 | stroke: stroke.opacify(1 / (depth / 4 + 1)).toHex(), 285 | }) 286 | } 287 | // recurse if we're above maxDepth and "lady chance allows it" 288 | if (depth < maxDepth && (depth < 2 || random(0, 1, rng) < 0.75)) { 289 | const ab = Vector2.mix(a, b, 0.5) 290 | const ac = Vector2.mix(a, c, 0.5) 291 | const bc = Vector2.mix(b, c, 0.5) 292 | drawTriangle(ab, ac, bc, depth + 1) 293 | drawTriangle(a, ab, ac, depth + 1) 294 | drawTriangle(b, bc, ab, depth + 1) 295 | drawTriangle(c, bc, ac, depth + 1) 296 | } 297 | } 298 | 299 | // construct an equilateral triangle from the center of the canvas with a random rotation 300 | const angle = random(0, TAU, rng) 301 | const a = svg.center.add(Vector2.fromAngle(angle).scale(45)) 302 | const b = svg.center.add(Vector2.fromAngle(angle + (PI * 2) / 3).scale(45)) 303 | const c = svg.center.add(Vector2.fromAngle(angle + (PI * 4) / 3).scale(45)) 304 | drawTriangle(a, b, c) 305 | 306 | // when loopCount > 1, this will randomize the seed on each iteration 307 | return () => { 308 | seed = randomSeed() 309 | } 310 | }) 311 | ``` 312 | 313 |
314 | 315 | ![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.](./examples/recursive-triangles.svg) 316 | 317 | ## Getting Started, Documentation, and FAQ 318 | 319 | [Please see the project Wiki](https://github.com/ericyd/salamivg/wiki) 320 | 321 | ## Design Philosophy 322 | 323 | 1. Inspired by the APIs of [OPENRNDR](https://openrndr.org/), expressed in idiomatic TypeScript 324 | 2. Local first 325 | 3. Fully type-checked and thoroughly documented 326 | 4. Small, fast, and focused 327 | 3. Don't take yourself too seriously 328 | 329 | ## Internal Development 330 | 331 | Recommended: [install `asdf` version manager](https://asdf-vm.com/guide/getting-started.html). Then: 332 | 333 | ```shell 334 | asdf plugin-add deno https://github.com/asdf-community/asdf-deno.git 335 | asdf plugin add bun 336 | asdf install 337 | ``` 338 | 339 | Install dependencies: 340 | 341 | ```shell 342 | npm i 343 | ``` 344 | 345 | Before committing: 346 | 347 | ```shell 348 | npm run check:all 349 | ``` 350 | 351 | ## Publishing 352 | 353 | ```shell 354 | npm version minor 355 | git push --tags && git push 356 | ./scripts/changelog.sh 357 | npm login --registry https://registry.npmjs.org --scope=@salamivg 358 | npm publish --access public 359 | ``` 360 | 361 | ## NodeJS Version Compatibility 362 | 363 | SalamiVG was developed with Node 23 but it probably works back to Node 14 or so. 364 | 365 | This library has been tested against 366 | * Node 23.9.0 367 | * Node 20.8.0 368 | * Node 18.19.0 369 | * Node 16.20.2 370 | * Attempted to test against Node 14 but [asdf](https://asdf-vm.com/) wouldn't install it on our M1 Mac. Please open an issue if this is causing you problems. 371 | 372 | ## Deno / Bun Support? Yes! 🎉 373 | 374 | As of Deno v2.2.3 and Bun v1.2.4, SalamiVG is fully compatible with both Deno and Bun. You can check this claim yourself with the following commands, assuming you have `git` and `asdf` installed: 375 | 376 | ```shell 377 | git clone git@github.com:ericyd/salamivg 378 | cd salamivg 379 | asdf plugin-add deno https://github.com/asdf-community/asdf-deno.git 380 | asdf plugin add bun 381 | asdf install 382 | npm i 383 | npm run build 384 | deno examples/concentric-circles.js 385 | bun examples/concentric-circles.js 386 | ``` 387 | 388 | ## ES Modules Only 389 | 390 | SalamiVG ships ES Modules, and does not include CommonJS builds. 391 | 392 | Is this a problem? Feel free to open an issue if you need CommonJS. It would probably be trivial to ship both ES Modules and CommonJS, but I'm not going to do this until it is requested. 393 | --------------------------------------------------------------------------------