telesoft-waves
Version:
The Siri wave replicated in a JS library.
328 lines (280 loc) • 8.39 kB
text/typescript
import { ClassicCurve } from "./classic-curve";
import { iOS9Curve } from "./ios9-curve";
type CurveStyle = "ios" | "ios9";
export type Options = {
// The DOM container where the DOM canvas element will be added
container: HTMLElement;
// The style of the wave: `ios` or `ios9`
style?: CurveStyle;
// Ratio of the display to use. Calculated by default.
ratio?: number;
// The speed of the animation.
speed?: number;
// The amplitude of the complete wave.
amplitude?: number;
// The frequency for the complete wave (how many waves). - Not available in iOS9 Style
frequency?: number;
// The color of the wave, in hexadecimal form (`#336699`, `#FF0`). - Not available in iOS9 Style
color?: string;
// The `canvas` covers the entire width or height of the container.
cover?: boolean;
// Width of the canvas. Calculated by default.
width?: number;
// Height of the canvas. Calculated by default.
height?: number;
// Decide wether start the animation on boot.
autostart?: boolean;
// Number of step (in pixels) used when drawed on canvas.
pixelDepth?: number;
// Lerp speed to interpolate properties.
lerpSpeed?: number;
// Curve definition override
curveDefinition?: ICurveDefinition[];
};
export type IiOS9CurveDefinition = {
supportLine?: boolean;
color: string;
};
export type IClassicCurveDefinition = {
attenuation: number;
lineWidth: number;
opacity: number;
};
export type ICurveDefinition = IiOS9CurveDefinition | IClassicCurveDefinition;
export interface ICurve {
draw: () => void;
}
export default class SiriWave {
opt: Options;
// Phase of the wave (passed to Math.sin function)
phase: number = 0;
// Boolean value indicating the the animation is running
run: boolean = false;
// Curves objects to animate
curves: ICurve[] = [];
speed: number;
amplitude: number;
width: number;
height: number;
heightMax: number;
color: string;
interpolation: {
speed: number | null;
amplitude: number | null;
};
canvas: HTMLCanvasElement | null;
ctx: CanvasRenderingContext2D;
animationFrameId: number | undefined;
timeoutId: ReturnType<typeof setTimeout> | undefined;
constructor({ container, ...rest }: Options) {
const csStyle = window.getComputedStyle(container);
this.opt = {
container,
style: "ios",
ratio: window.devicePixelRatio || 1,
speed: 0.2,
amplitude: 1,
frequency: 6,
color: "#fff",
cover: false,
width: parseInt(csStyle.width.replace("px", ""), 10),
height: parseInt(csStyle.height.replace("px", ""), 10),
autostart: true,
pixelDepth: 0.02,
lerpSpeed: 0.1,
...rest,
};
/**
* Actual speed of the animation. Is not safe to change this value directly, use `setSpeed` instead.
*/
this.speed = Number(this.opt.speed);
/**
* Actual amplitude of the animation. Is not safe to change this value directly, use `setAmplitude` instead.
*/
this.amplitude = Number(this.opt.amplitude);
/**
* Width of the canvas multiplied by pixel ratio
*/
this.width = Number(this.opt.ratio! * this.opt.width!);
/**
* Height of the canvas multiplied by pixel ratio
*/
this.height = Number(this.opt.ratio! * this.opt.height!);
/**
* Maximum height for a single wave
*/
this.heightMax = Number(this.height / 2) - 6;
/**
* Color of the wave (used in Classic iOS)
*/
this.color = `rgb(${this.hex2rgb(this.opt.color!)})`;
/**
* An object containing controller variables that need to be interpolated
* to an another value before to be actually changed
*/
this.interpolation = {
speed: this.speed,
amplitude: this.amplitude,
};
/**
* Canvas DOM Element where curves will be drawn
*/
this.canvas = document.createElement("canvas");
/**
* 2D Context from Canvas
*/
const ctx = this.canvas.getContext("2d");
if (ctx === null) {
throw new Error("Unable to create 2D Context");
}
this.ctx = ctx;
// Set dimensions
this.canvas.width = this.width;
this.canvas.height = this.height;
// By covering, we ensure the canvas is in the same size of the parent
if (this.opt.cover === true) {
this.canvas.style.width = this.canvas.style.height = "100%";
} else {
this.canvas.style.width = `${this.width / this.opt.ratio!}px`;
this.canvas.style.height = `${this.height / this.opt.ratio!}px`;
}
// Instantiate all curves based on the style
switch (this.opt.style) {
case "ios9":
this.curves = ((this.opt.curveDefinition || iOS9Curve.getDefinition()) as IiOS9CurveDefinition[]).map(
(def) => new iOS9Curve(this, def),
);
break;
case "ios":
default:
this.curves = ((this.opt.curveDefinition || ClassicCurve.getDefinition()) as IClassicCurveDefinition[]).map(
(def) => new ClassicCurve(this, def),
);
break;
}
// Attach to the container
this.opt.container.appendChild(this.canvas);
// Start the animation
if (this.opt.autostart) {
this.start();
}
}
/**
* Convert an HEX color to RGB
*/
private hex2rgb(hex: string): string | null {
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? `${parseInt(result[1], 16).toString()},${parseInt(result[2], 16).toString()},${parseInt(
result[3],
16,
).toString()}`
: null;
}
private intLerp(v0: number, v1: number, t: number): number {
return v0 * (1 - t) + v1 * t;
}
/**
* Interpolate a property to the value found in this.interpolation
*/
private lerp(propertyStr: "amplitude" | "speed"): number | null {
const prop = this.interpolation[propertyStr];
if (prop !== null) {
this[propertyStr] = this.intLerp(this[propertyStr], prop, this.opt.lerpSpeed!);
if (this[propertyStr] - prop === 0) {
this.interpolation[propertyStr] = null;
}
}
return this[propertyStr];
}
/**
* Clear the canvas
*/
private clear() {
this.ctx.globalCompositeOperation = "destination-out";
this.ctx.fillRect(0, 0, this.width, this.height);
this.ctx.globalCompositeOperation = "source-over";
}
/**
* Draw all curves
*/
private draw() {
this.curves.forEach((curve) => curve.draw());
}
/**
* Clear the space, interpolate values, calculate new steps and draws
* @returns
*/
private startDrawCycle() {
this.clear();
// Interpolate values
this.lerp("amplitude");
this.lerp("speed");
this.draw();
this.phase = (this.phase + (Math.PI / 2) * this.speed) % (2 * Math.PI);
if (window.requestAnimationFrame) {
this.animationFrameId = window.requestAnimationFrame(this.startDrawCycle.bind(this));
} else {
this.timeoutId = setTimeout(this.startDrawCycle.bind(this), 20);
}
}
/* API */
/**
* Start the animation
*/
start() {
if (!this.canvas) {
throw new Error("This instance of SiriWave has been disposed, please create a new instance");
}
this.phase = 0;
// Ensure we don't re-launch the draw cycle
if (!this.run) {
this.run = true;
this.startDrawCycle();
}
}
/**
* Stop the animation
*/
stop() {
this.phase = 0;
this.run = false;
// Clear old draw cycle on stop
if (this.animationFrameId) {
window.cancelAnimationFrame(this.animationFrameId);
}
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
}
/**
* Dispose
*/
dispose() {
this.stop();
if (this.canvas) {
this.canvas.remove();
this.canvas = null;
}
}
/**
* Set a new value for a property (interpolated)
*/
set(propertyStr: "amplitude" | "speed", value: number) {
this.interpolation[propertyStr] = value;
}
/**
* Set a new value for the speed property (interpolated)
*/
setSpeed(value: number) {
this.set("speed", value);
}
/**
* Set a new value for the amplitude property (interpolated)
*/
setAmplitude(value: number) {
this.set("amplitude", value);
}
}