Eduard Kuzmenko
3 years ago
19 changed files with 1055 additions and 188 deletions
@ -0,0 +1,393 @@
@@ -0,0 +1,393 @@
|
||||
import { animate } from "../../helpers/animation"; |
||||
import { hexToRgb } from "../../helpers/color"; |
||||
|
||||
const WIDTH = 50; |
||||
const HEIGHT = WIDTH; |
||||
|
||||
export default class ChatBackgroundGradientRenderer { |
||||
private readonly _width = WIDTH; |
||||
private readonly _height = HEIGHT; |
||||
private _phase: number; |
||||
private _tail: number; |
||||
private readonly _tails = 90; |
||||
private readonly _scrollTails = 50; |
||||
private _frames: ImageData[]; |
||||
private _colors: {r: number, g: number, b: number}[]; |
||||
/* private readonly _curve = [ |
||||
0, 25, 50, 75, 100, 150, 200, 250, 300, 350, 400, 500, 600, 700, 800, 900, |
||||
1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1830, 1860, 1890, 1920, |
||||
1950, 1980, 2010, 2040, 2070, 2100, 2130, 2160, 2190, 2220, 2250, 2280, 2310, |
||||
2340, 2370, 2400, 2430, 2460, 2490, 2520, 2550, 2580, 2610, 2630, 2640, 2650, |
||||
2660, 2670, 2680, 2690, 2700 |
||||
]; */ |
||||
private readonly _curve = [ |
||||
0 , 0.25 , 0.50 , 0.75 , 1 , 1.5 , 2 , 2.5 , 3 , 3.5 , 4 , 5 , 6 , 7 , 8 , 9 , 10 , 11 , 12 , |
||||
13 , 14 , 15 , 16 , 17 , 18 , 18.3 , 18.6 , 18.9 , 19.2 , 19.5 , 19.8 , 20.1 , 20.4 , 20.7 , |
||||
21.0 , 21.3 , 21.6 , 21.9 , 22.2 , 22.5 , 22.8 , 23.1 , 23.4 , 23.7 , 24.0 , 24.3 , 24.6 , |
||||
24.9 , 25.2 , 25.5 , 25.8 , 26.1 , 26.3 , 26.4 , 26.5 , 26.6 , 26.7 , 26.8 , 26.9 , 27 , |
||||
]; |
||||
private readonly _incrementalCurve: number[]; |
||||
private readonly _positions = [ |
||||
{ x: 0.80, y: 0.10 }, |
||||
{ x: 0.60, y: 0.20 }, |
||||
{ x: 0.35, y: 0.25 }, |
||||
{ x: 0.25, y: 0.60 }, |
||||
{ x: 0.20, y: 0.90 }, |
||||
{ x: 0.40, y: 0.80 }, |
||||
{ x: 0.65, y: 0.75 }, |
||||
{ x: 0.75, y: 0.40 } |
||||
]; |
||||
private readonly _phases = this._positions.length; |
||||
private _onWheelRAF: number; |
||||
private _scrollDelta: number; |
||||
|
||||
// private _ts = 0;
|
||||
// private _fps = 15;
|
||||
// private _frametime = 1000 / this._fps;
|
||||
// private _raf: number;
|
||||
|
||||
private _canvas: HTMLCanvasElement; |
||||
private _ctx: CanvasRenderingContext2D; |
||||
private _hc: HTMLCanvasElement; |
||||
private _hctx: CanvasRenderingContext2D; |
||||
|
||||
private _addedScrollListener: boolean; |
||||
private _animatingToNextPosition: boolean; |
||||
|
||||
constructor() { |
||||
const diff = this._tails / this._curve[this._curve.length - 1]; |
||||
|
||||
for(let i = 0, length = this._curve.length; i < length; ++i) { |
||||
this._curve[i] = this._curve[i] * diff; |
||||
} |
||||
|
||||
this._incrementalCurve = this._curve.map((v, i, arr) => { |
||||
return v - (arr[i - 1] ?? 0); |
||||
}); |
||||
} |
||||
|
||||
private hexToRgb(hex: string) { |
||||
const result = hexToRgb(hex); |
||||
return {r: result[0], g: result[1], b: result[2]}; |
||||
} |
||||
|
||||
private getPositions(shift: number) { |
||||
const positions = this._positions.slice(); |
||||
while(shift > 0) { |
||||
positions.push(positions.shift()); |
||||
--shift; |
||||
} |
||||
|
||||
const result: typeof positions = []; |
||||
for(let i = 0; i < positions.length; i += 2) { |
||||
result.push(positions[i]); |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
private getNextPositions(phase: number, curveMax: number, curve: number[]) { |
||||
const pos = this.getPositions(phase); |
||||
if(!curve[0] && curve.length === 1) { |
||||
return [pos]; |
||||
} |
||||
|
||||
const nextPos = this.getPositions(++phase % this._phases); |
||||
const distances = nextPos.map((nextPos, idx) => { |
||||
return { |
||||
x: (nextPos.x - pos[idx].x) / curveMax, |
||||
y: (nextPos.y - pos[idx].y) / curveMax, |
||||
}; |
||||
}); |
||||
|
||||
const positions = curve.map((value) => { |
||||
return distances.map((distance, idx) => { |
||||
return { |
||||
x: pos[idx].x + distance.x * value, |
||||
y: pos[idx].y + distance.y * value |
||||
}; |
||||
}); |
||||
}); |
||||
|
||||
return positions; |
||||
} |
||||
|
||||
private curPosition(phase: number, tail: number) { |
||||
const positions = this.getNextPositions(phase, this._tails, [tail]); |
||||
return positions[0]; |
||||
} |
||||
|
||||
private changeTail(diff: number) { |
||||
this._tail += diff; |
||||
|
||||
while(this._tail >= this._tails) { |
||||
this._tail -= this._tails; |
||||
if(++this._phase >= this._phases) { |
||||
this._phase -= this._phases; |
||||
} |
||||
} |
||||
|
||||
while(this._tail < 0) { |
||||
this._tail += this._tails; |
||||
if(--this._phase < 0) { |
||||
this._phase += this._phases; |
||||
} |
||||
} |
||||
} |
||||
|
||||
private onWheel = (e: {deltaY: number}) => { |
||||
if(this._animatingToNextPosition) { |
||||
return; |
||||
} |
||||
|
||||
this._scrollDelta += e.deltaY; |
||||
if(this._onWheelRAF === undefined) { |
||||
this._onWheelRAF = requestAnimationFrame(this.drawOnWheel); |
||||
} |
||||
}; |
||||
|
||||
private drawOnWheel = () => { |
||||
let diff = this._scrollDelta / this._scrollTails; |
||||
this._scrollDelta %= this._scrollTails; |
||||
diff = diff > 0 ? Math.floor(diff) : Math.ceil(diff); |
||||
if(diff) { |
||||
this.changeTail(diff); |
||||
const curPos = this.curPosition(this._phase, this._tail); |
||||
this.drawGradient(curPos); |
||||
} |
||||
this._onWheelRAF = undefined; |
||||
}; |
||||
|
||||
private drawNextPositionAnimated = () => { |
||||
const frames = this._frames; |
||||
const id = frames.shift(); |
||||
if(id) { |
||||
this.drawImageData(id); |
||||
} |
||||
|
||||
const leftLength = frames.length; |
||||
if(!leftLength) { |
||||
this._animatingToNextPosition = undefined; |
||||
} |
||||
|
||||
return !!leftLength; |
||||
}; |
||||
|
||||
private getGradientImageData(positions: {x: number, y: number}[]) { |
||||
const id = this._hctx.createImageData(this._width, this._height); |
||||
const pixels = id.data; |
||||
|
||||
let offset = 0; |
||||
for(let y = 0; y < this._height; ++y) { |
||||
const directPixelY = y / this._height; |
||||
const centerDistanceY = directPixelY - 0.5; |
||||
const centerDistanceY2 = centerDistanceY * centerDistanceY; |
||||
|
||||
for(let x = 0; x < this._width; ++x) { |
||||
const directPixelX = x / this._width; |
||||
|
||||
const centerDistanceX = directPixelX - 0.5; |
||||
const centerDistance = Math.sqrt(centerDistanceX * centerDistanceX + centerDistanceY2); |
||||
|
||||
const swirlFactor = 0.35 * centerDistance; |
||||
const theta = swirlFactor * swirlFactor * 0.8 * 8.0; |
||||
const sinTheta = Math.sin(theta); |
||||
const cosTheta = Math.cos(theta); |
||||
|
||||
const pixelX = Math.max(0.0, Math.min(1.0, 0.5 + centerDistanceX * cosTheta - centerDistanceY * sinTheta)); |
||||
const pixelY = Math.max(0.0, Math.min(1.0, 0.5 + centerDistanceX * sinTheta + centerDistanceY * cosTheta)); |
||||
|
||||
let distanceSum = 0.0; |
||||
|
||||
let r = 0.0; |
||||
let g = 0.0; |
||||
let b = 0.0; |
||||
|
||||
for(let i = 0; i < this._colors.length; i++) { |
||||
const colorX = positions[i].x; |
||||
const colorY = positions[i].y; |
||||
|
||||
const distanceX = pixelX - colorX; |
||||
const distanceY = pixelY - colorY; |
||||
|
||||
let distance = Math.max(0.0, 0.9 - Math.sqrt(distanceX * distanceX + distanceY * distanceY)); |
||||
distance = distance * distance * distance * distance; |
||||
distanceSum += distance; |
||||
|
||||
r += distance * this._colors[i].r / 255; |
||||
g += distance * this._colors[i].g / 255; |
||||
b += distance * this._colors[i].b / 255; |
||||
} |
||||
|
||||
pixels[offset++] = r / distanceSum * 255.0; |
||||
pixels[offset++] = g / distanceSum * 255.0; |
||||
pixels[offset++] = b / distanceSum * 255.0; |
||||
pixels[offset++] = 0xFF; |
||||
} |
||||
} |
||||
return id; |
||||
} |
||||
|
||||
private drawImageData(id: ImageData) { |
||||
this._hctx.putImageData(id, 0, 0); |
||||
this._ctx.drawImage(this._hc, 0, 0, this._width, this._height); |
||||
} |
||||
|
||||
private drawGradient(positions: {x: number, y: number}[]) { |
||||
this.drawImageData(this.getGradientImageData(positions)); |
||||
} |
||||
|
||||
// private doAnimate = () => {
|
||||
// const now = +Date.now();
|
||||
// if(!document.hasFocus() || (now - this._ts) < this._frametime) {
|
||||
// this._raf = requestAnimationFrame(this.doAnimate);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// this._ts = now;
|
||||
// this.changeTail(1);
|
||||
// const cur_pos = this.curPosition(this._phase, this._tail);
|
||||
// this.drawGradient(cur_pos);
|
||||
// this._raf = requestAnimationFrame(this.doAnimate);
|
||||
// };
|
||||
|
||||
// public animate(start?: boolean) {
|
||||
// if(!start) {
|
||||
// cancelAnimationFrame(this._raf);
|
||||
// return;
|
||||
// }
|
||||
// this.doAnimate();
|
||||
// }
|
||||
|
||||
public init(el: HTMLCanvasElement) { |
||||
this._frames = []; |
||||
this._phase = 0; |
||||
this._tail = 0; |
||||
this._scrollDelta = 0; |
||||
if(this._onWheelRAF !== undefined) { |
||||
cancelAnimationFrame(this._onWheelRAF); |
||||
this._onWheelRAF = undefined; |
||||
} |
||||
|
||||
const colors = el.getAttribute('data-colors').split(',').reverse(); |
||||
this._colors = colors.map(color => { |
||||
return this.hexToRgb(color); |
||||
}); |
||||
|
||||
if(!this._hc) { |
||||
this._hc = document.createElement('canvas'); |
||||
this._hc.width = this._width; |
||||
this._hc.height = this._height; |
||||
this._hctx = this._hc.getContext('2d'); |
||||
} |
||||
|
||||
this._canvas = el; |
||||
this._ctx = this._canvas.getContext('2d'); |
||||
this.update(); |
||||
} |
||||
|
||||
public update() { |
||||
if(this._colors.length < 2) { |
||||
const color = this._colors[0]; |
||||
this._ctx.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`; |
||||
this._ctx.fillRect(0, 0, this._width, this._height); |
||||
return; |
||||
} |
||||
|
||||
const pos = this.curPosition(this._phase, this._tail); |
||||
this.drawGradient(pos); |
||||
} |
||||
|
||||
public toNextPosition() { |
||||
if(this._colors.length < 2) { |
||||
return; |
||||
} |
||||
|
||||
const tail = this._tail; |
||||
const tails = this._tails; |
||||
|
||||
let nextPhaseOnIdx: number; |
||||
|
||||
const curve: number[] = []; |
||||
for(let i = 0, length = this._incrementalCurve.length; i < length; ++i) { |
||||
const inc = this._incrementalCurve[i]; |
||||
let value = (curve[i - 1] ?? tail) + inc; |
||||
|
||||
if(+value.toFixed(2) > tails && nextPhaseOnIdx === undefined) { |
||||
nextPhaseOnIdx = i; |
||||
value %= tails; |
||||
} |
||||
|
||||
curve.push(value); |
||||
} |
||||
|
||||
const currentPhaseCurve = curve.slice(0, nextPhaseOnIdx); |
||||
const nextPhaseCurve = nextPhaseOnIdx !== undefined ? curve.slice(nextPhaseOnIdx) : []; |
||||
|
||||
[currentPhaseCurve, nextPhaseCurve].forEach((curve, idx, curves) => { |
||||
const last = curve[curve.length - 1]; |
||||
if(last !== undefined && last > tails) { |
||||
curve[curve.length - 1] = +last.toFixed(2); |
||||
} |
||||
|
||||
this._tail = last ?? 0; |
||||
|
||||
if(!curve.length) { |
||||
return; |
||||
} |
||||
|
||||
const positions = this.getNextPositions(this._phase, tails, curve); |
||||
if(idx !== (curves.length - 1)) { |
||||
if(++this._phase >= this._phases) { |
||||
this._phase -= this._phases; |
||||
} |
||||
} |
||||
|
||||
const ids = positions.map((pos) => { |
||||
return this.getGradientImageData(pos); |
||||
}); |
||||
|
||||
this._frames.push(...ids); |
||||
}); |
||||
|
||||
this._animatingToNextPosition = true; |
||||
animate(this.drawNextPositionAnimated); |
||||
} |
||||
|
||||
public scrollAnimate(start?: boolean) { |
||||
if(this._colors.length < 2 && start) { |
||||
return; |
||||
} |
||||
|
||||
if(start && !this._addedScrollListener) { |
||||
document.addEventListener('wheel', this.onWheel); |
||||
this._addedScrollListener = true; |
||||
} else if(!start && this._addedScrollListener) { |
||||
document.removeEventListener('wheel', this.onWheel); |
||||
this._addedScrollListener = false; |
||||
} |
||||
} |
||||
|
||||
public cleanup() { |
||||
this.scrollAnimate(false); |
||||
// this.animate(false);
|
||||
} |
||||
|
||||
public static createCanvas(colors?: string) { |
||||
const canvas = document.createElement('canvas'); |
||||
canvas.width = WIDTH; |
||||
canvas.height = HEIGHT; |
||||
if(colors !== undefined) { |
||||
canvas.dataset.colors = colors; |
||||
} |
||||
|
||||
return canvas; |
||||
} |
||||
|
||||
public static create(colors?: string) { |
||||
const canvas = this.createCanvas(colors); |
||||
const gradientRenderer = new ChatBackgroundGradientRenderer(); |
||||
gradientRenderer.init(canvas); |
||||
|
||||
return {gradientRenderer, canvas}; |
||||
} |
||||
} |
@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
|
||||
/* |
||||
* https://github.com/morethanwords/tweb
|
||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||
*/ |
||||
|
||||
import { IS_SAFARI } from "../../environment/userAgent"; |
||||
import { indexOfAndSplice } from "../../helpers/array"; |
||||
import { renderImageFromUrlPromise } from "../../helpers/dom/renderImageFromUrl"; |
||||
import { deepEqual } from "../../helpers/object"; |
||||
|
||||
type ChatBackgroundPatternRendererInitOptions = { |
||||
url: string, |
||||
width: number, |
||||
height: number |
||||
}; |
||||
|
||||
export default class ChatBackgroundPatternRenderer { |
||||
private static INSTANCES: ChatBackgroundPatternRenderer[] = []; |
||||
|
||||
private pattern: CanvasPattern; |
||||
private objectUrl: string; |
||||
private options: ChatBackgroundPatternRendererInitOptions; |
||||
private canvases: Set<HTMLCanvasElement>; |
||||
private createCanvasPatternPromise: Promise<void>; |
||||
private exportCanvasPatternToImagePromise: Promise<string>; |
||||
// private img: HTMLImageElement;
|
||||
|
||||
constructor() { |
||||
this.canvases = new Set(); |
||||
} |
||||
|
||||
public static getInstance(options: ChatBackgroundPatternRendererInitOptions) { |
||||
let instance = this.INSTANCES.find((instance) => { |
||||
return deepEqual(instance.options, options); |
||||
}); |
||||
|
||||
if(!instance) { |
||||
instance = new ChatBackgroundPatternRenderer(); |
||||
instance.init(options); |
||||
this.INSTANCES.push(instance); |
||||
} |
||||
|
||||
return instance; |
||||
} |
||||
|
||||
public init(options: ChatBackgroundPatternRendererInitOptions) { |
||||
this.options = options; |
||||
} |
||||
|
||||
public renderToCanvas(canvas: HTMLCanvasElement) { |
||||
return this.createCanvasPattern(canvas).then(() => { |
||||
return this.fillCanvas(canvas); |
||||
}); |
||||
} |
||||
|
||||
private createCanvasPattern(canvas: HTMLCanvasElement) { |
||||
if(this.createCanvasPatternPromise) return this.createCanvasPatternPromise; |
||||
return this.createCanvasPatternPromise = new Promise((resolve) => { |
||||
const img = document.createElement('img'); |
||||
img.crossOrigin = 'anonymous'; |
||||
renderImageFromUrlPromise(img, this.options.url, false).then(() => { |
||||
let createPatternFrom: HTMLImageElement | HTMLCanvasElement; |
||||
if(IS_SAFARI) { |
||||
const canvas = createPatternFrom = document.createElement('canvas'); |
||||
canvas.width = img.naturalWidth; |
||||
canvas.height = img.naturalHeight; |
||||
const ctx = canvas.getContext('2d'); |
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height); |
||||
} else { |
||||
createPatternFrom = img; |
||||
} |
||||
|
||||
// this.img = img;
|
||||
this.pattern = canvas.getContext('2d').createPattern(createPatternFrom, 'repeat-x'); |
||||
resolve(); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
public exportCanvasPatternToImage(canvas: HTMLCanvasElement) { |
||||
if(this.exportCanvasPatternToImagePromise) return this.exportCanvasPatternToImagePromise; |
||||
return this.exportCanvasPatternToImagePromise = new Promise<string>((resolve) => { |
||||
canvas.toBlob((blob) => { |
||||
const newUrl = this.objectUrl = URL.createObjectURL(blob); |
||||
resolve(newUrl); |
||||
}, 'image/png'); |
||||
}); |
||||
} |
||||
|
||||
public cleanup(canvas: HTMLCanvasElement) { |
||||
this.canvases.delete(canvas); |
||||
|
||||
if(!this.canvases.size) { |
||||
indexOfAndSplice(ChatBackgroundPatternRenderer.INSTANCES, this); |
||||
|
||||
if(this.objectUrl) { |
||||
URL.revokeObjectURL(this.objectUrl); |
||||
} |
||||
} |
||||
} |
||||
|
||||
public fillCanvas(canvas: HTMLCanvasElement) { |
||||
const context = canvas.getContext('2d'); |
||||
context.fillStyle = this.pattern; |
||||
context.fillRect(0, 0, canvas.width, canvas.height); |
||||
// context.drawImage(this.img, 0, 0, canvas.width, canvas.height);
|
||||
} |
||||
|
||||
public setCanvasDimensions(canvas: HTMLCanvasElement) { |
||||
canvas.width = this.options.width * window.devicePixelRatio; |
||||
canvas.height = this.options.height * window.devicePixelRatio * 1.5; |
||||
} |
||||
|
||||
public createCanvas() { |
||||
const canvas = document.createElement('canvas'); |
||||
this.canvases.add(canvas); |
||||
this.setCanvasDimensions(canvas); |
||||
return canvas; |
||||
} |
||||
} |
Loading…
Reference in new issue