Repository: g-harel/blobs Branch: master Commit: 9f4506913d4b Files: 41 Total size: 274.3 KB Directory structure: gitextract_u04v749x/ ├── .github/ │ └── workflows/ │ └── push.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── CNAME ├── LICENSE ├── README.legacy.md ├── README.md ├── 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.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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/push.yml ================================================ on: push name: on-push jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: '18.x' - run: yarn - run: yarn run build - run: yarn run test ================================================ FILE: .gitignore ================================================ coverage/ node_modules/ ~* *.js *.js.map *.d.ts .cache dist !rollup.config.js docs/*.js docs/*.svg docs/*.css ================================================ FILE: .npmignore ================================================ * !README.md !LICENSE !package.json !**/*.js !**/*.js.map !**/index.d.ts ================================================ FILE: CHANGELOG.md ================================================ # 2.3.0 - Add `CanvasCustomKeyframe` to `v2/animate` - Add `wigglePreset` to `v2/animate` # 2.2.1 - Add support for custom point-based keyframes - Add option to set custom timestamp provider - Add module support, thank you to #4 and #7 - Export `Animation` and `TimestampProvider` types from `v2/animate` # 2.2.0 - Remove added points from end keyframe after interpolation completes. - Add play/pause/playPause API for animations. # 2.1.0 - Improved type checks on user-provided data - Added `"blobs/v2/animate"` - Animate between arbitrary blob keyframes - Separate import to keep main bundle small - New demo website with animated blob transitions - Supports only canvas rendering # 2.0.1 - Fix typo in code example of README # 2.0.0 - **BREAKING** Editable SVG element creation function has moved to `blobs.xml(tagName)`. - Added `"blobs/v2"` - 30% smaller compressed size - Supports canvas rendering - Supports raw SVG path rendering # 1.1.0 - Add support for editable output # 1.0.5 - Fix assets in README on npmjs.com # 1.0.4 - Use snapshot tests to verify consistency - Ignore unnecessary files in npm tarball - Output sourcemap file - Add project logo - README content updates # 1.0.3 - Add link to demo page in the README # 1.0.2 - Make transpiled output minified - Minor changes to the README # 1.0.1 - Remove accidental dependency - Minor changes to the README # 1.0.0 - Initial release ================================================ FILE: CNAME ================================================ blobs.dev ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) Gabriel Harel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.legacy.md ================================================ The legacy API exists to preserve compatibility for users importing the package using a `script` tag. Because [unpkg.com](https://unpkg.com) serves the latest version of the package if no version is specified, I can't break backwards compatibility, even with a major release. This API also preserves a few features that could potentially still be useful to some users (guide rendering and editable svg). --- ## Install ```ts // $ npm install blobs const blobs = require("blobs"); ``` ```html ``` ## Usage ```typescript const svg = blobs(options); ``` ![](https://svgsaur.us?t=&w=5&h=32&b=fdcc56) ![](https://svgsaur.us/?t=WARNING&w=103&h=32&s=16&y=21&x=12&b=feefcd&f=arial&o=b) ![](https://svgsaur.us?t=&w=1&h=48&) _Options are **not** [sanitized](https://en.wikipedia.org/wiki/HTML_sanitization). Never trust raw user-submitted values in the options._ ## Options #### Required | Name | Type | Description | | ------------ | -------- | -------------------------------------------- | | `size` | `number` | Bounding box dimensions (in pixels) | | `complexity` | `number` | Blob complexity (number of points) | | `contrast` | `number` | Blob contrast (randomness of point position) | #### Optional | Name | Type | Default | Description | | -------------- | ---------- | ---------- | ------------------------------------- | | `color` | `string?` | `"none"` | Fill color | | `stroke` | `object?` | `...` | Stroke options | | `stroke.color` | `string` | `"none"` | Stroke color | | `stroke.width` | `number` | `0` | Stroke width (in pixels) | | `seed` | `string?` | _`random`_ | Value to seed random number generator | | `guides` | `boolean?` | `false` | Render points, handles and stroke | _Either `stroke` or `color` must be defined._ _Guides will use stroke color and width if defined. Otherwise, they default to `black` stroke with width of `1`._ ##### Example Options Object ```typescript const options = { size: 600, complexity: 0.2, contrast: 0.4, color: "#ec576b", stroke: { width: 0, color: "black", }, guides: false, seed: "1234", }; ``` ## Advanced If you need to edit the output svg for your use case, blobs also allows for _editable_ output. ```typescript const editableSvg = blobs.editable(options); ``` The output of this function is a data structure that represents a nested svg document. This structure can be changed and rendered to a string using its `render` function. ```typescript editableSvg.attributes.width = 1000; const svg = editableSvg.render(); ``` New elements can be added anywhere in the hierarchy. ```typescript const xmlChild = blobs.xml("path"); xmlChild.attributes.stroke = "red"; // ... editableSvg.children.push(xmlChild); ``` ================================================ FILE: README.md ================================================

Legacy documentation

## Install ```bash $ npm install blobs ``` ```ts import * as blobs2 from "blobs/v2"; ``` ```ts import * as blobs2Animate from "blobs/v2/animate"; ```

OR

```html ``` ```html ``` ## SVG Path ```js const svgPath = blobs2.svgPath({ seed: Math.random(), extraPoints: 8, randomness: 4, size: 256, }); doSomething(svgPath); ``` ## SVG ```js const svgString = blobs2.svg( { seed: Math.random(), extraPoints: 8, randomness: 4, size: 256, }, { fill: "white", // 🚨 NOT SANITIZED stroke: "black", // 🚨 NOT SANITIZED strokeWidth: 4, }, ); container.innerHTML = svgString; ``` ## Canvas ```js const path = blobs2.canvasPath( { seed: Math.random(), extraPoints: 16, randomness: 2, size: 128, }, { offsetX: 16, offsetY: 32, }, ); ctx.stroke(path); ``` ## Canvas Animation ```js const ctx = /* ... */; const animation = blobs2Animate.canvasPath(); // Set up rendering loop. const renderAnimation = () => { ctx.clearRect(0, 0, width, height); ctx.fill(animation.renderFrame()); requestAnimationFrame(renderAnimation); }; requestAnimationFrame(renderAnimation); // Transition to new blob on canvas click. ctx.canvas.onclick = () => { animation.transition({ duration: 4000, timingFunction: "ease", callback: loopAnimation, blobOptions: {...}, }); }; ``` ## Canvas Wiggle ```js const ctx = /* ... */; const animation = blobs2Animate.canvasPath(); // Set up rendering loop. const renderAnimation = () => { ctx.clearRect(0, 0, width, height); ctx.fill(animation.renderFrame()); requestAnimationFrame(renderAnimation); }; requestAnimationFrame(renderAnimation); // Begin wiggle animation. blobs2Animate.wigglePreset( animation /* blobOptions= */ {...}, /* canvasOptions= */ {}, /* wiggleOptions= */ {speed: 2}, ) ``` ## Complete API ### `"blobs/v2"` ```ts export interface BlobOptions { // A given seed will always produce the same blob. // Use `Math.random()` for pseudorandom behavior. seed: string | number; // Actual number of points will be `3 + extraPoints`. extraPoints: number; // Increases the amount of variation in point position. randomness: number; // Size of the bounding box. size: number; } export interface CanvasOptions { // Coordinates of top-left corner of the blob. offsetX?: number; offsetY?: number; } export const canvasPath: (blobOptions: BlobOptions, canvasOptions?: CanvasOptions) => Path2D; export interface SvgOptions { fill?: string; // Default: "#ec576b". stroke?: string; // Default: "none". strokeWidth?: number; // Default: 0. } export const svg: (blobOptions: BlobOptions, svgOptions?: SvgOptions) => string; export const svgPath: (blobOptions: BlobOptions) => string; ``` ### `"blobs/v2/animate"` ```ts interface Keyframe { // Duration of the keyframe animation in milliseconds. duration: number; // Delay before animation begins in milliseconds. // Default: 0. delay?: number; // Controls the speed of the animation over time. // Default: "linear". timingFunction?: | "linear" | "easeEnd" | "easeStart" | "ease" | "elasticEnd0" | "elasticEnd1" | "elasticEnd2" | "elasticEnd3"; // Called after keyframe end-state is reached or passed. // Called exactly once when the keyframe end-state is rendered. // Not called if the keyframe is preempted by a new transition. callback?: () => void; // Standard options, refer to "blobs/v2" documentation. canvasOptions?: { offsetX?: number; offsetY?: number; }; } export interface CanvasKeyframe extends Keyframe { // Standard options, refer to "blobs/v2" documentation. blobOptions: { seed: number | string; randomness: number; extraPoints: number; size: number; }; } export interface CanvasCustomKeyframe extends Keyframe { // List of point coordinates that produce a single, closed shape. points: Point[]; } export interface Animation { // Renders the current state of the animation. renderFrame: () => Path2D; // Renders the current state of the animation as points. renderPoints: () => Point[]; // Immediately begin animating through the given keyframes. // Non-rendered keyframes from previous transitions are cancelled. transition: (...keyframes: (CanvasKeyframe | CanvasCustomKeyframe)[]) => void; // Resume a paused animation. Has no effect if already playing. play: () => void; // Pause a playing animation. Has no effect if already paused. pause: () => void; // Toggle between playing and pausing the animation. playPause: () => void; } // Function that returns the current timestamp. This value will be used for all // duration/delay values and will be used to interpolate between keyframes. It // must produce values increasing in size. // Default: `Date.now`. export interface TimestampProvider { (): number; } export const canvasPath: (timestampProvider?: TimestampProvider) => Animation; export interface WiggleOptions { // Speed of the wiggle movement. Higher is faster. speed: number; // Length of the transition from the current state to the wiggle blob. // Default: 0 initialTransition?: number; } // Preset animation that produces natural-looking random movement. // The wiggle animation will continue indefinitely until the next transition. export const wigglePreset = ( animation: Animation, blobOptions: BlobOptions, canvasOptions: CanvasOptions, wiggleOptions: WiggleOptions, ) ``` ## License [MIT](./LICENSE) ================================================ FILE: demo/content.ts ================================================ import {addCanvas, addTitle, colors, sizes} from "./internal/layout"; import { calcBouncePercentage, drawClosed, drawHandles, drawLine, drawOpen, drawPoint, forceStyles, point, tempStyles, } from "./internal/canvas"; import { coordPoint, deg, distance, expandHandle, forPoints, mapPoints, mod, shift, split, splitLine, } from "../internal/util"; import {timingFunctions} from "../internal/animate/timing"; import {Coord, Point} from "../internal/types"; import {rand} from "../internal/rand"; import {genFromOptions, smoothBlob} from "../internal/gen"; import {BlobOptions} from "../public/blobs"; import {interpolateBetween, interpolateBetweenSmooth} from "../internal/animate/interpolate"; import {divide} from "../internal/animate/prepare"; import {statefulAnimationGenerator} from "../internal/animate/state"; import {CanvasKeyframe, canvasPath, wigglePreset} from "../public/animate"; const makePoly = (pointCount: number, radius: number, center: Coord): Point[] => { const angle = (2 * Math.PI) / pointCount; const points: Point[] = []; const nullHandle = {angle: 0, length: 0}; for (let i = 0; i < pointCount; i++) { const coord = expandHandle(center, {angle: i * angle, length: radius}); points.push({...coord, handleIn: nullHandle, handleOut: nullHandle}); } return points; }; const centeredBlob = (options: BlobOptions, center: Coord): Point[] => { return mapPoints(genFromOptions(options), ({curr}) => { curr.x += center.x - options.size / 2; curr.y += center.y - options.size / 2; return curr; }); }; const calcFullDetails = (percentage: number, a: Point, b: Point) => { const a0: Coord = a; const a1 = expandHandle(a, a.handleOut); const a2 = expandHandle(b, b.handleIn); const a3: Coord = b; const b0 = splitLine(percentage, a0, a1); const b1 = splitLine(percentage, a1, a2); const b2 = splitLine(percentage, a2, a3); const c0 = splitLine(percentage, b0, b1); const c1 = splitLine(percentage, b1, b2); const d0 = splitLine(percentage, c0, c1); return {a0, a1, a2, a3, b0, b1, b2, c0, c1, d0}; }; addTitle(4, "Vector graphics"); addCanvas( 1.3, // Pixelated circle. (ctx, width, height) => { const center: Coord = {x: width * 0.5, y: height * 0.5}; const gridSize = width * 0.01; const gridCountX = width / gridSize; const gridCountY = height / gridSize; // https://www.desmos.com/calculator/psohl602g5 const radius = width * 0.3; const falloff = width * 0.0015; const thickness = width * 0.01; for (let x = 0; x < gridCountX; x++) { for (let y = 0; y < gridCountY; y++) { const curr = { x: x * gridSize + gridSize / 2, y: y * gridSize + gridSize / 2, }; const d = distance(curr, center); const opacity = Math.max( 0, Math.min(1, Math.abs(thickness / (d - radius)) - falloff), ); tempStyles( ctx, () => { ctx.globalAlpha = opacity; ctx.fillStyle = colors.highlight; }, () => ctx.fillRect(x * gridSize, y * gridSize, gridSize, gridSize), ); } } return `Raster image formats encode images as a finite number of pixel values. They therefore have a maximum scale which depends on the display.`; }, // Smooth circle. (ctx, width, height) => { const pt = width * 0.01; const shapeSize = width * 0.6; const cx = width * 0.5; const cy = height * 0.5; tempStyles( ctx, () => { ctx.lineWidth = pt; ctx.strokeStyle = colors.highlight; }, () => { ctx.beginPath(); ctx.arc(cx, cy, shapeSize / 2, 0, 2 * Math.PI); ctx.stroke(); }, ); return `By contrast vector formats are defined by formulas and can scale infinitely. They are well suited for artwork with sharp lines and are used for font glyphs.`; }, ); addCanvas( 1.3, (ctx, width, height, animate) => { const startPeriod = (1 + Math.E) * 1000; const endPeriod = (1 + Math.PI) * 1000; animate((frameTime) => { const startPercentage = calcBouncePercentage( startPeriod, timingFunctions.ease, frameTime, ); const startLengthPercentage = calcBouncePercentage( startPeriod * 0.8, timingFunctions.ease, frameTime, ); const startAngle = split(startPercentage, -45, +45); const startLength = width * 0.1 + width * 0.2 * startLengthPercentage; const start = point(width * 0.2, height * 0.5, 0, 0, startAngle, startLength); const endPercentage = calcBouncePercentage(endPeriod, timingFunctions.ease, frameTime); const endLengthPercentage = calcBouncePercentage( endPeriod * 0.8, timingFunctions.ease, frameTime, ); const endAngle = split(endPercentage, 135, 225); const endLength = width * 0.1 + width * 0.2 * endLengthPercentage; const end = point(width * 0.8, height * 0.5, endAngle, endLength, 0, 0); drawOpen(ctx, start, end, true); }); return `Vector-based image formats often support Bezier curves. A cubic bezier curve is defined by four coordinates: the start/end points and corresponding "handle" points. Visually, these handles define the direction and "momentum" of the line. The curve is tangent to the handle at either of the points.`; }, (ctx, width, height, animate) => { const angleRange = 20; const lengthRange = 40; const period = 5000; const r = rand("blobs"); const ra = r(); const rb = r(); const rc = r(); const rd = r(); const wobbleHandle = ( frameTime: number, period: number, p: Point, locked: boolean, ): Point => { const angleIn = deg(p.handleIn.angle) + angleRange * (0.5 - calcBouncePercentage(period * 1.1, timingFunctions.ease, frameTime)); const lengthIn = p.handleIn.length + lengthRange * (0.5 - calcBouncePercentage(period * 0.9, timingFunctions.ease, frameTime)); const angleOut = deg(p.handleOut.angle) + angleRange * (0.5 - calcBouncePercentage(period * 0.9, timingFunctions.ease, frameTime)); const lengthOut = p.handleOut.length + lengthRange * (0.5 - calcBouncePercentage(period * 1.1, timingFunctions.ease, frameTime)); return point(p.x, p.y, angleIn, lengthIn, locked ? angleIn + 180 : angleOut, lengthOut); }; animate((frameTime) => { const a = wobbleHandle( frameTime, period / 2 + (ra * period) / 2, point(width * 0.5, height * 0.3, 210, 100, -30, 100), false, ); const b = wobbleHandle( frameTime, period / 2 + (rb * period) / 2, point(width * 0.8, height * 0.5, -90, 100, 90, 100), true, ); const c = wobbleHandle( frameTime, period / 2 + (rc * period) / 2, point(width * 0.5, height * 0.9, -30, 75, -150, 75), false, ); const d = wobbleHandle( frameTime, period / 2 + (rd * period) / 2, point(width * 0.2, height * 0.5, 90, 100, -90, 100), true, ); drawClosed(ctx, [a, b, c, d], true); }); return `Chaining curves together creates closed shapes. When the in/out handles of a point form a line, the transition is smooth, and the curve is tangent to the line.`; }, ); addCanvas(2, (ctx, width, height, animate) => { const period = Math.PI * Math.E * 1000; const start = point(width * 0.3, height * 0.8, 0, 0, -105, width * 0.32); const end = point(width * 0.7, height * 0.8, -75, width * 0.25, 0, 0); animate((frameTime) => { const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); const d = calcFullDetails(percentage, start, end); tempStyles( ctx, () => { ctx.fillStyle = colors.secondary; ctx.strokeStyle = colors.secondary; }, () => { drawLine(ctx, d.a0, d.a1, 1); drawLine(ctx, d.a1, d.a2, 1); drawLine(ctx, d.a2, d.a3, 1); drawLine(ctx, d.b0, d.b1, 1); drawLine(ctx, d.b1, d.b2, 1); drawLine(ctx, d.c0, d.c1, 1); drawPoint(ctx, d.a0, 1.3, "a0"); drawPoint(ctx, d.a1, 1.3, "a1"); drawPoint(ctx, d.a2, 1.3, "a2"); drawPoint(ctx, d.a3, 1.3, "a3"); drawPoint(ctx, d.b0, 1.3, "b0"); drawPoint(ctx, d.b1, 1.3, "b1"); drawPoint(ctx, d.b2, 1.3, "b2"); drawPoint(ctx, d.c0, 1.3, "c0"); drawPoint(ctx, d.c1, 1.3, "c1"); drawPoint(ctx, d.d0, 1.3, "d0"); }, ); tempStyles( ctx, () => (ctx.fillStyle = colors.highlight), () => drawPoint(ctx, d.d0, 3), ); drawOpen(ctx, start, end, false); }); return `Curves are rendered using the four input points (ends + handles). By connecting points a0-a3 with a line and then splitting each line by the same percentage, we've reduced the number of points by one. Repeating the same process with the new set of points until there is only one point remaining (d0) produces a single point on the line. Repeating this calculation for many different percentage values will produce a curve.

Note there is no constant relationship between the percentage that "drew" the point and the arc lengths before/after it. Uniform motion along the curve can only be approximated.`; }); addTitle(4, "Making a blob"); addCanvas( 1.3, (ctx, width, height, animate) => { const center: Coord = {x: width * 0.5, y: height * 0.5}; const radius = width * 0.3; const minPoints = 3; const extraPoints = 6; const pointDurationMs = 2000; animate((frameTime) => { const points = minPoints + extraPoints + (extraPoints / 2) * Math.sin(frameTime / pointDurationMs); const shape = makePoly(points, radius, center); // Draw lines from center to each point.. tempStyles( ctx, () => { ctx.fillStyle = colors.secondary; ctx.strokeStyle = colors.secondary; }, () => { drawPoint(ctx, center, 2); forPoints(shape, ({curr}) => { drawLine(ctx, center, curr, 1, 2); }); }, ); drawClosed(ctx, shape, false); }); return `Points are first distributed evenly around the center. At this stage the points technically have handles, but since they have a length of zero, they have no effect on the shape and it looks like a polygon.`; }, (ctx, width, height, animate) => { const period = Math.PI * 1500; const center: Coord = {x: width * 0.5, y: height * 0.5}; const radius = width * 0.3; const points = 5; const randSeed = Math.random(); const randStrength = 0.5; const shape = makePoly(points, radius, center); animate((frameTime) => { const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); const rgen = rand(randSeed + Math.floor(frameTime / period) + ""); // Draw original shape. tempStyles( ctx, () => { ctx.fillStyle = colors.secondary; ctx.strokeStyle = colors.secondary; }, () => { drawPoint(ctx, center, 2); forPoints(shape, ({curr, next}) => { drawLine(ctx, curr, next(), 1, 2); }); }, ); // Draw randomly shifted shape. const shiftedShape = shape.map( (p): Point => { const randOffset = percentage * (randStrength * rgen() - randStrength / 2); return coordPoint(splitLine(randOffset, p, center)); }, ); drawClosed(ctx, shiftedShape, true); }); return `Points are then randomly moved further or closer to the center. Using a seeded random number generator allows repeatable "randomness" whenever the blob is generated at a different time or place.`; }, ); addCanvas( 1.3, (ctx, width, height, animate) => { const options: BlobOptions = { extraPoints: 2, randomness: 6, seed: "random", size: width * 0.7, }; const center: Coord = {x: width * 0.5, y: height * 0.5}; const interval = 2000; const blob = centeredBlob(options, center); const handles = mapPoints(blob, ({curr: p}) => { p.handleIn.length = 150; p.handleOut.length = 150; return p; }); const polyBlob = blob.map(coordPoint); const pointCount = polyBlob.length; animate((frameTime) => { const activeIndex = Math.floor(frameTime / interval) % pointCount; const opacity = Math.abs(Math.sin((frameTime * Math.PI) / interval)); tempStyles( ctx, () => { ctx.strokeStyle = colors.secondary; ctx.globalAlpha = opacity; }, () => { forPoints(polyBlob, ({prev, next, index}) => { if (index !== activeIndex) return; drawLine(ctx, prev(), next(), 1, 2); }); forPoints(handles, ({curr, index}) => { if (index !== activeIndex) return; drawHandles(ctx, curr, 1); }); }, ); tempStyles( ctx, () => { ctx.fillStyle = colors.secondary; }, () => { drawPoint(ctx, center, 2); }, ); drawClosed(ctx, polyBlob, false); }); return `The angle of the handles for each point is parallel with the imaginary line stretching between its neighbors. Even when they have length zero, the angle of the handles can still be calculated.`; }, (ctx, width, height, animate) => { const period = Math.PI * 1500; const options: BlobOptions = { extraPoints: 2, randomness: 6, seed: "random", size: width * 0.7, }; const center: Coord = {x: width * 0.5, y: height * 0.5}; const blob = centeredBlob(options, center); animate((frameTime) => { const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); // Draw original blob. tempStyles( ctx, () => { ctx.fillStyle = colors.secondary; ctx.strokeStyle = colors.secondary; }, () => { drawPoint(ctx, center, 2); forPoints(blob, ({curr, next}) => { drawLine(ctx, curr, next(), 1, 2); }); }, ); // Draw animated blob. const animatedBlob = mapPoints(blob, ({curr}) => { curr.handleIn.length *= percentage; curr.handleOut.length *= percentage; return curr; }); drawClosed(ctx, animatedBlob, true); }); return `The blob is then made smooth by extending the handles. The exact length depends on the distance between the given point and it's next neighbor. This value is multiplied by a ratio that would roughly produce a circle if the points had not been randomly moved.`; }, ); addTitle(4, "Interpolating between blobs"); addCanvas(2, (ctx, width, height, animate) => { const period = Math.PI * 1000; const center: Coord = {x: width * 0.5, y: height * 0.5}; const fadeSpeed = 10; const fadeLead = 0.05; const fadeFloor = 0.2; const blobA = centeredBlob( { extraPoints: 3, randomness: 6, seed: "12345", size: height * 0.8, }, center, ); const blobB = centeredBlob( { extraPoints: 3, randomness: 6, seed: "abc", size: height * 0.8, }, center, ); animate((frameTime) => { const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); const shiftedFrameTime = frameTime + period * fadeLead; const shiftedPercentage = calcBouncePercentage( period, timingFunctions.ease, shiftedFrameTime, ); const shiftedPeriodPercentage = mod(shiftedFrameTime, period) / period; forceStyles(ctx, () => { const {pt} = sizes(); ctx.fillStyle = "transparent"; ctx.lineWidth = pt; ctx.strokeStyle = colors.secondary; ctx.setLineDash([2 * pt]); if (shiftedPeriodPercentage > 0.5) { ctx.globalAlpha = fadeFloor + fadeSpeed * (1 - shiftedPercentage); drawClosed(ctx, blobA, false); ctx.globalAlpha = fadeFloor; drawClosed(ctx, blobB, false); } else { ctx.globalAlpha = fadeFloor + fadeSpeed * shiftedPercentage; drawClosed(ctx, blobB, false); ctx.globalAlpha = fadeFloor; drawClosed(ctx, blobA, false); } }); drawClosed(ctx, interpolateBetween(percentage, blobA, blobB), true); }); return `The simplest way to interpolate between blobs would be to move points 0-N from their position in the start blob to their position in the end blob. The problem with this approach is that it doesn't allow for all blob to map to all blobs. Specifically it would only be possible to animate between blobs that have the same number of points. This means something more generic is required.`; }); addCanvas( 1.3, (ctx, width, height, animate) => { const center: Coord = {x: width * 0.5, y: height * 0.5}; const maxExtraPoints = 7; const period = maxExtraPoints * Math.PI * 300; const {pt} = sizes(); const blob = centeredBlob( { extraPoints: 0, randomness: 6, seed: "flip", size: height * 0.9, }, center, ); animate((frameTime) => { const percentage = mod(frameTime, period) / period; const extraPoints = Math.floor(percentage * (maxExtraPoints + 1)); drawClosed(ctx, divide(extraPoints + blob.length, blob), true); forPoints(blob, ({curr}) => { ctx.beginPath(); ctx.arc(curr.x, curr.y, pt * 6, 0, 2 * Math.PI); tempStyles( ctx, () => { ctx.strokeStyle = colors.secondary; ctx.lineWidth = pt; }, () => { ctx.stroke(); }, ); }); }); return `The first step to prepare animation is to make the number of points between the start and end shapes equal. This is done by adding points to the shape with least points until they are both equal.

For best animation quality it is important that these points are as evenly distributed as possible all around the shape so this is not a recursive algorithm.`; }, (ctx, width, height, animate) => { const period = Math.PI ** Math.E * 1000; const start = point(width * 0.1, height * 0.6, 0, 0, -45, width * 0.5); const end = point(width * 0.9, height * 0.6, 160, width * 0.3, 0, 0); animate((frameTime) => { const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); const d = calcFullDetails(percentage, start, end); tempStyles( ctx, () => { ctx.fillStyle = colors.secondary; ctx.strokeStyle = colors.secondary; }, () => { drawLine(ctx, d.a0, d.a1, 1); drawLine(ctx, d.a1, d.a2, 1, 2); drawLine(ctx, d.a2, d.a3, 1); drawLine(ctx, d.b0, d.b1, 1, 2); drawLine(ctx, d.b1, d.b2, 1, 2); drawPoint(ctx, d.a0, 1.3, "a0"); drawPoint(ctx, d.a1, 1.3, "a1"); drawPoint(ctx, d.a2, 1.3, "a2"); drawPoint(ctx, d.a3, 1.3, "a3"); drawPoint(ctx, d.b1, 1.3, "b1"); }, ); forceStyles(ctx, () => { const {pt} = sizes(); ctx.fillStyle = colors.secondary; ctx.strokeStyle = colors.secondary; ctx.lineWidth = pt; drawOpen(ctx, start, end, false); }); tempStyles( ctx, () => { ctx.fillStyle = colors.highlight; ctx.strokeStyle = colors.highlight; }, () => { drawLine(ctx, d.c0, d.c1, 1); drawLine(ctx, d.a0, d.b0, 1); drawLine(ctx, d.a3, d.b2, 1); drawPoint(ctx, d.b0, 1.3, "b0"); drawPoint(ctx, d.b2, 1.3, "b2"); drawPoint(ctx, d.c0, 1.3, "c0"); drawPoint(ctx, d.c1, 1.3, "c1"); }, ); tempStyles( ctx, () => (ctx.fillStyle = colors.highlight), () => drawPoint(ctx, d.d0, 1.3, "d0"), ); }); return `It is only possible to reliably add points to a blob because attempting to remove points without modifying the shape is almost never possible and is expensive to compute.

Adding a point is done using the line-drawing geometry. In this example "d0" is the new point with its handles being "c0" and "c1". The original points get new handles "b0" and "b2"`; }, ); addCanvas( 1.3, (ctx, width, height, animate) => { const period = (Math.E / Math.PI) * 1000; const center: Coord = {x: width * 0.5, y: height * 0.5}; const blob = centeredBlob( { extraPoints: 3, randomness: 6, seed: "shift", size: height * 0.9, }, center, ); const shiftedBlob = shift(1, blob); let prev = 0; let count = 0; animate((frameTime) => { const animationTime = mod(frameTime, period); const percentage = timingFunctions.ease(mod(animationTime, period) / period); // Count animation loops. if (percentage < prev) count++; prev = percentage; // Draw lines points are travelling. tempStyles( ctx, () => { ctx.fillStyle = colors.secondary; ctx.strokeStyle = colors.secondary; }, () => { drawPoint(ctx, center, 2); forPoints(blob, ({curr, next}) => { drawLine(ctx, curr, next(), 1, 2); }); }, ); // Pause in-place every other animation loop. if (count % 2 === 0) { drawClosed(ctx, interpolateBetweenSmooth(2, percentage, blob, shiftedBlob), true); } else { drawClosed(ctx, blob, true); } }); return `Once both shapes have the same amount of points, an ordering of points which reduces the total amount of distance traveled by the points during the transition needs to be selected. Because the shapes are closed, points can be shifted by any amount without visually affecting the shape.`; }, (ctx, width, height, animate) => { const period = Math.PI * Math.E * 1000; const center: Coord = {x: width * 0.5, y: height * 0.5}; const blob = centeredBlob( { extraPoints: 3, randomness: 6, seed: "flip", size: height * 0.9, }, center, ); const reversedBlob = mapPoints(blob, ({curr}) => { const temp = curr.handleIn; curr.handleIn = curr.handleOut; curr.handleOut = temp; return curr; }); reversedBlob.reverse(); animate((frameTime) => { const percentage = calcBouncePercentage(period, timingFunctions.ease, frameTime); forceStyles(ctx, () => { const {pt} = sizes(); ctx.fillStyle = "transparent"; ctx.lineWidth = pt; ctx.strokeStyle = colors.secondary; ctx.setLineDash([2 * pt]); drawClosed(ctx, blob, false); }); drawClosed(ctx, interpolateBetweenSmooth(2, percentage, blob, reversedBlob), true); }); return `Points can also be reversed without visually affecting the shape. Then, again can be shifted all around. Although reversed ordering doesn't change the shape, it has a dramatic effect on the animation as it makes the loop flip over itself.

In total there are 2 * num_points different orderings of the points that can work for transition purposes.`; }, ); addCanvas( 1.3, (ctx, width, height) => { // Only animate in the most recent painter call. const animationID = Math.random(); const wasReplaced = () => (ctx.canvas as any).animationID !== animationID; const period = Math.PI * 1000; const center: Coord = {x: width * 0.5, y: height * 0.5}; const size = Math.min(width, height) * 0.8; const canvasBlobGenerator = (keyframe: CanvasKeyframe): Point[] => { return mapPoints(genFromOptions(keyframe.blobOptions), ({curr}) => { curr.x += center.x - size / 2; curr.y += center.y - size / 2; return curr; }); }; const animation = statefulAnimationGenerator( canvasBlobGenerator, (points: Point[]) => drawClosed(ctx, points, true), () => {}, )(Date.now); const renderFrame = () => { if (wasReplaced()) return; ctx.clearRect(0, 0, width, height); animation.renderFrame(); requestAnimationFrame(renderFrame); }; requestAnimationFrame(renderFrame); const loopAnimation = (): void => { if (wasReplaced()) return; animation.transition(genFrame()); }; let frameCount = -1; const genFrame = (overrides: Partial = {}): CanvasKeyframe => { frameCount++; return { duration: period, timingFunction: "ease", callback: loopAnimation, blobOptions: { extraPoints: Math.max(0, mod(frameCount, 4) - 1), randomness: 4, seed: Math.random(), size, }, ...overrides, }; }; animation.transition(genFrame({duration: 0})); ctx.canvas.onclick = () => { if (wasReplaced()) return; animation.playPause(); }; (ctx.canvas as any).animationID = animationID; return `The added points can be removed at the end of a transition when the target shape has been reached. However, if the animation is interrupted during interpolation there is no opportunity to clean up the extra points.`; }, (ctx, width, height, animate) => { const center: Coord = {x: width * 0.5, y: height * 0.5}; const size = Math.min(width, height) * 0.8; const drawStar = (rays: number, od: number, id: number): Point[] => { const pointCount = 2 * rays; const angle = (Math.PI * 2) / pointCount; const points: Point[] = []; for (let i = 0; i < pointCount; i++) { const pointX = Math.sin(i * angle); const pointY = Math.cos(i * angle); const distanceMultiplier = (i % 2 === 0 ? od : id) / 2; points.push({ x: center.x + pointX * distanceMultiplier, y: center.y + pointY * distanceMultiplier, handleIn: {angle: 0, length: 0}, handleOut: {angle: 0, length: 0}, }); } return points; }; const drawPolygon = (sides: number, od: number): Point[] => { const angle = (Math.PI * 2) / sides; const points: Point[] = []; for (let i = 0; i < sides; i++) { const pointX = Math.sin(i * angle); const pointY = Math.cos(i * angle); const distanceMultiplier = od / 2; points.push({ x: center.x + pointX * distanceMultiplier, y: center.y + pointY * distanceMultiplier, handleIn: {angle: 0, length: 0}, handleOut: {angle: 0, length: 0}, }); } return points; }; const shapes = [ drawStar(8, size, size * 0.7), smoothBlob(drawPolygon(3, size)), smoothBlob(drawStar(10, size, size * 0.9)), drawPolygon(4, size), smoothBlob(drawStar(3, size, size * 0.6)), ]; const animation = canvasPath(); const genFrame = (index: number) => () => { animation.transition({ points: shapes[index % shapes.length], duration: 3000, delay: 1000, timingFunction: "ease", callback: genFrame(index + 1), }); }; animation.transition({ points: shapes[0], duration: 0, callback: genFrame(1), }); animate(() => { drawClosed(ctx, animation.renderPoints(), true); }); return `Putting all these pieces together, the blob transition library can also be used to tween between non-blob shapes. The more detail a shape has, the more unconvincing the animation will look. In these cases, manually creating in-between frames can be a helpful tool.`; }, ); addTitle(4, "Gooeyness"); addCanvas( 1.3, (ctx, width, height, animate) => { const size = Math.min(width, height) * 0.8; const center: Coord = {x: (width - size) * 0.5, y: (height - size) * 0.5}; const animation = canvasPath(); const genFrame = (duration: number) => { animation.transition({ duration: duration, blobOptions: { extraPoints: 2, randomness: 3, seed: Math.random(), size, }, callback: () => genFrame(3000), timingFunction: "ease", canvasOptions: {offsetX: center.x, offsetY: center.y}, }); }; genFrame(0); animate(() => { drawClosed(ctx, animation.renderPoints(), true); }); return `This library uses the keyframe model to define animations. This is a flexible approach, but it does not lend itself well to the kind of gooey blob shapes invite.

When looking at this animation, you may be able to notice the rhythm of the keyframes where the points start moving and stop moving at the same time.`; }, (ctx, width, height, animate) => { const size = Math.min(width, height) * 0.8; const center: Coord = {x: width * 0.5, y: height * 0.5}; const animation = canvasPath(); wigglePreset( animation, { extraPoints: 2, randomness: 3, seed: Math.random(), size, }, { offsetX: center.x - size / 2, offsetY: center.y - size / 2, }, { speed: 2, }, ); animate(() => { drawClosed(ctx, animation.renderPoints(), true); }); return `In addition to the keyframe API, there is now also pre-built preset which produces a gooey animation without much effort and much prettier results.

This approach uses a noise field instead of random numbers to move individual points around continuously and independently. Repeated calls to a noise-field-powered random number generator will produce self-similar results.`; }, ); ================================================ FILE: demo/example.ts ================================================ import {CanvasKeyframe, canvasPath, wigglePreset} from "../public/animate"; import {drawHandles, drawPoint} from "./internal/canvas"; import {isDebug} from "./internal/debug"; import {colors} from "./internal/layout"; // Fetch reference to example container. const exampleContainer = document.querySelector(".example")!; const canvas = document.createElement("canvas")!; exampleContainer.appendChild(canvas); let size = 0; const resize = () => { // Set blob size relative to window, but limit to 600. const rawSize = Math.min(600, Math.min(window.innerWidth - 64, window.innerHeight / 2)); canvas.style.width = `${rawSize}px`; canvas.style.height = `${rawSize}px`; // Scale resolution to take into account device pixel ratio. size = rawSize * (window.devicePixelRatio || 1); canvas.width = size; canvas.height = size; }; // Set blob color and set context to erase intersection of content. const ctx = canvas.getContext("2d")!; // Create animation and draw its frames in `requestAnimationFrame` callbacks. const animation = canvasPath(); const renderFrame = () => { ctx.clearRect(0, 0, size, size); ctx.fillStyle = colors.highlight; ctx.strokeStyle = colors.highlight; if (isDebug()) { const points = animation.renderPoints(); for (const point of points) { drawPoint(ctx, point, 2); drawHandles(ctx, point, 1); } } ctx.fill(animation.renderFrame()); requestAnimationFrame(renderFrame); }; requestAnimationFrame(renderFrame); // Extra points that increases when blob gets clicked. let extraPoints = 0; const genWiggle = (transition: number) => { wigglePreset( animation, { extraPoints: 3 + extraPoints, randomness: 1.5, seed: Math.random(), size, }, {}, {speed: 2, initialTransition: transition}, ); }; // Generate a keyframe with overridable default values. const genFrame = (overrides: any = {}): CanvasKeyframe => { const blobOptions = { extraPoints: 3 + extraPoints, randomness: 4, seed: Math.random(), size, ...overrides.blobOptions, }; return { duration: 4000, timingFunction: "ease", callback: loopAnimation, ...overrides, blobOptions, }; }; // Callback for every frame which starts transition to a new frame. const loopAnimation = (): void => { extraPoints = 0; genWiggle(5000); }; // Quickly animate to a new frame when canvas is clicked. canvas.onclick = () => { extraPoints++; animation.transition( genFrame({ duration: 400, timingFunction: "elasticEnd0", blobOptions: {extraPoints}, }), ); }; // Immediately show a new frame. window.addEventListener("load", () => { resize(); genWiggle(0); }); // Make blob a circle while window is being resized. window.addEventListener("resize", () => { resize(); const tempSize = (size * 6) / 7; animation.transition( genFrame({ duration: 100, timingFunction: "easeEnd", blobOptions: { extraPoints: 0, randomness: 0, seed: "", size: tempSize, }, canvasOptions: { offsetX: (size - tempSize) / 2, offsetY: (size - tempSize) / 2, }, }), ); }); ================================================ FILE: demo/index.html ================================================
How it works
================================================ FILE: demo/internal/canvas.ts ================================================ import {TimingFunc} from "../../internal/animate/timing"; import {Coord, Point} from "../../internal/types"; import {expandHandle, forPoints, mod, rad} from "../../internal/util"; import {isDebug} from "../internal/debug"; import {colors, sizes} from "../internal/layout"; export const forceStyles = (ctx: CanvasRenderingContext2D, fn: () => void) => { if (!(ctx as any).forcedStyles) (ctx as any).forcedStyles = 0; (ctx as any).forcedStyles++; ctx.save(); fn(); ctx.restore(); (ctx as any).forcedStyles--; }; export const tempStyles = (ctx: CanvasRenderingContext2D, style: () => void, fn: () => void) => { if ((ctx as any).forcedStyles > 0) { fn(); } else { ctx.save(); style(); fn(); ctx.restore(); } }; export const rotateAround = ( options: { ctx: CanvasRenderingContext2D; angle: number; cx: number; cy: number; }, fn: () => void, ) => { tempStyles( options.ctx, () => { options.ctx.translate(options.cx, options.cy); options.ctx.rotate(options.angle); }, () => { if (isDebug()) { tempStyles( options.ctx, () => (options.ctx.fillStyle = colors.debug), () => { options.ctx.fillRect(0, -4, 1, 8); options.ctx.fillRect(-32, 0, 64, 1); }, ); } fn(); }, ); }; export const point = ( x: number, y: number, ia: number, il: number, oa: number, ol: number, ): Point => { return { x: x, y: y, handleIn: {angle: rad(ia), length: il}, handleOut: {angle: rad(oa), length: ol}, }; }; export const drawPoint = ( ctx: CanvasRenderingContext2D, coord: Coord, size: number, label?: string, ) => { const radius = sizes().pt * size; const pointPath = new Path2D(); pointPath.arc(coord.x, coord.y, radius, 0, 2 * Math.PI); ctx.fill(pointPath); if (label) { tempStyles( ctx, () => (ctx.font = `${6 * radius}px monospace`), () => ctx.fillText(label, coord.x + 2 * radius, coord.y - radius), ); } }; export const drawLine = ( ctx: CanvasRenderingContext2D, a: Coord, b: Coord, size: number, dash?: number, ) => { tempStyles( ctx, () => { const width = sizes().pt * size; if (dash) ctx.setLineDash([dash * width]); }, () => { const width = sizes().pt * size; const linePath = new Path2D(); linePath.moveTo(a.x, a.y); linePath.lineTo(b.x, b.y); ctx.lineWidth = width; ctx.stroke(linePath); }, ); }; export const drawClosed = (ctx: CanvasRenderingContext2D, points: Point[], handles?: boolean) => { forPoints(points, ({curr, next}) => { drawOpen(ctx, curr, next(), handles); }); }; export const drawDebugClosed = (ctx: CanvasRenderingContext2D, points: Point[], size: number) => { forPoints(points, ({curr, next: nextFn}) => { drawHandles(ctx, curr, size); const next = nextFn(); const currHandle = expandHandle(curr, curr.handleIn); const nextHandle = expandHandle(curr, curr.handleOut); const curve = new Path2D(); curve.moveTo(curr.x, curr.y); curve.bezierCurveTo(currHandle.x, currHandle.y, nextHandle.x, nextHandle.y, next.x, next.y); ctx.lineWidth = sizes().pt * size * 2; ctx.stroke(curve); drawPoint(ctx, curr, size * 1.1); }); }; export const drawHandles = (ctx: CanvasRenderingContext2D, point: Point, size: number) => { const inHandle = expandHandle(point, point.handleIn); const outHandle = expandHandle(point, point.handleOut); drawLine(ctx, point, inHandle, size); drawLine(ctx, point, outHandle, size, 2); drawPoint(ctx, inHandle, size * 1.4); drawPoint(ctx, outHandle, size * 1.4); }; export const drawOpen = ( ctx: CanvasRenderingContext2D, start: Point, end: Point, handles?: boolean, ) => { const width = sizes().width; const startHandle = expandHandle(start, start.handleOut); const endHandle = expandHandle(end, end.handleIn); // Draw handles. if (handles) { tempStyles( ctx, () => { ctx.fillStyle = colors.secondary; ctx.strokeStyle = colors.secondary; }, () => { drawLine(ctx, start, startHandle, 1); drawLine(ctx, end, endHandle, 1, 2); drawPoint(ctx, startHandle, 1.4); drawPoint(ctx, endHandle, 1.4); }, ); } // Draw curve. tempStyles( ctx, () => { const lineWidth = width * 0.003; ctx.lineWidth = lineWidth; }, () => { const curve = new Path2D(); curve.moveTo(start.x, start.y); curve.bezierCurveTo( startHandle.x, startHandle.y, endHandle.x, endHandle.y, end.x, end.y, ); tempStyles( ctx, () => (ctx.strokeStyle = colors.highlight), () => ctx.stroke(curve), ); tempStyles( ctx, () => (ctx.fillStyle = colors.highlight), () => { drawPoint(ctx, start, 2); drawPoint(ctx, end, 2); }, ); }, ); }; export const calcBouncePercentage = (period: number, timingFunc: TimingFunc, frameTime: number) => { const halfPeriod = period / 2; const animationTime = mod(frameTime, period); if (animationTime <= halfPeriod) { return timingFunc(animationTime / halfPeriod); } else { return timingFunc(1 - (animationTime - halfPeriod) / halfPeriod); } }; ================================================ FILE: demo/internal/debug.ts ================================================ // If debug is initially set to false it will not be toggleable. let debug = window.location.search.includes("debug") && location.hostname === "localhost"; export const isDebug = () => debug; const debugListeners: ((debug: boolean) => void)[] = []; export const onDebugStateChange = (fn: (debug: boolean) => void) => { debugListeners.push(fn); fn(debug); }; if (debug && document.body) { const toggleButton = document.createElement("button"); toggleButton.innerHTML = "debug"; toggleButton.style.padding = "2rem"; toggleButton.style.position = "fixed"; toggleButton.style.top = "0"; toggleButton.onclick = () => { debug = !debug; for (const listener of debugListeners) { listener(debug); } }; document.body.prepend(toggleButton); } ================================================ FILE: demo/internal/layout.ts ================================================ import {tempStyles} from "./canvas"; import {isDebug, onDebugStateChange} from "./debug"; export const colors = { debug: "green", highlight: "#ec576b", secondary: "#555", }; interface Cell { aspectRatio: number; canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; painter: CellPainter; animationID: number; } export interface CellPainter { ( ctx: CanvasRenderingContext2D, width: number, height: number, animate: (painter: AnimationPainter) => void, ): string | void; } export interface AnimationPainter { (timestamp: number): void; } // Global cell state. const cells = ((window as any).cells as Cell[][]) || []; ((window as any).cells as Cell[][]) = cells; const containerElement = document.querySelector(".container"); if (!containerElement) throw "missing container"; const howItWorksElement = document.querySelector(".how-it-works"); if (!howItWorksElement) throw "missing container"; let animating = false; const reveal = () => { containerElement.classList.add("open"); howItWorksElement.classList.add("hidden"); animating = true; redraw(); }; howItWorksElement.addEventListener("click", reveal); if (document.location.hash || isDebug()) setTimeout(reveal); export const sizes = (): {width: number; pt: number} => { const sectionStyle = window.getComputedStyle( (containerElement.lastChild as any) || document.body, ); const sectionWidth = Number(sectionStyle.getPropertyValue("width").slice(0, -2)); const width = sectionWidth * window.devicePixelRatio; return {width, pt: width * 0.002}; }; const createSection = (): HTMLElement => { const numberLabel = ("000" + cells.length).substr(-3); const sectionElement = document.createElement("div"); sectionElement.classList.add("section"); sectionElement.setAttribute("id", numberLabel); containerElement.appendChild(sectionElement); const numberElement = document.createElement("a"); numberElement.classList.add("number"); numberElement.setAttribute("href", "#" + numberLabel); numberElement.appendChild(document.createTextNode(numberLabel)); sectionElement.appendChild(numberElement); return sectionElement; }; // Adds a section of text to the bottom of the layout. export const addTitle = (heading: number, text: string) => { const wrapperElement = document.createElement(`h${heading}`); wrapperElement.classList.add("title"); containerElement.appendChild(wrapperElement); const textWrapperElement = document.createElement("div"); textWrapperElement.classList.add("text"); wrapperElement.appendChild(textWrapperElement); text = text.replace("\n", " ").replace(/\s+/g, " ").trim(); const textElement = document.createTextNode(text); textWrapperElement.appendChild(textElement); }; const handleIntersection = (entries: any) => { entries.map((entry: any) => { entry.target.setAttribute("data-visible", entry.isIntersecting); }); }; // Adds a row of cells to the bottom of the layout. export const addCanvas = (aspectRatio: number, ...painters: CellPainter[]) => { const sectionElement = createSection(); if (painters.length == 0) { painters = [() => {}]; } const cellRow: Cell[] = []; for (const painter of painters) { const cellElement = document.createElement("div"); cellElement.classList.add("cell"); sectionElement.appendChild(cellElement); const canvas = document.createElement("canvas"); cellElement.appendChild(canvas); const labelElement = document.createElement("div"); labelElement.classList.add("label"); cellElement.appendChild(labelElement); const ctx = canvas.getContext("2d"); if (!ctx) throw "missing canvas context"; const cell = {aspectRatio, canvas, ctx, painter, animationID: -1}; cellRow.push(cell); new IntersectionObserver(handleIntersection, { threshold: 0.1, }).observe(canvas); } cells.push(cellRow); redraw(); }; // Lazily redraw canvas cells to match window resolution. let redrawTimeout: undefined | number = undefined; const redraw = () => { window.clearTimeout(redrawTimeout); redrawTimeout = window.setTimeout(() => { for (const cellRow of cells) { const cellWidth = sizes().width / cellRow.length; for (const cell of cellRow) { const cellHeight = cellWidth / cell.aspectRatio; // Resize canvas; cell.canvas.width = cellWidth; cell.canvas.height = cellHeight; // Draw canvas debug info. const drawDebug = () => { if (isDebug()) { tempStyles( cell.ctx, () => (cell.ctx.strokeStyle = colors.debug), () => cell.ctx.strokeRect(0, 0, cellWidth, cellHeight - 1), ); } }; drawDebug(); // Keep track of paused state. let pausedAt = 0; let pauseOffset = 0; cell.canvas.onclick = () => { if (pausedAt === 0) { pausedAt = Date.now(); } else { pauseOffset += Date.now() - pausedAt; pausedAt = 0; } }; // Cell-specific callback for providing an animation painter. const animate = (painter: AnimationPainter) => { if (!animating) return; const animationID = Math.random(); const startTime = Date.now(); cell.animationID = animationID; const drawFrame = () => { // Stop animating if cell is redrawn. if (cell.animationID !== animationID) return; const visible = cell.canvas.getAttribute("data-visible") === "true"; if (pausedAt === 0 && visible) { const frameTime = Date.now() - startTime - pauseOffset; cell.ctx.clearRect(0, 0, cellWidth, cellHeight); drawDebug(); if (isDebug()) { tempStyles( cell.ctx, () => (cell.ctx.fillStyle = colors.debug), () => cell.ctx.fillText(String(frameTime), 10, 15), ); } painter(frameTime); } requestAnimationFrame(drawFrame); }; drawFrame(); }; // Redraw canvas contents and replace label if changed. const label = cell.painter(cell.ctx, cellWidth, cellHeight, animate); if (label) { const cellElement = cell.canvas.parentElement; if (cellElement) { cellElement.style.width = `${100 / cellRow.length}%`; const labelElement = cellElement.querySelector(".label"); if (labelElement && labelElement.innerHTML !== label) { labelElement.innerHTML = ""; labelElement.innerHTML = label; } } } } } }, 100); }; window.addEventListener("load", redraw); window.addEventListener("resize", redraw); onDebugStateChange(redraw); ================================================ FILE: examples/corner-expand.html ================================================ Expand demo
================================================ FILE: index.html ================================================
How it works
================================================ FILE: internal/animate/frames.ts ================================================ import {TimingFunc, timingFunctions} from "./timing"; import {Point} from "../types"; import {prepare} from "./prepare"; import {interpolateBetween} from "./interpolate"; export interface Keyframe { delay?: number; duration: number; timingFunction?: keyof typeof timingFunctions; } export interface InternalKeyframe { id: string; timestamp: number; timingFunction: TimingFunc; initialPoints: Point[]; transitionSourceFrameIndex: number; // Synthetic keyframes are generated to represent the current state when // a new transition is begun. isSynthetic: boolean; } export interface RenderCache { [frameId: string]: { preparedEndPoints?: Point[]; preparedStartPoints?: Point[]; }; } export interface RenderInput { currentFrames: InternalKeyframe[]; timestamp: number; renderCache: RenderCache; } export interface RenderOutput { points: Point[]; lastFrameId: string | null; renderCache: RenderCache; } export interface TransitionInput extends RenderInput { newFrames: T[]; shapeGenerator: (keyframe: T) => Point[]; } export interface TransitionOutput { newFrames: InternalKeyframe[]; } const genId = (): string => { return String(Math.random()).substr(2); }; export const renderFramesAt = (input: RenderInput): RenderOutput => { const {renderCache, currentFrames} = input; if (currentFrames.length === 0) { return {renderCache, lastFrameId: null, points: []}; } // Animation freezes at the final shape if there are no more keyframes. if (currentFrames.length === 1) { const first = currentFrames[0]; return {renderCache, lastFrameId: first.id, points: first.initialPoints}; } // Find the start/end keyframes according to the timestamp. let startKeyframe = currentFrames[0]; let endKeyframe = currentFrames[1]; for (let i = 2; i < currentFrames.length; i++) { if (endKeyframe.timestamp > input.timestamp) break; startKeyframe = currentFrames[i - 1]; endKeyframe = currentFrames[i]; } // Return original end shape when past the end of the animation. const endKeyframeIsLast = endKeyframe === currentFrames[currentFrames.length - 1]; const animationIsPastEndKeyframe = endKeyframe.timestamp < input.timestamp; if (animationIsPastEndKeyframe && endKeyframeIsLast) { return { renderCache, lastFrameId: endKeyframe.id, points: endKeyframe.initialPoints, }; } // Use and cache prepared points for current interpolation. let preparedStartPoints: Point[] | undefined = renderCache[startKeyframe.id]?.preparedStartPoints; let preparedEndPoints: Point[] | undefined = renderCache[endKeyframe.id]?.preparedEndPoints; if (!preparedStartPoints || !preparedEndPoints) { [preparedStartPoints, preparedEndPoints] = prepare( startKeyframe.initialPoints, endKeyframe.initialPoints, {rawAngles: false, divideRatio: 1}, ); renderCache[startKeyframe.id] = renderCache[startKeyframe.id] || {}; renderCache[startKeyframe.id].preparedStartPoints = preparedStartPoints; renderCache[endKeyframe.id] = renderCache[endKeyframe.id] || {}; renderCache[endKeyframe.id].preparedEndPoints = preparedEndPoints; } // Calculate progress between frames as a fraction. const progress = (input.timestamp - startKeyframe.timestamp) / (endKeyframe.timestamp - startKeyframe.timestamp); // Keep progress within expected range (ex. division by 0). const clampedProgress = Math.max(0, Math.min(1, progress)); // Apply timing function of end frame. const adjustedProgress = endKeyframe.timingFunction(clampedProgress); return { renderCache, lastFrameId: clampedProgress === 1 ? endKeyframe.id : startKeyframe.id, points: interpolateBetween(adjustedProgress, preparedStartPoints, preparedEndPoints), }; }; export const transitionFrames = ( input: TransitionInput, ): TransitionOutput => { // Erase all old frames. const newInternalFrames: InternalKeyframe[] = []; // Reset animation when given no keyframes. if (input.newFrames.length === 0) { return {newFrames: newInternalFrames}; } // Add current state as initial frame. const currentState = renderFramesAt(input); if (currentState.lastFrameId === null) { // If there is currently no shape being rendered, use a point in the // center of the next frame as the initial point. const firstShape = input.shapeGenerator(input.newFrames[0]); let firstShapeCenterPoint: Point = { x: 0, y: 0, handleIn: {angle: 0, length: 0}, handleOut: {angle: 0, length: 0}, }; for (const point of firstShape) { firstShapeCenterPoint.x += point.x / firstShape.length; firstShapeCenterPoint.y += point.y / firstShape.length; } currentState.points = [firstShapeCenterPoint, firstShapeCenterPoint, firstShapeCenterPoint]; } newInternalFrames.push({ id: genId(), initialPoints: currentState.points, timestamp: input.timestamp, timingFunction: timingFunctions.linear, transitionSourceFrameIndex: -1, isSynthetic: true, }); // Generate and add new frames. let totalOffset = 0; for (let i = 0; i < input.newFrames.length; i++) { const keyframe = input.newFrames[i]; // Copy previous frame when current one has a delay. if (keyframe.delay) { totalOffset += keyframe.delay; const prevFrame = newInternalFrames[newInternalFrames.length - 1]; newInternalFrames.push({ id: genId(), initialPoints: prevFrame.initialPoints, timestamp: input.timestamp + totalOffset, timingFunction: timingFunctions.linear, transitionSourceFrameIndex: i - 1, isSynthetic: true, }); } totalOffset += keyframe.duration; newInternalFrames.push({ id: genId(), initialPoints: input.shapeGenerator(keyframe), timestamp: input.timestamp + totalOffset, timingFunction: timingFunctions[keyframe.timingFunction || "linear"], transitionSourceFrameIndex: i, isSynthetic: false, }); } return {newFrames: newInternalFrames}; }; ================================================ FILE: internal/animate/interpolate.ts ================================================ import {Point} from "../types"; import {mapPoints, mod, smooth, split, splitLine} from "../util"; // Interpolates between angles a and b. Angles are normalized to avoid unnecessary rotation. // Direction is chosen to produce the smallest possible movement. const interpolateAngle = (percentage: number, a: number, b: number): number => { const tau = Math.PI * 2; let aNorm = mod(a, tau); let bNorm = mod(b, tau); if (Math.abs(aNorm - bNorm) > Math.PI) { if (aNorm < bNorm) { aNorm += tau; } else { bNorm += tau; } } return split(percentage, aNorm, bNorm); }; // Interpolates linearly between a and b. Can only interpolate between point lists that have the // same number of points. Easing effects can be applied to the percentage given to this function. // Percentages outside the 0-1 range are supported. export const interpolateBetween = (percentage: number, a: Point[], b: Point[]): Point[] => { if (a.length !== b.length) { throw new Error("must have equal number of points"); } // Clamped range for use in values that could look incorrect otherwise. // ex. Handles that invert if their value goes negative (creates loops at corners). const clamped = Math.min(1, Math.max(0, percentage)); const points: Point[] = []; for (let i = 0; i < a.length; i++) { points.push({ ...splitLine(percentage, a[i], b[i]), handleIn: { angle: interpolateAngle(percentage, a[i].handleIn.angle, b[i].handleIn.angle), length: split(clamped, a[i].handleIn.length, b[i].handleIn.length), }, handleOut: { angle: interpolateAngle(percentage, a[i].handleOut.angle, b[i].handleOut.angle), length: split(clamped, a[i].handleOut.length, b[i].handleOut.length), }, }); } return points; }; // Interpolates between a and b while applying a smoothing effect. Smoothing effect's strength is // relative to how far away the percentage is from either 0 or 1. It is strongest in the middle of // the animation (percentage = 0.5) or when bounds are exceeded (percentage = 1.8). export const interpolateBetweenSmooth = ( strength: number, percentage: number, a: Point[], b: Point[], ): Point[] => { strength *= Math.min(1, Math.min(Math.abs(0 - percentage), Math.abs(1 - percentage))); const interpolated = interpolateBetween(percentage, a, b); const smoothed = smooth(interpolated, Math.sqrt(strength + 0.25) / 3); return mapPoints(interpolated, ({index, curr}) => { const sp = smoothed[index]; curr.handleIn.angle = interpolateAngle(strength, curr.handleIn.angle, sp.handleIn.angle); curr.handleIn.length = split(strength, curr.handleIn.length, sp.handleIn.length); curr.handleOut.angle = interpolateAngle(strength, curr.handleOut.angle, sp.handleOut.angle); curr.handleOut.length = split(strength, curr.handleOut.length, sp.handleOut.length); return curr; }); }; ================================================ FILE: internal/animate/prepare.ts ================================================ import { angleOf, coordEqual, distance, forPoints, insertCount, length, mapPoints, mod, reverse, shift, } from "../util"; import {Point} from "../types"; // Iterate through point ordering possibilities to find an option with the least // distance between points. Also reverse the list to try and optimize. const optimizeOrder = (a: Point[], b: Point[]): Point[] => { const count = a.length; let minTotal = Infinity; let minOffset = 0; let minOffsetBase: Point[] = []; const setMinOffset = (points: Point[]) => { for (let i = 0; i < count; i++) { let total = 0; for (let j = 0; j < count; j++) { total += (100 * distance(a[j], points[mod(j + i, count)])) ** 2; if (total > minTotal) break; } if (total <= minTotal) { minTotal = total; minOffset = i; minOffsetBase = points; } } }; setMinOffset(b); setMinOffset(reverse(b)); return shift(minOffset, minOffsetBase); }; // Modify the input shape to be the exact same path visually, but with // additional points so that the total number of points is "count". export const divide = (count: number, points: Point[]): Point[] => { if (points.length < 3) throw new Error("not enough points"); if (count < points.length) throw new Error("cannot remove points"); if (count === points.length) return points.slice(); const lengths: number[] = []; forPoints(points, ({curr, next}) => { lengths.push(length(curr, next())); }); const divisors = divideLengths(lengths, count - points.length); const out: Point[] = []; for (let i = 0; i < points.length; i++) { const curr: Point = out[out.length - 1] || points[i]; const next = points[mod(i + 1, points.length)]; out.pop(); out.push(...insertCount(divisors[i], curr, next)); } // Remove redundant last point to produce closed shape, but use its incoming \ // handle for the first point. const last = out.pop(); out[0] = Object.assign({}, out[0], {handleIn: last!.handleIn}); return out; }; // If point has no handle and is on top of the point before or after it, use the // angle of the fixer shape's point at the same index. This is especially useful // when all the points of the initial shape are concentrated on the same // coordinates and "expand" into the target shape. const fixAnglesWith = (fixee: Point[], fixer: Point[]): Point[] => { return mapPoints(fixee, ({index, curr, prev, next}) => { if (curr.handleIn.length === 0 && coordEqual(prev(), curr)) { curr.handleIn.angle = fixer[index].handleIn.angle; } if (curr.handleOut.length === 0 && coordEqual(next(), curr)) { curr.handleOut.angle = fixer[index].handleOut.angle; } return curr; }); }; // If point has no handle, use angle between before and after points. const fixAnglesSelf = (points: Point[]): Point[] => { return mapPoints(points, ({curr, prev, next}) => { const angle = angleOf(prev(), next()); if (curr.handleIn.length === 0) { curr.handleIn.angle = angle + Math.PI; } if (curr.handleOut.length === 0) { curr.handleOut.angle = angle; } return curr; }); }; // Split the input lengths into smaller segments to add the target amount of // lengths while minimizing the standard deviation of the list of lengths. const divideLengths = (lengths: number[], add: number): number[] => { const divisors = lengths.map(() => 1); const sizes = lengths.slice(); for (let i = 0; i < add; i++) { let maxSizeIndex = 0; for (let j = 1; j < sizes.length; j++) { if (sizes[j] > sizes[maxSizeIndex]) { maxSizeIndex = j; continue; } if (sizes[j] === sizes[maxSizeIndex]) { if (lengths[j] > lengths[maxSizeIndex]) { maxSizeIndex = j; } } } divisors[maxSizeIndex]++; sizes[maxSizeIndex] = lengths[maxSizeIndex] / divisors[maxSizeIndex]; } return divisors; }; export const prepare = ( a: Point[], b: Point[], options: {rawAngles: boolean; divideRatio: number}, ): [Point[], Point[]] => { const pointCount = options.divideRatio * Math.max(a.length, b.length); const aNorm = divide(pointCount, a); const bNorm = divide(pointCount, b); const bOpt = optimizeOrder(aNorm, bNorm); return [ options.rawAngles ? aNorm : fixAnglesWith(fixAnglesSelf(aNorm), bOpt), options.rawAngles ? bOpt : fixAnglesWith(fixAnglesSelf(bOpt), aNorm), ]; }; ================================================ FILE: internal/animate/state.ts ================================================ import {Point} from "../types"; import {InternalKeyframe, Keyframe, RenderCache, renderFramesAt, transitionFrames} from "./frames"; interface CallbackKeyframe extends Keyframe { callback?: () => void; } interface FrameCallbackStore { [frameId: string]: () => void; } export const statefulAnimationGenerator = ( generator: (keyframe: K) => Point[], renderer: (points: Point[]) => T, checker: (keyframe: K, index: number) => void, ) => (timestampProvider: () => number) => { let internalFrames: InternalKeyframe[] = []; let renderCache: RenderCache = {}; let frameCallbackStore: FrameCallbackStore = {}; // Keep track of paused state. let pausedAt = 0; let pauseOffset = 0; const getAnimationTimestamp = () => timestampProvider() - pauseOffset; const isPaused = () => pausedAt !== 0; const play = () => { if (!isPaused()) return; pauseOffset += getAnimationTimestamp() - pausedAt; pausedAt = 0; }; const pause = () => { if (isPaused()) return; pausedAt = getAnimationTimestamp(); }; const playPause = () => { if (isPaused()) { play(); } else { pause(); } }; const renderPoints = (): Point[] => { const renderOutput = renderFramesAt({ renderCache: renderCache, timestamp: isPaused() ? pausedAt : getAnimationTimestamp(), currentFrames: internalFrames, }); // Update render cache with returned value. renderCache = renderOutput.renderCache; // Invoke callback if defined and the first time the frame is reached. if (renderOutput.lastFrameId && frameCallbackStore[renderOutput.lastFrameId]) { setTimeout(frameCallbackStore[renderOutput.lastFrameId]); delete frameCallbackStore[renderOutput.lastFrameId]; } return renderOutput.points; }; const renderFrame = (): T => { return renderer(renderPoints()); }; const transition = (...keyframes: K[]) => { // Make sure frame info is valid. for (let i = 0; i < keyframes.length; i++) { checker(keyframes[i], i); } const transitionOutput = transitionFrames({ renderCache: renderCache, timestamp: getAnimationTimestamp(), currentFrames: internalFrames, newFrames: keyframes, shapeGenerator: generator, }); // Reset internal state.. internalFrames = transitionOutput.newFrames; frameCallbackStore = {}; renderCache = {}; // Populate callback store using returned frame ids. for (const newFrame of internalFrames) { if (newFrame.isSynthetic) continue; const {callback} = keyframes[newFrame.transitionSourceFrameIndex]; if (callback) frameCallbackStore[newFrame.id] = callback; } }; return {renderFrame, renderPoints, transition, play, pause, playPause}; }; ================================================ FILE: internal/animate/testing/index.html ================================================
================================================ FILE: internal/animate/testing/script.ts ================================================ import {interpolateBetweenSmooth} from "../interpolate"; import {divide, prepare} from "../prepare"; import {Coord, Point} from "../../types"; import {forPoints, insertAt, insertCount, length, mapPoints, mod, rad} from "../../util"; import {clear, drawClosed, drawInfo} from "../../render/canvas"; import {genBlob, genFromOptions} from "../../gen"; import {rand} from "../../rand"; import * as blobs2 from "../../../public/blobs"; import * as blobs2Animate from "../../../public/animate"; let animationSpeed = 2; let animationStart = 0.3; let debug = true; let size = 1300; const canvas = document.createElement("canvas"); document.body.appendChild(canvas); canvas.height = size; canvas.width = size; const temp = canvas.getContext("2d"); if (temp === null) throw new Error("context is null"); const ctx = temp; const toggle = document.getElementById("toggle"); if (toggle === null) throw new Error("no toggle"); toggle.onclick = () => (debug = !debug); const interact = document.getElementById("interact") as any; if (toggle === null) throw new Error("no interact"); const addInteraction = (newOnclick: () => void) => { const oldOnclick = interact.onclick || (() => 0); interact.onclick = () => { oldOnclick(); newOnclick(); }; }; const point = (x: number, y: number, ia: number, il: number, oa: number, ol: number): Point => { return { x: x * size, y: y * size, handleIn: {angle: rad(ia), length: il * size}, handleOut: {angle: rad(oa), length: ol * size}, }; }; const testSplitAt = (percentage: number) => { let points: Point[] = [ point(0.15, 0.15, 135, 0.1, 315, 0.2), point(0.85, 0.15, 225, 0.1, 45, 0.2), point(0.85, 0.85, 315, 0.1, 135, 0.2), point(0.15, 0.85, 45, 0.1, 225, 0.2), ]; const count = points.length; const stop = 2 * count - 1; for (let i = 0; i < count; i++) { const double = i * 2; const next = mod(double + 1, stop); points.splice(double, 2, ...insertAt(percentage, points[double], points[next])); } points.splice(0, 1); let sum = 0; forPoints(points, ({curr, next}) => { sum += length(curr, next()); }); drawInfo(ctx, 1, "split at lengths sum", sum); drawClosed(ctx, debug, points); }; const testSplitBy = () => { const count = 10; for (let i = 0; i < count; i++) { drawClosed( ctx, debug, insertCount( i + 1, point(0.15, 0.2 + i * 0.06, 30, 0.04, -30, 0.04), point(0.25, 0.2 + i * 0.06, 135, 0.04, 225, 0.04), ), ); } }; const testDividePoints = () => { const count = 10; for (let i = 0; i < count; i++) { drawClosed( ctx, debug, divide(i + 3, [ point(0.3, 0.2 + i * 0.05, -10, 0.04, -45, 0.02), point(0.35, 0.2 + i * 0.05 - 0.02, 180, 0.02, 0, 0.02), point(0.4, 0.2 + i * 0.05, -135, 0.02, 170, 0.04), ]), ); } }; const testInterpolateBetween = (percentage: number) => { const a = [ point(0.3, 0.72, 135, 0.05, -45, 0.05), point(0.4, 0.72, -135, 0.05, 45, 0.05), point(0.4, 0.82, -45, 0.05, 135, 0.05), point(0.3, 0.82, 45, 0.05, 225, 0.05), ]; const b = [ point(0.35, 0.72, 180, 0, 0, 0), point(0.4, 0.77, -90, 0, 90, 0), point(0.35, 0.82, 360 * 10, 0, 180, 0), point(0.3, 0.77, 90, 0, -90, 0), ]; drawClosed(ctx, debug, loopBetween(percentage, a, b)); }; const testPrepPointsA = (percentage: number) => { const a = blob("a", 6, 0.15, {x: 0.45, y: 0.1}); const b = blob("b", 10, 0.15, {x: 0.45, y: 0.1}); drawClosed( ctx, debug, loopBetween(percentage, ...prepare(a, b, {rawAngles: false, divideRatio: 1})), ); }; const testPrepPointsB = (percentage: number) => { const a = blob("a", 8, 0.15, {x: 0.45, y: 0.25}); const b: Point[] = [ point(0.45, 0.25, 0, 0, 0, 0), point(0.6, 0.25, 0, 0, 0, 0), point(0.6, 0.4, 0, 0, 0, 0), point(0.45, 0.4, 0, 0, 0, 0), ]; drawClosed( ctx, debug, loopBetween(percentage, ...prepare(a, b, {rawAngles: false, divideRatio: 1})), ); }; const testPrepPointsC = (percentage: number) => { const a = blob("c", 8, 0.15, {x: 0.45, y: 0.45}); const b: Point[] = [ point(0.5, 0.45, 0, 0, 0, 0), point(0.55, 0.45, 0, 0, 0, 0), point(0.55, 0.5, 0, 0, 0, 0), point(0.6, 0.5, 0, 0, 0, 0), point(0.6, 0.55, 0, 0, 0, 0), point(0.55, 0.55, 0, 0, 0, 0), point(0.55, 0.6, 0, 0, 0, 0), point(0.5, 0.6, 0, 0, 0, 0), point(0.5, 0.55, 0, 0, 0, 0), point(0.45, 0.55, 0, 0, 0, 0), point(0.45, 0.5, 0, 0, 0, 0), point(0.5, 0.5, 0, 0, 0, 0), ]; drawClosed( ctx, debug, loopBetween(percentage, ...prepare(b, a, {rawAngles: false, divideRatio: 1})), ); }; const testPrepPointsD = (percentage: number) => { const a = blob("d", 8, 0.15, {x: 0.45, y: 0.65}); const b: Point[] = [ point(0.525, 0.725, 0, 0, 0, 0), point(0.525, 0.725, 0, 0, 0, 0), point(0.525, 0.725, 0, 0, 0, 0), ]; drawClosed( ctx, debug, loopBetween(percentage, ...prepare(a, b, {rawAngles: false, divideRatio: 1})), ); }; const testPrepLetters = (percentage: number) => { const a: Point[] = [ point(0.65, 0.2, 0, 0, 0, 0), point(0.85, 0.2, 0, 0, 0, 0), point(0.85, 0.25, 0, 0, 0, 0), point(0.7, 0.25, 0, 0, 0, 0), point(0.7, 0.4, 0, 0, 0, 0), point(0.8, 0.4, 0, 0, 0, 0), point(0.8, 0.35, 0, 0, 0, 0), point(0.75, 0.35, 0, 0, 0, 0), point(0.75, 0.3, 0, 0, 0, 0), point(0.85, 0.3, 0, 0, 0, 0), point(0.85, 0.45, 0, 0, 0, 0), point(0.65, 0.45, 0, 0, 0, 0), ]; const b: Point[] = blob("", 8, 0.25, {x: 0.65, y: 0.2}); drawClosed( ctx, debug, loopBetween(percentage, ...prepare(a, b, {rawAngles: false, divideRatio: 1})), ); }; const testGen = () => { const cellSideCount = 16; const cellSize = size / cellSideCount; ctx.save(); ctx.strokeStyle = "#fafafa"; ctx.fillStyle = "#f1f1f1"; for (let i = 0; i < cellSideCount; i++) { for (let j = 0; j < cellSideCount; j++) { ctx.strokeRect(i * cellSize, j * cellSize, cellSize, cellSize); ctx.fill( blobs2.canvasPath( { extraPoints: j, randomness: i, seed: i + j - i * j, size: cellSize, }, { offsetX: i * cellSize, offsetY: j * cellSize, }, ), ); } } ctx.restore(); }; const blob = (seed: string, count: number, scale: number, offset: Coord): Point[] => { const rgen = rand(seed); const points = genBlob(count, () => 0.3 + 0.2 * rgen()); return mapPoints(points, ({curr}) => { curr.x *= scale * size; curr.y *= scale * size; curr.x += offset.x * size; curr.y += offset.y * size; curr.handleIn.length *= scale * size; curr.handleOut.length *= scale * size; return curr; }); }; const loopBetween = (percentage: number, a: Point[], b: Point[]): Point[] => { // Draw before/after shapes + point path. ctx.save(); ctx.strokeStyle = "#ffaaaa"; drawClosed(ctx, false, a); ctx.strokeStyle = "#aaaaff"; drawClosed(ctx, false, b); ctx.strokeStyle = "#33ff33"; for (let i = 0; i < a.length; i++) { ctx.beginPath(); ctx.moveTo(a[i].x, a[i].y); ctx.lineTo(b[i].x, b[i].y); ctx.stroke(); } ctx.restore(); if (percentage < 0.5) { return interpolateBetweenSmooth(1, 2 * percentage, a, b); } else { return interpolateBetweenSmooth(1, -2 * percentage + 2, a, b); } }; const genBlobAnimation = ( speed: number, offset: number, timing: blobs2Animate.CanvasKeyframe["timingFunction"], timeWarp: number, ) => { const animation = blobs2Animate.canvasPath(() => Date.now() * timeWarp); const loopAnimation = () => { animation.transition( { duration: speed, delay: speed, timingFunction: "ease", blobOptions: { extraPoints: 3, randomness: 4, seed: Math.random(), size: 200, }, canvasOptions: { offsetX: offset, }, }, { duration: speed, timingFunction: "ease", blobOptions: { extraPoints: 3, randomness: 4, seed: Math.random(), size: 200, }, canvasOptions: { offsetX: offset, }, }, { duration: speed, delay: speed, timingFunction: "ease", blobOptions: { extraPoints: 3, randomness: 4, seed: Math.random(), size: 200, }, canvasOptions: { offsetX: offset, }, }, { duration: speed, callback: loopAnimation, timingFunction: "ease", blobOptions: { extraPoints: 39, randomness: 2, seed: Math.random(), size: 200, }, canvasOptions: { offsetX: offset, }, }, ); }; animation.transition({ duration: 0, callback: loopAnimation, blobOptions: { extraPoints: 1, randomness: 0, seed: 0, size: 200, }, canvasOptions: { offsetX: offset, }, }); addInteraction(() => { animation.transition({ duration: speed, callback: loopAnimation, timingFunction: timing, blobOptions: { extraPoints: 30, randomness: 8, seed: Math.random(), size: 180, }, canvasOptions: { offsetX: 10 + offset, offsetY: 10, }, }); }); return animation; }; const genCustomAnimation = (speed: number, offset: number) => { const noHandles = { handleIn: {angle: 0, length: 0}, handleOut: {angle: 0, length: 0}, }; const animation = blobs2Animate.canvasPath(); const loopAnimation = (immediate: boolean = false) => { const size = 200; animation.transition( { duration: immediate ? 0 : speed, delay: 100, timingFunction: "elasticEnd0", blobOptions: { extraPoints: 3, randomness: 4, seed: Math.random(), size: size, }, canvasOptions: {offsetX: offset, offsetY: 220}, }, { duration: speed, delay: 100, timingFunction: "elasticEnd0", points: [ {x: 0, y: 0, ...noHandles}, {x: 0, y: size, ...noHandles}, {x: size, y: size, ...noHandles}, {x: size, y: 0, ...noHandles}, ], canvasOptions: {offsetX: offset, offsetY: 220}, callback: loopAnimation, }, ); }; loopAnimation(true); addInteraction(() => animation.playPause()); return animation; }; const wigglePresetBad = ( animation: blobs2Animate.Animation, config: { blobOptions: blobs2.BlobOptions; period: number; delay?: number; timingFunction?: blobs2Animate.CanvasKeyframe["timingFunction"]; canvasOptions?: { offsetX?: number; offsetY?: number; }; }, ) => { const targetBlob: Point[] = genFromOptions(config.blobOptions); const numberOfPoints = 3 + config.blobOptions.extraPoints; const mutatesPerPeriod = 1 * numberOfPoints; const mutateInterval = config.period / mutatesPerPeriod; const mutateRatio = 1 / mutatesPerPeriod; console.log( "mutatesPerPeriod", mutatesPerPeriod, "mutateInterval", mutateInterval, "mutateRatio", mutateRatio, "config", JSON.stringify(config), ); const loopAnimation = () => { const newBlob = genFromOptions(Object.assign(config.blobOptions, {seed: Math.random()})); for (let i = 0; i < newBlob.length; i++) { if (Math.random() < mutateRatio) { targetBlob[i] = newBlob[i]; } } animation.transition({ duration: config.period, timingFunction: config.timingFunction, canvasOptions: config.canvasOptions, points: targetBlob, }); }; animation.transition({ duration: 0, delay: config.delay || 0, timingFunction: config.timingFunction, canvasOptions: config.canvasOptions, points: genFromOptions(config.blobOptions), callback: () => setInterval(loopAnimation, mutateInterval), }); addInteraction(() => animation.playPause()); }; const genBadWiggle = (period: number, offset: number) => { const animation = blobs2Animate.canvasPath(); wigglePresetBad(animation, { blobOptions: { extraPoints: 1, randomness: 4, seed: Math.random(), size: 200, }, period, timingFunction: "ease", canvasOptions: {offsetX: offset, offsetY: 220}, }); return animation; }; const genWiggle = (offset: number, speed: number) => { const animation = blobs2Animate.canvasPath(); blobs2Animate.wigglePreset( animation, { extraPoints: 4, randomness: 2, seed: Math.random(), size: 200, }, {offsetX: offset, offsetY: 220}, {speed}, ); addInteraction(() => animation.playPause()); return animation; }; (() => { let percentage = animationStart; const animations = [ genBlobAnimation(500, 0, "elasticEnd0", 1), genBlobAnimation(500, 200, "elasticEnd1", 1), genBlobAnimation(500, 400, "elasticEnd2", 1), genBlobAnimation(500, 600, "elasticEnd3", 1), genBlobAnimation(500, 800, "elasticEnd3", 0.1), genCustomAnimation(1000, 0), genBadWiggle(200, 200), genWiggle(400, 5), ]; const renderFrame = () => { clear(ctx); testGen(); drawInfo(ctx, 0, "percentage", percentage); testSplitAt(percentage); testSplitBy(); testDividePoints(); testInterpolateBetween(percentage); testPrepPointsA(percentage); testPrepPointsB(percentage); testPrepPointsC(percentage); testPrepPointsD(percentage); testPrepLetters(percentage); for (const animation of animations) { ctx.save(); ctx.strokeStyle = "orange"; ctx.fillStyle = "rgba(255, 200, 0, 0.5)"; const path = animation.renderFrame(); ctx.stroke(path); ctx.fill(path); ctx.restore(); } percentage += animationSpeed / 1000; percentage = mod(percentage, 1); if (animationSpeed > 0) requestAnimationFrame(renderFrame); }; renderFrame(); })(); ================================================ FILE: internal/animate/timing.ts ================================================ export interface TimingFunc { (percentage: number): number; } const linear: TimingFunc = (p) => { return p; }; const easeEnd: TimingFunc = (p) => { return 1 - (p - 1) ** 2; }; const easeStart: TimingFunc = (p) => { return 1 - easeEnd(1 - p); }; const ease: TimingFunc = (p) => { return 0.5 + 0.5 * Math.sin(Math.PI * (p + 1.5)); }; const elasticEnd = (s: number): TimingFunc => (p) => { return Math.pow(2, -10 * p) * Math.sin(((p - s / 4) * (2 * Math.PI)) / s) + 1; }; // https://www.desmos.com/calculator/fqisoq1kuw export const timingFunctions = { linear, easeEnd, easeStart, ease, elasticEnd0: elasticEnd(1), elasticEnd1: elasticEnd(0.64), elasticEnd2: elasticEnd(0.32), elasticEnd3: elasticEnd(0.16), }; // @ts-ignore: Type assertion. const _: Record = timingFunctions; ================================================ FILE: internal/check.ts ================================================ import {timingFunctions} from "./animate/timing"; const typeCheck = (name: string, val: any, expected: string[]) => { let actual: string = typeof val; if (actual === "number" && isNaN(val)) actual = "NaN"; if (actual === "object" && val === null) actual = "null"; if (!expected.includes(actual)) { throw `"${name}" should have type "${expected.join("|")}" but was "${actual}".`; } }; export const checkKeyframeOptions = (keyframe: any) => { typeCheck(`keyframe`, keyframe, ["object"]); const {delay, duration, timingFunction, callback} = keyframe; typeCheck(`delay`, delay, ["number", "undefined"]); if (delay && delay < 0) throw `delay is invalid "${delay}".`; typeCheck(`duration`, duration, ["number"]); if (duration && duration < 0) throw `duration is invalid "${duration}".`; typeCheck(`timingFunction`, timingFunction, ["string", "undefined"]); if (timingFunction && !(timingFunctions as any)[timingFunction]) { throw `".timingFunction" is not recognized "${timingFunction}".`; } typeCheck(`callback`, callback, ["function", "undefined"]); }; export const checkBlobOptions = (blobOptions: any) => { typeCheck(`blobOptions`, blobOptions, ["object"]); const {seed, extraPoints, randomness, size} = blobOptions; typeCheck(`blobOptions.seed`, seed, ["string", "number"]); typeCheck(`blobOptions.extraPoints`, extraPoints, ["number"]); if (extraPoints < 0) { throw `blobOptions.extraPoints is invalid "${extraPoints}".`; } typeCheck(`blobOptions.randomness`, randomness, ["number"]); if (randomness < 0) { throw `blobOptions.randomness is invalid "${randomness}".`; } typeCheck(`blobOptions.size`, size, ["number"]); if (size < 0) throw `blobOptions.size is invalid "${size}".`; }; export const checkCanvasOptions = (canvasOptions: any) => { typeCheck(`canvasOptions`, canvasOptions, ["object", "undefined"]); if (canvasOptions) { const {offsetX, offsetY} = canvasOptions; typeCheck(`canvasOptions.offsetX`, offsetX, ["number", "undefined"]); typeCheck(`canvasOptions.offsetY`, offsetY, ["number", "undefined"]); } }; export const checkSvgOptions = (svgOptions: any) => { typeCheck(`svgOptions`, svgOptions, ["object", "undefined"]); if (svgOptions) { const {fill, stroke, strokeWidth} = svgOptions; typeCheck(`svgOptions.fill`, fill, ["string", "undefined"]); typeCheck(`svgOptions.stroke`, stroke, ["string", "undefined"]); typeCheck(`svgOptions.strokeWidth`, strokeWidth, ["number", "undefined"]); } }; export const checkPoints = (points: any) => { if (!Array.isArray(points)) { throw `points should be an array but was "${typeof points}".`; } if (points.length < 3) { throw `expected more than two points but received "${points.length}".`; } for (const point of points) { typeCheck(`point.x`, point.x, ["number"]); typeCheck(`point.y`, point.y, ["number"]); typeCheck(`point.handleIn`, point.handleIn, ["object"]); typeCheck(`point.handleIn.angle`, point.handleIn.angle, ["number"]); typeCheck(`point.handleIn.length`, point.handleIn.length, ["number"]); typeCheck(`point.handleOut`, point.handleOut, ["object"]); typeCheck(`point.handleOut.angle`, point.handleOut.angle, ["number"]); typeCheck(`point.handleOut.length`, point.handleOut.length, ["number"]); } }; ================================================ FILE: internal/gen.ts ================================================ import {rand} from "../internal/rand"; import {mapPoints} from "../internal/util"; import {BlobOptions} from "../public/blobs"; import {Point} from "./types"; import {smooth} from "./util"; export const smoothBlob = (blobygon: Point[]): Point[] => { // https://math.stackexchange.com/a/873589/235756 const angle = (Math.PI * 2) / blobygon.length; const smoothingStrength = ((4 / 3) * Math.tan(angle / 4)) / Math.sin(angle / 2) / 2; return smooth(blobygon, smoothingStrength); }; export const genBlobygon = (pointCount: number, offset: (index: number) => number): Point[] => { const angle = (Math.PI * 2) / pointCount; const points: Point[] = []; for (let i = 0; i < pointCount; i++) { const randPointOffset = offset(i); const pointX = Math.sin(i * angle); const pointY = Math.cos(i * angle); points.push({ x: 0.5 + pointX * randPointOffset, y: 0.5 + pointY * randPointOffset, handleIn: {angle: 0, length: 0}, handleOut: {angle: 0, length: 0}, }); } return points; }; export const genBlob = (pointCount: number, offset: (index: number) => number): Point[] => { return smoothBlob(genBlobygon(pointCount, offset)); }; export const genFromOptions = ( blobOptions: BlobOptions, r?: (index: number) => number, ): Point[] => { const rgen = r || rand(String(blobOptions.seed)); // Scale of random movement increases as randomness approaches infinity. // randomness = 0 -> rangeStart = 1 // randomness = 2 -> rangeStart = 0.8333 // randomness = 5 -> rangeStart = 0.6667 // randomness = 10 -> rangeStart = 0.5 // randomness = 20 -> rangeStart = 0.3333 // randomness = 50 -> rangeStart = 0.1667 // randomness = 100 -> rangeStart = 0.0909 const rangeStart = 1 / (1 + blobOptions.randomness / 10); const points = genBlob( 3 + blobOptions.extraPoints, (index) => (rangeStart + rgen(index) * (1 - rangeStart)) / 2, ); const size = blobOptions.size; return mapPoints(points, ({curr}) => { curr.x *= size; curr.y *= size; curr.handleIn.length *= size; curr.handleOut.length *= size; return curr; }); }; ================================================ FILE: internal/rand.ts ================================================ import {createNoise2D} from "simplex-noise"; // Seeded random number generator. // https://stackoverflow.com/a/47593316/3053361 export const rand = (seed: string) => { const xfnv1a = (str: string) => { let h = 2166136261 >>> 0; for (let i = 0; i < str.length; i++) { h = Math.imul(h ^ str.charCodeAt(i), 16777619); } return () => { h += h << 13; h ^= h >>> 7; h += h << 3; h ^= h >>> 17; return (h += h << 5) >>> 0; }; }; const sfc32 = (a: number, b: number, c: number, d: number) => () => { a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0; var t = (a + b) | 0; a = b ^ (b >>> 9); b = (c + (c << 3)) | 0; c = (c << 21) | (c >>> 11); d = (d + 1) | 0; t = (t + d) | 0; c = (c + t) | 0; return (t >>> 0) / 4294967296; }; const seedGenerator = xfnv1a(seed); return sfc32(seedGenerator(), seedGenerator(), seedGenerator(), seedGenerator()); }; // Simplex noise. // TODO(2023-01-08) implement to remove dep // TODO(2023-02-16) https://asserttrue.blogspot.com/2011/12/perlin-noise-in-javascript_31.html // https://en.wikipedia.org/wiki/Simplex_noise export const noise = (seed: string) => { const noise2D = createNoise2D(rand(seed)); return (x: number, y: number) => { return noise2D(x, y); }; }; ================================================ FILE: internal/render/canvas.ts ================================================ import {Coord, Point} from "../types"; import {expandHandle, forPoints} from "../util"; const pointSize = 2; const infoSpacing = 20; export const clear = (ctx: CanvasRenderingContext2D) => { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); }; export const drawInfo = (ctx: CanvasRenderingContext2D, pos: number, label: string, value: any) => { ctx.fillText(`${label}: ${value}`, infoSpacing, (pos + 1) * infoSpacing); }; const drawLine = (ctx: CanvasRenderingContext2D, a: Coord, b: Coord, style: string) => { const backupStrokeStyle = ctx.strokeStyle; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.strokeStyle = style; ctx.stroke(); ctx.strokeStyle = backupStrokeStyle; }; const drawPoint = (ctx: CanvasRenderingContext2D, p: Coord, style: string) => { const backupFillStyle = ctx.fillStyle; ctx.beginPath(); ctx.arc(p.x, p.y, pointSize, 0, 2 * Math.PI); ctx.fillStyle = style; ctx.fill(); ctx.fillStyle = backupFillStyle; }; export const drawClosed = (ctx: CanvasRenderingContext2D, debug: boolean, points: Point[]) => { if (points.length < 2) throw new Error("not enough points"); // Draw debug points. if (debug) { forPoints(points, ({curr, next: getNext}) => { const next = getNext(); // Compute coordinates of handles. const currHandle = expandHandle(curr, curr.handleOut); const nextHandle = expandHandle(next, next.handleIn); drawPoint(ctx, curr, ""); drawLine(ctx, curr, currHandle, "#ccc"); drawLine(ctx, next, nextHandle, "#b6b"); }); } ctx.stroke(renderPath2D(points)); }; export const renderPath2D = (points: Point[]): Path2D => { const path = new Path2D(); if (points.length < 1) return path; path.moveTo(points[0].x, points[0].y); forPoints(points, ({curr, next: getNext}) => { const next = getNext(); const currHandle = expandHandle(curr, curr.handleOut); const nextHandle = expandHandle(next, next.handleIn); path.bezierCurveTo(currHandle.x, currHandle.y, nextHandle.x, nextHandle.y, next.x, next.y); }); return path; }; ================================================ FILE: internal/render/svg.test.ts ================================================ import {XmlElement} from "./svg"; describe("internal/render/svg", () => { describe("XmlElement", () => { it("should render element tags", () => { const elem = new XmlElement("test"); expect(elem.render()).toBe(""); }); it("should render element attributes", () => { const elem = new XmlElement("test"); elem.attributes.a = 1; elem.attributes["b-c"] = "d"; expect(elem.render()).toBe(''); }); it("should render nested elements", () => { const a = new XmlElement("a"); const aa = new XmlElement("aa"); const ab = new XmlElement("ab"); const aba = {render: () => "aba"}; a.children.push(aa); a.children.push(ab); ab.children.push(aba); expect(a.render()).toBe("aba"); }); }); }); ================================================ FILE: internal/render/svg.ts ================================================ import {Point} from "../types"; import {expandHandle, forPoints} from "../util"; export interface RenderOptions { // Viewport size. width: number; height: number; // Transformation applied to all drawn points. transform?: string; // Declare whether the path should be closed. // This option is currently always true. closed: true; // Output path styling. fill?: string; stroke?: string; strokeWidth?: number; // Option to render guides (points, handles and viewport). guides?: boolean; boundingBox?: boolean; } export const renderPath = (points: Point[]): string => { // Render path data attribute from points and handles. let path = `M${points[0].x},${points[0].y}`; forPoints(points, ({curr, next: getNext}) => { const next = getNext(); const currControl = expandHandle(curr, curr.handleOut); const nextControl = expandHandle(next, next.handleIn); path += `C${currControl.x},${currControl.y},${nextControl.x},${nextControl.y},${next.x},${next.y}`; }); return path; }; // Renders the input points to an editable data structure which can be rendered to svg. export const renderEditable = (points: Point[], options: RenderOptions): XmlElement => { const stroke = options.stroke || (options.guides ? "black" : "none"); const strokeWidth = options.strokeWidth || (options.guides ? 1 : 0); const xmlRoot = new XmlElement("svg"); xmlRoot.attributes.width = options.width; xmlRoot.attributes.height = options.height; xmlRoot.attributes.viewBox = `0 0 ${options.width} ${options.height}`; xmlRoot.attributes.xmlns = "http://www.w3.org/2000/svg"; const xmlContentGroup = new XmlElement("g"); xmlContentGroup.attributes.transform = options.transform || ""; const xmlBlobPath = new XmlElement("path"); xmlBlobPath.attributes.stroke = stroke; xmlBlobPath.attributes["stroke-width"] = strokeWidth; xmlBlobPath.attributes.fill = options.fill || "none"; xmlBlobPath.attributes.d = renderPath(points); xmlContentGroup.children.push(xmlBlobPath); xmlRoot.children.push(xmlContentGroup); // Render guides if configured to do so. if (options.guides) { const color = options.stroke || "black"; const size = options.strokeWidth || 1; // Bounding box. if (options.boundingBox) { const xmlBoundingRect = new XmlElement("rect"); xmlBoundingRect.attributes.x = 0; xmlBoundingRect.attributes.y = 0; xmlBoundingRect.attributes.width = options.width; xmlBoundingRect.attributes.height = options.height; xmlBoundingRect.attributes.fill = "none"; xmlBoundingRect.attributes.stroke = color; xmlBoundingRect.attributes["stroke-width"] = 2 * size; xmlBoundingRect.attributes["stroke-dasharray"] = 2 * size; xmlContentGroup.children.push(xmlBoundingRect); } // Points and handles. forPoints(points, ({curr, next: getNext}) => { const next = getNext(); const currControl = expandHandle(curr, curr.handleOut); const nextControl = expandHandle(next, next.handleIn); const xmlOutgoingHandleLine = new XmlElement("line"); xmlOutgoingHandleLine.attributes.x1 = curr.x; xmlOutgoingHandleLine.attributes.y1 = curr.y; xmlOutgoingHandleLine.attributes.x2 = currControl.x; xmlOutgoingHandleLine.attributes.y2 = currControl.y; xmlOutgoingHandleLine.attributes["stroke-width"] = size; xmlOutgoingHandleLine.attributes.stroke = color; const xmlIncomingHandleLine = new XmlElement("line"); xmlIncomingHandleLine.attributes.x1 = next.x; xmlIncomingHandleLine.attributes.y1 = next.y; xmlIncomingHandleLine.attributes.x2 = nextControl.x; xmlIncomingHandleLine.attributes.y2 = nextControl.y; xmlIncomingHandleLine.attributes["stroke-width"] = size; xmlIncomingHandleLine.attributes.stroke = color; xmlIncomingHandleLine.attributes["stroke-dasharray"] = 2 * size; const xmlOutgoingHandleCircle = new XmlElement("circle"); xmlOutgoingHandleCircle.attributes.cx = currControl.x; xmlOutgoingHandleCircle.attributes.cy = currControl.y; xmlOutgoingHandleCircle.attributes.r = size; xmlOutgoingHandleCircle.attributes.fill = color; const xmlIncomingHandleCircle = new XmlElement("circle"); xmlIncomingHandleCircle.attributes.cx = nextControl.x; xmlIncomingHandleCircle.attributes.cy = nextControl.y; xmlIncomingHandleCircle.attributes.r = size; xmlIncomingHandleCircle.attributes.fill = color; const xmlPointCircle = new XmlElement("circle"); xmlPointCircle.attributes.cx = curr.x; xmlPointCircle.attributes.cy = curr.y; xmlPointCircle.attributes.r = 2 * size; xmlPointCircle.attributes.fill = color; xmlContentGroup.children.push(xmlOutgoingHandleLine); xmlContentGroup.children.push(xmlIncomingHandleLine); xmlContentGroup.children.push(xmlOutgoingHandleCircle); xmlContentGroup.children.push(xmlIncomingHandleCircle); xmlContentGroup.children.push(xmlPointCircle); }); } return xmlRoot; }; // Structured element with tag, attributes and children. export class XmlElement { public attributes: Record = {}; public children: any[] = []; public constructor(public tag: string) {} public render(): string { const attributes = this.renderAttributes(); const content = this.renderChildren(); if (content === "") { return `<${this.tag}${attributes}/>`; } return `<${this.tag}${attributes}>${content}`; } private renderAttributes(): string { const attributes = Object.keys(this.attributes); if (attributes.length === 0) return ""; let out = ""; for (const attribute of attributes) { out += ` ${attribute}="${this.attributes[attribute]}"`; } return out; } private renderChildren(): string { let out = ""; for (const child of this.children) { out += child.render(); } return out; } } ================================================ FILE: internal/types.ts ================================================ // Position in a coordinate system with an origin in the top left corner. export interface Coord { x: number; y: number; } export interface Handle { // Angle in radians relative to the 3:00 position going clockwise. angle: number; // Length of the handle. length: number; } export interface Point extends Coord { // Cubic bezier handles. handleIn: Handle; handleOut: Handle; } ================================================ FILE: internal/util.ts ================================================ import {Coord, Handle, Point} from "./types"; export const copyPoint = (p: Point): Point => ({ x: p.x, y: p.y, handleIn: {...p.handleIn}, handleOut: {...p.handleOut}, }); export interface PointIteratorArgs { curr: Point; index: number; sibling: (pos: number) => Point; prev: () => Point; next: () => Point; } export const coordPoint = (coord: Coord): Point => { return { ...coord, handleIn: {angle: 0, length: 0}, handleOut: {angle: 0, length: 0}, }; }; export const forPoints = (points: Point[], callback: (args: PointIteratorArgs) => void) => { for (let i = 0; i < points.length; i++) { const sibling = (pos: number) => copyPoint(points[mod(pos, points.length)]); callback({ curr: copyPoint(points[i]), index: i, sibling, prev: () => sibling(i - 1), next: () => sibling(i + 1), }); } }; export const mapPoints = ( points: Point[], callback: (args: PointIteratorArgs) => Point, ): Point[] => { const out: Point[] = []; forPoints(points, (args) => { out.push(callback(args)); }); return out; }; export const coordEqual = (a: Coord, b: Coord): boolean => { return a.x === b.x && a.y === b.y; }; export const angleOf = (a: Coord, b: Coord): number => { const dx = b.x - a.x; const dy = -b.y + a.y; const angle = Math.atan2(dy, dx); if (angle < 0) { return Math.abs(angle); } else { return 2 * Math.PI - angle; } }; export const expandHandle = (point: Coord, handle: Handle): Coord => ({ x: point.x + handle.length * Math.cos(handle.angle), y: point.y + handle.length * Math.sin(handle.angle), }); const collapseHandle = (point: Coord, handle: Coord): Handle => ({ angle: angleOf(point, handle), length: Math.sqrt((handle.x - point.x) ** 2 + (handle.y - point.y) ** 2), }); export const length = (a: Point, b: Point): number => { const aHandle = expandHandle(a, a.handleOut); const bHandle = expandHandle(b, b.handleIn); const ab = distance(a, b); const abHandle = distance(aHandle, bHandle); return (ab + abHandle + a.handleOut.length + b.handleIn.length) / 2; }; export const reverse = (points: Point[]): Point[] => { return mapPoints(points, ({index, sibling}) => { const point = sibling(points.length - index - 1); point.handleIn.angle += Math.PI; point.handleOut.angle += Math.PI; return point; }); }; export const shift = (offset: number, points: Point[]): Point[] => { return mapPoints(points, ({index, sibling}) => { return sibling(index + offset); }); }; // Add a control point to the curve between a and b. // Percentage [0, 1] from a to b. // a: original first point. // b: original last point. // c: new first point. // d: new added point. // e: new last point. // f: split point between a and b's handles. // g: split point between c's handle and f. // h: split point between e's handle and f. export const insertAt = (percentage: number, a: Point, b: Point): [Point, Point, Point] => { const c = copyPoint(a); c.handleOut.length *= percentage; const e = copyPoint(b); e.handleIn.length *= 1 - percentage; const aHandle = expandHandle(a, a.handleOut); const bHandle = expandHandle(b, b.handleIn); const cHandle = expandHandle(c, c.handleOut); const eHandle = expandHandle(e, e.handleIn); const f = splitLine(percentage, aHandle, bHandle); const g = splitLine(percentage, cHandle, f); const h = splitLine(1 - percentage, eHandle, f); const dCoord = splitLine(percentage, g, h); const d: Point = { x: dCoord.x, y: dCoord.y, handleIn: collapseHandle(dCoord, g), handleOut: collapseHandle(dCoord, h), }; return [c, d, e]; }; export const insertCount = (count: number, a: Point, b: Point): Point[] => { if (count < 2) return [a, b]; const percentage = 1 / count; const [c, d, e] = insertAt(percentage, a, b); if (count === 2) return [c, d, e]; return [c, ...insertCount(count - 1, d, e)]; }; // Smooths out the path made up of the given points. // Existing handles are ignored. export const smooth = (points: Point[], strength: number): Point[] => { return mapPoints(points, ({curr, next, prev}) => { const angle = angleOf(prev(), next()); return { x: curr.x, y: curr.y, handleIn: { angle: angle + Math.PI, length: strength * distance(curr, prev()), }, handleOut: { angle, length: strength * distance(curr, next()), }, }; }); }; // Modulo operation that always produces a positive result. // https://stackoverflow.com/q/4467539/3053361 export const mod = (a: number, n: number): number => { return ((a % n) + n) % n; }; // Converts degrees to radians. export const rad = (deg: number) => { return (deg / 360) * 2 * Math.PI; }; // Converts radians to degrees. export const deg = (rad: number) => { return (((rad / Math.PI) * 1) / 2) * 360; }; // Calculates distance between two points. export const distance = (a: Coord, b: Coord): number => { return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); }; // Calculates the angle of the line from a to b in degrees. export const angle = (a: Coord, b: Coord): number => { return deg(Math.atan2(b.y - a.y, b.x - a.x)); }; export const split = (percentage: number, a: number, b: number): number => { return a + percentage * (b - a); }; export const splitLine = (percentage: number, a: Coord, b: Coord): Coord => { return { x: split(percentage, a.x, b.x), y: split(percentage, a.y, b.y), }; }; ================================================ FILE: package.json ================================================ { "name": "blobs", "version": "2.3.0", "description": "Random blob generation and animation", "author": "g-harel", "license": "MIT", "main": "index.js", "module": "index.module.js", "types": "index.d.ts", "scripts": { "prepack": "npm run build", "postpublish": "npm run clean", "build": "npm run clean && rollup -c rollup.config.mjs", "clean": "trash '**/*.js' '**/*.js.map' '**/*.d.ts' '!**/node_modules/**/*' '!rollup.config.mjs'", "fmt": "prettier --list-different --write --ignore-path .gitignore '**/*.{js,ts,md,html}' '!index.html'", "demo:dev": "parcel demo/index.html --open", "demo:build": "parcel build demo/index.html && move-file dist/index.html index.html", "test": "jest", "test:playground": "parcel internal/animate/testing/index.html --open" }, "dependencies": { "simplex-noise": "^4.0.1" }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.0.1", "@types/jest": "25.1.4", "jest": "29.5.0", "jest-canvas-mock": "2.5.0", "move-file-cli": "2.0.0", "parcel": "1.12.3", "parcel-plugin-inliner": "1.0.14", "path2d-polyfill": "^2.0.1", "prettier": "2.0.2", "rollup": "3.8.1", "rollup-plugin-copy": "3.4.0", "rollup-plugin-typescript2": "0.34.1", "rollup-plugin-uglify": "6.0.1", "trash-cli": "3.0.0", "ts-jest": "29.1.0", "tslib": "2.4.1", "typescript": "4.9.4" }, "homepage": "https://blobs.dev", "repository": { "type": "git", "url": "git+https://github.com/g-harel/blobs" }, "bugs": { "url": "https://github.com/g-harel/blobs/issues" }, "keywords": [ "random", "blob", "svg", "path", "canvas", "animation" ], "prettier": { "tabWidth": 4, "printWidth": 100, "trailingComma": "all", "bracketSpacing": false, "arrowParens": "always" }, "jest": { "preset": "ts-jest", "setupFiles": [ "jest-canvas-mock" ] } } ================================================ FILE: public/__snapshots__/legacy.test.ts.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`fill 1`] = `""`; exports[`guides 1`] = `""`; exports[`stroke 1`] = `""`; ================================================ FILE: public/animate.test.ts ================================================ import {CanvasKeyframe, canvasPath} from "./animate"; const genKeyframe = (): CanvasKeyframe => ({ duration: 1000 * Math.random(), delay: 1000 * Math.random(), timingFunction: "linear", callback: () => {}, blobOptions: { extraPoints: Math.floor(10 * Math.random()), randomness: Math.floor(10 * Math.random()), seed: Math.random(), size: 100 + 200 * Math.random(), }, canvasOptions: { offsetX: 100 * Math.random(), offsetY: 100 * Math.random(), }, }); describe("animate", () => { describe("canvasPath", () => { describe("transition", () => { describe("keyframe", () => { it("should accept generated keyframe", () => { const animation = canvasPath(); const keyframe = genKeyframe(); expect(() => animation.transition(keyframe)).not.toThrow(); }); it("should indicate the rejected frame index", () => { const animation = canvasPath(); const keyframes = [genKeyframe(), null as any, genKeyframe()]; expect(() => animation.transition(...keyframes)).toThrow(/keyframe.*1/g); }); interface TestCase { name: string; edit: (keyframe: CanvasKeyframe) => void; error?: RegExp; } const testCases: Array = [ // duration { name: "should accept valid duration", edit: (keyframe) => (keyframe.duration = 100), }, { name: "should accept zero duration", edit: (keyframe) => (keyframe.duration = 0), }, { name: "should reject undefined duration", edit: (keyframe) => delete (keyframe as any).duration, error: /duration.*number.*undefined/g, }, { name: "should reject negative duration", edit: (keyframe) => (keyframe.duration = -10), error: /duration.*invalid/g, }, { name: "should reject broken duration", edit: (keyframe) => (keyframe.duration = NaN), error: /duration.*number.*NaN/g, }, { name: "should reject invalid duration", edit: (keyframe) => (keyframe.duration = "123" as any), error: /duration.*number.*string/g, }, // delay { name: "should accept valid delay", edit: (keyframe) => (keyframe.delay = 200), }, { name: "should accept zero delay", edit: (keyframe) => (keyframe.delay = 0), }, { name: "should accept undefined delay", edit: (keyframe) => delete keyframe.delay, }, { name: "should reject negative delay", edit: (keyframe) => (keyframe.delay = -10), error: /delay.*invalid/g, }, { name: "should reject broken delay", edit: (keyframe) => (keyframe.delay = NaN), error: /delay.*number.*NaN/g, }, { name: "should reject invalid delay", edit: (keyframe) => (keyframe.delay = "123" as any), error: /delay.*number.*string/g, }, // timingFunction { name: "should accept known timingFunction", edit: (keyframe) => (keyframe.timingFunction = "ease"), }, { name: "should accept undefined timingFunction", edit: (keyframe) => delete keyframe.timingFunction, }, { name: "should reject invalid timingFunction", edit: (keyframe) => (keyframe.timingFunction = (() => 0) as any), error: /timingFunction.*string.*function/g, }, { name: "should reject unknown timingFunction", edit: (keyframe) => (keyframe.timingFunction = "unknown" as any), error: /timingFunction.*not recognized.*unknown/g, }, // callback { name: "should accept valid callback", edit: (keyframe) => (keyframe.callback = () => console.log("test")), }, { name: "should accept undefined callback", edit: (keyframe) => delete keyframe.callback, }, { name: "should reject invalid callback", edit: (keyframe) => (keyframe.callback = {} as any), error: /callback.*function.*object/g, }, // blobOptions { name: "should reject undefined blobOptions", edit: (keyframe) => delete (keyframe as any).blobOptions, error: /blobOptions.*object.*undefined/g, }, { name: "should reject invalid blobOptions", edit: (keyframe) => (keyframe.blobOptions = null as any), error: /blobOptions.*object.*null/g, }, // blobOptions.seed { name: "should accept number blobOptions seed", edit: (keyframe) => (keyframe.blobOptions.seed = 123), }, { name: "should accept string blobOptions seed", edit: (keyframe) => (keyframe.blobOptions.seed = "test"), }, { name: "should reject undefined blobOptions seed", edit: (keyframe) => delete (keyframe as any).blobOptions.seed, error: /seed.*string.*number.*undefined/g, }, { name: "should reject broken blobOptions seed", edit: (keyframe) => (keyframe.blobOptions.seed = NaN), error: /seed.*string.*number.*NaN/g, }, // blobOptions.extraPoints { name: "should accept valid blobOptions extraPoints", edit: (keyframe) => (keyframe.blobOptions.extraPoints = 4), }, { name: "should reject undefined blobOptions extraPoints", edit: (keyframe) => delete (keyframe as any).blobOptions.extraPoints, error: /blobOptions.*extraPoints.*number.*undefined/g, }, { name: "should reject broken blobOptions extraPoints", edit: (keyframe) => (keyframe.blobOptions.extraPoints = NaN), error: /blobOptions.*extraPoints.*number.*NaN/g, }, { name: "should reject negative blobOptions extraPoints", edit: (keyframe) => (keyframe.blobOptions.extraPoints = -2), error: /blobOptions.*extraPoints.*invalid/g, }, // blobOptions.randomness { name: "should accept valid blobOptions randomness", edit: (keyframe) => (keyframe.blobOptions.randomness = 3), }, { name: "should reject undefined blobOptions randomness", edit: (keyframe) => delete (keyframe as any).blobOptions.randomness, error: /blobOptions.*randomness.*number.*undefined/g, }, { name: "should reject broken blobOptions randomness", edit: (keyframe) => (keyframe.blobOptions.randomness = NaN), error: /blobOptions.*randomness.*number.*NaN/g, }, { name: "should reject negative blobOptions randomness", edit: (keyframe) => (keyframe.blobOptions.randomness = -10), error: /blobOptions.*randomness.*invalid/g, }, // blobOptions.size { name: "should accept valid blobOptions size", edit: (keyframe) => (keyframe.blobOptions.size = 40), }, { name: "should reject undefined blobOptions size", edit: (keyframe) => delete (keyframe as any).blobOptions.size, error: /blobOptions.*size.*number.*undefined/g, }, { name: "should reject broken blobOptions size", edit: (keyframe) => (keyframe.blobOptions.size = NaN), error: /blobOptions.*size.*number.*NaN/g, }, { name: "should reject negative blobOptions size", edit: (keyframe) => (keyframe.blobOptions.size = -1), error: /blobOptions.*size.*invalid/g, }, // canvasOptions { name: "should accept empty canvasOptions", edit: (keyframe) => (keyframe.canvasOptions = {}), }, { name: "should accept undefined canvasOptions", edit: (keyframe) => delete keyframe.canvasOptions, }, { name: "should reject invalid canvasOptions", edit: (keyframe) => (keyframe.canvasOptions = null as any), error: /canvasOptions.*object.*null/g, }, // canvasOptions.offsetX { name: "should accept valid canvasOptions offsetX", edit: (keyframe) => (keyframe.canvasOptions = {offsetX: 100}), }, { name: "should accept undefined canvasOptions offsetX", edit: (keyframe) => delete keyframe.canvasOptions?.offsetX, }, { name: "should reject broken canvasOptions offsetX", edit: (keyframe) => (keyframe.canvasOptions = {offsetX: NaN}), error: /canvasOptions.*offsetX.*number.*NaN/g, }, // canvasOptions.offsetY { name: "should accept valid canvasOptions offsetY", edit: (keyframe) => (keyframe.canvasOptions = {offsetY: 222}), }, { name: "should accept undefined canvasOptions offsetY", edit: (keyframe) => delete keyframe.canvasOptions?.offsetY, }, { name: "should reject broken canvasOptions offsetY", edit: (keyframe) => (keyframe.canvasOptions = {offsetY: NaN}), error: /canvasOptions.*offsetY.*number.*NaN/g, }, ]; // Run all test cases with a configurable amount of keyframes // and index of the keyframe being edited for the tests. const runSuite = (keyframeCount: number, editIndex: number) => { for (const testCase of testCases) { it(testCase.name, () => { // Create blank animation. const animation = canvasPath(); // Create keyframes to call transition with. const keyframes: CanvasKeyframe[] = []; for (let i = 0; i < keyframeCount; i++) { keyframes.push(genKeyframe()); } // Modify selected keyframe. testCase.edit(keyframes[editIndex]); if (testCase.error) { // Copy regexp because they are stateful. const pattern = new RegExp(testCase.error); expect(() => animation.transition(...keyframes)).toThrow(pattern); } else { expect(() => animation.transition(...keyframes)).not.toThrow(); } }); } }; // Run all cases when given a single test frame and asserting on it. describe("first", () => runSuite(1, 0)); // Run all cases when given more than one frame, asserting on last one. const lastLength = 2 + Math.floor(4 * Math.random()); describe("last", () => runSuite(lastLength, lastLength - 1)); // Run all cases when given more than one frame, asserting on a random one. const nthLength = 2 + Math.floor(16 * Math.random()); const nthIndex = Math.floor(nthLength * Math.random()); describe(`nth (${nthIndex + 1}/${nthLength})`, () => runSuite(nthLength, nthIndex)); }); }); }); }); ================================================ FILE: public/animate.ts ================================================ import {Point} from "../internal/types"; import {renderPath2D} from "../internal/render/canvas"; import {genFromOptions} from "../internal/gen"; import {mapPoints} from "../internal/util"; import {statefulAnimationGenerator} from "../internal/animate/state"; import { checkBlobOptions, checkCanvasOptions, checkKeyframeOptions, checkPoints, } from "../internal/check"; import {BlobOptions, CanvasOptions} from "./blobs"; import {noise} from "../internal/rand"; import {interpolateBetween} from "../internal/animate/interpolate"; import {prepare} from "../internal/animate/prepare"; interface Keyframe { // Duration of the keyframe animation in milliseconds. duration: number; // Delay before animation begins in milliseconds. // Default: 0. delay?: number; // Controls the speed of the animation over time. // Default: "linear". timingFunction?: | "linear" | "easeEnd" | "easeStart" | "ease" | "elasticEnd0" | "elasticEnd1" | "elasticEnd2" | "elasticEnd3"; // Called after keyframe end-state is reached or passed. // Called exactly once when the keyframe end-state is rendered. // Not called if the keyframe is preempted by a new transition. callback?: () => void; // Standard options, refer to "blobs/v2" documentation. canvasOptions?: { offsetX?: number; offsetY?: number; }; } export interface CanvasKeyframe extends Keyframe { // Standard options, refer to "blobs/v2" documentation. blobOptions: { seed: number | string; randomness: number; extraPoints: number; size: number; }; } export interface CanvasCustomKeyframe extends Keyframe { // List of point coordinates that produce a single, closed shape. points: Point[]; } export interface Animation { // Renders the current state of the animation. renderFrame: () => Path2D; // Renders the current state of the animation as points. renderPoints: () => Point[]; // Immediately begin animating through the given keyframes. // Non-rendered keyframes from previous transitions are cancelled. transition: (...keyframes: (CanvasKeyframe | CanvasCustomKeyframe)[]) => void; // Resume a paused animation. Has no effect if already playing. play: () => void; // Pause a playing animation. Has no effect if already paused. pause: () => void; // Toggle between playing and pausing the animation. playPause: () => void; } // Function that returns the current timestamp. This value will be used for all // duration/delay values and will be used to interpolate between keyframes. It // must produce values increasing in size. // Default: `Date.now`. export interface TimestampProvider { (): number; } export interface WiggleOptions { // Speed of the wiggle movement. Higher is faster. speed: number; // Length of the transition from the current state to the wiggle blob. // Default: 0 initialTransition?: number; } const canvasPointGenerator = (keyframe: CanvasKeyframe | CanvasCustomKeyframe): Point[] => { let points: Point[]; if ("points" in keyframe) { points = keyframe.points; } else { points = genFromOptions(keyframe.blobOptions); } return mapPoints(points, ({curr}) => { curr.x += keyframe?.canvasOptions?.offsetX || 0; curr.y += keyframe?.canvasOptions?.offsetY || 0; return curr; }); }; const canvasKeyframeChecker = (keyframe: CanvasKeyframe | CanvasCustomKeyframe, index: number) => { try { if ("points" in keyframe) return checkPoints(keyframe.points); checkBlobOptions(keyframe.blobOptions); checkCanvasOptions(keyframe.canvasOptions); checkKeyframeOptions(keyframe); } catch (e) { throw `(blobs2): keyframe ${index}: ${e}`; } }; export const canvasPath = (timestampProvider?: () => number): Animation => { let actualTimestampProvider = Date.now; // Make sure timestamps are always increasing. if (timestampProvider !== undefined) { let lastTimestamp = 0; actualTimestampProvider = () => { const currentTimestamp = timestampProvider(); if (currentTimestamp < lastTimestamp) { throw `timestamp provider generated decreasing value: ${lastTimestamp} then ${currentTimestamp}.`; } lastTimestamp = currentTimestamp; return currentTimestamp; }; } return statefulAnimationGenerator( canvasPointGenerator, renderPath2D, canvasKeyframeChecker, )(actualTimestampProvider); }; export const wigglePreset = ( animation: Animation, blobOptions: BlobOptions, canvasOptions: CanvasOptions, wiggleOptions: WiggleOptions, ) => { // Interval at which a new sample is taken. // Multiple of 16 to do work every N frames. const intervalMs = 16 * 10; const leapSize = 0.01 * wiggleOptions.speed; const noiseField = noise(String(blobOptions.seed)); const transitionFrameCount = Math.min((wiggleOptions.initialTransition || 0) / intervalMs); let transitionStartFrame = animation.renderPoints(); let count = 0; const loopAnimation = () => { count++; // Constantly changing blob. const noiseBlob = genFromOptions(blobOptions, (index) => { return noiseField(leapSize * count, index); }); if (count < transitionFrameCount) { // Create intermediate frame between the current state and the // moving noiseBlob target. const [preparedStartPoints, preparedEndPoints] = prepare( transitionStartFrame, noiseBlob, { rawAngles: true, divideRatio: 1, }, ); const progress = Math.min(1, 2 / (transitionFrameCount - count)); const targetPoints = interpolateBetween( progress, preparedStartPoints, preparedEndPoints, ); transitionStartFrame = targetPoints; animation.transition({ duration: intervalMs, delay: 0, timingFunction: "linear", canvasOptions, points: targetPoints, callback: loopAnimation, }); } else { animation.transition({ duration: intervalMs, delay: 0, timingFunction: "linear", canvasOptions, points: noiseBlob, callback: loopAnimation, }); } }; loopAnimation(); }; ================================================ FILE: public/blobs.test.ts ================================================ import {BlobOptions, CanvasOptions, canvasPath, svg, SvgOptions, svgPath} from "./blobs"; // @ts-ignore import {Path2D, polyfillPath2D} from "path2d-polyfill"; global.Path2D = Path2D; const genBlobOptions = (): BlobOptions => ({ extraPoints: Math.floor(10 * Math.random()), randomness: Math.floor(10 * Math.random()), seed: Math.random(), size: 100 + 200 * Math.random(), }); const genSvgOptions = (): SvgOptions => ({ fill: String(Math.random()), stroke: String(Math.random()), strokeWidth: 4 * Math.random(), }); const genCanvasOptions = (): CanvasOptions => ({ offsetX: 100 * Math.random(), offsetY: 100 * Math.random(), }); interface TestCase { name: string; edit: (options: T) => void; error?: RegExp; } const runSuite = (t: { optionsGenerator: () => T; functionBeingTested: (options: any) => void; }) => (testCases: TestCase[]) => { for (const testCase of testCases) { it(testCase.name, () => { const options = t.optionsGenerator(); testCase.edit(options); if (testCase.error) { // Copy regexp because they are stateful. const pattern = new RegExp(testCase.error); expect(() => t.functionBeingTested(options)).toThrow(pattern); } else { expect(() => t.functionBeingTested(options)).not.toThrow(); } }); } }; const testBlobOptions = (functionBeingTested: (options: any) => void) => { it("should accept generated blobOptions", () => { expect(() => svgPath(genBlobOptions())).not.toThrow(); }); it("should reject undefined blobOptions", () => { expect(() => svgPath(undefined as any)).toThrow(/blobOptions.*object.*undefined/g); }); it("should reject invalid blobOptions", () => { expect(() => svgPath(null as any)).toThrow(/blobOptions.*object.*null/g); }); runSuite({ functionBeingTested, optionsGenerator: genBlobOptions, })([ // seed { name: "should accept number blobOptions seed", edit: (blobOptions) => (blobOptions.seed = 123), }, { name: "should accept string blobOptions seed", edit: (blobOptions) => (blobOptions.seed = "test"), }, { name: "should reject undefined blobOptions seed", edit: (blobOptions) => delete (blobOptions as any).seed, error: /seed.*string.*number.*undefined/g, }, { name: "should reject broken blobOptions seed", edit: (blobOptions) => (blobOptions.seed = NaN), error: /seed.*string.*number.*NaN/g, }, // extraPoints { name: "should accept valid blobOptions extraPoints", edit: (blobOptions) => (blobOptions.extraPoints = 4), }, { name: "should reject undefined blobOptions extraPoints", edit: (blobOptions) => delete (blobOptions as any).extraPoints, error: /blobOptions.*extraPoints.*number.*undefined/g, }, { name: "should reject broken blobOptions extraPoints", edit: (blobOptions) => (blobOptions.extraPoints = NaN), error: /blobOptions.*extraPoints.*number.*NaN/g, }, { name: "should reject negative blobOptions extraPoints", edit: (blobOptions) => (blobOptions.extraPoints = -2), error: /blobOptions.*extraPoints.*invalid/g, }, // randomness { name: "should accept valid blobOptions randomness", edit: (blobOptions) => (blobOptions.randomness = 3), }, { name: "should reject undefined blobOptions randomness", edit: (blobOptions) => delete (blobOptions as any).randomness, error: /blobOptions.*randomness.*number.*undefined/g, }, { name: "should reject broken blobOptions randomness", edit: (blobOptions) => (blobOptions.randomness = NaN), error: /blobOptions.*randomness.*number.*NaN/g, }, { name: "should reject negative blobOptions randomness", edit: (blobOptions) => (blobOptions.randomness = -10), error: /blobOptions.*randomness.*invalid/g, }, // size { name: "should accept valid blobOptions size", edit: (blobOptions) => (blobOptions.size = 40), }, { name: "should reject undefined blobOptions size", edit: (blobOptions) => delete (blobOptions as any).size, error: /blobOptions.*size.*number.*undefined/g, }, { name: "should reject broken blobOptions size", edit: (blobOptions) => (blobOptions.size = NaN), error: /blobOptions.*size.*number.*NaN/g, }, { name: "should reject negative blobOptions size", edit: (blobOptions) => (blobOptions.size = -1), error: /blobOptions.*size.*invalid/g, }, ]); }; describe("blobs", () => { describe("canvasPath", () => { describe("blobOptions", () => { testBlobOptions((blobOptions) => canvasPath(blobOptions, genCanvasOptions())); }); describe("canvasOptions", () => { it("should accept generated canvasOptions", () => { expect(() => canvasPath(genBlobOptions(), genCanvasOptions())).not.toThrow(); }); it("should accept undefined canvasOptions", () => { expect(() => canvasPath(genBlobOptions(), undefined as any)).not.toThrow(); }); it("should reject invalid canvasOptions", () => { expect(() => canvasPath(genBlobOptions(), null as any)).toThrow( /canvasOptions.*object.*null/g, ); }); runSuite({ functionBeingTested: (canvasOptions) => canvasPath(genBlobOptions(), canvasOptions), optionsGenerator: genCanvasOptions, })([ // offsetX { name: "should accept valid canvasOptions offsetX", edit: (canvasOptions) => (canvasOptions.offsetX = 100), }, { name: "should accept undefined canvasOptions offsetX", edit: (canvasOptions) => delete canvasOptions?.offsetX, }, { name: "should reject broken canvasOptions offsetX", edit: (canvasOptions) => (canvasOptions.offsetX = NaN), error: /canvasOptions.*offsetX.*number.*NaN/g, }, // offsetY { name: "should accept valid canvasOptions offsetY", edit: (canvasOptions) => (canvasOptions.offsetY = 222), }, { name: "should accept undefined canvasOptions offsetY", edit: (canvasOptions) => delete canvasOptions?.offsetY, }, { name: "should reject broken canvasOptions offsetY", edit: (canvasOptions) => (canvasOptions.offsetY = NaN), error: /canvasOptions.*offsetY.*number.*NaN/g, }, ]); }); }); describe("svg", () => { describe("blobOptions", () => { testBlobOptions((blobOptions) => svg(blobOptions, genSvgOptions())); }); describe("svgOptions", () => { it("should accept generated svgOptions", () => { expect(() => svg(genBlobOptions(), genSvgOptions())).not.toThrow(); }); it("should accept undefined svgOptions", () => { expect(() => svg(genBlobOptions(), undefined as any)).not.toThrow(); }); it("should reject invalid svgOptions", () => { expect(() => svg(genBlobOptions(), null as any)).toThrow( /svgOptions.*object.*null/g, ); }); runSuite({ functionBeingTested: (svgOptions) => svg(genBlobOptions(), svgOptions), optionsGenerator: genSvgOptions, })([ // fill { name: "should accept valid svgOptions fill", edit: (svgOptions) => (svgOptions.fill = "red"), }, { name: "should accept undefined svgOptions fill", edit: (svgOptions) => delete svgOptions?.fill, }, { name: "should reject broken svgOptions fill", edit: (svgOptions) => (svgOptions.fill = null as any), error: /svgOptions.*fill.*string.*null/g, }, // stroke { name: "should accept valid svgOptions stroke", edit: (svgOptions) => (svgOptions.stroke = "red"), }, { name: "should accept undefined svgOptions stroke", edit: (svgOptions) => delete svgOptions?.stroke, }, { name: "should reject broken svgOptions stroke", edit: (svgOptions) => (svgOptions.stroke = null as any), error: /svgOptions.*stroke.*string.*null/g, }, // strokeWidth { name: "should accept valid svgOptions strokeWidth", edit: (svgOptions) => (svgOptions.strokeWidth = 222), }, { name: "should accept undefined svgOptions strokeWidth", edit: (svgOptions) => delete svgOptions?.strokeWidth, }, { name: "should reject broken svgOptions strokeWidth", edit: (svgOptions) => (svgOptions.strokeWidth = NaN), error: /svgOptions.*strokeWidth.*number.*NaN/g, }, ]); }); }); describe("svgPath", () => { describe("blobOptions", () => { testBlobOptions(svgPath); }); }); }); ================================================ FILE: public/blobs.ts ================================================ import {genFromOptions} from "../internal/gen"; import {renderPath} from "../internal/render/svg"; import {renderPath2D} from "../internal/render/canvas"; import {mapPoints} from "../internal/util"; import {checkBlobOptions, checkCanvasOptions, checkSvgOptions} from "../internal/check"; export interface BlobOptions { // A given seed will always produce the same blob. // Use `Math.random()` for pseudorandom behavior. seed: string | number; // Actual number of points will be `3 + extraPoints`. extraPoints: number; // Increases the amount of variation in point position. randomness: number; // Size of the bounding box. size: number; } export interface CanvasOptions { // Coordinates of top-left corner of the blob. offsetX?: number; offsetY?: number; } export interface SvgOptions { fill?: string; // Default: "#ec576b". stroke?: string; // Default: "none". strokeWidth?: number; // Default: 0. } export const canvasPath = (blobOptions: BlobOptions, canvasOptions: CanvasOptions = {}): Path2D => { try { checkBlobOptions(blobOptions); checkCanvasOptions(canvasOptions); } catch (e) { throw `(blobs2): ${e}`; } return renderPath2D( mapPoints(genFromOptions(blobOptions), ({curr}) => { curr.x += canvasOptions.offsetX || 0; curr.y += canvasOptions.offsetY || 0; return curr; }), ); }; export const svg = (blobOptions: BlobOptions, svgOptions: SvgOptions = {}): string => { try { checkBlobOptions(blobOptions); checkSvgOptions(svgOptions); } catch (e) { throw `(blobs2): ${e}`; } const path = svgPath(blobOptions); const size = Math.floor(blobOptions.size); const fill = svgOptions.fill === undefined ? "#ec576b" : svgOptions.fill; const stroke = svgOptions.stroke === undefined ? "none" : svgOptions.stroke; const strokeWidth = svgOptions.strokeWidth === undefined ? 0 : svgOptions.strokeWidth; return ` `.trim(); }; export const svgPath = (blobOptions: BlobOptions): string => { try { checkBlobOptions(blobOptions); } catch (e) { throw `(blobs2): ${e}`; } return renderPath(genFromOptions(blobOptions)); }; ================================================ FILE: public/legacy.test.ts ================================================ import blobs, {BlobOptions} from "./legacy"; const genMinimalOptions = (): BlobOptions => ({ size: 1000 * Math.random(), complexity: 1 - Math.random(), contrast: 1 - Math.random(), color: "#fff", }); describe("legacy", () => { it("should return a different result when seed is not provided", () => { const options = genMinimalOptions(); const a = blobs(options); const b = blobs(options); expect(a).not.toEqual(b); }); it("should return the same result when the seed is provided", () => { const options = genMinimalOptions(); options.seed = "abcde"; const a = blobs(options); const b = blobs(options); expect(a).toEqual(b); }); it("should require options be provided", () => { expect(() => (blobs as any)()).toThrow("options"); }); it("should require a size be provided", () => { const options = genMinimalOptions(); delete (options as any).size; expect(() => blobs(options)).toThrow("size"); }); it("should reject out of range complexity values", () => { const options = genMinimalOptions(); options.complexity = 1234; expect(() => blobs(options)).toThrow("complexity"); options.complexity = 0; expect(() => blobs(options)).toThrow("complexity"); }); it("should reject out of range contrast values", () => { const options = genMinimalOptions(); options.contrast = 999; expect(() => blobs(options)).toThrow("contrast"); options.contrast = -1; expect(() => blobs(options)).toThrow("contrast"); }); it("should reject options without stroke or color", () => { const options = genMinimalOptions(); delete options.stroke; delete options.color; expect(() => blobs(options)).toThrow("stroke"); expect(() => blobs(options)).toThrow("color"); }); }); describe("editable", () => { it("should reflect changes when edited", () => { const options = genMinimalOptions(); const out = blobs.editable(options); const initial = out.render(); out.attributes.id = "test"; const modified = out.render(); expect(modified).not.toBe(initial); }); }); // Sanity checks to ensure the output remains consistent // across changes to the source. const testCases: Record = { fill: { size: 109, complexity: 0.1, contrast: 0.331, color: "red", seed: "fill", }, stroke: { size: 226, complexity: 0.91, contrast: 0.6, stroke: { color: "#ff00bb", width: 3.8, }, seed: "stroke", }, guides: { size: 781, complexity: 1, contrast: 0.331, color: "yellow", guides: true, seed: "guides", }, }; for (const testCase of Object.keys(testCases)) { test(testCase, () => { expect(blobs(testCases[testCase])).toMatchSnapshot(); }); } ================================================ FILE: public/legacy.ts ================================================ import {rand} from "../internal/rand"; import {renderEditable, XmlElement as InternalXmlElement} from "../internal/render/svg"; import {genBlob} from "../internal/gen"; import {mapPoints} from "../internal/util"; const isBrowser = new Function("try {return this===window;}catch(e){ return false;}"); const isLocalhost = () => location.hostname === "localhost" || location.hostname === "127.0.0.1"; const isFile = () => location.protocol === "file:"; if (!isBrowser() || isLocalhost() || isFile()) { console.warn("You are using the legacy blobs API!\nPlease use 'blobs/v2' instead."); } export interface PathOptions { // Bounding box dimensions. size: number; // Number of points. complexity: number; // Amount of randomness. contrast: number; // Value to seed random number generator. seed?: string; } export interface BlobOptions extends PathOptions { // Fill color. color?: string; stroke?: { // Stroke color. color: string; // Stroke width. width: number; }; // Render points, handles and stroke. guides?: boolean; } // Generates an svg document string containing a randomized blob. const blobs = (options: BlobOptions): string => { return blobs.editable(options).render(); }; // Generates a randomized blob as an editable data structure which can be rendered to an svg document. blobs.editable = (options: BlobOptions): XmlElement => { if (!options) { throw new Error("no options specified"); } // Random number generator. const rgen = rand(options.seed || String(Math.random())); if (!options.size) { throw new Error("no size specified"); } if (!options.stroke && !options.color) { throw new Error("no color or stroke specified"); } if (options.complexity <= 0 || options.complexity > 1) { throw new Error("complexity out of range ]0,1]"); } if (options.contrast < 0 || options.contrast > 1) { throw new Error("contrast out of range [0,1]"); } const count = 3 + Math.floor(14 * options.complexity); const offset = (): number => (1 - 0.8 * options.contrast * rgen()) / Math.E; const points = mapPoints(genBlob(count, offset), ({curr}) => { // Scale. curr.x *= options.size; curr.y *= options.size; curr.handleIn.length *= options.size; curr.handleOut.length *= options.size; // Flip around x-axis. curr.y = options.size - curr.y; curr.handleIn.angle *= -1; curr.handleOut.angle *= -1; return curr; }); return renderEditable(points, { closed: true, width: options.size, height: options.size, fill: options.color, transform: `rotate(${rgen() * (360 / count)},${options.size / 2},${options.size / 2})`, stroke: options.stroke && options.stroke.color, strokeWidth: options.stroke && options.stroke.width, guides: options.guides, }); }; export interface XmlElement { tag: string; attributes: Record; children: XmlElement[]; render(): string; } // Shortcut to create an XmlElement without "new"; blobs.xml = (tag: string): XmlElement => new InternalXmlElement(tag); export default blobs; ================================================ FILE: rollup.config.mjs ================================================ import typescript from "rollup-plugin-typescript2"; import { uglify } from "rollup-plugin-uglify"; import copy from "rollup-plugin-copy"; import { nodeResolve } from "@rollup/plugin-node-resolve"; const bundles = [ { name: "blobs", entry: "public/legacy.ts", types: "public/legacy.d.ts", output: ".", }, { name: "blobs", entry: "public/legacy.ts", types: "public/legacy.d.ts", output: "v1", }, { name: "blobs2", entry: "public/blobs.ts", types: "public/blobs.d.ts", output: "v2", }, { name: "blobs2Animate", entry: "public/animate.ts", types: "public/animate.d.ts", output: "v2/animate", }, ]; export default ["es", "umd"].flatMap((format) => bundles.map((bundle) => ({ input: bundle.entry, output: { file: bundle.output + `/index${format == "es" ? ".module" : ""}.js`, format: format, name: bundle.name, sourcemap: true, }, plugins: [ nodeResolve(), typescript({ cacheRoot: "./node_modules/.cache/rpt2" }), uglify(), copy({ hook: "writeBundle", targets: [{ src: bundle.types, dest: bundle.output, rename: "index.d.ts", }], verbose: true, }), ], })) ); ================================================ FILE: tsconfig.json ================================================ { "exclude": [ "example", "test", "node_modules" ], "compilerOptions": { "target": "es5", "module": "es2015", "moduleResolution": "node", "esModuleInterop": true, "lib": [ "es2018", "dom" ], "sourceMap": true, "removeComments": true, "declaration": true, "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictPropertyInitialization": true, "alwaysStrict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "allowSyntheticDefaultImports": true, "stripInternal": true } }