├── .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 | 
28 | 
29 | 
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 |
40 |
--------------------------------------------------------------------------------
/assets/logo-grey.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
40 |
--------------------------------------------------------------------------------
/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
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}${this.tag}>`;
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 | `.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 |
--------------------------------------------------------------------------------