From 576d06e4bd6203c0095da0c2f77b22dd0939ac99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Schmelczer=20Andr=C3=A1s?= Date: Fri, 3 Jan 2020 20:51:18 +0100 Subject: [PATCH] Basic canvas parallax --- .idea/dictionaries/andras.xml | 1 + .idea/workspace.xml | 20 ++- src/page/background/animation.ts | 40 +++++ src/page/background/background.scss | 1 - src/page/background/background.ts | 249 +++++++++++++--------------- src/page/background/blob.ts | 79 +++++---- src/page/background/vec2.ts | 17 ++ src/page/background/vec3.ts | 23 +++ src/portfolio.ts | 2 +- 9 files changed, 260 insertions(+), 172 deletions(-) create mode 100644 src/page/background/animation.ts create mode 100644 src/page/background/vec2.ts create mode 100644 src/page/background/vec3.ts diff --git a/.idea/dictionaries/andras.xml b/.idea/dictionaries/andras.xml index b6585d1..6de328f 100644 --- a/.idea/dictionaries/andras.xml +++ b/.idea/dictionaries/andras.xml @@ -2,6 +2,7 @@ andrĂ¡s + deltatime forex schmelczer diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 253397b..8c5bf83 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -2,12 +2,15 @@ + + + + - - + - - - - - - @@ -60,6 +57,12 @@ + + + + + + diff --git a/src/page/background/animation.ts b/src/page/background/animation.ts new file mode 100644 index 0000000..cecb122 --- /dev/null +++ b/src/page/background/animation.ts @@ -0,0 +1,40 @@ +import { Vec2 } from "./vec2"; + +export class Animation { + private _value: Vec2; + private elapsedTime = 0; + + public constructor( + private from: Vec2, + private to: Vec2, + private intervalInMs: number, + private onChange?: (currentValue: Vec2) => void + ) {} + + public step(deltaTimeInMs: number) { + if (this.elapsedTime === this.intervalInMs) { + return; + } + + this.elapsedTime = Math.min( + this.elapsedTime + deltaTimeInMs, + this.intervalInMs + ); + const q = this.elapsedTime / this.intervalInMs; + + this._value = new Vec2( + Animation.interpolate(this.from.x, this.to.x, q), + Animation.interpolate(this.from.y, this.to.y, q) + ); + + this.onChange?.call(null, this._value); + } + + private static interpolate(from: number, to: number, q: number): number { + return from + q * (to - from); + } + + public get value(): Vec2 { + return this._value; + } +} diff --git a/src/page/background/background.scss b/src/page/background/background.scss index c03ed19..65d97eb 100644 --- a/src/page/background/background.scss +++ b/src/page/background/background.scss @@ -8,5 +8,4 @@ canvas#background { height: 100vh; width: 100%; z-index: -10; - background-color: hotpink; } diff --git a/src/page/background/background.ts b/src/page/background/background.ts index d90e346..c9d7be4 100644 --- a/src/page/background/background.ts +++ b/src/page/background/background.ts @@ -1,33 +1,31 @@ import { PageElement } from "../../framework/page-element"; -import { - getHeight, - createElement, - randomFactory, - sum, - randomInInterval -} from "../../framework/helper"; +import { getHeight, createElement, sum } from "../../framework/helper"; import { PageEvent, PageEventType } from "../../framework/page-event"; import { Blob } from "./blob"; import { generate } from "./background.html"; +import { Animation } from "./animation"; +import { Vec3 } from "./vec3"; +import { Vec2 } from "./vec2"; export class PageBackground extends PageElement { private readonly blobs: Array = []; - private readonly blobSpacing = 350; - private readonly baseDeltaTime = (1 / 30) * 1000; + private readonly blobSpacing = 140; private readonly perspective = 5; private readonly zMin = 10; private readonly zMax = 30; - private width: number; - private height: number; + private readonly animationTime = 350; + private backgroundSize: Animation; private scrollPosition: number = 0; private previousTimestamp: DOMHighResTimeStamp = null; - private readonly ctx: RenderingContext; + private readonly canvas: HTMLCanvasElement; + private readonly ctx: CanvasRenderingContext2D; public constructor(private start: PageElement, private end: PageElement) { super(); - const canvas = createElement(generate()) as HTMLCanvasElement; - this.ctx = canvas.getContext("2d"); - this.setElement(canvas); + this.canvas = createElement(generate()) as HTMLCanvasElement; + this.ctx = this.canvas.getContext("2d"); + this.setElement(this.canvas); + Blob.initialize(this.zMin, this.zMax); } protected handleEvent(event: PageEvent, parent: PageElement) { @@ -40,130 +38,48 @@ export class PageBackground extends PageElement { private bindListeners(parent: PageElement) { window.addEventListener("resize", () => this.resize(parent)); - window.addEventListener("load", () => this.resize(parent)); - window.requestAnimationFrame(timestamp => - this.scrollContainer(timestamp, parent) - ); - } - - public drawBlob(blob: Blob) { - const topLeft = this.convertFrom3Dto2D(blob.topLeft); - const bottomRight = this.convertFrom3Dto2D(blob.bottomRight); - } - - private convertFrom3Dto2D(point: [number, number, number]): [number, number] { - let [x, y, z] = point; - return [ - (z / this.perspective) * (this.width / 2 - x) + x, - (z / this.perspective) * (this.height / 2 - y) + y - this.scrollPosition - ]; - } - - private randomWithKnownZ( - random: () => number, - viewportSize: number, - scrollSize: number, - startOffset = 0, - endOffset = 0 - ): number { - const m = 1 + this.z / Blob.perspective; - - const variableOffset = (offset, q) => - Math.max( - 0, - offset - ((this.z - Blob.zMin) / (Blob.zMax - Blob.zMin)) * (offset * q) - ); - - startOffset = variableOffset(startOffset, 1); - endOffset = variableOffset(endOffset, 0.2); - - const lowerBound = viewportSize / 2 - (viewportSize / 2 - startOffset) * m; - const l = - scrollSize - viewportSize + (viewportSize - startOffset - endOffset) * m; - - return randomInInterval(lowerBound, lowerBound + l, random); - } - - private scrollContainer(timestamp: DOMHighResTimeStamp, parent: PageElement) { - /*const deltaTime = this.getDeltaTime(timestamp); - const scrollPositionToSet = parent.getElement().scrollTop; - const deltaScroll = scrollPositionToSet - this.previousScrollPositionToSet; - this.previousScrollPositionToSet = scrollPositionToSet; - - const threshold = 2; - if (deltaScroll > threshold) { - const smoothDeltaScroll = - (deltaScroll / deltaTime) * Math.min(deltaTime, this.baseDeltaTime); - this.getElement().scrollTop += smoothDeltaScroll; - } else { - const error = scrollPositionToSet - this.getElement().scrollTop; - if (Math.abs(error) > threshold) { - this.getElement().scrollTop += Math.min( - error / 4, - this.maxBaseSpeedInPixels, - error - ); - } - } - - window.requestAnimationFrame(timestamp => - this.scrollContainer(timestamp, parent) - );*/ - } - - private getDeltaTime(timestamp: DOMHighResTimeStamp): number { - const deltaTime = this.previousTimestamp - ? timestamp - this.previousTimestamp - : 0; - this.previousTimestamp = timestamp; - return deltaTime; + window.addEventListener("load", e => { + this.resize(parent); + this.redraw(e.timeStamp, parent); + }); } private resize(parent: PageElement, heightChange?: number) { + this.resizeCanvas(); + this.resizeBackground(parent, heightChange); + } + + private resizeCanvas() { + this.canvas.width = this.canvas.clientWidth; + this.canvas.height = this.canvas.clientHeight; + } + + private resizeBackground(parent: PageElement, heightChange?: number) { + const targetWidth = parent.getElement().clientWidth; + const siblings: Array = this.getSiblings(parent); - - const width = parent.getElement().clientWidth; - let height = sum(siblings.map(getHeight)); + let targetHeight = sum(siblings.map(getHeight)); if (heightChange) { - height += heightChange; + targetHeight += heightChange; } - if (this.previousHeight === height && this.previousWidth === width) { - return; - } - this.previousHeight = height; - this.previousWidth = width; + const targetSize = new Vec2(targetWidth, targetHeight); - this.query("#background").style.width = `${width}px`; - this.query("#background").style.height = `${height}px`; - - const requiredBlobCount = Math.round( - (width * height) / this.blobSpacing ** 2 + this.backgroundSize = new Animation( + this.backgroundSize ? this.backgroundSize.value : targetSize, + targetSize, + this.animationTime, + backgroundSize => + this.blobs.forEach(blob => { + const topLeft = this.convertFrom2Dto3D(Vec2.Zero, blob.z); + const bottomRight = this.convertFrom2Dto3D( + backgroundSize, + blob.z, + backgroundSize.y - this.canvas.height + ); + blob.positionScale = bottomRight.subtract(topLeft); + }) ); - - while (requiredBlobCount > this.blobs.length) { - const blob = new Blob(); - // this.query("#background").appendChild(blob.htmlElement); - this.blobs.push(blob); - } - - const random = randomFactory(2662); - - this.blobs.forEach((b, i) => { - /*if (i >= requiredBlobCount) { - b.hide(); - } else { - b.transform( - random, - width, - parent.getElement().clientHeight, - height, - getHeight(this.start.getElement()), - getHeight(this.end.getElement()) - ); - b.show(); - }*/ - }); } private getSiblings(parent: PageElement): Array { @@ -171,4 +87,77 @@ export class PageBackground extends PageElement { .call(parent.getElement().children) .filter(e => e !== this.getElement()); } + + private redraw(timestamp: DOMHighResTimeStamp, parent: PageElement) { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + const deltaTime = this.getDeltaTime(timestamp); + this.backgroundSize.step(deltaTime); + this.scrollPosition = parent.getElement().scrollTop; + const requiredBlobCount = this.requiredBlobCount; + + while (requiredBlobCount > this.blobs.length) { + this.blobs.push(new Blob()); + } + + this.blobs.sort((b1, b2) => b2.z - b1.z); + + this.blobs.forEach((blob, i) => { + if (i >= requiredBlobCount) { + return; + } + + const topLeft = this.convertFrom3Dto2D(blob.topLeft); + const bottomRight = this.convertFrom3Dto2D( + blob.topLeft.add(Vec3.from(blob.size, 0)) + ); + + if (this.isInView(topLeft) || this.isInView(bottomRight)) { + blob.draw(this.ctx, topLeft, bottomRight.subtract(topLeft)); + } + }); + + window.requestAnimationFrame(timestamp => this.redraw(timestamp, parent)); + } + + private getDeltaTime(timestamp: DOMHighResTimeStamp): number { + const deltaTime = this.previousTimestamp + ? timestamp - this.previousTimestamp + : 0; + this.previousTimestamp = timestamp; + return Math.max(0, deltaTime); + } + + private convertFrom3Dto2D(p: Vec3): Vec2 { + const m = this.perspective / (this.perspective + p.z); + return new Vec2( + m * (p.z / 2 + p.x), + m * (p.z / 2 + p.y - this.scrollPosition) + ); + } + + private convertFrom2Dto3D( + p: Vec2, + z: number, + scrollPosition: number = 0 + ): Vec2 { + const m = 1 + z / this.perspective; + return new Vec2(p.x * m - z / 2, p.y * m - z / 2 + scrollPosition); + } + + private isInView(p: Vec2): boolean { + return ( + 0 <= p.x && + p.x <= this.canvas.width && + 0 <= p.y && + p.y <= this.canvas.height + ); + } + + private get requiredBlobCount(): number { + return Math.round( + (this.backgroundSize.value.x * this.backgroundSize.value.y) / + this.blobSpacing ** 2 + ); + } } diff --git a/src/page/background/blob.ts b/src/page/background/blob.ts index c235c65..902390f 100644 --- a/src/page/background/blob.ts +++ b/src/page/background/blob.ts @@ -4,54 +4,69 @@ import { randomFactory, randomInInterval } from "../../framework/helper"; +import { Vec2 } from "./vec2"; +import { Vec3 } from "./vec3"; export class Blob { private static readonly creatorRandom = randomFactory(44); private static readonly colors = ["#fff9e0", "#ffd6d6"]; + private static readonly rotation = (-20 / 180) * Math.PI; - private readonly x = Blob.creatorRandom(); - private readonly y = Blob.creatorRandom(); - private readonly z = Blob.creatorRandom(); - private readonly rotation = 20; - private readonly width = 140; - private readonly height = randomInInterval(160, 740, Blob.creatorRandom); - private readonly color: string; - - constructor() { - this.color = - "#" + - mixColors("#ffffff", choose(Blob.colors, Blob.creatorRandom), this.z); + private static zMin: number; + private static zMax: number; + public static initialize(zMin: number, zMax: number) { + Blob.zMin = zMin; + Blob.zMax = zMax; } - public get topLeft(): [number, number, number] { - return [this.x, this.y, this.z]; + public readonly z = randomInInterval( + Blob.zMin, + Blob.zMax, + Blob.creatorRandom + ); + + private readonly positionQ = new Vec2( + Blob.creatorRandom(), + Blob.creatorRandom() + ); + private _positionScale = new Vec2(0, 0); + + private readonly _size = new Vec2( + 140, + randomInInterval(160, 740, Blob.creatorRandom) + ); + private readonly color = + "#" + + mixColors( + "#ffffff", + choose(Blob.colors, Blob.creatorRandom), + (this.z - Blob.zMin) / (Blob.zMax - Blob.zMin) + ); + + public get topLeft(): Vec3 { + return Vec3.from(this.positionQ.multiply(this._positionScale), this.z); } - public get bottomRight(): [number, number, number] { - return [this.x + this.width, this.y + this.height, this.z]; + public get size(): Vec2 { + return this._size; } - public draw( - ctx: CanvasRenderingContext2D, - position: [number, number], - size: [number, number] - ) { - const [x, y] = position; - const [width, height] = size; - const radius = Math.min(width, height) / 2; + public set positionScale(value: Vec2) { + this._positionScale = value; + } + public draw(ctx: CanvasRenderingContext2D, position: Vec2, size: Vec2) { ctx.save(); - ctx.translate(-x, -y); - ctx.rotate(this.rotation); - ctx.fillStyle = this.color; + + ctx.translate(position.x, position.y); + ctx.rotate(Blob.rotation); ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.arcTo(x + width, y, x + width, y + height, radius); - ctx.arcTo(x + width, y + height, x, y + height, radius); - ctx.arcTo(x, y + height, x, y, radius); - ctx.arcTo(x, y, x + width, y, radius); + ctx.arc(0, size.x / 2 - size.y / 2, size.x / 2, Math.PI, 0); + ctx.arc(0, size.y / 2 - size.x / 2, size.x / 2, 0, Math.PI); ctx.closePath(); + + ctx.fillStyle = this.color; ctx.fill(); ctx.restore(); diff --git a/src/page/background/vec2.ts b/src/page/background/vec2.ts new file mode 100644 index 0000000..a7be2ac --- /dev/null +++ b/src/page/background/vec2.ts @@ -0,0 +1,17 @@ +export class Vec2 { + public static readonly Zero = new Vec2(0, 0); + + public constructor(public readonly x: number, public readonly y: number) {} + + public add(other: Vec2): Vec2 { + return new Vec2(this.x + other.x, this.y + other.y); + } + + public subtract(other: Vec2): Vec2 { + return new Vec2(this.x - other.x, this.y - other.y); + } + + public multiply(other: Vec2): Vec2 { + return new Vec2(this.x * other.x, this.y * other.y); + } +} diff --git a/src/page/background/vec3.ts b/src/page/background/vec3.ts new file mode 100644 index 0000000..3eda035 --- /dev/null +++ b/src/page/background/vec3.ts @@ -0,0 +1,23 @@ +import { Vec2 } from "./vec2"; + +export class Vec3 { + public static readonly Zero = new Vec3(0, 0, 0); + + public static from(vec2: Vec2, z: number): Vec3 { + return new Vec3(vec2.x, vec2.y, z); + } + + public constructor( + public readonly x: number, + public readonly y: number, + public readonly z: number + ) {} + + public add(other: Vec3): Vec3 { + return new Vec3(this.x + other.x, this.y + other.y, this.z + other.z); + } + + public multiply(other: Vec3): Vec3 { + return new Vec3(this.x * other.x, this.y * other.y, this.z * other.z); + } +} diff --git a/src/portfolio.ts b/src/portfolio.ts index 0a9b419..6bdbf00 100644 --- a/src/portfolio.ts +++ b/src/portfolio.ts @@ -206,6 +206,6 @@ export const portfolio: Portfolio = { email: `schmelczerandras@gmail.com`, cvName: `Curriculum vitae`, lastEditName: `Last modified on `, - lastEdit: new Date(2019, 11, 29) // months are 0 indexed + lastEdit: new Date(2020, 0, 2) // months are 0 indexed } };