├── .github └── workflows │ └── push.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── CNAME ├── LICENSE ├── README.legacy.md ├── README.md ├── assets ├── favicon.ico ├── logo-color.svg ├── logo-grey.svg ├── logo.svg └── preview.png ├── demo ├── content.ts ├── example.ts ├── index.html └── internal │ ├── canvas.ts │ ├── debug.ts │ └── layout.ts ├── examples └── corner-expand.html ├── index.html ├── internal ├── animate │ ├── frames.ts │ ├── interpolate.ts │ ├── prepare.ts │ ├── state.ts │ ├── testing │ │ ├── index.html │ │ └── script.ts │ └── timing.ts ├── check.ts ├── gen.ts ├── rand.ts ├── render │ ├── canvas.ts │ ├── svg.test.ts │ └── svg.ts ├── types.ts └── util.ts ├── package-lock.json ├── package.json ├── public ├── __snapshots__ │ └── legacy.test.ts.snap ├── animate.test.ts ├── animate.ts ├── blobs.test.ts ├── blobs.ts ├── legacy.test.ts └── legacy.ts ├── rollup.config.mjs ├── tsconfig.json └── yarn.lock /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: on-push 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions/setup-node@v1 9 | with: 10 | node-version: '18.x' 11 | - run: yarn 12 | - run: yarn run build 13 | - run: yarn run test 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | ~* 4 | *.js 5 | *.js.map 6 | *.d.ts 7 | .cache 8 | dist 9 | !rollup.config.js 10 | docs/*.js 11 | docs/*.svg 12 | docs/*.css 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !README.md 3 | !LICENSE 4 | !package.json 5 | !**/*.js 6 | !**/*.js.map 7 | !**/index.d.ts 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.3.0 2 | 3 | - Add `CanvasCustomKeyframe` to `v2/animate` 4 | - Add `wigglePreset` to `v2/animate` 5 | 6 | # 2.2.1 7 | 8 | - Add support for custom point-based keyframes 9 | - Add option to set custom timestamp provider 10 | - Add module support, thank you to #4 and #7 11 | - Export `Animation` and `TimestampProvider` types from `v2/animate` 12 | 13 | # 2.2.0 14 | 15 | - Remove added points from end keyframe after interpolation completes. 16 | - Add play/pause/playPause API for animations. 17 | 18 | # 2.1.0 19 | 20 | - Improved type checks on user-provided data 21 | - Added `"blobs/v2/animate"` 22 | - Animate between arbitrary blob keyframes 23 | - Separate import to keep main bundle small 24 | - New demo website with animated blob transitions 25 | - Supports only canvas rendering 26 | 27 | # 2.0.1 28 | 29 | - Fix typo in code example of README 30 | 31 | # 2.0.0 32 | 33 | - **BREAKING** Editable SVG element creation function has moved to 34 | `blobs.xml(tagName)`. 35 | - Added `"blobs/v2"` 36 | - 30% smaller compressed size 37 | - Supports canvas rendering 38 | - Supports raw SVG path rendering 39 | 40 | # 1.1.0 41 | 42 | - Add support for editable output 43 | 44 | # 1.0.5 45 | 46 | - Fix assets in README on npmjs.com 47 | 48 | # 1.0.4 49 | 50 | - Use snapshot tests to verify consistency 51 | - Ignore unnecessary files in npm tarball 52 | - Output sourcemap file 53 | - Add project logo 54 | - README content updates 55 | 56 | # 1.0.3 57 | 58 | - Add link to demo page in the README 59 | 60 | # 1.0.2 61 | 62 | - Make transpiled output minified 63 | - Minor changes to the README 64 | 65 | # 1.0.1 66 | 67 | - Remove accidental dependency 68 | - Minor changes to the README 69 | 70 | # 1.0.0 71 | 72 | - Initial release 73 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | blobs.dev -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Gabriel Harel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.legacy.md: -------------------------------------------------------------------------------- 1 | The legacy API exists to preserve compatibility for users importing the package 2 | using a `script` tag. Because [unpkg.com](https://unpkg.com) serves the latest 3 | version of the package if no version is specified, I can't break backwards 4 | compatibility, even with a major release. This API also preserves a few features 5 | that could potentially still be useful to some users (guide rendering and 6 | editable svg). 7 | 8 | --- 9 | 10 | ## Install 11 | 12 | ```ts 13 | // $ npm install blobs 14 | const blobs = require("blobs"); 15 | ``` 16 | 17 | ```html 18 | 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```typescript 24 | const svg = blobs(options); 25 | ``` 26 | 27 | ![](https://svgsaur.us?t=&w=5&h=32&b=fdcc56) 28 | ![](https://svgsaur.us/?t=WARNING&w=103&h=32&s=16&y=21&x=12&b=feefcd&f=arial&o=b) 29 | ![](https://svgsaur.us?t=&w=1&h=48&) 30 | 31 | _Options are **not** 32 | [sanitized](https://en.wikipedia.org/wiki/HTML_sanitization). Never trust raw 33 | user-submitted values in the options._ 34 | 35 | ## Options 36 | 37 | #### Required 38 | 39 | | Name | Type | Description | 40 | | ------------ | -------- | -------------------------------------------- | 41 | | `size` | `number` | Bounding box dimensions (in pixels) | 42 | | `complexity` | `number` | Blob complexity (number of points) | 43 | | `contrast` | `number` | Blob contrast (randomness of point position) | 44 | 45 | #### Optional 46 | 47 | | Name | Type | Default | Description | 48 | | -------------- | ---------- | ---------- | ------------------------------------- | 49 | | `color` | `string?` | `"none"` | Fill color | 50 | | `stroke` | `object?` | `...` | Stroke options | 51 | | `stroke.color` | `string` | `"none"` | Stroke color | 52 | | `stroke.width` | `number` | `0` | Stroke width (in pixels) | 53 | | `seed` | `string?` | _`random`_ | Value to seed random number generator | 54 | | `guides` | `boolean?` | `false` | Render points, handles and stroke | 55 | 56 | _Either `stroke` or `color` must be defined._ 57 | 58 | _Guides will use stroke color and width if defined. Otherwise, they default to 59 | `black` stroke with width of `1`._ 60 | 61 | ##### Example Options Object 62 | 63 | ```typescript 64 | const options = { 65 | size: 600, 66 | complexity: 0.2, 67 | contrast: 0.4, 68 | color: "#ec576b", 69 | stroke: { 70 | width: 0, 71 | color: "black", 72 | }, 73 | guides: false, 74 | seed: "1234", 75 | }; 76 | ``` 77 | 78 | ## Advanced 79 | 80 | If you need to edit the output svg for your use case, blobs also allows for 81 | _editable_ output. 82 | 83 | ```typescript 84 | const editableSvg = blobs.editable(options); 85 | ``` 86 | 87 | The output of this function is a data structure that represents a nested svg 88 | document. This structure can be changed and rendered to a string using its 89 | `render` function. 90 | 91 | ```typescript 92 | editableSvg.attributes.width = 1000; 93 | const svg = editableSvg.render(); 94 | ``` 95 | 96 | New elements can be added anywhere in the hierarchy. 97 | 98 | ```typescript 99 | const xmlChild = blobs.xml("path"); 100 | xmlChild.attributes.stroke = "red"; 101 | // ... 102 | editableSvg.children.push(xmlChild); 103 | ``` 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Legacy documentation 3 |

4 | 5 |

6 | 9 | 12 |

13 | 14 |

15 | 16 | 17 | 18 |

19 | 20 | ## Install 21 | 22 | ```bash 23 | $ npm install blobs 24 | ``` 25 | 26 | ```ts 27 | import * as blobs2 from "blobs/v2"; 28 | ``` 29 | 30 | ```ts 31 | import * as blobs2Animate from "blobs/v2/animate"; 32 | ``` 33 | 34 |

35 | OR 36 |

37 | 38 | ```html 39 | 40 | ``` 41 | 42 | ```html 43 | 44 | ``` 45 | 46 | ## SVG Path 47 | 48 | ```js 49 | const svgPath = blobs2.svgPath({ 50 | seed: Math.random(), 51 | extraPoints: 8, 52 | randomness: 4, 53 | size: 256, 54 | }); 55 | doSomething(svgPath); 56 | ``` 57 | 58 | ## SVG 59 | 60 | ```js 61 | const svgString = blobs2.svg( 62 | { 63 | seed: Math.random(), 64 | extraPoints: 8, 65 | randomness: 4, 66 | size: 256, 67 | }, 68 | { 69 | fill: "white", // 🚨 NOT SANITIZED 70 | stroke: "black", // 🚨 NOT SANITIZED 71 | strokeWidth: 4, 72 | }, 73 | ); 74 | container.innerHTML = svgString; 75 | ``` 76 | 77 | ## Canvas 78 | 79 | ```js 80 | const path = blobs2.canvasPath( 81 | { 82 | seed: Math.random(), 83 | extraPoints: 16, 84 | randomness: 2, 85 | size: 128, 86 | }, 87 | { 88 | offsetX: 16, 89 | offsetY: 32, 90 | }, 91 | ); 92 | ctx.stroke(path); 93 | ``` 94 | 95 | ## Canvas Animation 96 | 97 | ```js 98 | const ctx = /* ... */; 99 | const animation = blobs2Animate.canvasPath(); 100 | 101 | // Set up rendering loop. 102 | const renderAnimation = () => { 103 | ctx.clearRect(0, 0, width, height); 104 | ctx.fill(animation.renderFrame()); 105 | requestAnimationFrame(renderAnimation); 106 | }; 107 | requestAnimationFrame(renderAnimation); 108 | 109 | // Transition to new blob on canvas click. 110 | ctx.canvas.onclick = () => { 111 | animation.transition({ 112 | duration: 4000, 113 | timingFunction: "ease", 114 | callback: loopAnimation, 115 | blobOptions: {...}, 116 | }); 117 | }; 118 | ``` 119 | 120 | ## Canvas Wiggle 121 | 122 | ```js 123 | const ctx = /* ... */; 124 | const animation = blobs2Animate.canvasPath(); 125 | 126 | // Set up rendering loop. 127 | const renderAnimation = () => { 128 | ctx.clearRect(0, 0, width, height); 129 | ctx.fill(animation.renderFrame()); 130 | requestAnimationFrame(renderAnimation); 131 | }; 132 | requestAnimationFrame(renderAnimation); 133 | 134 | // Begin wiggle animation. 135 | blobs2Animate.wigglePreset( 136 | animation 137 | /* blobOptions= */ {...}, 138 | /* canvasOptions= */ {}, 139 | /* wiggleOptions= */ {speed: 2}, 140 | ) 141 | ``` 142 | 143 | ## Complete API 144 | 145 | ### `"blobs/v2"` 146 | 147 | ```ts 148 | export interface BlobOptions { 149 | // A given seed will always produce the same blob. 150 | // Use `Math.random()` for pseudorandom behavior. 151 | seed: string | number; 152 | // Actual number of points will be `3 + extraPoints`. 153 | extraPoints: number; 154 | // Increases the amount of variation in point position. 155 | randomness: number; 156 | // Size of the bounding box. 157 | size: number; 158 | } 159 | 160 | export interface CanvasOptions { 161 | // Coordinates of top-left corner of the blob. 162 | offsetX?: number; 163 | offsetY?: number; 164 | } 165 | export const canvasPath: (blobOptions: BlobOptions, canvasOptions?: CanvasOptions) => Path2D; 166 | 167 | export interface SvgOptions { 168 | fill?: string; // Default: "#ec576b". 169 | stroke?: string; // Default: "none". 170 | strokeWidth?: number; // Default: 0. 171 | } 172 | export const svg: (blobOptions: BlobOptions, svgOptions?: SvgOptions) => string; 173 | export const svgPath: (blobOptions: BlobOptions) => string; 174 | ``` 175 | 176 | ### `"blobs/v2/animate"` 177 | 178 | ```ts 179 | interface Keyframe { 180 | // Duration of the keyframe animation in milliseconds. 181 | duration: number; 182 | // Delay before animation begins in milliseconds. 183 | // Default: 0. 184 | delay?: number; 185 | // Controls the speed of the animation over time. 186 | // Default: "linear". 187 | timingFunction?: 188 | | "linear" 189 | | "easeEnd" 190 | | "easeStart" 191 | | "ease" 192 | | "elasticEnd0" 193 | | "elasticEnd1" 194 | | "elasticEnd2" 195 | | "elasticEnd3"; 196 | // Called after keyframe end-state is reached or passed. 197 | // Called exactly once when the keyframe end-state is rendered. 198 | // Not called if the keyframe is preempted by a new transition. 199 | callback?: () => void; 200 | // Standard options, refer to "blobs/v2" documentation. 201 | canvasOptions?: { 202 | offsetX?: number; 203 | offsetY?: number; 204 | }; 205 | } 206 | 207 | export interface CanvasKeyframe extends Keyframe { 208 | // Standard options, refer to "blobs/v2" documentation. 209 | blobOptions: { 210 | seed: number | string; 211 | randomness: number; 212 | extraPoints: number; 213 | size: number; 214 | }; 215 | } 216 | 217 | export interface CanvasCustomKeyframe extends Keyframe { 218 | // List of point coordinates that produce a single, closed shape. 219 | points: Point[]; 220 | } 221 | 222 | export interface Animation { 223 | // Renders the current state of the animation. 224 | renderFrame: () => Path2D; 225 | // Renders the current state of the animation as points. 226 | renderPoints: () => Point[]; 227 | // Immediately begin animating through the given keyframes. 228 | // Non-rendered keyframes from previous transitions are cancelled. 229 | transition: (...keyframes: (CanvasKeyframe | CanvasCustomKeyframe)[]) => void; 230 | // Resume a paused animation. Has no effect if already playing. 231 | play: () => void; 232 | // Pause a playing animation. Has no effect if already paused. 233 | pause: () => void; 234 | // Toggle between playing and pausing the animation. 235 | playPause: () => void; 236 | } 237 | 238 | // Function that returns the current timestamp. This value will be used for all 239 | // duration/delay values and will be used to interpolate between keyframes. It 240 | // must produce values increasing in size. 241 | // Default: `Date.now`. 242 | export interface TimestampProvider { 243 | (): number; 244 | } 245 | export const canvasPath: (timestampProvider?: TimestampProvider) => Animation; 246 | 247 | export interface WiggleOptions { 248 | // Speed of the wiggle movement. Higher is faster. 249 | speed: number; 250 | // Length of the transition from the current state to the wiggle blob. 251 | // Default: 0 252 | initialTransition?: number; 253 | } 254 | // Preset animation that produces natural-looking random movement. 255 | // The wiggle animation will continue indefinitely until the next transition. 256 | export const wigglePreset = ( 257 | animation: Animation, 258 | blobOptions: BlobOptions, 259 | canvasOptions: CanvasOptions, 260 | wiggleOptions: WiggleOptions, 261 | ) 262 | ``` 263 | 264 | ## License 265 | 266 | [MIT](./LICENSE) 267 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/blobs/9f4506913d4b6d6acb9947769417fabc057b45b3/assets/favicon.ico -------------------------------------------------------------------------------- /assets/logo-color.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 10 | 12 | 14 | 16 | 18 | 39 | 40 | -------------------------------------------------------------------------------- /assets/logo-grey.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 10 | 12 | 14 | 16 | 18 | 39 | 40 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 11 | 16 | 25 | 34 | 42 | 43 | -------------------------------------------------------------------------------- /assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g-harel/blobs/9f4506913d4b6d6acb9947769417fabc057b45b3/assets/preview.png -------------------------------------------------------------------------------- /demo/content.ts: -------------------------------------------------------------------------------- 1 | import {addCanvas, addTitle, colors, sizes} from "./internal/layout"; 2 | import { 3 | calcBouncePercentage, 4 | drawClosed, 5 | drawHandles, 6 | drawLine, 7 | drawOpen, 8 | drawPoint, 9 | forceStyles, 10 | point, 11 | tempStyles, 12 | } from "./internal/canvas"; 13 | import { 14 | coordPoint, 15 | deg, 16 | distance, 17 | expandHandle, 18 | forPoints, 19 | mapPoints, 20 | mod, 21 | shift, 22 | split, 23 | splitLine, 24 | } from "../internal/util"; 25 | import {timingFunctions} from "../internal/animate/timing"; 26 | import {Coord, Point} from "../internal/types"; 27 | import {rand} from "../internal/rand"; 28 | import {genFromOptions, smoothBlob} from "../internal/gen"; 29 | import {BlobOptions} from "../public/blobs"; 30 | import {interpolateBetween, interpolateBetweenSmooth} from "../internal/animate/interpolate"; 31 | import {divide} from "../internal/animate/prepare"; 32 | import {statefulAnimationGenerator} from "../internal/animate/state"; 33 | import {CanvasKeyframe, canvasPath, wigglePreset} from "../public/animate"; 34 | 35 | const makePoly = (pointCount: number, radius: number, center: Coord): Point[] => { 36 | const angle = (2 * Math.PI) / pointCount; 37 | const points: Point[] = []; 38 | const nullHandle = {angle: 0, length: 0}; 39 | for (let i = 0; i < pointCount; i++) { 40 | const coord = expandHandle(center, {angle: i * angle, length: radius}); 41 | points.push({...coord, handleIn: nullHandle, handleOut: nullHandle}); 42 | } 43 | return points; 44 | }; 45 | 46 | const centeredBlob = (options: BlobOptions, center: Coord): Point[] => { 47 | return mapPoints(genFromOptions(options), ({curr}) => { 48 | curr.x += center.x - options.size / 2; 49 | curr.y += center.y - options.size / 2; 50 | return curr; 51 | }); 52 | }; 53 | 54 | const calcFullDetails = (percentage: number, a: Point, b: Point) => { 55 | const a0: Coord = a; 56 | const a1 = expandHandle(a, a.handleOut); 57 | const a2 = expandHandle(b, b.handleIn); 58 | const a3: Coord = b; 59 | 60 | const b0 = splitLine(percentage, a0, a1); 61 | const b1 = splitLine(percentage, a1, a2); 62 | const b2 = splitLine(percentage, a2, a3); 63 | const c0 = splitLine(percentage, b0, b1); 64 | const c1 = splitLine(percentage, b1, b2); 65 | const d0 = splitLine(percentage, c0, c1); 66 | 67 | return {a0, a1, a2, a3, b0, b1, b2, c0, c1, d0}; 68 | }; 69 | 70 | addTitle(4, "Vector graphics"); 71 | 72 | addCanvas( 73 | 1.3, 74 | // Pixelated circle. 75 | (ctx, width, height) => { 76 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 77 | const gridSize = width * 0.01; 78 | const gridCountX = width / gridSize; 79 | const gridCountY = height / gridSize; 80 | 81 | // https://www.desmos.com/calculator/psohl602g5 82 | const radius = width * 0.3; 83 | const falloff = width * 0.0015; 84 | const thickness = width * 0.01; 85 | 86 | for (let x = 0; x < gridCountX; x++) { 87 | for (let y = 0; y < gridCountY; y++) { 88 | const curr = { 89 | x: x * gridSize + gridSize / 2, 90 | y: y * gridSize + gridSize / 2, 91 | }; 92 | const d = distance(curr, center); 93 | const opacity = Math.max( 94 | 0, 95 | Math.min(1, Math.abs(thickness / (d - radius)) - falloff), 96 | ); 97 | 98 | tempStyles( 99 | ctx, 100 | () => { 101 | ctx.globalAlpha = opacity; 102 | ctx.fillStyle = colors.highlight; 103 | }, 104 | () => ctx.fillRect(x * gridSize, y * gridSize, gridSize, gridSize), 105 | ); 106 | } 107 | } 108 | 109 | return `Raster image formats encode images as a finite number of pixel values. They 110 | therefore have a maximum scale which depends on the display.`; 111 | }, 112 | // Smooth circle. 113 | (ctx, width, height) => { 114 | const pt = width * 0.01; 115 | const shapeSize = width * 0.6; 116 | const cx = width * 0.5; 117 | const cy = height * 0.5; 118 | 119 | tempStyles( 120 | ctx, 121 | () => { 122 | ctx.lineWidth = pt; 123 | ctx.strokeStyle = colors.highlight; 124 | }, 125 | () => { 126 | ctx.beginPath(); 127 | ctx.arc(cx, cy, shapeSize / 2, 0, 2 * Math.PI); 128 | ctx.stroke(); 129 | }, 130 | ); 131 | 132 | return `By contrast vector formats are defined by formulas and can scale infinitely. They 133 | are well suited for artwork with sharp lines and are used for font glyphs.`; 134 | }, 135 | ); 136 | 137 | addCanvas( 138 | 1.3, 139 | (ctx, width, height, animate) => { 140 | const startPeriod = (1 + Math.E) * 1000; 141 | const endPeriod = (1 + Math.PI) * 1000; 142 | 143 | animate((frameTime) => { 144 | const startPercentage = calcBouncePercentage( 145 | startPeriod, 146 | timingFunctions.ease, 147 | frameTime, 148 | ); 149 | const startLengthPercentage = calcBouncePercentage( 150 | startPeriod * 0.8, 151 | timingFunctions.ease, 152 | frameTime, 153 | ); 154 | const startAngle = split(startPercentage, -45, +45); 155 | const startLength = width * 0.1 + width * 0.2 * startLengthPercentage; 156 | const start = point(width * 0.2, height * 0.5, 0, 0, startAngle, startLength); 157 | 158 | const endPercentage = calcBouncePercentage(endPeriod, timingFunctions.ease, frameTime); 159 | const endLengthPercentage = calcBouncePercentage( 160 | endPeriod * 0.8, 161 | timingFunctions.ease, 162 | frameTime, 163 | ); 164 | const endAngle = split(endPercentage, 135, 225); 165 | const endLength = width * 0.1 + width * 0.2 * endLengthPercentage; 166 | const end = point(width * 0.8, height * 0.5, endAngle, endLength, 0, 0); 167 | 168 | drawOpen(ctx, start, end, true); 169 | }); 170 | 171 | return `Vector-based image formats often support Bezier curves. A cubic bezier curve is defined 172 | by four coordinates: the start/end points and corresponding "handle" points. Visually, these 173 | handles define the direction and "momentum" of the line. The curve is tangent to the handle 174 | at either of the points.`; 175 | }, 176 | (ctx, width, height, animate) => { 177 | const angleRange = 20; 178 | const lengthRange = 40; 179 | const period = 5000; 180 | 181 | const r = rand("blobs"); 182 | const ra = r(); 183 | const rb = r(); 184 | const rc = r(); 185 | const rd = r(); 186 | 187 | const wobbleHandle = ( 188 | frameTime: number, 189 | period: number, 190 | p: Point, 191 | locked: boolean, 192 | ): Point => { 193 | const angleIn = 194 | deg(p.handleIn.angle) + 195 | angleRange * 196 | (0.5 - calcBouncePercentage(period * 1.1, timingFunctions.ease, frameTime)); 197 | const lengthIn = 198 | p.handleIn.length + 199 | lengthRange * 200 | (0.5 - calcBouncePercentage(period * 0.9, timingFunctions.ease, frameTime)); 201 | const angleOut = 202 | deg(p.handleOut.angle) + 203 | angleRange * 204 | (0.5 - calcBouncePercentage(period * 0.9, timingFunctions.ease, frameTime)); 205 | const lengthOut = 206 | p.handleOut.length + 207 | lengthRange * 208 | (0.5 - calcBouncePercentage(period * 1.1, timingFunctions.ease, frameTime)); 209 | return point(p.x, p.y, angleIn, lengthIn, locked ? angleIn + 180 : angleOut, lengthOut); 210 | }; 211 | 212 | animate((frameTime) => { 213 | const a = wobbleHandle( 214 | frameTime, 215 | period / 2 + (ra * period) / 2, 216 | point(width * 0.5, height * 0.3, 210, 100, -30, 100), 217 | false, 218 | ); 219 | const b = wobbleHandle( 220 | frameTime, 221 | period / 2 + (rb * period) / 2, 222 | point(width * 0.8, height * 0.5, -90, 100, 90, 100), 223 | true, 224 | ); 225 | const c = wobbleHandle( 226 | frameTime, 227 | period / 2 + (rc * period) / 2, 228 | point(width * 0.5, height * 0.9, -30, 75, -150, 75), 229 | false, 230 | ); 231 | const d = wobbleHandle( 232 | frameTime, 233 | period / 2 + (rd * period) / 2, 234 | point(width * 0.2, height * 0.5, 90, 100, -90, 100), 235 | true, 236 | ); 237 | 238 | drawClosed(ctx, [a, b, c, d], true); 239 | }); 240 | 241 | return `Chaining curves together creates closed shapes. When the in/out handles of a point 242 | form a line, the transition is smooth, and the curve is tangent to the line.`; 243 | }, 244 | ); 245 | 246 | addCanvas(2, (ctx, width, height, animate) => { 247 | const period = Math.PI * Math.E * 1000; 248 | const start = point(width * 0.3, height * 0.8, 0, 0, -105, width * 0.32); 249 | const end = point(width * 0.7, height * 0.8, -75, width * 0.25, 0, 0); 250 | 251 | animate((frameTime) => { 252 | const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); 253 | const d = calcFullDetails(percentage, start, end); 254 | 255 | tempStyles( 256 | ctx, 257 | () => { 258 | ctx.fillStyle = colors.secondary; 259 | ctx.strokeStyle = colors.secondary; 260 | }, 261 | () => { 262 | drawLine(ctx, d.a0, d.a1, 1); 263 | drawLine(ctx, d.a1, d.a2, 1); 264 | drawLine(ctx, d.a2, d.a3, 1); 265 | drawLine(ctx, d.b0, d.b1, 1); 266 | drawLine(ctx, d.b1, d.b2, 1); 267 | drawLine(ctx, d.c0, d.c1, 1); 268 | 269 | drawPoint(ctx, d.a0, 1.3, "a0"); 270 | drawPoint(ctx, d.a1, 1.3, "a1"); 271 | drawPoint(ctx, d.a2, 1.3, "a2"); 272 | drawPoint(ctx, d.a3, 1.3, "a3"); 273 | drawPoint(ctx, d.b0, 1.3, "b0"); 274 | drawPoint(ctx, d.b1, 1.3, "b1"); 275 | drawPoint(ctx, d.b2, 1.3, "b2"); 276 | drawPoint(ctx, d.c0, 1.3, "c0"); 277 | drawPoint(ctx, d.c1, 1.3, "c1"); 278 | drawPoint(ctx, d.d0, 1.3, "d0"); 279 | }, 280 | ); 281 | 282 | tempStyles( 283 | ctx, 284 | () => (ctx.fillStyle = colors.highlight), 285 | () => drawPoint(ctx, d.d0, 3), 286 | ); 287 | 288 | drawOpen(ctx, start, end, false); 289 | }); 290 | 291 | return `Curves are rendered using the four input points (ends + handles). By connecting 292 | points a0-a3 with a line and then splitting each line by the same percentage, we've reduced 293 | the number of points by one. Repeating the same process with the new set of points until 294 | there is only one point remaining (d0) produces a single point on the line. Repeating this 295 | calculation for many different percentage values will produce a curve. 296 |

297 | Note there is no constant relationship between the 298 | percentage that "drew" the point and the arc lengths before/after it. Uniform motion along 299 | the curve can only be approximated.`; 300 | }); 301 | 302 | addTitle(4, "Making a blob"); 303 | 304 | addCanvas( 305 | 1.3, 306 | (ctx, width, height, animate) => { 307 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 308 | const radius = width * 0.3; 309 | const minPoints = 3; 310 | const extraPoints = 6; 311 | const pointDurationMs = 2000; 312 | 313 | animate((frameTime) => { 314 | const points = 315 | minPoints + extraPoints + (extraPoints / 2) * Math.sin(frameTime / pointDurationMs); 316 | const shape = makePoly(points, radius, center); 317 | 318 | // Draw lines from center to each point.. 319 | tempStyles( 320 | ctx, 321 | () => { 322 | ctx.fillStyle = colors.secondary; 323 | ctx.strokeStyle = colors.secondary; 324 | }, 325 | () => { 326 | drawPoint(ctx, center, 2); 327 | forPoints(shape, ({curr}) => { 328 | drawLine(ctx, center, curr, 1, 2); 329 | }); 330 | }, 331 | ); 332 | 333 | drawClosed(ctx, shape, false); 334 | }); 335 | 336 | return `Points are first distributed evenly around the center. At this stage the points 337 | technically have handles, but since they have a length of zero, they have no effect on 338 | the shape and it looks like a polygon.`; 339 | }, 340 | (ctx, width, height, animate) => { 341 | const period = Math.PI * 1500; 342 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 343 | const radius = width * 0.3; 344 | const points = 5; 345 | const randSeed = Math.random(); 346 | const randStrength = 0.5; 347 | 348 | const shape = makePoly(points, radius, center); 349 | 350 | animate((frameTime) => { 351 | const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); 352 | const rgen = rand(randSeed + Math.floor(frameTime / period) + ""); 353 | 354 | // Draw original shape. 355 | tempStyles( 356 | ctx, 357 | () => { 358 | ctx.fillStyle = colors.secondary; 359 | ctx.strokeStyle = colors.secondary; 360 | }, 361 | () => { 362 | drawPoint(ctx, center, 2); 363 | forPoints(shape, ({curr, next}) => { 364 | drawLine(ctx, curr, next(), 1, 2); 365 | }); 366 | }, 367 | ); 368 | 369 | // Draw randomly shifted shape. 370 | const shiftedShape = shape.map( 371 | (p): Point => { 372 | const randOffset = percentage * (randStrength * rgen() - randStrength / 2); 373 | return coordPoint(splitLine(randOffset, p, center)); 374 | }, 375 | ); 376 | 377 | drawClosed(ctx, shiftedShape, true); 378 | }); 379 | 380 | return `Points are then randomly moved further or closer to the center. Using a seeded 381 | random number generator allows repeatable "randomness" whenever the blob is generated 382 | at a different time or place.`; 383 | }, 384 | ); 385 | 386 | addCanvas( 387 | 1.3, 388 | (ctx, width, height, animate) => { 389 | const options: BlobOptions = { 390 | extraPoints: 2, 391 | randomness: 6, 392 | seed: "random", 393 | size: width * 0.7, 394 | }; 395 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 396 | const interval = 2000; 397 | 398 | const blob = centeredBlob(options, center); 399 | const handles = mapPoints(blob, ({curr: p}) => { 400 | p.handleIn.length = 150; 401 | p.handleOut.length = 150; 402 | return p; 403 | }); 404 | const polyBlob = blob.map(coordPoint); 405 | const pointCount = polyBlob.length; 406 | 407 | animate((frameTime) => { 408 | const activeIndex = Math.floor(frameTime / interval) % pointCount; 409 | const opacity = Math.abs(Math.sin((frameTime * Math.PI) / interval)); 410 | 411 | tempStyles( 412 | ctx, 413 | () => { 414 | ctx.strokeStyle = colors.secondary; 415 | ctx.globalAlpha = opacity; 416 | }, 417 | () => { 418 | forPoints(polyBlob, ({prev, next, index}) => { 419 | if (index !== activeIndex) return; 420 | drawLine(ctx, prev(), next(), 1, 2); 421 | }); 422 | forPoints(handles, ({curr, index}) => { 423 | if (index !== activeIndex) return; 424 | drawHandles(ctx, curr, 1); 425 | }); 426 | }, 427 | ); 428 | 429 | tempStyles( 430 | ctx, 431 | () => { 432 | ctx.fillStyle = colors.secondary; 433 | }, 434 | () => { 435 | drawPoint(ctx, center, 2); 436 | }, 437 | ); 438 | 439 | drawClosed(ctx, polyBlob, false); 440 | }); 441 | 442 | return `The angle of the handles for each point is parallel with the imaginary line 443 | stretching between its neighbors. Even when they have length zero, the angle of the 444 | handles can still be calculated.`; 445 | }, 446 | (ctx, width, height, animate) => { 447 | const period = Math.PI * 1500; 448 | const options: BlobOptions = { 449 | extraPoints: 2, 450 | randomness: 6, 451 | seed: "random", 452 | size: width * 0.7, 453 | }; 454 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 455 | 456 | const blob = centeredBlob(options, center); 457 | 458 | animate((frameTime) => { 459 | const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); 460 | 461 | // Draw original blob. 462 | tempStyles( 463 | ctx, 464 | () => { 465 | ctx.fillStyle = colors.secondary; 466 | ctx.strokeStyle = colors.secondary; 467 | }, 468 | () => { 469 | drawPoint(ctx, center, 2); 470 | forPoints(blob, ({curr, next}) => { 471 | drawLine(ctx, curr, next(), 1, 2); 472 | }); 473 | }, 474 | ); 475 | 476 | // Draw animated blob. 477 | const animatedBlob = mapPoints(blob, ({curr}) => { 478 | curr.handleIn.length *= percentage; 479 | curr.handleOut.length *= percentage; 480 | return curr; 481 | }); 482 | 483 | drawClosed(ctx, animatedBlob, true); 484 | }); 485 | 486 | return `The blob is then made smooth by extending the handles. The exact length 487 | depends on the distance between the given point and it's next neighbor. This value is 488 | multiplied by a ratio that would roughly produce a circle if the points had not been 489 | randomly moved.`; 490 | }, 491 | ); 492 | 493 | addTitle(4, "Interpolating between blobs"); 494 | 495 | addCanvas(2, (ctx, width, height, animate) => { 496 | const period = Math.PI * 1000; 497 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 498 | const fadeSpeed = 10; 499 | const fadeLead = 0.05; 500 | const fadeFloor = 0.2; 501 | 502 | const blobA = centeredBlob( 503 | { 504 | extraPoints: 3, 505 | randomness: 6, 506 | seed: "12345", 507 | size: height * 0.8, 508 | }, 509 | center, 510 | ); 511 | const blobB = centeredBlob( 512 | { 513 | extraPoints: 3, 514 | randomness: 6, 515 | seed: "abc", 516 | size: height * 0.8, 517 | }, 518 | center, 519 | ); 520 | 521 | animate((frameTime) => { 522 | const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); 523 | 524 | const shiftedFrameTime = frameTime + period * fadeLead; 525 | const shiftedPercentage = calcBouncePercentage( 526 | period, 527 | timingFunctions.ease, 528 | shiftedFrameTime, 529 | ); 530 | const shiftedPeriodPercentage = mod(shiftedFrameTime, period) / period; 531 | 532 | forceStyles(ctx, () => { 533 | const {pt} = sizes(); 534 | ctx.fillStyle = "transparent"; 535 | ctx.lineWidth = pt; 536 | ctx.strokeStyle = colors.secondary; 537 | ctx.setLineDash([2 * pt]); 538 | 539 | if (shiftedPeriodPercentage > 0.5) { 540 | ctx.globalAlpha = fadeFloor + fadeSpeed * (1 - shiftedPercentage); 541 | drawClosed(ctx, blobA, false); 542 | 543 | ctx.globalAlpha = fadeFloor; 544 | drawClosed(ctx, blobB, false); 545 | } else { 546 | ctx.globalAlpha = fadeFloor + fadeSpeed * shiftedPercentage; 547 | drawClosed(ctx, blobB, false); 548 | 549 | ctx.globalAlpha = fadeFloor; 550 | drawClosed(ctx, blobA, false); 551 | } 552 | }); 553 | 554 | drawClosed(ctx, interpolateBetween(percentage, blobA, blobB), true); 555 | }); 556 | 557 | return `The simplest way to interpolate between blobs would be to move points 0-N from their 558 | position in the start blob to their position in the end blob. The problem with this approach 559 | is that it doesn't allow for all blob to map to all blobs. Specifically it would only be 560 | possible to animate between blobs that have the same number of points. This means something 561 | more generic is required.`; 562 | }); 563 | 564 | addCanvas( 565 | 1.3, 566 | (ctx, width, height, animate) => { 567 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 568 | const maxExtraPoints = 7; 569 | const period = maxExtraPoints * Math.PI * 300; 570 | const {pt} = sizes(); 571 | 572 | const blob = centeredBlob( 573 | { 574 | extraPoints: 0, 575 | randomness: 6, 576 | seed: "flip", 577 | size: height * 0.9, 578 | }, 579 | center, 580 | ); 581 | 582 | animate((frameTime) => { 583 | const percentage = mod(frameTime, period) / period; 584 | const extraPoints = Math.floor(percentage * (maxExtraPoints + 1)); 585 | drawClosed(ctx, divide(extraPoints + blob.length, blob), true); 586 | 587 | forPoints(blob, ({curr}) => { 588 | ctx.beginPath(); 589 | ctx.arc(curr.x, curr.y, pt * 6, 0, 2 * Math.PI); 590 | 591 | tempStyles( 592 | ctx, 593 | () => { 594 | ctx.strokeStyle = colors.secondary; 595 | ctx.lineWidth = pt; 596 | }, 597 | () => { 598 | ctx.stroke(); 599 | }, 600 | ); 601 | }); 602 | }); 603 | 604 | return `The first step to prepare animation is to make the number of points between the 605 | start and end shapes equal. This is done by adding points to the shape with least points 606 | until they are both equal. 607 |

608 | For best animation quality it is important that these points are as evenly distributed 609 | as possible all around the shape so this is not a recursive algorithm.`; 610 | }, 611 | (ctx, width, height, animate) => { 612 | const period = Math.PI ** Math.E * 1000; 613 | const start = point(width * 0.1, height * 0.6, 0, 0, -45, width * 0.5); 614 | const end = point(width * 0.9, height * 0.6, 160, width * 0.3, 0, 0); 615 | 616 | animate((frameTime) => { 617 | const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); 618 | const d = calcFullDetails(percentage, start, end); 619 | 620 | tempStyles( 621 | ctx, 622 | () => { 623 | ctx.fillStyle = colors.secondary; 624 | ctx.strokeStyle = colors.secondary; 625 | }, 626 | () => { 627 | drawLine(ctx, d.a0, d.a1, 1); 628 | drawLine(ctx, d.a1, d.a2, 1, 2); 629 | drawLine(ctx, d.a2, d.a3, 1); 630 | drawLine(ctx, d.b0, d.b1, 1, 2); 631 | drawLine(ctx, d.b1, d.b2, 1, 2); 632 | 633 | drawPoint(ctx, d.a0, 1.3, "a0"); 634 | drawPoint(ctx, d.a1, 1.3, "a1"); 635 | drawPoint(ctx, d.a2, 1.3, "a2"); 636 | drawPoint(ctx, d.a3, 1.3, "a3"); 637 | drawPoint(ctx, d.b1, 1.3, "b1"); 638 | }, 639 | ); 640 | 641 | forceStyles(ctx, () => { 642 | const {pt} = sizes(); 643 | ctx.fillStyle = colors.secondary; 644 | ctx.strokeStyle = colors.secondary; 645 | ctx.lineWidth = pt; 646 | 647 | drawOpen(ctx, start, end, false); 648 | }); 649 | 650 | tempStyles( 651 | ctx, 652 | () => { 653 | ctx.fillStyle = colors.highlight; 654 | ctx.strokeStyle = colors.highlight; 655 | }, 656 | () => { 657 | drawLine(ctx, d.c0, d.c1, 1); 658 | drawLine(ctx, d.a0, d.b0, 1); 659 | drawLine(ctx, d.a3, d.b2, 1); 660 | 661 | drawPoint(ctx, d.b0, 1.3, "b0"); 662 | drawPoint(ctx, d.b2, 1.3, "b2"); 663 | drawPoint(ctx, d.c0, 1.3, "c0"); 664 | drawPoint(ctx, d.c1, 1.3, "c1"); 665 | }, 666 | ); 667 | 668 | tempStyles( 669 | ctx, 670 | () => (ctx.fillStyle = colors.highlight), 671 | () => drawPoint(ctx, d.d0, 1.3, "d0"), 672 | ); 673 | }); 674 | 675 | return `It is only possible to reliably add points to a blob because attempting to 676 | remove points without modifying the shape is almost never possible and is expensive to 677 | compute. 678 |

679 | Adding a point is done using the line-drawing geometry. In this example "d0" is the new 680 | point with its handles being "c0" and "c1". The original points get new handles "b0" and 681 | "b2"`; 682 | }, 683 | ); 684 | 685 | addCanvas( 686 | 1.3, 687 | (ctx, width, height, animate) => { 688 | const period = (Math.E / Math.PI) * 1000; 689 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 690 | 691 | const blob = centeredBlob( 692 | { 693 | extraPoints: 3, 694 | randomness: 6, 695 | seed: "shift", 696 | size: height * 0.9, 697 | }, 698 | center, 699 | ); 700 | 701 | const shiftedBlob = shift(1, blob); 702 | 703 | let prev = 0; 704 | let count = 0; 705 | animate((frameTime) => { 706 | const animationTime = mod(frameTime, period); 707 | const percentage = timingFunctions.ease(mod(animationTime, period) / period); 708 | 709 | // Count animation loops. 710 | if (percentage < prev) count++; 711 | prev = percentage; 712 | 713 | // Draw lines points are travelling. 714 | tempStyles( 715 | ctx, 716 | () => { 717 | ctx.fillStyle = colors.secondary; 718 | ctx.strokeStyle = colors.secondary; 719 | }, 720 | () => { 721 | drawPoint(ctx, center, 2); 722 | forPoints(blob, ({curr, next}) => { 723 | drawLine(ctx, curr, next(), 1, 2); 724 | }); 725 | }, 726 | ); 727 | 728 | // Pause in-place every other animation loop. 729 | if (count % 2 === 0) { 730 | drawClosed(ctx, interpolateBetweenSmooth(2, percentage, blob, shiftedBlob), true); 731 | } else { 732 | drawClosed(ctx, blob, true); 733 | } 734 | }); 735 | 736 | return `Once both shapes have the same amount of points, an ordering of points which reduces 737 | the total amount of distance traveled by the points during the transition needs to be 738 | selected. Because the shapes are closed, points can be shifted by any amount without 739 | visually affecting the shape.`; 740 | }, 741 | (ctx, width, height, animate) => { 742 | const period = Math.PI * Math.E * 1000; 743 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 744 | 745 | const blob = centeredBlob( 746 | { 747 | extraPoints: 3, 748 | randomness: 6, 749 | seed: "flip", 750 | size: height * 0.9, 751 | }, 752 | center, 753 | ); 754 | const reversedBlob = mapPoints(blob, ({curr}) => { 755 | const temp = curr.handleIn; 756 | curr.handleIn = curr.handleOut; 757 | curr.handleOut = temp; 758 | return curr; 759 | }); 760 | reversedBlob.reverse(); 761 | 762 | animate((frameTime) => { 763 | const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); 764 | 765 | forceStyles(ctx, () => { 766 | const {pt} = sizes(); 767 | ctx.fillStyle = "transparent"; 768 | ctx.lineWidth = pt; 769 | ctx.strokeStyle = colors.secondary; 770 | ctx.setLineDash([2 * pt]); 771 | drawClosed(ctx, blob, false); 772 | }); 773 | 774 | drawClosed(ctx, interpolateBetweenSmooth(2, percentage, blob, reversedBlob), true); 775 | }); 776 | 777 | return `Points can also be reversed without visually affecting the shape. Then, again can 778 | be shifted all around. Although reversed ordering doesn't change the shape, it has a 779 | dramatic effect on the animation as it makes the loop flip over itself. 780 |

781 | In total there are 2 * num_points different orderings of the 782 | points that can work for transition purposes.`; 783 | }, 784 | ); 785 | 786 | addCanvas( 787 | 1.3, 788 | (ctx, width, height) => { 789 | // Only animate in the most recent painter call. 790 | const animationID = Math.random(); 791 | const wasReplaced = () => (ctx.canvas as any).animationID !== animationID; 792 | 793 | const period = Math.PI * 1000; 794 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 795 | const size = Math.min(width, height) * 0.8; 796 | 797 | const canvasBlobGenerator = (keyframe: CanvasKeyframe): Point[] => { 798 | return mapPoints(genFromOptions(keyframe.blobOptions), ({curr}) => { 799 | curr.x += center.x - size / 2; 800 | curr.y += center.y - size / 2; 801 | return curr; 802 | }); 803 | }; 804 | 805 | const animation = statefulAnimationGenerator( 806 | canvasBlobGenerator, 807 | (points: Point[]) => drawClosed(ctx, points, true), 808 | () => {}, 809 | )(Date.now); 810 | 811 | const renderFrame = () => { 812 | if (wasReplaced()) return; 813 | ctx.clearRect(0, 0, width, height); 814 | animation.renderFrame(); 815 | requestAnimationFrame(renderFrame); 816 | }; 817 | requestAnimationFrame(renderFrame); 818 | 819 | const loopAnimation = (): void => { 820 | if (wasReplaced()) return; 821 | animation.transition(genFrame()); 822 | }; 823 | 824 | let frameCount = -1; 825 | const genFrame = (overrides: Partial = {}): CanvasKeyframe => { 826 | frameCount++; 827 | return { 828 | duration: period, 829 | timingFunction: "ease", 830 | callback: loopAnimation, 831 | blobOptions: { 832 | extraPoints: Math.max(0, mod(frameCount, 4) - 1), 833 | randomness: 4, 834 | seed: Math.random(), 835 | size, 836 | }, 837 | ...overrides, 838 | }; 839 | }; 840 | 841 | animation.transition(genFrame({duration: 0})); 842 | 843 | ctx.canvas.onclick = () => { 844 | if (wasReplaced()) return; 845 | animation.playPause(); 846 | }; 847 | 848 | (ctx.canvas as any).animationID = animationID; 849 | 850 | return `The added points can be removed at the end of a transition when the target shape has 851 | been reached. However, if the animation is interrupted during interpolation there is no 852 | opportunity to clean up the extra points.`; 853 | }, 854 | (ctx, width, height, animate) => { 855 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 856 | const size = Math.min(width, height) * 0.8; 857 | 858 | const drawStar = (rays: number, od: number, id: number): Point[] => { 859 | const pointCount = 2 * rays; 860 | const angle = (Math.PI * 2) / pointCount; 861 | const points: Point[] = []; 862 | for (let i = 0; i < pointCount; i++) { 863 | const pointX = Math.sin(i * angle); 864 | const pointY = Math.cos(i * angle); 865 | const distanceMultiplier = (i % 2 === 0 ? od : id) / 2; 866 | points.push({ 867 | x: center.x + pointX * distanceMultiplier, 868 | y: center.y + pointY * distanceMultiplier, 869 | handleIn: {angle: 0, length: 0}, 870 | handleOut: {angle: 0, length: 0}, 871 | }); 872 | } 873 | return points; 874 | }; 875 | 876 | const drawPolygon = (sides: number, od: number): Point[] => { 877 | const angle = (Math.PI * 2) / sides; 878 | const points: Point[] = []; 879 | for (let i = 0; i < sides; i++) { 880 | const pointX = Math.sin(i * angle); 881 | const pointY = Math.cos(i * angle); 882 | const distanceMultiplier = od / 2; 883 | points.push({ 884 | x: center.x + pointX * distanceMultiplier, 885 | y: center.y + pointY * distanceMultiplier, 886 | handleIn: {angle: 0, length: 0}, 887 | handleOut: {angle: 0, length: 0}, 888 | }); 889 | } 890 | return points; 891 | }; 892 | 893 | const shapes = [ 894 | drawStar(8, size, size * 0.7), 895 | smoothBlob(drawPolygon(3, size)), 896 | smoothBlob(drawStar(10, size, size * 0.9)), 897 | drawPolygon(4, size), 898 | smoothBlob(drawStar(3, size, size * 0.6)), 899 | ]; 900 | 901 | const animation = canvasPath(); 902 | const genFrame = (index: number) => () => { 903 | animation.transition({ 904 | points: shapes[index % shapes.length], 905 | duration: 3000, 906 | delay: 1000, 907 | timingFunction: "ease", 908 | callback: genFrame(index + 1), 909 | }); 910 | }; 911 | animation.transition({ 912 | points: shapes[0], 913 | duration: 0, 914 | callback: genFrame(1), 915 | }); 916 | 917 | animate(() => { 918 | drawClosed(ctx, animation.renderPoints(), true); 919 | }); 920 | 921 | return `Putting all these pieces together, the blob transition library can also be used to 922 | tween between non-blob shapes. The more detail a shape has, the more unconvincing the 923 | animation will look. In these cases, manually creating in-between frames can be a 924 | helpful tool.`; 925 | }, 926 | ); 927 | 928 | addTitle(4, "Gooeyness"); 929 | 930 | addCanvas( 931 | 1.3, 932 | (ctx, width, height, animate) => { 933 | const size = Math.min(width, height) * 0.8; 934 | const center: Coord = {x: (width - size) * 0.5, y: (height - size) * 0.5}; 935 | 936 | const animation = canvasPath(); 937 | 938 | const genFrame = (duration: number) => { 939 | animation.transition({ 940 | duration: duration, 941 | blobOptions: { 942 | extraPoints: 2, 943 | randomness: 3, 944 | seed: Math.random(), 945 | size, 946 | }, 947 | callback: () => genFrame(3000), 948 | timingFunction: "ease", 949 | canvasOptions: {offsetX: center.x, offsetY: center.y}, 950 | }); 951 | }; 952 | genFrame(0); 953 | 954 | animate(() => { 955 | drawClosed(ctx, animation.renderPoints(), true); 956 | }); 957 | 958 | return `This library uses the keyframe model to define animations. This is a flexible 959 | approach, but it does not lend itself well to the kind of gooey blob shapes invite. 960 |

961 | When looking at this animation, you may be able to notice the rhythm of the 962 | keyframes where the points start moving and stop moving at the same time.`; 963 | }, 964 | (ctx, width, height, animate) => { 965 | const size = Math.min(width, height) * 0.8; 966 | const center: Coord = {x: width * 0.5, y: height * 0.5}; 967 | 968 | const animation = canvasPath(); 969 | 970 | wigglePreset( 971 | animation, 972 | { 973 | extraPoints: 2, 974 | randomness: 3, 975 | seed: Math.random(), 976 | size, 977 | }, 978 | { 979 | offsetX: center.x - size / 2, 980 | offsetY: center.y - size / 2, 981 | }, 982 | { 983 | speed: 2, 984 | }, 985 | ); 986 | 987 | animate(() => { 988 | drawClosed(ctx, animation.renderPoints(), true); 989 | }); 990 | 991 | return `In addition to the keyframe API, there is now also pre-built preset which produces a 992 | gooey animation without much effort and much prettier results. 993 |

994 | This approach uses a noise field instead of random numbers to move individual points 995 | around continuously and independently. Repeated calls to a noise-field-powered random 996 | number generator will produce self-similar results.`; 997 | }, 998 | ); 999 | -------------------------------------------------------------------------------- /demo/example.ts: -------------------------------------------------------------------------------- 1 | import {CanvasKeyframe, canvasPath, wigglePreset} from "../public/animate"; 2 | import {drawHandles, drawPoint} from "./internal/canvas"; 3 | import {isDebug} from "./internal/debug"; 4 | import {colors} from "./internal/layout"; 5 | 6 | // Fetch reference to example container. 7 | const exampleContainer = document.querySelector(".example")!; 8 | 9 | const canvas = document.createElement("canvas")!; 10 | exampleContainer.appendChild(canvas); 11 | 12 | let size = 0; 13 | const resize = () => { 14 | // Set blob size relative to window, but limit to 600. 15 | const rawSize = Math.min(600, Math.min(window.innerWidth - 64, window.innerHeight / 2)); 16 | canvas.style.width = `${rawSize}px`; 17 | canvas.style.height = `${rawSize}px`; 18 | 19 | // Scale resolution to take into account device pixel ratio. 20 | size = rawSize * (window.devicePixelRatio || 1); 21 | 22 | canvas.width = size; 23 | canvas.height = size; 24 | }; 25 | 26 | // Set blob color and set context to erase intersection of content. 27 | const ctx = canvas.getContext("2d")!; 28 | 29 | // Create animation and draw its frames in `requestAnimationFrame` callbacks. 30 | const animation = canvasPath(); 31 | const renderFrame = () => { 32 | ctx.clearRect(0, 0, size, size); 33 | ctx.fillStyle = colors.highlight; 34 | ctx.strokeStyle = colors.highlight; 35 | 36 | if (isDebug()) { 37 | const points = animation.renderPoints(); 38 | for (const point of points) { 39 | drawPoint(ctx, point, 2); 40 | drawHandles(ctx, point, 1); 41 | } 42 | } 43 | 44 | ctx.fill(animation.renderFrame()); 45 | requestAnimationFrame(renderFrame); 46 | }; 47 | requestAnimationFrame(renderFrame); 48 | 49 | // Extra points that increases when blob gets clicked. 50 | let extraPoints = 0; 51 | 52 | const genWiggle = (transition: number) => { 53 | wigglePreset( 54 | animation, 55 | { 56 | extraPoints: 3 + extraPoints, 57 | randomness: 1.5, 58 | seed: Math.random(), 59 | size, 60 | }, 61 | {}, 62 | {speed: 2, initialTransition: transition}, 63 | ); 64 | }; 65 | 66 | // Generate a keyframe with overridable default values. 67 | const genFrame = (overrides: any = {}): CanvasKeyframe => { 68 | const blobOptions = { 69 | extraPoints: 3 + extraPoints, 70 | randomness: 4, 71 | seed: Math.random(), 72 | size, 73 | ...overrides.blobOptions, 74 | }; 75 | return { 76 | duration: 4000, 77 | timingFunction: "ease", 78 | callback: loopAnimation, 79 | ...overrides, 80 | blobOptions, 81 | }; 82 | }; 83 | 84 | // Callback for every frame which starts transition to a new frame. 85 | const loopAnimation = (): void => { 86 | extraPoints = 0; 87 | genWiggle(5000); 88 | }; 89 | 90 | // Quickly animate to a new frame when canvas is clicked. 91 | canvas.onclick = () => { 92 | extraPoints++; 93 | animation.transition( 94 | genFrame({ 95 | duration: 400, 96 | timingFunction: "elasticEnd0", 97 | blobOptions: {extraPoints}, 98 | }), 99 | ); 100 | }; 101 | 102 | // Immediately show a new frame. 103 | window.addEventListener("load", () => { 104 | resize(); 105 | genWiggle(0); 106 | }); 107 | 108 | // Make blob a circle while window is being resized. 109 | window.addEventListener("resize", () => { 110 | resize(); 111 | const tempSize = (size * 6) / 7; 112 | animation.transition( 113 | genFrame({ 114 | duration: 100, 115 | timingFunction: "easeEnd", 116 | blobOptions: { 117 | extraPoints: 0, 118 | randomness: 0, 119 | seed: "", 120 | size: tempSize, 121 | }, 122 | canvasOptions: { 123 | offsetX: (size - tempSize) / 2, 124 | offsetY: (size - tempSize) / 2, 125 | }, 126 | }), 127 | ); 128 | }); 129 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 129 | 130 | 131 |
132 | 133 | 150 |
151 |
152 |
153 |
How it works
154 |
155 |
156 | 157 | 158 | 159 | 160 | -------------------------------------------------------------------------------- /demo/internal/canvas.ts: -------------------------------------------------------------------------------- 1 | import {TimingFunc} from "../../internal/animate/timing"; 2 | import {Coord, Point} from "../../internal/types"; 3 | import {expandHandle, forPoints, mod, rad} from "../../internal/util"; 4 | import {isDebug} from "../internal/debug"; 5 | import {colors, sizes} from "../internal/layout"; 6 | 7 | export const forceStyles = (ctx: CanvasRenderingContext2D, fn: () => void) => { 8 | if (!(ctx as any).forcedStyles) (ctx as any).forcedStyles = 0; 9 | (ctx as any).forcedStyles++; 10 | ctx.save(); 11 | fn(); 12 | ctx.restore(); 13 | (ctx as any).forcedStyles--; 14 | }; 15 | 16 | export const tempStyles = (ctx: CanvasRenderingContext2D, style: () => void, fn: () => void) => { 17 | if ((ctx as any).forcedStyles > 0) { 18 | fn(); 19 | } else { 20 | ctx.save(); 21 | style(); 22 | fn(); 23 | ctx.restore(); 24 | } 25 | }; 26 | 27 | export const rotateAround = ( 28 | options: { 29 | ctx: CanvasRenderingContext2D; 30 | angle: number; 31 | cx: number; 32 | cy: number; 33 | }, 34 | fn: () => void, 35 | ) => { 36 | tempStyles( 37 | options.ctx, 38 | () => { 39 | options.ctx.translate(options.cx, options.cy); 40 | options.ctx.rotate(options.angle); 41 | }, 42 | () => { 43 | if (isDebug()) { 44 | tempStyles( 45 | options.ctx, 46 | () => (options.ctx.fillStyle = colors.debug), 47 | () => { 48 | options.ctx.fillRect(0, -4, 1, 8); 49 | options.ctx.fillRect(-32, 0, 64, 1); 50 | }, 51 | ); 52 | } 53 | fn(); 54 | }, 55 | ); 56 | }; 57 | 58 | export const point = ( 59 | x: number, 60 | y: number, 61 | ia: number, 62 | il: number, 63 | oa: number, 64 | ol: number, 65 | ): Point => { 66 | return { 67 | x: x, 68 | y: y, 69 | handleIn: {angle: rad(ia), length: il}, 70 | handleOut: {angle: rad(oa), length: ol}, 71 | }; 72 | }; 73 | 74 | export const drawPoint = ( 75 | ctx: CanvasRenderingContext2D, 76 | coord: Coord, 77 | size: number, 78 | label?: string, 79 | ) => { 80 | const radius = sizes().pt * size; 81 | const pointPath = new Path2D(); 82 | pointPath.arc(coord.x, coord.y, radius, 0, 2 * Math.PI); 83 | ctx.fill(pointPath); 84 | 85 | if (label) { 86 | tempStyles( 87 | ctx, 88 | () => (ctx.font = `${6 * radius}px monospace`), 89 | () => ctx.fillText(label, coord.x + 2 * radius, coord.y - radius), 90 | ); 91 | } 92 | }; 93 | 94 | export const drawLine = ( 95 | ctx: CanvasRenderingContext2D, 96 | a: Coord, 97 | b: Coord, 98 | size: number, 99 | dash?: number, 100 | ) => { 101 | tempStyles( 102 | ctx, 103 | () => { 104 | const width = sizes().pt * size; 105 | if (dash) ctx.setLineDash([dash * width]); 106 | }, 107 | () => { 108 | const width = sizes().pt * size; 109 | const linePath = new Path2D(); 110 | linePath.moveTo(a.x, a.y); 111 | linePath.lineTo(b.x, b.y); 112 | ctx.lineWidth = width; 113 | ctx.stroke(linePath); 114 | }, 115 | ); 116 | }; 117 | 118 | export const drawClosed = (ctx: CanvasRenderingContext2D, points: Point[], handles?: boolean) => { 119 | forPoints(points, ({curr, next}) => { 120 | drawOpen(ctx, curr, next(), handles); 121 | }); 122 | }; 123 | 124 | export const drawDebugClosed = (ctx: CanvasRenderingContext2D, points: Point[], size: number) => { 125 | forPoints(points, ({curr, next: nextFn}) => { 126 | drawHandles(ctx, curr, size); 127 | 128 | const next = nextFn(); 129 | const currHandle = expandHandle(curr, curr.handleIn); 130 | const nextHandle = expandHandle(curr, curr.handleOut); 131 | const curve = new Path2D(); 132 | curve.moveTo(curr.x, curr.y); 133 | curve.bezierCurveTo(currHandle.x, currHandle.y, nextHandle.x, nextHandle.y, next.x, next.y); 134 | ctx.lineWidth = sizes().pt * size * 2; 135 | ctx.stroke(curve); 136 | 137 | drawPoint(ctx, curr, size * 1.1); 138 | }); 139 | }; 140 | 141 | export const drawHandles = (ctx: CanvasRenderingContext2D, point: Point, size: number) => { 142 | const inHandle = expandHandle(point, point.handleIn); 143 | const outHandle = expandHandle(point, point.handleOut); 144 | drawLine(ctx, point, inHandle, size); 145 | drawLine(ctx, point, outHandle, size, 2); 146 | drawPoint(ctx, inHandle, size * 1.4); 147 | drawPoint(ctx, outHandle, size * 1.4); 148 | }; 149 | 150 | export const drawOpen = ( 151 | ctx: CanvasRenderingContext2D, 152 | start: Point, 153 | end: Point, 154 | handles?: boolean, 155 | ) => { 156 | const width = sizes().width; 157 | const startHandle = expandHandle(start, start.handleOut); 158 | const endHandle = expandHandle(end, end.handleIn); 159 | 160 | // Draw handles. 161 | if (handles) { 162 | tempStyles( 163 | ctx, 164 | () => { 165 | ctx.fillStyle = colors.secondary; 166 | ctx.strokeStyle = colors.secondary; 167 | }, 168 | () => { 169 | drawLine(ctx, start, startHandle, 1); 170 | drawLine(ctx, end, endHandle, 1, 2); 171 | 172 | drawPoint(ctx, startHandle, 1.4); 173 | drawPoint(ctx, endHandle, 1.4); 174 | }, 175 | ); 176 | } 177 | 178 | // Draw curve. 179 | tempStyles( 180 | ctx, 181 | () => { 182 | const lineWidth = width * 0.003; 183 | ctx.lineWidth = lineWidth; 184 | }, 185 | () => { 186 | const curve = new Path2D(); 187 | curve.moveTo(start.x, start.y); 188 | curve.bezierCurveTo( 189 | startHandle.x, 190 | startHandle.y, 191 | endHandle.x, 192 | endHandle.y, 193 | end.x, 194 | end.y, 195 | ); 196 | 197 | tempStyles( 198 | ctx, 199 | () => (ctx.strokeStyle = colors.highlight), 200 | () => ctx.stroke(curve), 201 | ); 202 | 203 | tempStyles( 204 | ctx, 205 | () => (ctx.fillStyle = colors.highlight), 206 | () => { 207 | drawPoint(ctx, start, 2); 208 | drawPoint(ctx, end, 2); 209 | }, 210 | ); 211 | }, 212 | ); 213 | }; 214 | 215 | export const calcBouncePercentage = (period: number, timingFunc: TimingFunc, frameTime: number) => { 216 | const halfPeriod = period / 2; 217 | const animationTime = mod(frameTime, period); 218 | if (animationTime <= halfPeriod) { 219 | return timingFunc(animationTime / halfPeriod); 220 | } else { 221 | return timingFunc(1 - (animationTime - halfPeriod) / halfPeriod); 222 | } 223 | }; 224 | -------------------------------------------------------------------------------- /demo/internal/debug.ts: -------------------------------------------------------------------------------- 1 | // If debug is initially set to false it will not be toggleable. 2 | let debug = window.location.search.includes("debug") && location.hostname === "localhost"; 3 | export const isDebug = () => debug; 4 | 5 | const debugListeners: ((debug: boolean) => void)[] = []; 6 | export const onDebugStateChange = (fn: (debug: boolean) => void) => { 7 | debugListeners.push(fn); 8 | fn(debug); 9 | }; 10 | 11 | if (debug && document.body) { 12 | const toggleButton = document.createElement("button"); 13 | toggleButton.innerHTML = "debug"; 14 | toggleButton.style.padding = "2rem"; 15 | toggleButton.style.position = "fixed"; 16 | toggleButton.style.top = "0"; 17 | toggleButton.onclick = () => { 18 | debug = !debug; 19 | for (const listener of debugListeners) { 20 | listener(debug); 21 | } 22 | }; 23 | document.body.prepend(toggleButton); 24 | } 25 | -------------------------------------------------------------------------------- /demo/internal/layout.ts: -------------------------------------------------------------------------------- 1 | import {tempStyles} from "./canvas"; 2 | import {isDebug, onDebugStateChange} from "./debug"; 3 | 4 | export const colors = { 5 | debug: "green", 6 | highlight: "#ec576b", 7 | secondary: "#555", 8 | }; 9 | 10 | interface Cell { 11 | aspectRatio: number; 12 | canvas: HTMLCanvasElement; 13 | ctx: CanvasRenderingContext2D; 14 | painter: CellPainter; 15 | animationID: number; 16 | } 17 | 18 | export interface CellPainter { 19 | ( 20 | ctx: CanvasRenderingContext2D, 21 | width: number, 22 | height: number, 23 | animate: (painter: AnimationPainter) => void, 24 | ): string | void; 25 | } 26 | 27 | export interface AnimationPainter { 28 | (timestamp: number): void; 29 | } 30 | 31 | // Global cell state. 32 | const cells = ((window as any).cells as Cell[][]) || []; 33 | ((window as any).cells as Cell[][]) = cells; 34 | 35 | const containerElement = document.querySelector(".container"); 36 | if (!containerElement) throw "missing container"; 37 | 38 | const howItWorksElement = document.querySelector(".how-it-works"); 39 | if (!howItWorksElement) throw "missing container"; 40 | 41 | let animating = false; 42 | const reveal = () => { 43 | containerElement.classList.add("open"); 44 | howItWorksElement.classList.add("hidden"); 45 | animating = true; 46 | redraw(); 47 | }; 48 | howItWorksElement.addEventListener("click", reveal); 49 | if (document.location.hash || isDebug()) setTimeout(reveal); 50 | 51 | export const sizes = (): {width: number; pt: number} => { 52 | const sectionStyle = window.getComputedStyle( 53 | (containerElement.lastChild as any) || document.body, 54 | ); 55 | const sectionWidth = Number(sectionStyle.getPropertyValue("width").slice(0, -2)); 56 | const width = sectionWidth * window.devicePixelRatio; 57 | return {width, pt: width * 0.002}; 58 | }; 59 | 60 | const createSection = (): HTMLElement => { 61 | const numberLabel = ("000" + cells.length).substr(-3); 62 | 63 | const sectionElement = document.createElement("div"); 64 | sectionElement.classList.add("section"); 65 | sectionElement.setAttribute("id", numberLabel); 66 | containerElement.appendChild(sectionElement); 67 | 68 | const numberElement = document.createElement("a"); 69 | numberElement.classList.add("number"); 70 | numberElement.setAttribute("href", "#" + numberLabel); 71 | numberElement.appendChild(document.createTextNode(numberLabel)); 72 | sectionElement.appendChild(numberElement); 73 | 74 | return sectionElement; 75 | }; 76 | 77 | // Adds a section of text to the bottom of the layout. 78 | export const addTitle = (heading: number, text: string) => { 79 | const wrapperElement = document.createElement(`h${heading}`); 80 | wrapperElement.classList.add("title"); 81 | containerElement.appendChild(wrapperElement); 82 | 83 | const textWrapperElement = document.createElement("div"); 84 | textWrapperElement.classList.add("text"); 85 | wrapperElement.appendChild(textWrapperElement); 86 | 87 | text = text.replace("\n", " ").replace(/\s+/g, " ").trim(); 88 | const textElement = document.createTextNode(text); 89 | textWrapperElement.appendChild(textElement); 90 | }; 91 | 92 | const handleIntersection = (entries: any) => { 93 | entries.map((entry: any) => { 94 | entry.target.setAttribute("data-visible", entry.isIntersecting); 95 | }); 96 | }; 97 | 98 | // Adds a row of cells to the bottom of the layout. 99 | export const addCanvas = (aspectRatio: number, ...painters: CellPainter[]) => { 100 | const sectionElement = createSection(); 101 | 102 | if (painters.length == 0) { 103 | painters = [() => {}]; 104 | } 105 | 106 | const cellRow: Cell[] = []; 107 | for (const painter of painters) { 108 | const cellElement = document.createElement("div"); 109 | cellElement.classList.add("cell"); 110 | sectionElement.appendChild(cellElement); 111 | 112 | const canvas = document.createElement("canvas"); 113 | cellElement.appendChild(canvas); 114 | 115 | const labelElement = document.createElement("div"); 116 | labelElement.classList.add("label"); 117 | cellElement.appendChild(labelElement); 118 | 119 | const ctx = canvas.getContext("2d"); 120 | if (!ctx) throw "missing canvas context"; 121 | 122 | const cell = {aspectRatio, canvas, ctx, painter, animationID: -1}; 123 | cellRow.push(cell); 124 | 125 | new IntersectionObserver(handleIntersection, { 126 | threshold: 0.1, 127 | }).observe(canvas); 128 | } 129 | cells.push(cellRow); 130 | 131 | redraw(); 132 | }; 133 | 134 | // Lazily redraw canvas cells to match window resolution. 135 | let redrawTimeout: undefined | number = undefined; 136 | const redraw = () => { 137 | window.clearTimeout(redrawTimeout); 138 | redrawTimeout = window.setTimeout(() => { 139 | for (const cellRow of cells) { 140 | const cellWidth = sizes().width / cellRow.length; 141 | for (const cell of cellRow) { 142 | const cellHeight = cellWidth / cell.aspectRatio; 143 | 144 | // Resize canvas; 145 | cell.canvas.width = cellWidth; 146 | cell.canvas.height = cellHeight; 147 | 148 | // Draw canvas debug info. 149 | const drawDebug = () => { 150 | if (isDebug()) { 151 | tempStyles( 152 | cell.ctx, 153 | () => (cell.ctx.strokeStyle = colors.debug), 154 | () => cell.ctx.strokeRect(0, 0, cellWidth, cellHeight - 1), 155 | ); 156 | } 157 | }; 158 | drawDebug(); 159 | 160 | // Keep track of paused state. 161 | let pausedAt = 0; 162 | let pauseOffset = 0; 163 | cell.canvas.onclick = () => { 164 | if (pausedAt === 0) { 165 | pausedAt = Date.now(); 166 | } else { 167 | pauseOffset += Date.now() - pausedAt; 168 | pausedAt = 0; 169 | } 170 | }; 171 | 172 | // Cell-specific callback for providing an animation painter. 173 | const animate = (painter: AnimationPainter) => { 174 | if (!animating) return; 175 | 176 | const animationID = Math.random(); 177 | const startTime = Date.now(); 178 | cell.animationID = animationID; 179 | 180 | const drawFrame = () => { 181 | // Stop animating if cell is redrawn. 182 | if (cell.animationID !== animationID) return; 183 | 184 | const visible = cell.canvas.getAttribute("data-visible") === "true"; 185 | if (pausedAt === 0 && visible) { 186 | const frameTime = Date.now() - startTime - pauseOffset; 187 | cell.ctx.clearRect(0, 0, cellWidth, cellHeight); 188 | drawDebug(); 189 | if (isDebug()) { 190 | tempStyles( 191 | cell.ctx, 192 | () => (cell.ctx.fillStyle = colors.debug), 193 | () => cell.ctx.fillText(String(frameTime), 10, 15), 194 | ); 195 | } 196 | painter(frameTime); 197 | } 198 | 199 | requestAnimationFrame(drawFrame); 200 | }; 201 | drawFrame(); 202 | }; 203 | 204 | // Redraw canvas contents and replace label if changed. 205 | const label = cell.painter(cell.ctx, cellWidth, cellHeight, animate); 206 | if (label) { 207 | const cellElement = cell.canvas.parentElement; 208 | if (cellElement) { 209 | cellElement.style.width = `${100 / cellRow.length}%`; 210 | const labelElement = cellElement.querySelector(".label"); 211 | if (labelElement && labelElement.innerHTML !== label) { 212 | labelElement.innerHTML = ""; 213 | labelElement.innerHTML = label; 214 | } 215 | } 216 | } 217 | } 218 | } 219 | }, 100); 220 | }; 221 | 222 | window.addEventListener("load", redraw); 223 | window.addEventListener("resize", redraw); 224 | onDebugStateChange(redraw); 225 | -------------------------------------------------------------------------------- /examples/corner-expand.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Expand demo 4 | 5 | 39 | 40 | 41 |
42 | 43 | 44 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /internal/animate/frames.ts: -------------------------------------------------------------------------------- 1 | import {TimingFunc, timingFunctions} from "./timing"; 2 | import {Point} from "../types"; 3 | import {prepare} from "./prepare"; 4 | import {interpolateBetween} from "./interpolate"; 5 | 6 | export interface Keyframe { 7 | delay?: number; 8 | duration: number; 9 | timingFunction?: keyof typeof timingFunctions; 10 | } 11 | 12 | export interface InternalKeyframe { 13 | id: string; 14 | timestamp: number; 15 | timingFunction: TimingFunc; 16 | initialPoints: Point[]; 17 | transitionSourceFrameIndex: number; 18 | 19 | // Synthetic keyframes are generated to represent the current state when 20 | // a new transition is begun. 21 | isSynthetic: boolean; 22 | } 23 | 24 | export interface RenderCache { 25 | [frameId: string]: { 26 | preparedEndPoints?: Point[]; 27 | preparedStartPoints?: Point[]; 28 | }; 29 | } 30 | 31 | export interface RenderInput { 32 | currentFrames: InternalKeyframe[]; 33 | timestamp: number; 34 | renderCache: RenderCache; 35 | } 36 | 37 | export interface RenderOutput { 38 | points: Point[]; 39 | lastFrameId: string | null; 40 | renderCache: RenderCache; 41 | } 42 | 43 | export interface TransitionInput extends RenderInput { 44 | newFrames: T[]; 45 | shapeGenerator: (keyframe: T) => Point[]; 46 | } 47 | 48 | export interface TransitionOutput { 49 | newFrames: InternalKeyframe[]; 50 | } 51 | 52 | const genId = (): string => { 53 | return String(Math.random()).substr(2); 54 | }; 55 | 56 | export const renderFramesAt = (input: RenderInput): RenderOutput => { 57 | const {renderCache, currentFrames} = input; 58 | 59 | if (currentFrames.length === 0) { 60 | return {renderCache, lastFrameId: null, points: []}; 61 | } 62 | 63 | // Animation freezes at the final shape if there are no more keyframes. 64 | if (currentFrames.length === 1) { 65 | const first = currentFrames[0]; 66 | return {renderCache, lastFrameId: first.id, points: first.initialPoints}; 67 | } 68 | 69 | // Find the start/end keyframes according to the timestamp. 70 | let startKeyframe = currentFrames[0]; 71 | let endKeyframe = currentFrames[1]; 72 | for (let i = 2; i < currentFrames.length; i++) { 73 | if (endKeyframe.timestamp > input.timestamp) break; 74 | startKeyframe = currentFrames[i - 1]; 75 | endKeyframe = currentFrames[i]; 76 | } 77 | 78 | // Return original end shape when past the end of the animation. 79 | const endKeyframeIsLast = endKeyframe === currentFrames[currentFrames.length - 1]; 80 | const animationIsPastEndKeyframe = endKeyframe.timestamp < input.timestamp; 81 | if (animationIsPastEndKeyframe && endKeyframeIsLast) { 82 | return { 83 | renderCache, 84 | lastFrameId: endKeyframe.id, 85 | points: endKeyframe.initialPoints, 86 | }; 87 | } 88 | 89 | // Use and cache prepared points for current interpolation. 90 | let preparedStartPoints: Point[] | undefined = 91 | renderCache[startKeyframe.id]?.preparedStartPoints; 92 | let preparedEndPoints: Point[] | undefined = renderCache[endKeyframe.id]?.preparedEndPoints; 93 | if (!preparedStartPoints || !preparedEndPoints) { 94 | [preparedStartPoints, preparedEndPoints] = prepare( 95 | startKeyframe.initialPoints, 96 | endKeyframe.initialPoints, 97 | {rawAngles: false, divideRatio: 1}, 98 | ); 99 | 100 | renderCache[startKeyframe.id] = renderCache[startKeyframe.id] || {}; 101 | renderCache[startKeyframe.id].preparedStartPoints = preparedStartPoints; 102 | 103 | renderCache[endKeyframe.id] = renderCache[endKeyframe.id] || {}; 104 | renderCache[endKeyframe.id].preparedEndPoints = preparedEndPoints; 105 | } 106 | 107 | // Calculate progress between frames as a fraction. 108 | const progress = 109 | (input.timestamp - startKeyframe.timestamp) / 110 | (endKeyframe.timestamp - startKeyframe.timestamp); 111 | 112 | // Keep progress within expected range (ex. division by 0). 113 | const clampedProgress = Math.max(0, Math.min(1, progress)); 114 | 115 | // Apply timing function of end frame. 116 | const adjustedProgress = endKeyframe.timingFunction(clampedProgress); 117 | 118 | return { 119 | renderCache, 120 | lastFrameId: clampedProgress === 1 ? endKeyframe.id : startKeyframe.id, 121 | points: interpolateBetween(adjustedProgress, preparedStartPoints, preparedEndPoints), 122 | }; 123 | }; 124 | 125 | export const transitionFrames = ( 126 | input: TransitionInput, 127 | ): TransitionOutput => { 128 | // Erase all old frames. 129 | const newInternalFrames: InternalKeyframe[] = []; 130 | 131 | // Reset animation when given no keyframes. 132 | if (input.newFrames.length === 0) { 133 | return {newFrames: newInternalFrames}; 134 | } 135 | 136 | // Add current state as initial frame. 137 | const currentState = renderFramesAt(input); 138 | if (currentState.lastFrameId === null) { 139 | // If there is currently no shape being rendered, use a point in the 140 | // center of the next frame as the initial point. 141 | const firstShape = input.shapeGenerator(input.newFrames[0]); 142 | let firstShapeCenterPoint: Point = { 143 | x: 0, 144 | y: 0, 145 | handleIn: {angle: 0, length: 0}, 146 | handleOut: {angle: 0, length: 0}, 147 | }; 148 | for (const point of firstShape) { 149 | firstShapeCenterPoint.x += point.x / firstShape.length; 150 | firstShapeCenterPoint.y += point.y / firstShape.length; 151 | } 152 | currentState.points = [firstShapeCenterPoint, firstShapeCenterPoint, firstShapeCenterPoint]; 153 | } 154 | newInternalFrames.push({ 155 | id: genId(), 156 | initialPoints: currentState.points, 157 | timestamp: input.timestamp, 158 | timingFunction: timingFunctions.linear, 159 | transitionSourceFrameIndex: -1, 160 | isSynthetic: true, 161 | }); 162 | 163 | // Generate and add new frames. 164 | let totalOffset = 0; 165 | for (let i = 0; i < input.newFrames.length; i++) { 166 | const keyframe = input.newFrames[i]; 167 | 168 | // Copy previous frame when current one has a delay. 169 | if (keyframe.delay) { 170 | totalOffset += keyframe.delay; 171 | const prevFrame = newInternalFrames[newInternalFrames.length - 1]; 172 | newInternalFrames.push({ 173 | id: genId(), 174 | initialPoints: prevFrame.initialPoints, 175 | timestamp: input.timestamp + totalOffset, 176 | timingFunction: timingFunctions.linear, 177 | transitionSourceFrameIndex: i - 1, 178 | isSynthetic: true, 179 | }); 180 | } 181 | 182 | totalOffset += keyframe.duration; 183 | newInternalFrames.push({ 184 | id: genId(), 185 | initialPoints: input.shapeGenerator(keyframe), 186 | timestamp: input.timestamp + totalOffset, 187 | timingFunction: timingFunctions[keyframe.timingFunction || "linear"], 188 | transitionSourceFrameIndex: i, 189 | isSynthetic: false, 190 | }); 191 | } 192 | 193 | return {newFrames: newInternalFrames}; 194 | }; 195 | -------------------------------------------------------------------------------- /internal/animate/interpolate.ts: -------------------------------------------------------------------------------- 1 | import {Point} from "../types"; 2 | import {mapPoints, mod, smooth, split, splitLine} from "../util"; 3 | 4 | // Interpolates between angles a and b. Angles are normalized to avoid unnecessary rotation. 5 | // Direction is chosen to produce the smallest possible movement. 6 | const interpolateAngle = (percentage: number, a: number, b: number): number => { 7 | const tau = Math.PI * 2; 8 | let aNorm = mod(a, tau); 9 | let bNorm = mod(b, tau); 10 | if (Math.abs(aNorm - bNorm) > Math.PI) { 11 | if (aNorm < bNorm) { 12 | aNorm += tau; 13 | } else { 14 | bNorm += tau; 15 | } 16 | } 17 | return split(percentage, aNorm, bNorm); 18 | }; 19 | 20 | // Interpolates linearly between a and b. Can only interpolate between point lists that have the 21 | // same number of points. Easing effects can be applied to the percentage given to this function. 22 | // Percentages outside the 0-1 range are supported. 23 | export const interpolateBetween = (percentage: number, a: Point[], b: Point[]): Point[] => { 24 | if (a.length !== b.length) { 25 | throw new Error("must have equal number of points"); 26 | } 27 | 28 | // Clamped range for use in values that could look incorrect otherwise. 29 | // ex. Handles that invert if their value goes negative (creates loops at corners). 30 | const clamped = Math.min(1, Math.max(0, percentage)); 31 | 32 | const points: Point[] = []; 33 | for (let i = 0; i < a.length; i++) { 34 | points.push({ 35 | ...splitLine(percentage, a[i], b[i]), 36 | handleIn: { 37 | angle: interpolateAngle(percentage, a[i].handleIn.angle, b[i].handleIn.angle), 38 | length: split(clamped, a[i].handleIn.length, b[i].handleIn.length), 39 | }, 40 | handleOut: { 41 | angle: interpolateAngle(percentage, a[i].handleOut.angle, b[i].handleOut.angle), 42 | length: split(clamped, a[i].handleOut.length, b[i].handleOut.length), 43 | }, 44 | }); 45 | } 46 | return points; 47 | }; 48 | 49 | // Interpolates between a and b while applying a smoothing effect. Smoothing effect's strength is 50 | // relative to how far away the percentage is from either 0 or 1. It is strongest in the middle of 51 | // the animation (percentage = 0.5) or when bounds are exceeded (percentage = 1.8). 52 | export const interpolateBetweenSmooth = ( 53 | strength: number, 54 | percentage: number, 55 | a: Point[], 56 | b: Point[], 57 | ): Point[] => { 58 | strength *= Math.min(1, Math.min(Math.abs(0 - percentage), Math.abs(1 - percentage))); 59 | const interpolated = interpolateBetween(percentage, a, b); 60 | const smoothed = smooth(interpolated, Math.sqrt(strength + 0.25) / 3); 61 | return mapPoints(interpolated, ({index, curr}) => { 62 | const sp = smoothed[index]; 63 | curr.handleIn.angle = interpolateAngle(strength, curr.handleIn.angle, sp.handleIn.angle); 64 | curr.handleIn.length = split(strength, curr.handleIn.length, sp.handleIn.length); 65 | curr.handleOut.angle = interpolateAngle(strength, curr.handleOut.angle, sp.handleOut.angle); 66 | curr.handleOut.length = split(strength, curr.handleOut.length, sp.handleOut.length); 67 | return curr; 68 | }); 69 | }; 70 | -------------------------------------------------------------------------------- /internal/animate/prepare.ts: -------------------------------------------------------------------------------- 1 | import { 2 | angleOf, 3 | coordEqual, 4 | distance, 5 | forPoints, 6 | insertCount, 7 | length, 8 | mapPoints, 9 | mod, 10 | reverse, 11 | shift, 12 | } from "../util"; 13 | import {Point} from "../types"; 14 | 15 | // Iterate through point ordering possibilities to find an option with the least 16 | // distance between points. Also reverse the list to try and optimize. 17 | const optimizeOrder = (a: Point[], b: Point[]): Point[] => { 18 | const count = a.length; 19 | 20 | let minTotal = Infinity; 21 | let minOffset = 0; 22 | let minOffsetBase: Point[] = []; 23 | 24 | const setMinOffset = (points: Point[]) => { 25 | for (let i = 0; i < count; i++) { 26 | let total = 0; 27 | for (let j = 0; j < count; j++) { 28 | total += (100 * distance(a[j], points[mod(j + i, count)])) ** 2; 29 | if (total > minTotal) break; 30 | } 31 | if (total <= minTotal) { 32 | minTotal = total; 33 | minOffset = i; 34 | minOffsetBase = points; 35 | } 36 | } 37 | }; 38 | setMinOffset(b); 39 | setMinOffset(reverse(b)); 40 | 41 | return shift(minOffset, minOffsetBase); 42 | }; 43 | 44 | // Modify the input shape to be the exact same path visually, but with 45 | // additional points so that the total number of points is "count". 46 | export const divide = (count: number, points: Point[]): Point[] => { 47 | if (points.length < 3) throw new Error("not enough points"); 48 | if (count < points.length) throw new Error("cannot remove points"); 49 | if (count === points.length) return points.slice(); 50 | 51 | const lengths: number[] = []; 52 | forPoints(points, ({curr, next}) => { 53 | lengths.push(length(curr, next())); 54 | }); 55 | 56 | const divisors = divideLengths(lengths, count - points.length); 57 | const out: Point[] = []; 58 | for (let i = 0; i < points.length; i++) { 59 | const curr: Point = out[out.length - 1] || points[i]; 60 | const next = points[mod(i + 1, points.length)]; 61 | out.pop(); 62 | out.push(...insertCount(divisors[i], curr, next)); 63 | } 64 | 65 | // Remove redundant last point to produce closed shape, but use its incoming \ 66 | // handle for the first point. 67 | const last = out.pop(); 68 | out[0] = Object.assign({}, out[0], {handleIn: last!.handleIn}); 69 | 70 | return out; 71 | }; 72 | 73 | // If point has no handle and is on top of the point before or after it, use the 74 | // angle of the fixer shape's point at the same index. This is especially useful 75 | // when all the points of the initial shape are concentrated on the same 76 | // coordinates and "expand" into the target shape. 77 | const fixAnglesWith = (fixee: Point[], fixer: Point[]): Point[] => { 78 | return mapPoints(fixee, ({index, curr, prev, next}) => { 79 | if (curr.handleIn.length === 0 && coordEqual(prev(), curr)) { 80 | curr.handleIn.angle = fixer[index].handleIn.angle; 81 | } 82 | if (curr.handleOut.length === 0 && coordEqual(next(), curr)) { 83 | curr.handleOut.angle = fixer[index].handleOut.angle; 84 | } 85 | return curr; 86 | }); 87 | }; 88 | 89 | // If point has no handle, use angle between before and after points. 90 | const fixAnglesSelf = (points: Point[]): Point[] => { 91 | return mapPoints(points, ({curr, prev, next}) => { 92 | const angle = angleOf(prev(), next()); 93 | if (curr.handleIn.length === 0) { 94 | curr.handleIn.angle = angle + Math.PI; 95 | } 96 | if (curr.handleOut.length === 0) { 97 | curr.handleOut.angle = angle; 98 | } 99 | return curr; 100 | }); 101 | }; 102 | 103 | // Split the input lengths into smaller segments to add the target amount of 104 | // lengths while minimizing the standard deviation of the list of lengths. 105 | const divideLengths = (lengths: number[], add: number): number[] => { 106 | const divisors = lengths.map(() => 1); 107 | const sizes = lengths.slice(); 108 | for (let i = 0; i < add; i++) { 109 | let maxSizeIndex = 0; 110 | for (let j = 1; j < sizes.length; j++) { 111 | if (sizes[j] > sizes[maxSizeIndex]) { 112 | maxSizeIndex = j; 113 | continue; 114 | } 115 | if (sizes[j] === sizes[maxSizeIndex]) { 116 | if (lengths[j] > lengths[maxSizeIndex]) { 117 | maxSizeIndex = j; 118 | } 119 | } 120 | } 121 | divisors[maxSizeIndex]++; 122 | sizes[maxSizeIndex] = lengths[maxSizeIndex] / divisors[maxSizeIndex]; 123 | } 124 | return divisors; 125 | }; 126 | 127 | export const prepare = ( 128 | a: Point[], 129 | b: Point[], 130 | options: {rawAngles: boolean; divideRatio: number}, 131 | ): [Point[], Point[]] => { 132 | const pointCount = options.divideRatio * Math.max(a.length, b.length); 133 | const aNorm = divide(pointCount, a); 134 | const bNorm = divide(pointCount, b); 135 | const bOpt = optimizeOrder(aNorm, bNorm); 136 | return [ 137 | options.rawAngles ? aNorm : fixAnglesWith(fixAnglesSelf(aNorm), bOpt), 138 | options.rawAngles ? bOpt : fixAnglesWith(fixAnglesSelf(bOpt), aNorm), 139 | ]; 140 | }; 141 | -------------------------------------------------------------------------------- /internal/animate/state.ts: -------------------------------------------------------------------------------- 1 | import {Point} from "../types"; 2 | import {InternalKeyframe, Keyframe, RenderCache, renderFramesAt, transitionFrames} from "./frames"; 3 | 4 | interface CallbackKeyframe extends Keyframe { 5 | callback?: () => void; 6 | } 7 | 8 | interface FrameCallbackStore { 9 | [frameId: string]: () => void; 10 | } 11 | 12 | export const statefulAnimationGenerator = ( 13 | generator: (keyframe: K) => Point[], 14 | renderer: (points: Point[]) => T, 15 | checker: (keyframe: K, index: number) => void, 16 | ) => (timestampProvider: () => number) => { 17 | let internalFrames: InternalKeyframe[] = []; 18 | let renderCache: RenderCache = {}; 19 | let frameCallbackStore: FrameCallbackStore = {}; 20 | 21 | // Keep track of paused state. 22 | let pausedAt = 0; 23 | let pauseOffset = 0; 24 | const getAnimationTimestamp = () => timestampProvider() - pauseOffset; 25 | const isPaused = () => pausedAt !== 0; 26 | 27 | const play = () => { 28 | if (!isPaused()) return; 29 | pauseOffset += getAnimationTimestamp() - pausedAt; 30 | pausedAt = 0; 31 | }; 32 | 33 | const pause = () => { 34 | if (isPaused()) return; 35 | pausedAt = getAnimationTimestamp(); 36 | }; 37 | 38 | const playPause = () => { 39 | if (isPaused()) { 40 | play(); 41 | } else { 42 | pause(); 43 | } 44 | }; 45 | 46 | const renderPoints = (): Point[] => { 47 | const renderOutput = renderFramesAt({ 48 | renderCache: renderCache, 49 | timestamp: isPaused() ? pausedAt : getAnimationTimestamp(), 50 | currentFrames: internalFrames, 51 | }); 52 | 53 | // Update render cache with returned value. 54 | renderCache = renderOutput.renderCache; 55 | 56 | // Invoke callback if defined and the first time the frame is reached. 57 | if (renderOutput.lastFrameId && frameCallbackStore[renderOutput.lastFrameId]) { 58 | setTimeout(frameCallbackStore[renderOutput.lastFrameId]); 59 | delete frameCallbackStore[renderOutput.lastFrameId]; 60 | } 61 | 62 | return renderOutput.points; 63 | }; 64 | 65 | const renderFrame = (): T => { 66 | return renderer(renderPoints()); 67 | }; 68 | 69 | const transition = (...keyframes: K[]) => { 70 | // Make sure frame info is valid. 71 | for (let i = 0; i < keyframes.length; i++) { 72 | checker(keyframes[i], i); 73 | } 74 | 75 | const transitionOutput = transitionFrames({ 76 | renderCache: renderCache, 77 | timestamp: getAnimationTimestamp(), 78 | currentFrames: internalFrames, 79 | newFrames: keyframes, 80 | shapeGenerator: generator, 81 | }); 82 | 83 | // Reset internal state.. 84 | internalFrames = transitionOutput.newFrames; 85 | frameCallbackStore = {}; 86 | renderCache = {}; 87 | 88 | // Populate callback store using returned frame ids. 89 | for (const newFrame of internalFrames) { 90 | if (newFrame.isSynthetic) continue; 91 | const {callback} = keyframes[newFrame.transitionSourceFrameIndex]; 92 | if (callback) frameCallbackStore[newFrame.id] = callback; 93 | } 94 | }; 95 | 96 | return {renderFrame, renderPoints, transition, play, pause, playPause}; 97 | }; 98 | -------------------------------------------------------------------------------- /internal/animate/testing/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 27 | 28 | 29 | 30 |
31 | 32 | 33 |
34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /internal/animate/testing/script.ts: -------------------------------------------------------------------------------- 1 | import {interpolateBetweenSmooth} from "../interpolate"; 2 | import {divide, prepare} from "../prepare"; 3 | import {Coord, Point} from "../../types"; 4 | import {forPoints, insertAt, insertCount, length, mapPoints, mod, rad} from "../../util"; 5 | import {clear, drawClosed, drawInfo} from "../../render/canvas"; 6 | import {genBlob, genFromOptions} from "../../gen"; 7 | import {rand} from "../../rand"; 8 | import * as blobs2 from "../../../public/blobs"; 9 | import * as blobs2Animate from "../../../public/animate"; 10 | 11 | let animationSpeed = 2; 12 | let animationStart = 0.3; 13 | let debug = true; 14 | let size = 1300; 15 | 16 | const canvas = document.createElement("canvas"); 17 | document.body.appendChild(canvas); 18 | canvas.height = size; 19 | canvas.width = size; 20 | const temp = canvas.getContext("2d"); 21 | if (temp === null) throw new Error("context is null"); 22 | const ctx = temp; 23 | 24 | const toggle = document.getElementById("toggle"); 25 | if (toggle === null) throw new Error("no toggle"); 26 | toggle.onclick = () => (debug = !debug); 27 | 28 | const interact = document.getElementById("interact") as any; 29 | if (toggle === null) throw new Error("no interact"); 30 | const addInteraction = (newOnclick: () => void) => { 31 | const oldOnclick = interact.onclick || (() => 0); 32 | interact.onclick = () => { 33 | oldOnclick(); 34 | newOnclick(); 35 | }; 36 | }; 37 | 38 | const point = (x: number, y: number, ia: number, il: number, oa: number, ol: number): Point => { 39 | return { 40 | x: x * size, 41 | y: y * size, 42 | handleIn: {angle: rad(ia), length: il * size}, 43 | handleOut: {angle: rad(oa), length: ol * size}, 44 | }; 45 | }; 46 | 47 | const testSplitAt = (percentage: number) => { 48 | let points: Point[] = [ 49 | point(0.15, 0.15, 135, 0.1, 315, 0.2), 50 | point(0.85, 0.15, 225, 0.1, 45, 0.2), 51 | point(0.85, 0.85, 315, 0.1, 135, 0.2), 52 | point(0.15, 0.85, 45, 0.1, 225, 0.2), 53 | ]; 54 | 55 | const count = points.length; 56 | const stop = 2 * count - 1; 57 | for (let i = 0; i < count; i++) { 58 | const double = i * 2; 59 | const next = mod(double + 1, stop); 60 | points.splice(double, 2, ...insertAt(percentage, points[double], points[next])); 61 | } 62 | points.splice(0, 1); 63 | 64 | let sum = 0; 65 | forPoints(points, ({curr, next}) => { 66 | sum += length(curr, next()); 67 | }); 68 | drawInfo(ctx, 1, "split at lengths sum", sum); 69 | 70 | drawClosed(ctx, debug, points); 71 | }; 72 | 73 | const testSplitBy = () => { 74 | const count = 10; 75 | for (let i = 0; i < count; i++) { 76 | drawClosed( 77 | ctx, 78 | debug, 79 | insertCount( 80 | i + 1, 81 | point(0.15, 0.2 + i * 0.06, 30, 0.04, -30, 0.04), 82 | point(0.25, 0.2 + i * 0.06, 135, 0.04, 225, 0.04), 83 | ), 84 | ); 85 | } 86 | }; 87 | 88 | const testDividePoints = () => { 89 | const count = 10; 90 | for (let i = 0; i < count; i++) { 91 | drawClosed( 92 | ctx, 93 | debug, 94 | divide(i + 3, [ 95 | point(0.3, 0.2 + i * 0.05, -10, 0.04, -45, 0.02), 96 | point(0.35, 0.2 + i * 0.05 - 0.02, 180, 0.02, 0, 0.02), 97 | point(0.4, 0.2 + i * 0.05, -135, 0.02, 170, 0.04), 98 | ]), 99 | ); 100 | } 101 | }; 102 | 103 | const testInterpolateBetween = (percentage: number) => { 104 | const a = [ 105 | point(0.3, 0.72, 135, 0.05, -45, 0.05), 106 | point(0.4, 0.72, -135, 0.05, 45, 0.05), 107 | point(0.4, 0.82, -45, 0.05, 135, 0.05), 108 | point(0.3, 0.82, 45, 0.05, 225, 0.05), 109 | ]; 110 | const b = [ 111 | point(0.35, 0.72, 180, 0, 0, 0), 112 | point(0.4, 0.77, -90, 0, 90, 0), 113 | point(0.35, 0.82, 360 * 10, 0, 180, 0), 114 | point(0.3, 0.77, 90, 0, -90, 0), 115 | ]; 116 | drawClosed(ctx, debug, loopBetween(percentage, a, b)); 117 | }; 118 | 119 | const testPrepPointsA = (percentage: number) => { 120 | const a = blob("a", 6, 0.15, {x: 0.45, y: 0.1}); 121 | const b = blob("b", 10, 0.15, {x: 0.45, y: 0.1}); 122 | drawClosed( 123 | ctx, 124 | debug, 125 | loopBetween(percentage, ...prepare(a, b, {rawAngles: false, divideRatio: 1})), 126 | ); 127 | }; 128 | 129 | const testPrepPointsB = (percentage: number) => { 130 | const a = blob("a", 8, 0.15, {x: 0.45, y: 0.25}); 131 | const b: Point[] = [ 132 | point(0.45, 0.25, 0, 0, 0, 0), 133 | point(0.6, 0.25, 0, 0, 0, 0), 134 | point(0.6, 0.4, 0, 0, 0, 0), 135 | point(0.45, 0.4, 0, 0, 0, 0), 136 | ]; 137 | drawClosed( 138 | ctx, 139 | debug, 140 | loopBetween(percentage, ...prepare(a, b, {rawAngles: false, divideRatio: 1})), 141 | ); 142 | }; 143 | 144 | const testPrepPointsC = (percentage: number) => { 145 | const a = blob("c", 8, 0.15, {x: 0.45, y: 0.45}); 146 | const b: Point[] = [ 147 | point(0.5, 0.45, 0, 0, 0, 0), 148 | point(0.55, 0.45, 0, 0, 0, 0), 149 | point(0.55, 0.5, 0, 0, 0, 0), 150 | point(0.6, 0.5, 0, 0, 0, 0), 151 | point(0.6, 0.55, 0, 0, 0, 0), 152 | point(0.55, 0.55, 0, 0, 0, 0), 153 | point(0.55, 0.6, 0, 0, 0, 0), 154 | point(0.5, 0.6, 0, 0, 0, 0), 155 | point(0.5, 0.55, 0, 0, 0, 0), 156 | point(0.45, 0.55, 0, 0, 0, 0), 157 | point(0.45, 0.5, 0, 0, 0, 0), 158 | point(0.5, 0.5, 0, 0, 0, 0), 159 | ]; 160 | drawClosed( 161 | ctx, 162 | debug, 163 | loopBetween(percentage, ...prepare(b, a, {rawAngles: false, divideRatio: 1})), 164 | ); 165 | }; 166 | 167 | const testPrepPointsD = (percentage: number) => { 168 | const a = blob("d", 8, 0.15, {x: 0.45, y: 0.65}); 169 | const b: Point[] = [ 170 | point(0.525, 0.725, 0, 0, 0, 0), 171 | point(0.525, 0.725, 0, 0, 0, 0), 172 | point(0.525, 0.725, 0, 0, 0, 0), 173 | ]; 174 | drawClosed( 175 | ctx, 176 | debug, 177 | loopBetween(percentage, ...prepare(a, b, {rawAngles: false, divideRatio: 1})), 178 | ); 179 | }; 180 | 181 | const testPrepLetters = (percentage: number) => { 182 | const a: Point[] = [ 183 | point(0.65, 0.2, 0, 0, 0, 0), 184 | point(0.85, 0.2, 0, 0, 0, 0), 185 | point(0.85, 0.25, 0, 0, 0, 0), 186 | point(0.7, 0.25, 0, 0, 0, 0), 187 | point(0.7, 0.4, 0, 0, 0, 0), 188 | point(0.8, 0.4, 0, 0, 0, 0), 189 | point(0.8, 0.35, 0, 0, 0, 0), 190 | point(0.75, 0.35, 0, 0, 0, 0), 191 | point(0.75, 0.3, 0, 0, 0, 0), 192 | point(0.85, 0.3, 0, 0, 0, 0), 193 | point(0.85, 0.45, 0, 0, 0, 0), 194 | point(0.65, 0.45, 0, 0, 0, 0), 195 | ]; 196 | const b: Point[] = blob("", 8, 0.25, {x: 0.65, y: 0.2}); 197 | drawClosed( 198 | ctx, 199 | debug, 200 | loopBetween(percentage, ...prepare(a, b, {rawAngles: false, divideRatio: 1})), 201 | ); 202 | }; 203 | 204 | const testGen = () => { 205 | const cellSideCount = 16; 206 | const cellSize = size / cellSideCount; 207 | ctx.save(); 208 | ctx.strokeStyle = "#fafafa"; 209 | ctx.fillStyle = "#f1f1f1"; 210 | for (let i = 0; i < cellSideCount; i++) { 211 | for (let j = 0; j < cellSideCount; j++) { 212 | ctx.strokeRect(i * cellSize, j * cellSize, cellSize, cellSize); 213 | ctx.fill( 214 | blobs2.canvasPath( 215 | { 216 | extraPoints: j, 217 | randomness: i, 218 | seed: i + j - i * j, 219 | size: cellSize, 220 | }, 221 | { 222 | offsetX: i * cellSize, 223 | offsetY: j * cellSize, 224 | }, 225 | ), 226 | ); 227 | } 228 | } 229 | ctx.restore(); 230 | }; 231 | 232 | const blob = (seed: string, count: number, scale: number, offset: Coord): Point[] => { 233 | const rgen = rand(seed); 234 | const points = genBlob(count, () => 0.3 + 0.2 * rgen()); 235 | return mapPoints(points, ({curr}) => { 236 | curr.x *= scale * size; 237 | curr.y *= scale * size; 238 | curr.x += offset.x * size; 239 | curr.y += offset.y * size; 240 | curr.handleIn.length *= scale * size; 241 | curr.handleOut.length *= scale * size; 242 | return curr; 243 | }); 244 | }; 245 | 246 | const loopBetween = (percentage: number, a: Point[], b: Point[]): Point[] => { 247 | // Draw before/after shapes + point path. 248 | ctx.save(); 249 | ctx.strokeStyle = "#ffaaaa"; 250 | drawClosed(ctx, false, a); 251 | ctx.strokeStyle = "#aaaaff"; 252 | drawClosed(ctx, false, b); 253 | ctx.strokeStyle = "#33ff33"; 254 | for (let i = 0; i < a.length; i++) { 255 | ctx.beginPath(); 256 | ctx.moveTo(a[i].x, a[i].y); 257 | ctx.lineTo(b[i].x, b[i].y); 258 | ctx.stroke(); 259 | } 260 | ctx.restore(); 261 | 262 | if (percentage < 0.5) { 263 | return interpolateBetweenSmooth(1, 2 * percentage, a, b); 264 | } else { 265 | return interpolateBetweenSmooth(1, -2 * percentage + 2, a, b); 266 | } 267 | }; 268 | 269 | const genBlobAnimation = ( 270 | speed: number, 271 | offset: number, 272 | timing: blobs2Animate.CanvasKeyframe["timingFunction"], 273 | timeWarp: number, 274 | ) => { 275 | const animation = blobs2Animate.canvasPath(() => Date.now() * timeWarp); 276 | 277 | const loopAnimation = () => { 278 | animation.transition( 279 | { 280 | duration: speed, 281 | delay: speed, 282 | timingFunction: "ease", 283 | blobOptions: { 284 | extraPoints: 3, 285 | randomness: 4, 286 | seed: Math.random(), 287 | size: 200, 288 | }, 289 | canvasOptions: { 290 | offsetX: offset, 291 | }, 292 | }, 293 | { 294 | duration: speed, 295 | timingFunction: "ease", 296 | blobOptions: { 297 | extraPoints: 3, 298 | randomness: 4, 299 | seed: Math.random(), 300 | size: 200, 301 | }, 302 | canvasOptions: { 303 | offsetX: offset, 304 | }, 305 | }, 306 | { 307 | duration: speed, 308 | delay: speed, 309 | timingFunction: "ease", 310 | blobOptions: { 311 | extraPoints: 3, 312 | randomness: 4, 313 | seed: Math.random(), 314 | size: 200, 315 | }, 316 | canvasOptions: { 317 | offsetX: offset, 318 | }, 319 | }, 320 | { 321 | duration: speed, 322 | callback: loopAnimation, 323 | timingFunction: "ease", 324 | blobOptions: { 325 | extraPoints: 39, 326 | randomness: 2, 327 | seed: Math.random(), 328 | size: 200, 329 | }, 330 | canvasOptions: { 331 | offsetX: offset, 332 | }, 333 | }, 334 | ); 335 | }; 336 | 337 | animation.transition({ 338 | duration: 0, 339 | callback: loopAnimation, 340 | blobOptions: { 341 | extraPoints: 1, 342 | randomness: 0, 343 | seed: 0, 344 | size: 200, 345 | }, 346 | canvasOptions: { 347 | offsetX: offset, 348 | }, 349 | }); 350 | 351 | addInteraction(() => { 352 | animation.transition({ 353 | duration: speed, 354 | callback: loopAnimation, 355 | timingFunction: timing, 356 | blobOptions: { 357 | extraPoints: 30, 358 | randomness: 8, 359 | seed: Math.random(), 360 | size: 180, 361 | }, 362 | canvasOptions: { 363 | offsetX: 10 + offset, 364 | offsetY: 10, 365 | }, 366 | }); 367 | }); 368 | 369 | return animation; 370 | }; 371 | 372 | const genCustomAnimation = (speed: number, offset: number) => { 373 | const noHandles = { 374 | handleIn: {angle: 0, length: 0}, 375 | handleOut: {angle: 0, length: 0}, 376 | }; 377 | const animation = blobs2Animate.canvasPath(); 378 | const loopAnimation = (immediate: boolean = false) => { 379 | const size = 200; 380 | animation.transition( 381 | { 382 | duration: immediate ? 0 : speed, 383 | delay: 100, 384 | timingFunction: "elasticEnd0", 385 | blobOptions: { 386 | extraPoints: 3, 387 | randomness: 4, 388 | seed: Math.random(), 389 | size: size, 390 | }, 391 | canvasOptions: {offsetX: offset, offsetY: 220}, 392 | }, 393 | { 394 | duration: speed, 395 | delay: 100, 396 | timingFunction: "elasticEnd0", 397 | points: [ 398 | {x: 0, y: 0, ...noHandles}, 399 | {x: 0, y: size, ...noHandles}, 400 | {x: size, y: size, ...noHandles}, 401 | {x: size, y: 0, ...noHandles}, 402 | ], 403 | canvasOptions: {offsetX: offset, offsetY: 220}, 404 | callback: loopAnimation, 405 | }, 406 | ); 407 | }; 408 | loopAnimation(true); 409 | addInteraction(() => animation.playPause()); 410 | return animation; 411 | }; 412 | 413 | const wigglePresetBad = ( 414 | animation: blobs2Animate.Animation, 415 | config: { 416 | blobOptions: blobs2.BlobOptions; 417 | period: number; 418 | delay?: number; 419 | timingFunction?: blobs2Animate.CanvasKeyframe["timingFunction"]; 420 | canvasOptions?: { 421 | offsetX?: number; 422 | offsetY?: number; 423 | }; 424 | }, 425 | ) => { 426 | const targetBlob: Point[] = genFromOptions(config.blobOptions); 427 | const numberOfPoints = 3 + config.blobOptions.extraPoints; 428 | const mutatesPerPeriod = 1 * numberOfPoints; 429 | const mutateInterval = config.period / mutatesPerPeriod; 430 | const mutateRatio = 1 / mutatesPerPeriod; 431 | 432 | console.log( 433 | "mutatesPerPeriod", 434 | mutatesPerPeriod, 435 | "mutateInterval", 436 | mutateInterval, 437 | "mutateRatio", 438 | mutateRatio, 439 | "config", 440 | JSON.stringify(config), 441 | ); 442 | 443 | const loopAnimation = () => { 444 | const newBlob = genFromOptions(Object.assign(config.blobOptions, {seed: Math.random()})); 445 | for (let i = 0; i < newBlob.length; i++) { 446 | if (Math.random() < mutateRatio) { 447 | targetBlob[i] = newBlob[i]; 448 | } 449 | } 450 | animation.transition({ 451 | duration: config.period, 452 | timingFunction: config.timingFunction, 453 | canvasOptions: config.canvasOptions, 454 | points: targetBlob, 455 | }); 456 | }; 457 | animation.transition({ 458 | duration: 0, 459 | delay: config.delay || 0, 460 | timingFunction: config.timingFunction, 461 | canvasOptions: config.canvasOptions, 462 | points: genFromOptions(config.blobOptions), 463 | callback: () => setInterval(loopAnimation, mutateInterval), 464 | }); 465 | addInteraction(() => animation.playPause()); 466 | }; 467 | 468 | const genBadWiggle = (period: number, offset: number) => { 469 | const animation = blobs2Animate.canvasPath(); 470 | wigglePresetBad(animation, { 471 | blobOptions: { 472 | extraPoints: 1, 473 | randomness: 4, 474 | seed: Math.random(), 475 | size: 200, 476 | }, 477 | period, 478 | timingFunction: "ease", 479 | canvasOptions: {offsetX: offset, offsetY: 220}, 480 | }); 481 | return animation; 482 | }; 483 | 484 | const genWiggle = (offset: number, speed: number) => { 485 | const animation = blobs2Animate.canvasPath(); 486 | blobs2Animate.wigglePreset( 487 | animation, 488 | { 489 | extraPoints: 4, 490 | randomness: 2, 491 | seed: Math.random(), 492 | size: 200, 493 | }, 494 | {offsetX: offset, offsetY: 220}, 495 | {speed}, 496 | ); 497 | addInteraction(() => animation.playPause()); 498 | return animation; 499 | }; 500 | 501 | (() => { 502 | let percentage = animationStart; 503 | 504 | const animations = [ 505 | genBlobAnimation(500, 0, "elasticEnd0", 1), 506 | genBlobAnimation(500, 200, "elasticEnd1", 1), 507 | genBlobAnimation(500, 400, "elasticEnd2", 1), 508 | genBlobAnimation(500, 600, "elasticEnd3", 1), 509 | genBlobAnimation(500, 800, "elasticEnd3", 0.1), 510 | genCustomAnimation(1000, 0), 511 | genBadWiggle(200, 200), 512 | genWiggle(400, 5), 513 | ]; 514 | 515 | const renderFrame = () => { 516 | clear(ctx); 517 | 518 | testGen(); 519 | drawInfo(ctx, 0, "percentage", percentage); 520 | testSplitAt(percentage); 521 | testSplitBy(); 522 | testDividePoints(); 523 | testInterpolateBetween(percentage); 524 | testPrepPointsA(percentage); 525 | testPrepPointsB(percentage); 526 | testPrepPointsC(percentage); 527 | testPrepPointsD(percentage); 528 | testPrepLetters(percentage); 529 | 530 | for (const animation of animations) { 531 | ctx.save(); 532 | ctx.strokeStyle = "orange"; 533 | ctx.fillStyle = "rgba(255, 200, 0, 0.5)"; 534 | const path = animation.renderFrame(); 535 | ctx.stroke(path); 536 | ctx.fill(path); 537 | ctx.restore(); 538 | } 539 | 540 | percentage += animationSpeed / 1000; 541 | percentage = mod(percentage, 1); 542 | if (animationSpeed > 0) requestAnimationFrame(renderFrame); 543 | }; 544 | renderFrame(); 545 | })(); 546 | -------------------------------------------------------------------------------- /internal/animate/timing.ts: -------------------------------------------------------------------------------- 1 | export interface TimingFunc { 2 | (percentage: number): number; 3 | } 4 | 5 | const linear: TimingFunc = (p) => { 6 | return p; 7 | }; 8 | 9 | const easeEnd: TimingFunc = (p) => { 10 | return 1 - (p - 1) ** 2; 11 | }; 12 | 13 | const easeStart: TimingFunc = (p) => { 14 | return 1 - easeEnd(1 - p); 15 | }; 16 | 17 | const ease: TimingFunc = (p) => { 18 | return 0.5 + 0.5 * Math.sin(Math.PI * (p + 1.5)); 19 | }; 20 | 21 | const elasticEnd = (s: number): TimingFunc => (p) => { 22 | return Math.pow(2, -10 * p) * Math.sin(((p - s / 4) * (2 * Math.PI)) / s) + 1; 23 | }; 24 | 25 | // https://www.desmos.com/calculator/fqisoq1kuw 26 | export const timingFunctions = { 27 | linear, 28 | easeEnd, 29 | easeStart, 30 | ease, 31 | elasticEnd0: elasticEnd(1), 32 | elasticEnd1: elasticEnd(0.64), 33 | elasticEnd2: elasticEnd(0.32), 34 | elasticEnd3: elasticEnd(0.16), 35 | }; 36 | 37 | // @ts-ignore: Type assertion. 38 | const _: Record = timingFunctions; 39 | -------------------------------------------------------------------------------- /internal/check.ts: -------------------------------------------------------------------------------- 1 | import {timingFunctions} from "./animate/timing"; 2 | 3 | const typeCheck = (name: string, val: any, expected: string[]) => { 4 | let actual: string = typeof val; 5 | if (actual === "number" && isNaN(val)) actual = "NaN"; 6 | if (actual === "object" && val === null) actual = "null"; 7 | if (!expected.includes(actual)) { 8 | throw `"${name}" should have type "${expected.join("|")}" but was "${actual}".`; 9 | } 10 | }; 11 | 12 | export const checkKeyframeOptions = (keyframe: any) => { 13 | typeCheck(`keyframe`, keyframe, ["object"]); 14 | const {delay, duration, timingFunction, callback} = keyframe; 15 | typeCheck(`delay`, delay, ["number", "undefined"]); 16 | if (delay && delay < 0) throw `delay is invalid "${delay}".`; 17 | typeCheck(`duration`, duration, ["number"]); 18 | if (duration && duration < 0) throw `duration is invalid "${duration}".`; 19 | typeCheck(`timingFunction`, timingFunction, ["string", "undefined"]); 20 | if (timingFunction && !(timingFunctions as any)[timingFunction]) { 21 | throw `".timingFunction" is not recognized "${timingFunction}".`; 22 | } 23 | typeCheck(`callback`, callback, ["function", "undefined"]); 24 | }; 25 | 26 | export const checkBlobOptions = (blobOptions: any) => { 27 | typeCheck(`blobOptions`, blobOptions, ["object"]); 28 | const {seed, extraPoints, randomness, size} = blobOptions; 29 | typeCheck(`blobOptions.seed`, seed, ["string", "number"]); 30 | typeCheck(`blobOptions.extraPoints`, extraPoints, ["number"]); 31 | if (extraPoints < 0) { 32 | throw `blobOptions.extraPoints is invalid "${extraPoints}".`; 33 | } 34 | typeCheck(`blobOptions.randomness`, randomness, ["number"]); 35 | if (randomness < 0) { 36 | throw `blobOptions.randomness is invalid "${randomness}".`; 37 | } 38 | typeCheck(`blobOptions.size`, size, ["number"]); 39 | if (size < 0) throw `blobOptions.size is invalid "${size}".`; 40 | }; 41 | 42 | export const checkCanvasOptions = (canvasOptions: any) => { 43 | typeCheck(`canvasOptions`, canvasOptions, ["object", "undefined"]); 44 | if (canvasOptions) { 45 | const {offsetX, offsetY} = canvasOptions; 46 | typeCheck(`canvasOptions.offsetX`, offsetX, ["number", "undefined"]); 47 | typeCheck(`canvasOptions.offsetY`, offsetY, ["number", "undefined"]); 48 | } 49 | }; 50 | 51 | export const checkSvgOptions = (svgOptions: any) => { 52 | typeCheck(`svgOptions`, svgOptions, ["object", "undefined"]); 53 | if (svgOptions) { 54 | const {fill, stroke, strokeWidth} = svgOptions; 55 | typeCheck(`svgOptions.fill`, fill, ["string", "undefined"]); 56 | typeCheck(`svgOptions.stroke`, stroke, ["string", "undefined"]); 57 | typeCheck(`svgOptions.strokeWidth`, strokeWidth, ["number", "undefined"]); 58 | } 59 | }; 60 | 61 | export const checkPoints = (points: any) => { 62 | if (!Array.isArray(points)) { 63 | throw `points should be an array but was "${typeof points}".`; 64 | } 65 | if (points.length < 3) { 66 | throw `expected more than two points but received "${points.length}".`; 67 | } 68 | for (const point of points) { 69 | typeCheck(`point.x`, point.x, ["number"]); 70 | typeCheck(`point.y`, point.y, ["number"]); 71 | typeCheck(`point.handleIn`, point.handleIn, ["object"]); 72 | typeCheck(`point.handleIn.angle`, point.handleIn.angle, ["number"]); 73 | typeCheck(`point.handleIn.length`, point.handleIn.length, ["number"]); 74 | typeCheck(`point.handleOut`, point.handleOut, ["object"]); 75 | typeCheck(`point.handleOut.angle`, point.handleOut.angle, ["number"]); 76 | typeCheck(`point.handleOut.length`, point.handleOut.length, ["number"]); 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /internal/gen.ts: -------------------------------------------------------------------------------- 1 | import {rand} from "../internal/rand"; 2 | import {mapPoints} from "../internal/util"; 3 | import {BlobOptions} from "../public/blobs"; 4 | import {Point} from "./types"; 5 | import {smooth} from "./util"; 6 | 7 | export const smoothBlob = (blobygon: Point[]): Point[] => { 8 | // https://math.stackexchange.com/a/873589/235756 9 | const angle = (Math.PI * 2) / blobygon.length; 10 | const smoothingStrength = ((4 / 3) * Math.tan(angle / 4)) / Math.sin(angle / 2) / 2; 11 | return smooth(blobygon, smoothingStrength); 12 | }; 13 | 14 | export const genBlobygon = (pointCount: number, offset: (index: number) => number): Point[] => { 15 | const angle = (Math.PI * 2) / pointCount; 16 | const points: Point[] = []; 17 | for (let i = 0; i < pointCount; i++) { 18 | const randPointOffset = offset(i); 19 | const pointX = Math.sin(i * angle); 20 | const pointY = Math.cos(i * angle); 21 | points.push({ 22 | x: 0.5 + pointX * randPointOffset, 23 | y: 0.5 + pointY * randPointOffset, 24 | handleIn: {angle: 0, length: 0}, 25 | handleOut: {angle: 0, length: 0}, 26 | }); 27 | } 28 | return points; 29 | }; 30 | 31 | export const genBlob = (pointCount: number, offset: (index: number) => number): Point[] => { 32 | return smoothBlob(genBlobygon(pointCount, offset)); 33 | }; 34 | 35 | export const genFromOptions = ( 36 | blobOptions: BlobOptions, 37 | r?: (index: number) => number, 38 | ): Point[] => { 39 | const rgen = r || rand(String(blobOptions.seed)); 40 | 41 | // Scale of random movement increases as randomness approaches infinity. 42 | // randomness = 0 -> rangeStart = 1 43 | // randomness = 2 -> rangeStart = 0.8333 44 | // randomness = 5 -> rangeStart = 0.6667 45 | // randomness = 10 -> rangeStart = 0.5 46 | // randomness = 20 -> rangeStart = 0.3333 47 | // randomness = 50 -> rangeStart = 0.1667 48 | // randomness = 100 -> rangeStart = 0.0909 49 | const rangeStart = 1 / (1 + blobOptions.randomness / 10); 50 | 51 | const points = genBlob( 52 | 3 + blobOptions.extraPoints, 53 | (index) => (rangeStart + rgen(index) * (1 - rangeStart)) / 2, 54 | ); 55 | 56 | const size = blobOptions.size; 57 | return mapPoints(points, ({curr}) => { 58 | curr.x *= size; 59 | curr.y *= size; 60 | curr.handleIn.length *= size; 61 | curr.handleOut.length *= size; 62 | return curr; 63 | }); 64 | }; 65 | -------------------------------------------------------------------------------- /internal/rand.ts: -------------------------------------------------------------------------------- 1 | import {createNoise2D} from "simplex-noise"; 2 | 3 | // Seeded random number generator. 4 | // https://stackoverflow.com/a/47593316/3053361 5 | export const rand = (seed: string) => { 6 | const xfnv1a = (str: string) => { 7 | let h = 2166136261 >>> 0; 8 | for (let i = 0; i < str.length; i++) { 9 | h = Math.imul(h ^ str.charCodeAt(i), 16777619); 10 | } 11 | return () => { 12 | h += h << 13; 13 | h ^= h >>> 7; 14 | h += h << 3; 15 | h ^= h >>> 17; 16 | return (h += h << 5) >>> 0; 17 | }; 18 | }; 19 | 20 | const sfc32 = (a: number, b: number, c: number, d: number) => () => { 21 | a >>>= 0; 22 | b >>>= 0; 23 | c >>>= 0; 24 | d >>>= 0; 25 | var t = (a + b) | 0; 26 | a = b ^ (b >>> 9); 27 | b = (c + (c << 3)) | 0; 28 | c = (c << 21) | (c >>> 11); 29 | d = (d + 1) | 0; 30 | t = (t + d) | 0; 31 | c = (c + t) | 0; 32 | return (t >>> 0) / 4294967296; 33 | }; 34 | 35 | const seedGenerator = xfnv1a(seed); 36 | return sfc32(seedGenerator(), seedGenerator(), seedGenerator(), seedGenerator()); 37 | }; 38 | 39 | // Simplex noise. 40 | // TODO(2023-01-08) implement to remove dep 41 | // TODO(2023-02-16) https://asserttrue.blogspot.com/2011/12/perlin-noise-in-javascript_31.html 42 | // https://en.wikipedia.org/wiki/Simplex_noise 43 | export const noise = (seed: string) => { 44 | const noise2D = createNoise2D(rand(seed)); 45 | return (x: number, y: number) => { 46 | return noise2D(x, y); 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /internal/render/canvas.ts: -------------------------------------------------------------------------------- 1 | import {Coord, Point} from "../types"; 2 | import {expandHandle, forPoints} from "../util"; 3 | 4 | const pointSize = 2; 5 | const infoSpacing = 20; 6 | 7 | export const clear = (ctx: CanvasRenderingContext2D) => { 8 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 9 | }; 10 | 11 | export const drawInfo = (ctx: CanvasRenderingContext2D, pos: number, label: string, value: any) => { 12 | ctx.fillText(`${label}: ${value}`, infoSpacing, (pos + 1) * infoSpacing); 13 | }; 14 | 15 | const drawLine = (ctx: CanvasRenderingContext2D, a: Coord, b: Coord, style: string) => { 16 | const backupStrokeStyle = ctx.strokeStyle; 17 | ctx.beginPath(); 18 | ctx.moveTo(a.x, a.y); 19 | ctx.lineTo(b.x, b.y); 20 | ctx.strokeStyle = style; 21 | ctx.stroke(); 22 | ctx.strokeStyle = backupStrokeStyle; 23 | }; 24 | 25 | const drawPoint = (ctx: CanvasRenderingContext2D, p: Coord, style: string) => { 26 | const backupFillStyle = ctx.fillStyle; 27 | ctx.beginPath(); 28 | ctx.arc(p.x, p.y, pointSize, 0, 2 * Math.PI); 29 | ctx.fillStyle = style; 30 | ctx.fill(); 31 | ctx.fillStyle = backupFillStyle; 32 | }; 33 | 34 | export const drawClosed = (ctx: CanvasRenderingContext2D, debug: boolean, points: Point[]) => { 35 | if (points.length < 2) throw new Error("not enough points"); 36 | 37 | // Draw debug points. 38 | if (debug) { 39 | forPoints(points, ({curr, next: getNext}) => { 40 | const next = getNext(); 41 | 42 | // Compute coordinates of handles. 43 | const currHandle = expandHandle(curr, curr.handleOut); 44 | const nextHandle = expandHandle(next, next.handleIn); 45 | 46 | drawPoint(ctx, curr, ""); 47 | drawLine(ctx, curr, currHandle, "#ccc"); 48 | drawLine(ctx, next, nextHandle, "#b6b"); 49 | }); 50 | } 51 | 52 | ctx.stroke(renderPath2D(points)); 53 | }; 54 | 55 | export const renderPath2D = (points: Point[]): Path2D => { 56 | const path = new Path2D(); 57 | 58 | if (points.length < 1) return path; 59 | path.moveTo(points[0].x, points[0].y); 60 | 61 | forPoints(points, ({curr, next: getNext}) => { 62 | const next = getNext(); 63 | const currHandle = expandHandle(curr, curr.handleOut); 64 | const nextHandle = expandHandle(next, next.handleIn); 65 | path.bezierCurveTo(currHandle.x, currHandle.y, nextHandle.x, nextHandle.y, next.x, next.y); 66 | }); 67 | 68 | return path; 69 | }; 70 | -------------------------------------------------------------------------------- /internal/render/svg.test.ts: -------------------------------------------------------------------------------- 1 | import {XmlElement} from "./svg"; 2 | 3 | describe("internal/render/svg", () => { 4 | describe("XmlElement", () => { 5 | it("should render element tags", () => { 6 | const elem = new XmlElement("test"); 7 | expect(elem.render()).toBe(""); 8 | }); 9 | 10 | it("should render element attributes", () => { 11 | const elem = new XmlElement("test"); 12 | elem.attributes.a = 1; 13 | elem.attributes["b-c"] = "d"; 14 | expect(elem.render()).toBe(''); 15 | }); 16 | 17 | it("should render nested elements", () => { 18 | const a = new XmlElement("a"); 19 | const aa = new XmlElement("aa"); 20 | const ab = new XmlElement("ab"); 21 | const aba = {render: () => "aba"}; 22 | a.children.push(aa); 23 | a.children.push(ab); 24 | ab.children.push(aba); 25 | expect(a.render()).toBe("aba"); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /internal/render/svg.ts: -------------------------------------------------------------------------------- 1 | import {Point} from "../types"; 2 | import {expandHandle, forPoints} from "../util"; 3 | 4 | export interface RenderOptions { 5 | // Viewport size. 6 | width: number; 7 | height: number; 8 | 9 | // Transformation applied to all drawn points. 10 | transform?: string; 11 | 12 | // Declare whether the path should be closed. 13 | // This option is currently always true. 14 | closed: true; 15 | 16 | // Output path styling. 17 | fill?: string; 18 | stroke?: string; 19 | strokeWidth?: number; 20 | 21 | // Option to render guides (points, handles and viewport). 22 | guides?: boolean; 23 | boundingBox?: boolean; 24 | } 25 | 26 | export const renderPath = (points: Point[]): string => { 27 | // Render path data attribute from points and handles. 28 | let path = `M${points[0].x},${points[0].y}`; 29 | forPoints(points, ({curr, next: getNext}) => { 30 | const next = getNext(); 31 | const currControl = expandHandle(curr, curr.handleOut); 32 | const nextControl = expandHandle(next, next.handleIn); 33 | path += `C${currControl.x},${currControl.y},${nextControl.x},${nextControl.y},${next.x},${next.y}`; 34 | }); 35 | return path; 36 | }; 37 | 38 | // Renders the input points to an editable data structure which can be rendered to svg. 39 | export const renderEditable = (points: Point[], options: RenderOptions): XmlElement => { 40 | const stroke = options.stroke || (options.guides ? "black" : "none"); 41 | const strokeWidth = options.strokeWidth || (options.guides ? 1 : 0); 42 | 43 | const xmlRoot = new XmlElement("svg"); 44 | xmlRoot.attributes.width = options.width; 45 | xmlRoot.attributes.height = options.height; 46 | xmlRoot.attributes.viewBox = `0 0 ${options.width} ${options.height}`; 47 | xmlRoot.attributes.xmlns = "http://www.w3.org/2000/svg"; 48 | 49 | const xmlContentGroup = new XmlElement("g"); 50 | xmlContentGroup.attributes.transform = options.transform || ""; 51 | 52 | const xmlBlobPath = new XmlElement("path"); 53 | xmlBlobPath.attributes.stroke = stroke; 54 | xmlBlobPath.attributes["stroke-width"] = strokeWidth; 55 | xmlBlobPath.attributes.fill = options.fill || "none"; 56 | xmlBlobPath.attributes.d = renderPath(points); 57 | 58 | xmlContentGroup.children.push(xmlBlobPath); 59 | xmlRoot.children.push(xmlContentGroup); 60 | 61 | // Render guides if configured to do so. 62 | if (options.guides) { 63 | const color = options.stroke || "black"; 64 | const size = options.strokeWidth || 1; 65 | 66 | // Bounding box. 67 | if (options.boundingBox) { 68 | const xmlBoundingRect = new XmlElement("rect"); 69 | xmlBoundingRect.attributes.x = 0; 70 | xmlBoundingRect.attributes.y = 0; 71 | xmlBoundingRect.attributes.width = options.width; 72 | xmlBoundingRect.attributes.height = options.height; 73 | xmlBoundingRect.attributes.fill = "none"; 74 | xmlBoundingRect.attributes.stroke = color; 75 | xmlBoundingRect.attributes["stroke-width"] = 2 * size; 76 | xmlBoundingRect.attributes["stroke-dasharray"] = 2 * size; 77 | xmlContentGroup.children.push(xmlBoundingRect); 78 | } 79 | 80 | // Points and handles. 81 | forPoints(points, ({curr, next: getNext}) => { 82 | const next = getNext(); 83 | const currControl = expandHandle(curr, curr.handleOut); 84 | const nextControl = expandHandle(next, next.handleIn); 85 | 86 | const xmlOutgoingHandleLine = new XmlElement("line"); 87 | xmlOutgoingHandleLine.attributes.x1 = curr.x; 88 | xmlOutgoingHandleLine.attributes.y1 = curr.y; 89 | xmlOutgoingHandleLine.attributes.x2 = currControl.x; 90 | xmlOutgoingHandleLine.attributes.y2 = currControl.y; 91 | xmlOutgoingHandleLine.attributes["stroke-width"] = size; 92 | xmlOutgoingHandleLine.attributes.stroke = color; 93 | 94 | const xmlIncomingHandleLine = new XmlElement("line"); 95 | xmlIncomingHandleLine.attributes.x1 = next.x; 96 | xmlIncomingHandleLine.attributes.y1 = next.y; 97 | xmlIncomingHandleLine.attributes.x2 = nextControl.x; 98 | xmlIncomingHandleLine.attributes.y2 = nextControl.y; 99 | xmlIncomingHandleLine.attributes["stroke-width"] = size; 100 | xmlIncomingHandleLine.attributes.stroke = color; 101 | xmlIncomingHandleLine.attributes["stroke-dasharray"] = 2 * size; 102 | 103 | const xmlOutgoingHandleCircle = new XmlElement("circle"); 104 | xmlOutgoingHandleCircle.attributes.cx = currControl.x; 105 | xmlOutgoingHandleCircle.attributes.cy = currControl.y; 106 | xmlOutgoingHandleCircle.attributes.r = size; 107 | xmlOutgoingHandleCircle.attributes.fill = color; 108 | 109 | const xmlIncomingHandleCircle = new XmlElement("circle"); 110 | xmlIncomingHandleCircle.attributes.cx = nextControl.x; 111 | xmlIncomingHandleCircle.attributes.cy = nextControl.y; 112 | xmlIncomingHandleCircle.attributes.r = size; 113 | xmlIncomingHandleCircle.attributes.fill = color; 114 | 115 | const xmlPointCircle = new XmlElement("circle"); 116 | xmlPointCircle.attributes.cx = curr.x; 117 | xmlPointCircle.attributes.cy = curr.y; 118 | xmlPointCircle.attributes.r = 2 * size; 119 | xmlPointCircle.attributes.fill = color; 120 | 121 | xmlContentGroup.children.push(xmlOutgoingHandleLine); 122 | xmlContentGroup.children.push(xmlIncomingHandleLine); 123 | xmlContentGroup.children.push(xmlOutgoingHandleCircle); 124 | xmlContentGroup.children.push(xmlIncomingHandleCircle); 125 | xmlContentGroup.children.push(xmlPointCircle); 126 | }); 127 | } 128 | 129 | return xmlRoot; 130 | }; 131 | 132 | // Structured element with tag, attributes and children. 133 | export class XmlElement { 134 | public attributes: Record = {}; 135 | public children: any[] = []; 136 | 137 | public constructor(public tag: string) {} 138 | 139 | public render(): string { 140 | const attributes = this.renderAttributes(); 141 | const content = this.renderChildren(); 142 | if (content === "") { 143 | return `<${this.tag}${attributes}/>`; 144 | } 145 | return `<${this.tag}${attributes}>${content}`; 146 | } 147 | 148 | private renderAttributes(): string { 149 | const attributes = Object.keys(this.attributes); 150 | if (attributes.length === 0) return ""; 151 | let out = ""; 152 | for (const attribute of attributes) { 153 | out += ` ${attribute}="${this.attributes[attribute]}"`; 154 | } 155 | return out; 156 | } 157 | 158 | private renderChildren(): string { 159 | let out = ""; 160 | for (const child of this.children) { 161 | out += child.render(); 162 | } 163 | return out; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /internal/types.ts: -------------------------------------------------------------------------------- 1 | // Position in a coordinate system with an origin in the top left corner. 2 | export interface Coord { 3 | x: number; 4 | y: number; 5 | } 6 | 7 | export interface Handle { 8 | // Angle in radians relative to the 3:00 position going clockwise. 9 | angle: number; 10 | // Length of the handle. 11 | length: number; 12 | } 13 | 14 | export interface Point extends Coord { 15 | // Cubic bezier handles. 16 | handleIn: Handle; 17 | handleOut: Handle; 18 | } 19 | -------------------------------------------------------------------------------- /internal/util.ts: -------------------------------------------------------------------------------- 1 | import {Coord, Handle, Point} from "./types"; 2 | 3 | export const copyPoint = (p: Point): Point => ({ 4 | x: p.x, 5 | y: p.y, 6 | handleIn: {...p.handleIn}, 7 | handleOut: {...p.handleOut}, 8 | }); 9 | 10 | export interface PointIteratorArgs { 11 | curr: Point; 12 | index: number; 13 | sibling: (pos: number) => Point; 14 | prev: () => Point; 15 | next: () => Point; 16 | } 17 | 18 | export const coordPoint = (coord: Coord): Point => { 19 | return { 20 | ...coord, 21 | handleIn: {angle: 0, length: 0}, 22 | handleOut: {angle: 0, length: 0}, 23 | }; 24 | }; 25 | 26 | export const forPoints = (points: Point[], callback: (args: PointIteratorArgs) => void) => { 27 | for (let i = 0; i < points.length; i++) { 28 | const sibling = (pos: number) => copyPoint(points[mod(pos, points.length)]); 29 | callback({ 30 | curr: copyPoint(points[i]), 31 | index: i, 32 | sibling, 33 | prev: () => sibling(i - 1), 34 | next: () => sibling(i + 1), 35 | }); 36 | } 37 | }; 38 | 39 | export const mapPoints = ( 40 | points: Point[], 41 | callback: (args: PointIteratorArgs) => Point, 42 | ): Point[] => { 43 | const out: Point[] = []; 44 | forPoints(points, (args) => { 45 | out.push(callback(args)); 46 | }); 47 | return out; 48 | }; 49 | 50 | export const coordEqual = (a: Coord, b: Coord): boolean => { 51 | return a.x === b.x && a.y === b.y; 52 | }; 53 | 54 | export const angleOf = (a: Coord, b: Coord): number => { 55 | const dx = b.x - a.x; 56 | const dy = -b.y + a.y; 57 | const angle = Math.atan2(dy, dx); 58 | if (angle < 0) { 59 | return Math.abs(angle); 60 | } else { 61 | return 2 * Math.PI - angle; 62 | } 63 | }; 64 | 65 | export const expandHandle = (point: Coord, handle: Handle): Coord => ({ 66 | x: point.x + handle.length * Math.cos(handle.angle), 67 | y: point.y + handle.length * Math.sin(handle.angle), 68 | }); 69 | 70 | const collapseHandle = (point: Coord, handle: Coord): Handle => ({ 71 | angle: angleOf(point, handle), 72 | length: Math.sqrt((handle.x - point.x) ** 2 + (handle.y - point.y) ** 2), 73 | }); 74 | 75 | export const length = (a: Point, b: Point): number => { 76 | const aHandle = expandHandle(a, a.handleOut); 77 | const bHandle = expandHandle(b, b.handleIn); 78 | const ab = distance(a, b); 79 | const abHandle = distance(aHandle, bHandle); 80 | return (ab + abHandle + a.handleOut.length + b.handleIn.length) / 2; 81 | }; 82 | 83 | export const reverse = (points: Point[]): Point[] => { 84 | return mapPoints(points, ({index, sibling}) => { 85 | const point = sibling(points.length - index - 1); 86 | point.handleIn.angle += Math.PI; 87 | point.handleOut.angle += Math.PI; 88 | return point; 89 | }); 90 | }; 91 | 92 | export const shift = (offset: number, points: Point[]): Point[] => { 93 | return mapPoints(points, ({index, sibling}) => { 94 | return sibling(index + offset); 95 | }); 96 | }; 97 | 98 | // Add a control point to the curve between a and b. 99 | // Percentage [0, 1] from a to b. 100 | // a: original first point. 101 | // b: original last point. 102 | // c: new first point. 103 | // d: new added point. 104 | // e: new last point. 105 | // f: split point between a and b's handles. 106 | // g: split point between c's handle and f. 107 | // h: split point between e's handle and f. 108 | export const insertAt = (percentage: number, a: Point, b: Point): [Point, Point, Point] => { 109 | const c = copyPoint(a); 110 | c.handleOut.length *= percentage; 111 | 112 | const e = copyPoint(b); 113 | e.handleIn.length *= 1 - percentage; 114 | 115 | const aHandle = expandHandle(a, a.handleOut); 116 | const bHandle = expandHandle(b, b.handleIn); 117 | const cHandle = expandHandle(c, c.handleOut); 118 | const eHandle = expandHandle(e, e.handleIn); 119 | const f = splitLine(percentage, aHandle, bHandle); 120 | const g = splitLine(percentage, cHandle, f); 121 | const h = splitLine(1 - percentage, eHandle, f); 122 | const dCoord = splitLine(percentage, g, h); 123 | 124 | const d: Point = { 125 | x: dCoord.x, 126 | y: dCoord.y, 127 | handleIn: collapseHandle(dCoord, g), 128 | handleOut: collapseHandle(dCoord, h), 129 | }; 130 | return [c, d, e]; 131 | }; 132 | 133 | export const insertCount = (count: number, a: Point, b: Point): Point[] => { 134 | if (count < 2) return [a, b]; 135 | const percentage = 1 / count; 136 | const [c, d, e] = insertAt(percentage, a, b); 137 | if (count === 2) return [c, d, e]; 138 | return [c, ...insertCount(count - 1, d, e)]; 139 | }; 140 | 141 | // Smooths out the path made up of the given points. 142 | // Existing handles are ignored. 143 | export const smooth = (points: Point[], strength: number): Point[] => { 144 | return mapPoints(points, ({curr, next, prev}) => { 145 | const angle = angleOf(prev(), next()); 146 | return { 147 | x: curr.x, 148 | y: curr.y, 149 | handleIn: { 150 | angle: angle + Math.PI, 151 | length: strength * distance(curr, prev()), 152 | }, 153 | handleOut: { 154 | angle, 155 | length: strength * distance(curr, next()), 156 | }, 157 | }; 158 | }); 159 | }; 160 | 161 | // Modulo operation that always produces a positive result. 162 | // https://stackoverflow.com/q/4467539/3053361 163 | export const mod = (a: number, n: number): number => { 164 | return ((a % n) + n) % n; 165 | }; 166 | 167 | // Converts degrees to radians. 168 | export const rad = (deg: number) => { 169 | return (deg / 360) * 2 * Math.PI; 170 | }; 171 | 172 | // Converts radians to degrees. 173 | export const deg = (rad: number) => { 174 | return (((rad / Math.PI) * 1) / 2) * 360; 175 | }; 176 | 177 | // Calculates distance between two points. 178 | export const distance = (a: Coord, b: Coord): number => { 179 | return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); 180 | }; 181 | 182 | // Calculates the angle of the line from a to b in degrees. 183 | export const angle = (a: Coord, b: Coord): number => { 184 | return deg(Math.atan2(b.y - a.y, b.x - a.x)); 185 | }; 186 | 187 | export const split = (percentage: number, a: number, b: number): number => { 188 | return a + percentage * (b - a); 189 | }; 190 | 191 | export const splitLine = (percentage: number, a: Coord, b: Coord): Coord => { 192 | return { 193 | x: split(percentage, a.x, b.x), 194 | y: split(percentage, a.y, b.y), 195 | }; 196 | }; 197 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blobs", 3 | "version": "2.3.0", 4 | "description": "Random blob generation and animation", 5 | "author": "g-harel", 6 | "license": "MIT", 7 | "main": "index.js", 8 | "module": "index.module.js", 9 | "types": "index.d.ts", 10 | "scripts": { 11 | "prepack": "npm run build", 12 | "postpublish": "npm run clean", 13 | "build": "npm run clean && rollup -c rollup.config.mjs", 14 | "clean": "trash '**/*.js' '**/*.js.map' '**/*.d.ts' '!**/node_modules/**/*' '!rollup.config.mjs'", 15 | "fmt": "prettier --list-different --write --ignore-path .gitignore '**/*.{js,ts,md,html}' '!index.html'", 16 | "demo:dev": "parcel demo/index.html --open", 17 | "demo:build": "parcel build demo/index.html && move-file dist/index.html index.html", 18 | "test": "jest", 19 | "test:playground": "parcel internal/animate/testing/index.html --open" 20 | }, 21 | "dependencies": { 22 | "simplex-noise": "^4.0.1" 23 | }, 24 | "devDependencies": { 25 | "@rollup/plugin-node-resolve": "^15.0.1", 26 | "@types/jest": "25.1.4", 27 | "jest": "29.5.0", 28 | "jest-canvas-mock": "2.5.0", 29 | "move-file-cli": "2.0.0", 30 | "parcel": "1.12.3", 31 | "parcel-plugin-inliner": "1.0.14", 32 | "path2d-polyfill": "^2.0.1", 33 | "prettier": "2.0.2", 34 | "rollup": "3.8.1", 35 | "rollup-plugin-copy": "3.4.0", 36 | "rollup-plugin-typescript2": "0.34.1", 37 | "rollup-plugin-uglify": "6.0.1", 38 | "trash-cli": "3.0.0", 39 | "ts-jest": "29.1.0", 40 | "tslib": "2.4.1", 41 | "typescript": "4.9.4" 42 | }, 43 | "homepage": "https://blobs.dev", 44 | "repository": { 45 | "type": "git", 46 | "url": "git+https://github.com/g-harel/blobs" 47 | }, 48 | "bugs": { 49 | "url": "https://github.com/g-harel/blobs/issues" 50 | }, 51 | "keywords": [ 52 | "random", 53 | "blob", 54 | "svg", 55 | "path", 56 | "canvas", 57 | "animation" 58 | ], 59 | "prettier": { 60 | "tabWidth": 4, 61 | "printWidth": 100, 62 | "trailingComma": "all", 63 | "bracketSpacing": false, 64 | "arrowParens": "always" 65 | }, 66 | "jest": { 67 | "preset": "ts-jest", 68 | "setupFiles": [ 69 | "jest-canvas-mock" 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /public/__snapshots__/legacy.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`fill 1`] = `""`; 4 | 5 | exports[`guides 1`] = `""`; 6 | 7 | exports[`stroke 1`] = `""`; 8 | -------------------------------------------------------------------------------- /public/animate.test.ts: -------------------------------------------------------------------------------- 1 | import {CanvasKeyframe, canvasPath} from "./animate"; 2 | 3 | const genKeyframe = (): CanvasKeyframe => ({ 4 | duration: 1000 * Math.random(), 5 | delay: 1000 * Math.random(), 6 | timingFunction: "linear", 7 | callback: () => {}, 8 | blobOptions: { 9 | extraPoints: Math.floor(10 * Math.random()), 10 | randomness: Math.floor(10 * Math.random()), 11 | seed: Math.random(), 12 | size: 100 + 200 * Math.random(), 13 | }, 14 | canvasOptions: { 15 | offsetX: 100 * Math.random(), 16 | offsetY: 100 * Math.random(), 17 | }, 18 | }); 19 | 20 | describe("animate", () => { 21 | describe("canvasPath", () => { 22 | describe("transition", () => { 23 | describe("keyframe", () => { 24 | it("should accept generated keyframe", () => { 25 | const animation = canvasPath(); 26 | const keyframe = genKeyframe(); 27 | 28 | expect(() => animation.transition(keyframe)).not.toThrow(); 29 | }); 30 | 31 | it("should indicate the rejected frame index", () => { 32 | const animation = canvasPath(); 33 | const keyframes = [genKeyframe(), null as any, genKeyframe()]; 34 | 35 | expect(() => animation.transition(...keyframes)).toThrow(/keyframe.*1/g); 36 | }); 37 | 38 | interface TestCase { 39 | name: string; 40 | edit: (keyframe: CanvasKeyframe) => void; 41 | error?: RegExp; 42 | } 43 | 44 | const testCases: Array = [ 45 | // duration 46 | { 47 | name: "should accept valid duration", 48 | edit: (keyframe) => (keyframe.duration = 100), 49 | }, 50 | { 51 | name: "should accept zero duration", 52 | edit: (keyframe) => (keyframe.duration = 0), 53 | }, 54 | { 55 | name: "should reject undefined duration", 56 | edit: (keyframe) => delete (keyframe as any).duration, 57 | error: /duration.*number.*undefined/g, 58 | }, 59 | { 60 | name: "should reject negative duration", 61 | edit: (keyframe) => (keyframe.duration = -10), 62 | error: /duration.*invalid/g, 63 | }, 64 | { 65 | name: "should reject broken duration", 66 | edit: (keyframe) => (keyframe.duration = NaN), 67 | error: /duration.*number.*NaN/g, 68 | }, 69 | { 70 | name: "should reject invalid duration", 71 | edit: (keyframe) => (keyframe.duration = "123" as any), 72 | error: /duration.*number.*string/g, 73 | }, 74 | // delay 75 | { 76 | name: "should accept valid delay", 77 | edit: (keyframe) => (keyframe.delay = 200), 78 | }, 79 | { 80 | name: "should accept zero delay", 81 | edit: (keyframe) => (keyframe.delay = 0), 82 | }, 83 | { 84 | name: "should accept undefined delay", 85 | edit: (keyframe) => delete keyframe.delay, 86 | }, 87 | { 88 | name: "should reject negative delay", 89 | edit: (keyframe) => (keyframe.delay = -10), 90 | error: /delay.*invalid/g, 91 | }, 92 | { 93 | name: "should reject broken delay", 94 | edit: (keyframe) => (keyframe.delay = NaN), 95 | error: /delay.*number.*NaN/g, 96 | }, 97 | { 98 | name: "should reject invalid delay", 99 | edit: (keyframe) => (keyframe.delay = "123" as any), 100 | error: /delay.*number.*string/g, 101 | }, 102 | // timingFunction 103 | { 104 | name: "should accept known timingFunction", 105 | edit: (keyframe) => (keyframe.timingFunction = "ease"), 106 | }, 107 | { 108 | name: "should accept undefined timingFunction", 109 | edit: (keyframe) => delete keyframe.timingFunction, 110 | }, 111 | { 112 | name: "should reject invalid timingFunction", 113 | edit: (keyframe) => (keyframe.timingFunction = (() => 0) as any), 114 | error: /timingFunction.*string.*function/g, 115 | }, 116 | { 117 | name: "should reject unknown timingFunction", 118 | edit: (keyframe) => (keyframe.timingFunction = "unknown" as any), 119 | error: /timingFunction.*not recognized.*unknown/g, 120 | }, 121 | // callback 122 | { 123 | name: "should accept valid callback", 124 | edit: (keyframe) => (keyframe.callback = () => console.log("test")), 125 | }, 126 | { 127 | name: "should accept undefined callback", 128 | edit: (keyframe) => delete keyframe.callback, 129 | }, 130 | { 131 | name: "should reject invalid callback", 132 | edit: (keyframe) => (keyframe.callback = {} as any), 133 | error: /callback.*function.*object/g, 134 | }, 135 | // blobOptions 136 | { 137 | name: "should reject undefined blobOptions", 138 | edit: (keyframe) => delete (keyframe as any).blobOptions, 139 | error: /blobOptions.*object.*undefined/g, 140 | }, 141 | { 142 | name: "should reject invalid blobOptions", 143 | edit: (keyframe) => (keyframe.blobOptions = null as any), 144 | error: /blobOptions.*object.*null/g, 145 | }, 146 | // blobOptions.seed 147 | { 148 | name: "should accept number blobOptions seed", 149 | edit: (keyframe) => (keyframe.blobOptions.seed = 123), 150 | }, 151 | { 152 | name: "should accept string blobOptions seed", 153 | edit: (keyframe) => (keyframe.blobOptions.seed = "test"), 154 | }, 155 | { 156 | name: "should reject undefined blobOptions seed", 157 | edit: (keyframe) => delete (keyframe as any).blobOptions.seed, 158 | error: /seed.*string.*number.*undefined/g, 159 | }, 160 | { 161 | name: "should reject broken blobOptions seed", 162 | edit: (keyframe) => (keyframe.blobOptions.seed = NaN), 163 | error: /seed.*string.*number.*NaN/g, 164 | }, 165 | // blobOptions.extraPoints 166 | { 167 | name: "should accept valid blobOptions extraPoints", 168 | edit: (keyframe) => (keyframe.blobOptions.extraPoints = 4), 169 | }, 170 | { 171 | name: "should reject undefined blobOptions extraPoints", 172 | edit: (keyframe) => delete (keyframe as any).blobOptions.extraPoints, 173 | error: /blobOptions.*extraPoints.*number.*undefined/g, 174 | }, 175 | { 176 | name: "should reject broken blobOptions extraPoints", 177 | edit: (keyframe) => (keyframe.blobOptions.extraPoints = NaN), 178 | error: /blobOptions.*extraPoints.*number.*NaN/g, 179 | }, 180 | { 181 | name: "should reject negative blobOptions extraPoints", 182 | edit: (keyframe) => (keyframe.blobOptions.extraPoints = -2), 183 | error: /blobOptions.*extraPoints.*invalid/g, 184 | }, 185 | // blobOptions.randomness 186 | { 187 | name: "should accept valid blobOptions randomness", 188 | edit: (keyframe) => (keyframe.blobOptions.randomness = 3), 189 | }, 190 | { 191 | name: "should reject undefined blobOptions randomness", 192 | edit: (keyframe) => delete (keyframe as any).blobOptions.randomness, 193 | error: /blobOptions.*randomness.*number.*undefined/g, 194 | }, 195 | { 196 | name: "should reject broken blobOptions randomness", 197 | edit: (keyframe) => (keyframe.blobOptions.randomness = NaN), 198 | error: /blobOptions.*randomness.*number.*NaN/g, 199 | }, 200 | { 201 | name: "should reject negative blobOptions randomness", 202 | edit: (keyframe) => (keyframe.blobOptions.randomness = -10), 203 | error: /blobOptions.*randomness.*invalid/g, 204 | }, 205 | // blobOptions.size 206 | { 207 | name: "should accept valid blobOptions size", 208 | edit: (keyframe) => (keyframe.blobOptions.size = 40), 209 | }, 210 | { 211 | name: "should reject undefined blobOptions size", 212 | edit: (keyframe) => delete (keyframe as any).blobOptions.size, 213 | error: /blobOptions.*size.*number.*undefined/g, 214 | }, 215 | { 216 | name: "should reject broken blobOptions size", 217 | edit: (keyframe) => (keyframe.blobOptions.size = NaN), 218 | error: /blobOptions.*size.*number.*NaN/g, 219 | }, 220 | { 221 | name: "should reject negative blobOptions size", 222 | edit: (keyframe) => (keyframe.blobOptions.size = -1), 223 | error: /blobOptions.*size.*invalid/g, 224 | }, 225 | // canvasOptions 226 | { 227 | name: "should accept empty canvasOptions", 228 | edit: (keyframe) => (keyframe.canvasOptions = {}), 229 | }, 230 | { 231 | name: "should accept undefined canvasOptions", 232 | edit: (keyframe) => delete keyframe.canvasOptions, 233 | }, 234 | { 235 | name: "should reject invalid canvasOptions", 236 | edit: (keyframe) => (keyframe.canvasOptions = null as any), 237 | error: /canvasOptions.*object.*null/g, 238 | }, 239 | // canvasOptions.offsetX 240 | { 241 | name: "should accept valid canvasOptions offsetX", 242 | edit: (keyframe) => (keyframe.canvasOptions = {offsetX: 100}), 243 | }, 244 | { 245 | name: "should accept undefined canvasOptions offsetX", 246 | edit: (keyframe) => delete keyframe.canvasOptions?.offsetX, 247 | }, 248 | { 249 | name: "should reject broken canvasOptions offsetX", 250 | edit: (keyframe) => (keyframe.canvasOptions = {offsetX: NaN}), 251 | error: /canvasOptions.*offsetX.*number.*NaN/g, 252 | }, 253 | // canvasOptions.offsetY 254 | { 255 | name: "should accept valid canvasOptions offsetY", 256 | edit: (keyframe) => (keyframe.canvasOptions = {offsetY: 222}), 257 | }, 258 | { 259 | name: "should accept undefined canvasOptions offsetY", 260 | edit: (keyframe) => delete keyframe.canvasOptions?.offsetY, 261 | }, 262 | { 263 | name: "should reject broken canvasOptions offsetY", 264 | edit: (keyframe) => (keyframe.canvasOptions = {offsetY: NaN}), 265 | error: /canvasOptions.*offsetY.*number.*NaN/g, 266 | }, 267 | ]; 268 | 269 | // Run all test cases with a configurable amount of keyframes 270 | // and index of the keyframe being edited for the tests. 271 | const runSuite = (keyframeCount: number, editIndex: number) => { 272 | for (const testCase of testCases) { 273 | it(testCase.name, () => { 274 | // Create blank animation. 275 | const animation = canvasPath(); 276 | 277 | // Create keyframes to call transition with. 278 | const keyframes: CanvasKeyframe[] = []; 279 | for (let i = 0; i < keyframeCount; i++) { 280 | keyframes.push(genKeyframe()); 281 | } 282 | 283 | // Modify selected keyframe. 284 | testCase.edit(keyframes[editIndex]); 285 | 286 | if (testCase.error) { 287 | // Copy regexp because they are stateful. 288 | const pattern = new RegExp(testCase.error); 289 | expect(() => animation.transition(...keyframes)).toThrow(pattern); 290 | } else { 291 | expect(() => animation.transition(...keyframes)).not.toThrow(); 292 | } 293 | }); 294 | } 295 | }; 296 | 297 | // Run all cases when given a single test frame and asserting on it. 298 | describe("first", () => runSuite(1, 0)); 299 | 300 | // Run all cases when given more than one frame, asserting on last one. 301 | const lastLength = 2 + Math.floor(4 * Math.random()); 302 | describe("last", () => runSuite(lastLength, lastLength - 1)); 303 | 304 | // Run all cases when given more than one frame, asserting on a random one. 305 | const nthLength = 2 + Math.floor(16 * Math.random()); 306 | const nthIndex = Math.floor(nthLength * Math.random()); 307 | describe(`nth (${nthIndex + 1}/${nthLength})`, () => runSuite(nthLength, nthIndex)); 308 | }); 309 | }); 310 | }); 311 | }); 312 | -------------------------------------------------------------------------------- /public/animate.ts: -------------------------------------------------------------------------------- 1 | import {Point} from "../internal/types"; 2 | import {renderPath2D} from "../internal/render/canvas"; 3 | import {genFromOptions} from "../internal/gen"; 4 | import {mapPoints} from "../internal/util"; 5 | import {statefulAnimationGenerator} from "../internal/animate/state"; 6 | import { 7 | checkBlobOptions, 8 | checkCanvasOptions, 9 | checkKeyframeOptions, 10 | checkPoints, 11 | } from "../internal/check"; 12 | import {BlobOptions, CanvasOptions} from "./blobs"; 13 | import {noise} from "../internal/rand"; 14 | import {interpolateBetween} from "../internal/animate/interpolate"; 15 | import {prepare} from "../internal/animate/prepare"; 16 | 17 | interface Keyframe { 18 | // Duration of the keyframe animation in milliseconds. 19 | duration: number; 20 | // Delay before animation begins in milliseconds. 21 | // Default: 0. 22 | delay?: number; 23 | // Controls the speed of the animation over time. 24 | // Default: "linear". 25 | timingFunction?: 26 | | "linear" 27 | | "easeEnd" 28 | | "easeStart" 29 | | "ease" 30 | | "elasticEnd0" 31 | | "elasticEnd1" 32 | | "elasticEnd2" 33 | | "elasticEnd3"; 34 | // Called after keyframe end-state is reached or passed. 35 | // Called exactly once when the keyframe end-state is rendered. 36 | // Not called if the keyframe is preempted by a new transition. 37 | callback?: () => void; 38 | // Standard options, refer to "blobs/v2" documentation. 39 | canvasOptions?: { 40 | offsetX?: number; 41 | offsetY?: number; 42 | }; 43 | } 44 | 45 | export interface CanvasKeyframe extends Keyframe { 46 | // Standard options, refer to "blobs/v2" documentation. 47 | blobOptions: { 48 | seed: number | string; 49 | randomness: number; 50 | extraPoints: number; 51 | size: number; 52 | }; 53 | } 54 | 55 | export interface CanvasCustomKeyframe extends Keyframe { 56 | // List of point coordinates that produce a single, closed shape. 57 | points: Point[]; 58 | } 59 | 60 | export interface Animation { 61 | // Renders the current state of the animation. 62 | renderFrame: () => Path2D; 63 | // Renders the current state of the animation as points. 64 | renderPoints: () => Point[]; 65 | // Immediately begin animating through the given keyframes. 66 | // Non-rendered keyframes from previous transitions are cancelled. 67 | transition: (...keyframes: (CanvasKeyframe | CanvasCustomKeyframe)[]) => void; 68 | // Resume a paused animation. Has no effect if already playing. 69 | play: () => void; 70 | // Pause a playing animation. Has no effect if already paused. 71 | pause: () => void; 72 | // Toggle between playing and pausing the animation. 73 | playPause: () => void; 74 | } 75 | 76 | // Function that returns the current timestamp. This value will be used for all 77 | // duration/delay values and will be used to interpolate between keyframes. It 78 | // must produce values increasing in size. 79 | // Default: `Date.now`. 80 | export interface TimestampProvider { 81 | (): number; 82 | } 83 | 84 | export interface WiggleOptions { 85 | // Speed of the wiggle movement. Higher is faster. 86 | speed: number; 87 | // Length of the transition from the current state to the wiggle blob. 88 | // Default: 0 89 | initialTransition?: number; 90 | } 91 | 92 | const canvasPointGenerator = (keyframe: CanvasKeyframe | CanvasCustomKeyframe): Point[] => { 93 | let points: Point[]; 94 | if ("points" in keyframe) { 95 | points = keyframe.points; 96 | } else { 97 | points = genFromOptions(keyframe.blobOptions); 98 | } 99 | return mapPoints(points, ({curr}) => { 100 | curr.x += keyframe?.canvasOptions?.offsetX || 0; 101 | curr.y += keyframe?.canvasOptions?.offsetY || 0; 102 | return curr; 103 | }); 104 | }; 105 | 106 | const canvasKeyframeChecker = (keyframe: CanvasKeyframe | CanvasCustomKeyframe, index: number) => { 107 | try { 108 | if ("points" in keyframe) return checkPoints(keyframe.points); 109 | checkBlobOptions(keyframe.blobOptions); 110 | checkCanvasOptions(keyframe.canvasOptions); 111 | checkKeyframeOptions(keyframe); 112 | } catch (e) { 113 | throw `(blobs2): keyframe ${index}: ${e}`; 114 | } 115 | }; 116 | 117 | export const canvasPath = (timestampProvider?: () => number): Animation => { 118 | let actualTimestampProvider = Date.now; 119 | 120 | // Make sure timestamps are always increasing. 121 | if (timestampProvider !== undefined) { 122 | let lastTimestamp = 0; 123 | actualTimestampProvider = () => { 124 | const currentTimestamp = timestampProvider(); 125 | if (currentTimestamp < lastTimestamp) { 126 | throw `timestamp provider generated decreasing value: ${lastTimestamp} then ${currentTimestamp}.`; 127 | } 128 | lastTimestamp = currentTimestamp; 129 | return currentTimestamp; 130 | }; 131 | } 132 | 133 | return statefulAnimationGenerator( 134 | canvasPointGenerator, 135 | renderPath2D, 136 | canvasKeyframeChecker, 137 | )(actualTimestampProvider); 138 | }; 139 | 140 | export const wigglePreset = ( 141 | animation: Animation, 142 | blobOptions: BlobOptions, 143 | canvasOptions: CanvasOptions, 144 | wiggleOptions: WiggleOptions, 145 | ) => { 146 | // Interval at which a new sample is taken. 147 | // Multiple of 16 to do work every N frames. 148 | const intervalMs = 16 * 10; 149 | const leapSize = 0.01 * wiggleOptions.speed; 150 | const noiseField = noise(String(blobOptions.seed)); 151 | 152 | const transitionFrameCount = Math.min((wiggleOptions.initialTransition || 0) / intervalMs); 153 | let transitionStartFrame = animation.renderPoints(); 154 | 155 | let count = 0; 156 | const loopAnimation = () => { 157 | count++; 158 | 159 | // Constantly changing blob. 160 | const noiseBlob = genFromOptions(blobOptions, (index) => { 161 | return noiseField(leapSize * count, index); 162 | }); 163 | 164 | if (count < transitionFrameCount) { 165 | // Create intermediate frame between the current state and the 166 | // moving noiseBlob target. 167 | const [preparedStartPoints, preparedEndPoints] = prepare( 168 | transitionStartFrame, 169 | noiseBlob, 170 | { 171 | rawAngles: true, 172 | divideRatio: 1, 173 | }, 174 | ); 175 | const progress = Math.min(1, 2 / (transitionFrameCount - count)); 176 | const targetPoints = interpolateBetween( 177 | progress, 178 | preparedStartPoints, 179 | preparedEndPoints, 180 | ); 181 | transitionStartFrame = targetPoints; 182 | 183 | animation.transition({ 184 | duration: intervalMs, 185 | delay: 0, 186 | timingFunction: "linear", 187 | canvasOptions, 188 | points: targetPoints, 189 | callback: loopAnimation, 190 | }); 191 | } else { 192 | animation.transition({ 193 | duration: intervalMs, 194 | delay: 0, 195 | timingFunction: "linear", 196 | canvasOptions, 197 | points: noiseBlob, 198 | callback: loopAnimation, 199 | }); 200 | } 201 | }; 202 | loopAnimation(); 203 | }; 204 | -------------------------------------------------------------------------------- /public/blobs.test.ts: -------------------------------------------------------------------------------- 1 | import {BlobOptions, CanvasOptions, canvasPath, svg, SvgOptions, svgPath} from "./blobs"; 2 | 3 | // @ts-ignore 4 | import {Path2D, polyfillPath2D} from "path2d-polyfill"; 5 | global.Path2D = Path2D; 6 | 7 | const genBlobOptions = (): BlobOptions => ({ 8 | extraPoints: Math.floor(10 * Math.random()), 9 | randomness: Math.floor(10 * Math.random()), 10 | seed: Math.random(), 11 | size: 100 + 200 * Math.random(), 12 | }); 13 | 14 | const genSvgOptions = (): SvgOptions => ({ 15 | fill: String(Math.random()), 16 | stroke: String(Math.random()), 17 | strokeWidth: 4 * Math.random(), 18 | }); 19 | 20 | const genCanvasOptions = (): CanvasOptions => ({ 21 | offsetX: 100 * Math.random(), 22 | offsetY: 100 * Math.random(), 23 | }); 24 | 25 | interface TestCase { 26 | name: string; 27 | edit: (options: T) => void; 28 | error?: RegExp; 29 | } 30 | 31 | const runSuite = (t: { 32 | optionsGenerator: () => T; 33 | functionBeingTested: (options: any) => void; 34 | }) => (testCases: TestCase[]) => { 35 | for (const testCase of testCases) { 36 | it(testCase.name, () => { 37 | const options = t.optionsGenerator(); 38 | testCase.edit(options); 39 | 40 | if (testCase.error) { 41 | // Copy regexp because they are stateful. 42 | const pattern = new RegExp(testCase.error); 43 | expect(() => t.functionBeingTested(options)).toThrow(pattern); 44 | } else { 45 | expect(() => t.functionBeingTested(options)).not.toThrow(); 46 | } 47 | }); 48 | } 49 | }; 50 | 51 | const testBlobOptions = (functionBeingTested: (options: any) => void) => { 52 | it("should accept generated blobOptions", () => { 53 | expect(() => svgPath(genBlobOptions())).not.toThrow(); 54 | }); 55 | 56 | it("should reject undefined blobOptions", () => { 57 | expect(() => svgPath(undefined as any)).toThrow(/blobOptions.*object.*undefined/g); 58 | }); 59 | 60 | it("should reject invalid blobOptions", () => { 61 | expect(() => svgPath(null as any)).toThrow(/blobOptions.*object.*null/g); 62 | }); 63 | 64 | runSuite({ 65 | functionBeingTested, 66 | optionsGenerator: genBlobOptions, 67 | })([ 68 | // seed 69 | { 70 | name: "should accept number blobOptions seed", 71 | edit: (blobOptions) => (blobOptions.seed = 123), 72 | }, 73 | { 74 | name: "should accept string blobOptions seed", 75 | edit: (blobOptions) => (blobOptions.seed = "test"), 76 | }, 77 | { 78 | name: "should reject undefined blobOptions seed", 79 | edit: (blobOptions) => delete (blobOptions as any).seed, 80 | error: /seed.*string.*number.*undefined/g, 81 | }, 82 | { 83 | name: "should reject broken blobOptions seed", 84 | edit: (blobOptions) => (blobOptions.seed = NaN), 85 | error: /seed.*string.*number.*NaN/g, 86 | }, 87 | // extraPoints 88 | { 89 | name: "should accept valid blobOptions extraPoints", 90 | edit: (blobOptions) => (blobOptions.extraPoints = 4), 91 | }, 92 | { 93 | name: "should reject undefined blobOptions extraPoints", 94 | edit: (blobOptions) => delete (blobOptions as any).extraPoints, 95 | error: /blobOptions.*extraPoints.*number.*undefined/g, 96 | }, 97 | { 98 | name: "should reject broken blobOptions extraPoints", 99 | edit: (blobOptions) => (blobOptions.extraPoints = NaN), 100 | error: /blobOptions.*extraPoints.*number.*NaN/g, 101 | }, 102 | { 103 | name: "should reject negative blobOptions extraPoints", 104 | edit: (blobOptions) => (blobOptions.extraPoints = -2), 105 | error: /blobOptions.*extraPoints.*invalid/g, 106 | }, 107 | // randomness 108 | { 109 | name: "should accept valid blobOptions randomness", 110 | edit: (blobOptions) => (blobOptions.randomness = 3), 111 | }, 112 | { 113 | name: "should reject undefined blobOptions randomness", 114 | edit: (blobOptions) => delete (blobOptions as any).randomness, 115 | error: /blobOptions.*randomness.*number.*undefined/g, 116 | }, 117 | { 118 | name: "should reject broken blobOptions randomness", 119 | edit: (blobOptions) => (blobOptions.randomness = NaN), 120 | error: /blobOptions.*randomness.*number.*NaN/g, 121 | }, 122 | { 123 | name: "should reject negative blobOptions randomness", 124 | edit: (blobOptions) => (blobOptions.randomness = -10), 125 | error: /blobOptions.*randomness.*invalid/g, 126 | }, 127 | // size 128 | { 129 | name: "should accept valid blobOptions size", 130 | edit: (blobOptions) => (blobOptions.size = 40), 131 | }, 132 | { 133 | name: "should reject undefined blobOptions size", 134 | edit: (blobOptions) => delete (blobOptions as any).size, 135 | error: /blobOptions.*size.*number.*undefined/g, 136 | }, 137 | { 138 | name: "should reject broken blobOptions size", 139 | edit: (blobOptions) => (blobOptions.size = NaN), 140 | error: /blobOptions.*size.*number.*NaN/g, 141 | }, 142 | { 143 | name: "should reject negative blobOptions size", 144 | edit: (blobOptions) => (blobOptions.size = -1), 145 | error: /blobOptions.*size.*invalid/g, 146 | }, 147 | ]); 148 | }; 149 | 150 | describe("blobs", () => { 151 | describe("canvasPath", () => { 152 | describe("blobOptions", () => { 153 | testBlobOptions((blobOptions) => canvasPath(blobOptions, genCanvasOptions())); 154 | }); 155 | 156 | describe("canvasOptions", () => { 157 | it("should accept generated canvasOptions", () => { 158 | expect(() => canvasPath(genBlobOptions(), genCanvasOptions())).not.toThrow(); 159 | }); 160 | 161 | it("should accept undefined canvasOptions", () => { 162 | expect(() => canvasPath(genBlobOptions(), undefined as any)).not.toThrow(); 163 | }); 164 | 165 | it("should reject invalid canvasOptions", () => { 166 | expect(() => canvasPath(genBlobOptions(), null as any)).toThrow( 167 | /canvasOptions.*object.*null/g, 168 | ); 169 | }); 170 | 171 | runSuite({ 172 | functionBeingTested: (canvasOptions) => canvasPath(genBlobOptions(), canvasOptions), 173 | optionsGenerator: genCanvasOptions, 174 | })([ 175 | // offsetX 176 | { 177 | name: "should accept valid canvasOptions offsetX", 178 | edit: (canvasOptions) => (canvasOptions.offsetX = 100), 179 | }, 180 | { 181 | name: "should accept undefined canvasOptions offsetX", 182 | edit: (canvasOptions) => delete canvasOptions?.offsetX, 183 | }, 184 | { 185 | name: "should reject broken canvasOptions offsetX", 186 | edit: (canvasOptions) => (canvasOptions.offsetX = NaN), 187 | error: /canvasOptions.*offsetX.*number.*NaN/g, 188 | }, 189 | // offsetY 190 | { 191 | name: "should accept valid canvasOptions offsetY", 192 | edit: (canvasOptions) => (canvasOptions.offsetY = 222), 193 | }, 194 | { 195 | name: "should accept undefined canvasOptions offsetY", 196 | edit: (canvasOptions) => delete canvasOptions?.offsetY, 197 | }, 198 | { 199 | name: "should reject broken canvasOptions offsetY", 200 | edit: (canvasOptions) => (canvasOptions.offsetY = NaN), 201 | error: /canvasOptions.*offsetY.*number.*NaN/g, 202 | }, 203 | ]); 204 | }); 205 | }); 206 | 207 | describe("svg", () => { 208 | describe("blobOptions", () => { 209 | testBlobOptions((blobOptions) => svg(blobOptions, genSvgOptions())); 210 | }); 211 | 212 | describe("svgOptions", () => { 213 | it("should accept generated svgOptions", () => { 214 | expect(() => svg(genBlobOptions(), genSvgOptions())).not.toThrow(); 215 | }); 216 | 217 | it("should accept undefined svgOptions", () => { 218 | expect(() => svg(genBlobOptions(), undefined as any)).not.toThrow(); 219 | }); 220 | 221 | it("should reject invalid svgOptions", () => { 222 | expect(() => svg(genBlobOptions(), null as any)).toThrow( 223 | /svgOptions.*object.*null/g, 224 | ); 225 | }); 226 | 227 | runSuite({ 228 | functionBeingTested: (svgOptions) => svg(genBlobOptions(), svgOptions), 229 | optionsGenerator: genSvgOptions, 230 | })([ 231 | // fill 232 | { 233 | name: "should accept valid svgOptions fill", 234 | edit: (svgOptions) => (svgOptions.fill = "red"), 235 | }, 236 | { 237 | name: "should accept undefined svgOptions fill", 238 | edit: (svgOptions) => delete svgOptions?.fill, 239 | }, 240 | { 241 | name: "should reject broken svgOptions fill", 242 | edit: (svgOptions) => (svgOptions.fill = null as any), 243 | error: /svgOptions.*fill.*string.*null/g, 244 | }, 245 | // stroke 246 | { 247 | name: "should accept valid svgOptions stroke", 248 | edit: (svgOptions) => (svgOptions.stroke = "red"), 249 | }, 250 | { 251 | name: "should accept undefined svgOptions stroke", 252 | edit: (svgOptions) => delete svgOptions?.stroke, 253 | }, 254 | { 255 | name: "should reject broken svgOptions stroke", 256 | edit: (svgOptions) => (svgOptions.stroke = null as any), 257 | error: /svgOptions.*stroke.*string.*null/g, 258 | }, 259 | // strokeWidth 260 | { 261 | name: "should accept valid svgOptions strokeWidth", 262 | edit: (svgOptions) => (svgOptions.strokeWidth = 222), 263 | }, 264 | { 265 | name: "should accept undefined svgOptions strokeWidth", 266 | edit: (svgOptions) => delete svgOptions?.strokeWidth, 267 | }, 268 | { 269 | name: "should reject broken svgOptions strokeWidth", 270 | edit: (svgOptions) => (svgOptions.strokeWidth = NaN), 271 | error: /svgOptions.*strokeWidth.*number.*NaN/g, 272 | }, 273 | ]); 274 | }); 275 | }); 276 | 277 | describe("svgPath", () => { 278 | describe("blobOptions", () => { 279 | testBlobOptions(svgPath); 280 | }); 281 | }); 282 | }); 283 | -------------------------------------------------------------------------------- /public/blobs.ts: -------------------------------------------------------------------------------- 1 | import {genFromOptions} from "../internal/gen"; 2 | import {renderPath} from "../internal/render/svg"; 3 | import {renderPath2D} from "../internal/render/canvas"; 4 | import {mapPoints} from "../internal/util"; 5 | import {checkBlobOptions, checkCanvasOptions, checkSvgOptions} from "../internal/check"; 6 | 7 | export interface BlobOptions { 8 | // A given seed will always produce the same blob. 9 | // Use `Math.random()` for pseudorandom behavior. 10 | seed: string | number; 11 | // Actual number of points will be `3 + extraPoints`. 12 | extraPoints: number; 13 | // Increases the amount of variation in point position. 14 | randomness: number; 15 | // Size of the bounding box. 16 | size: number; 17 | } 18 | 19 | export interface CanvasOptions { 20 | // Coordinates of top-left corner of the blob. 21 | offsetX?: number; 22 | offsetY?: number; 23 | } 24 | 25 | export interface SvgOptions { 26 | fill?: string; // Default: "#ec576b". 27 | stroke?: string; // Default: "none". 28 | strokeWidth?: number; // Default: 0. 29 | } 30 | 31 | export const canvasPath = (blobOptions: BlobOptions, canvasOptions: CanvasOptions = {}): Path2D => { 32 | try { 33 | checkBlobOptions(blobOptions); 34 | checkCanvasOptions(canvasOptions); 35 | } catch (e) { 36 | throw `(blobs2): ${e}`; 37 | } 38 | return renderPath2D( 39 | mapPoints(genFromOptions(blobOptions), ({curr}) => { 40 | curr.x += canvasOptions.offsetX || 0; 41 | curr.y += canvasOptions.offsetY || 0; 42 | return curr; 43 | }), 44 | ); 45 | }; 46 | 47 | export const svg = (blobOptions: BlobOptions, svgOptions: SvgOptions = {}): string => { 48 | try { 49 | checkBlobOptions(blobOptions); 50 | checkSvgOptions(svgOptions); 51 | } catch (e) { 52 | throw `(blobs2): ${e}`; 53 | } 54 | const path = svgPath(blobOptions); 55 | const size = Math.floor(blobOptions.size); 56 | const fill = svgOptions.fill === undefined ? "#ec576b" : svgOptions.fill; 57 | const stroke = svgOptions.stroke === undefined ? "none" : svgOptions.stroke; 58 | const strokeWidth = svgOptions.strokeWidth === undefined ? 0 : svgOptions.strokeWidth; 59 | return ` 60 | 61 | 62 | `.trim(); 63 | }; 64 | 65 | export const svgPath = (blobOptions: BlobOptions): string => { 66 | try { 67 | checkBlobOptions(blobOptions); 68 | } catch (e) { 69 | throw `(blobs2): ${e}`; 70 | } 71 | return renderPath(genFromOptions(blobOptions)); 72 | }; 73 | -------------------------------------------------------------------------------- /public/legacy.test.ts: -------------------------------------------------------------------------------- 1 | import blobs, {BlobOptions} from "./legacy"; 2 | 3 | const genMinimalOptions = (): BlobOptions => ({ 4 | size: 1000 * Math.random(), 5 | complexity: 1 - Math.random(), 6 | contrast: 1 - Math.random(), 7 | color: "#fff", 8 | }); 9 | 10 | describe("legacy", () => { 11 | it("should return a different result when seed is not provided", () => { 12 | const options = genMinimalOptions(); 13 | 14 | const a = blobs(options); 15 | const b = blobs(options); 16 | 17 | expect(a).not.toEqual(b); 18 | }); 19 | 20 | it("should return the same result when the seed is provided", () => { 21 | const options = genMinimalOptions(); 22 | 23 | options.seed = "abcde"; 24 | const a = blobs(options); 25 | const b = blobs(options); 26 | 27 | expect(a).toEqual(b); 28 | }); 29 | 30 | it("should require options be provided", () => { 31 | expect(() => (blobs as any)()).toThrow("options"); 32 | }); 33 | 34 | it("should require a size be provided", () => { 35 | const options = genMinimalOptions(); 36 | 37 | delete (options as any).size; 38 | expect(() => blobs(options)).toThrow("size"); 39 | }); 40 | 41 | it("should reject out of range complexity values", () => { 42 | const options = genMinimalOptions(); 43 | 44 | options.complexity = 1234; 45 | expect(() => blobs(options)).toThrow("complexity"); 46 | 47 | options.complexity = 0; 48 | expect(() => blobs(options)).toThrow("complexity"); 49 | }); 50 | 51 | it("should reject out of range contrast values", () => { 52 | const options = genMinimalOptions(); 53 | 54 | options.contrast = 999; 55 | expect(() => blobs(options)).toThrow("contrast"); 56 | 57 | options.contrast = -1; 58 | expect(() => blobs(options)).toThrow("contrast"); 59 | }); 60 | 61 | it("should reject options without stroke or color", () => { 62 | const options = genMinimalOptions(); 63 | 64 | delete options.stroke; 65 | delete options.color; 66 | expect(() => blobs(options)).toThrow("stroke"); 67 | expect(() => blobs(options)).toThrow("color"); 68 | }); 69 | }); 70 | 71 | describe("editable", () => { 72 | it("should reflect changes when edited", () => { 73 | const options = genMinimalOptions(); 74 | 75 | const out = blobs.editable(options); 76 | const initial = out.render(); 77 | out.attributes.id = "test"; 78 | const modified = out.render(); 79 | 80 | expect(modified).not.toBe(initial); 81 | }); 82 | }); 83 | 84 | // Sanity checks to ensure the output remains consistent 85 | // across changes to the source. 86 | const testCases: Record = { 87 | fill: { 88 | size: 109, 89 | complexity: 0.1, 90 | contrast: 0.331, 91 | color: "red", 92 | seed: "fill", 93 | }, 94 | stroke: { 95 | size: 226, 96 | complexity: 0.91, 97 | contrast: 0.6, 98 | stroke: { 99 | color: "#ff00bb", 100 | width: 3.8, 101 | }, 102 | seed: "stroke", 103 | }, 104 | guides: { 105 | size: 781, 106 | complexity: 1, 107 | contrast: 0.331, 108 | color: "yellow", 109 | guides: true, 110 | seed: "guides", 111 | }, 112 | }; 113 | 114 | for (const testCase of Object.keys(testCases)) { 115 | test(testCase, () => { 116 | expect(blobs(testCases[testCase])).toMatchSnapshot(); 117 | }); 118 | } 119 | -------------------------------------------------------------------------------- /public/legacy.ts: -------------------------------------------------------------------------------- 1 | import {rand} from "../internal/rand"; 2 | import {renderEditable, XmlElement as InternalXmlElement} from "../internal/render/svg"; 3 | import {genBlob} from "../internal/gen"; 4 | import {mapPoints} from "../internal/util"; 5 | 6 | const isBrowser = new Function("try {return this===window;}catch(e){ return false;}"); 7 | const isLocalhost = () => location.hostname === "localhost" || location.hostname === "127.0.0.1"; 8 | const isFile = () => location.protocol === "file:"; 9 | if (!isBrowser() || isLocalhost() || isFile()) { 10 | console.warn("You are using the legacy blobs API!\nPlease use 'blobs/v2' instead."); 11 | } 12 | 13 | export interface PathOptions { 14 | // Bounding box dimensions. 15 | size: number; 16 | 17 | // Number of points. 18 | complexity: number; 19 | 20 | // Amount of randomness. 21 | contrast: number; 22 | 23 | // Value to seed random number generator. 24 | seed?: string; 25 | } 26 | 27 | export interface BlobOptions extends PathOptions { 28 | // Fill color. 29 | color?: string; 30 | 31 | stroke?: { 32 | // Stroke color. 33 | color: string; 34 | 35 | // Stroke width. 36 | width: number; 37 | }; 38 | 39 | // Render points, handles and stroke. 40 | guides?: boolean; 41 | } 42 | 43 | // Generates an svg document string containing a randomized blob. 44 | const blobs = (options: BlobOptions): string => { 45 | return blobs.editable(options).render(); 46 | }; 47 | 48 | // Generates a randomized blob as an editable data structure which can be rendered to an svg document. 49 | blobs.editable = (options: BlobOptions): XmlElement => { 50 | if (!options) { 51 | throw new Error("no options specified"); 52 | } 53 | 54 | // Random number generator. 55 | const rgen = rand(options.seed || String(Math.random())); 56 | 57 | if (!options.size) { 58 | throw new Error("no size specified"); 59 | } 60 | 61 | if (!options.stroke && !options.color) { 62 | throw new Error("no color or stroke specified"); 63 | } 64 | 65 | if (options.complexity <= 0 || options.complexity > 1) { 66 | throw new Error("complexity out of range ]0,1]"); 67 | } 68 | 69 | if (options.contrast < 0 || options.contrast > 1) { 70 | throw new Error("contrast out of range [0,1]"); 71 | } 72 | 73 | const count = 3 + Math.floor(14 * options.complexity); 74 | const offset = (): number => (1 - 0.8 * options.contrast * rgen()) / Math.E; 75 | 76 | const points = mapPoints(genBlob(count, offset), ({curr}) => { 77 | // Scale. 78 | curr.x *= options.size; 79 | curr.y *= options.size; 80 | curr.handleIn.length *= options.size; 81 | curr.handleOut.length *= options.size; 82 | 83 | // Flip around x-axis. 84 | curr.y = options.size - curr.y; 85 | curr.handleIn.angle *= -1; 86 | curr.handleOut.angle *= -1; 87 | 88 | return curr; 89 | }); 90 | 91 | return renderEditable(points, { 92 | closed: true, 93 | width: options.size, 94 | height: options.size, 95 | fill: options.color, 96 | transform: `rotate(${rgen() * (360 / count)},${options.size / 2},${options.size / 2})`, 97 | stroke: options.stroke && options.stroke.color, 98 | strokeWidth: options.stroke && options.stroke.width, 99 | guides: options.guides, 100 | }); 101 | }; 102 | 103 | export interface XmlElement { 104 | tag: string; 105 | attributes: Record; 106 | children: XmlElement[]; 107 | render(): string; 108 | } 109 | 110 | // Shortcut to create an XmlElement without "new"; 111 | blobs.xml = (tag: string): XmlElement => new InternalXmlElement(tag); 112 | 113 | export default blobs; 114 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from "rollup-plugin-typescript2"; 2 | import { uglify } from "rollup-plugin-uglify"; 3 | import copy from "rollup-plugin-copy"; 4 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 5 | 6 | const bundles = [ 7 | { 8 | name: "blobs", 9 | entry: "public/legacy.ts", 10 | types: "public/legacy.d.ts", 11 | output: ".", 12 | }, 13 | { 14 | name: "blobs", 15 | entry: "public/legacy.ts", 16 | types: "public/legacy.d.ts", 17 | output: "v1", 18 | }, 19 | { 20 | name: "blobs2", 21 | entry: "public/blobs.ts", 22 | types: "public/blobs.d.ts", 23 | output: "v2", 24 | }, 25 | { 26 | name: "blobs2Animate", 27 | entry: "public/animate.ts", 28 | types: "public/animate.d.ts", 29 | output: "v2/animate", 30 | }, 31 | ]; 32 | 33 | export default ["es", "umd"].flatMap((format) => 34 | bundles.map((bundle) => ({ 35 | input: bundle.entry, 36 | output: { 37 | file: bundle.output + `/index${format == "es" ? ".module" : ""}.js`, 38 | format: format, 39 | name: bundle.name, 40 | sourcemap: true, 41 | }, 42 | plugins: [ 43 | nodeResolve(), 44 | typescript({ cacheRoot: "./node_modules/.cache/rpt2" }), 45 | uglify(), 46 | copy({ 47 | hook: "writeBundle", 48 | targets: [{ 49 | src: bundle.types, 50 | dest: bundle.output, 51 | rename: "index.d.ts", 52 | }], 53 | verbose: true, 54 | }), 55 | ], 56 | })) 57 | ); 58 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "example", 4 | "test", 5 | "node_modules" 6 | ], 7 | "compilerOptions": { 8 | "target": "es5", 9 | "module": "es2015", 10 | "moduleResolution": "node", 11 | "esModuleInterop": true, 12 | "lib": [ 13 | "es2018", 14 | "dom" 15 | ], 16 | "sourceMap": true, 17 | "removeComments": true, 18 | "declaration": true, 19 | "strict": true, 20 | "noImplicitAny": true, 21 | "strictNullChecks": true, 22 | "strictFunctionTypes": true, 23 | "strictPropertyInitialization": true, 24 | "alwaysStrict": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "noImplicitReturns": true, 28 | "noFallthroughCasesInSwitch": true, 29 | "allowSyntheticDefaultImports": true, 30 | "stripInternal": true 31 | } 32 | } 33 | --------------------------------------------------------------------------------