add hover delay to carousel and remove dynamic background
All checks were successful
publish.yml / publish (push) Successful in 1m10s
All checks were successful
publish.yml / publish (push) Successful in 1m10s
This commit is contained in:
@@ -1,272 +1,4 @@
|
|||||||
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
|
import { AfterViewInit, Component, ElementRef, HostListener, ViewChild } from '@angular/core';
|
||||||
|
|
||||||
type PointerState = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
active: boolean;
|
|
||||||
pressed: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Particle = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
size: number;
|
|
||||||
alpha: number;
|
|
||||||
ageMs: number;
|
|
||||||
lifetimeMs: number;
|
|
||||||
respawnDelayMs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
class ParticleEngine {
|
|
||||||
private readonly connectionDistanceSq = 19600;
|
|
||||||
private readonly densityRadiusSq = 4900;
|
|
||||||
private readonly densityFadeStart = 6;
|
|
||||||
private readonly densityFadeRange = 10;
|
|
||||||
private readonly maxDensityFade = 0.75;
|
|
||||||
private readonly minLifetimeMs = 9000;
|
|
||||||
private readonly maxLifetimeMs = 24000;
|
|
||||||
private readonly minRespawnDelayMs = 140;
|
|
||||||
private readonly maxRespawnDelayMs = 900;
|
|
||||||
private readonly particles: Particle[] = [];
|
|
||||||
private readonly localDensity: number[] = [];
|
|
||||||
private readonly lifeAlpha: number[] = [];
|
|
||||||
private width = 0;
|
|
||||||
private height = 0;
|
|
||||||
private dpr = 1;
|
|
||||||
private pointer: PointerState = { x: 0, y: 0, active: false, pressed: false };
|
|
||||||
|
|
||||||
constructor(private readonly ctx: CanvasRenderingContext2D, particleCount: number) {
|
|
||||||
for (let i = 0; i < particleCount; i += 1) {
|
|
||||||
const particle: Particle = {
|
|
||||||
x: Math.random(),
|
|
||||||
y: Math.random(),
|
|
||||||
vx: (Math.random() - 0.5) * 0.24,
|
|
||||||
vy: (Math.random() - 0.5) * 0.24,
|
|
||||||
size: 1.2 + Math.random() * 2.8,
|
|
||||||
alpha: 0.5 + Math.random() * 0.45,
|
|
||||||
ageMs: 0,
|
|
||||||
lifetimeMs: this.randomLifetimeMs(),
|
|
||||||
respawnDelayMs: 0,
|
|
||||||
};
|
|
||||||
this.resetParticle(particle, false);
|
|
||||||
this.particles.push(particle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resize(width: number, height: number, dpr: number): void {
|
|
||||||
this.width = width;
|
|
||||||
this.height = height;
|
|
||||||
this.dpr = dpr;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPointer(pointer: PointerState): void {
|
|
||||||
this.pointer = pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderFrame(deltaMs: number): void {
|
|
||||||
const clampedDeltaMs = Math.min(deltaMs, 40);
|
|
||||||
const step = clampedDeltaMs / 16.6667;
|
|
||||||
this.update(step, clampedDeltaMs);
|
|
||||||
this.draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
renderStatic(): void {
|
|
||||||
this.draw();
|
|
||||||
}
|
|
||||||
|
|
||||||
private update(step: number, deltaMs: number): void {
|
|
||||||
if (this.width === 0 || this.height === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const influenceRadius = Math.min(this.width, this.height) * 0.3;
|
|
||||||
const influenceRadiusSq = influenceRadius * influenceRadius;
|
|
||||||
|
|
||||||
for (const particle of this.particles) {
|
|
||||||
if (particle.respawnDelayMs > 0) {
|
|
||||||
particle.respawnDelayMs = Math.max(0, particle.respawnDelayMs - deltaMs);
|
|
||||||
if (particle.respawnDelayMs === 0) {
|
|
||||||
this.resetParticle(particle, false);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
particle.ageMs += deltaMs;
|
|
||||||
if (particle.ageMs >= particle.lifetimeMs) {
|
|
||||||
particle.respawnDelayMs = this.randomRespawnDelayMs();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const px = particle.x * this.width;
|
|
||||||
const py = particle.y * this.height;
|
|
||||||
|
|
||||||
if (this.pointer.active) {
|
|
||||||
const dx = px - this.pointer.x;
|
|
||||||
const dy = py - this.pointer.y;
|
|
||||||
const distanceSq = dx * dx + dy * dy;
|
|
||||||
|
|
||||||
if (distanceSq > 1 && distanceSq < influenceRadiusSq) {
|
|
||||||
const distance = Math.sqrt(distanceSq);
|
|
||||||
const normalized = 1 - distance / influenceRadius;
|
|
||||||
const directionBoost = this.pointer.pressed ? -2.2 : 1.35;
|
|
||||||
const force = normalized * 0.033 * directionBoost;
|
|
||||||
|
|
||||||
particle.vx += (dx / distance) * force * step;
|
|
||||||
particle.vy += (dy / distance) * force * step;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
particle.vx *= 0.978;
|
|
||||||
particle.vy *= 0.978;
|
|
||||||
particle.x += (particle.vx * step) / this.width;
|
|
||||||
particle.y += (particle.vy * step) / this.height;
|
|
||||||
|
|
||||||
if (particle.x < 0 || particle.x > 1) {
|
|
||||||
particle.vx *= -0.95;
|
|
||||||
particle.x = Math.min(1, Math.max(0, particle.x));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (particle.y < 0 || particle.y > 1) {
|
|
||||||
particle.vy *= -0.95;
|
|
||||||
particle.y = Math.min(1, Math.max(0, particle.y));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private draw(): void {
|
|
||||||
if (this.width === 0 || this.height === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
||||||
this.ctx.clearRect(0, 0, this.width, this.height);
|
|
||||||
|
|
||||||
const gradient = this.ctx.createRadialGradient(
|
|
||||||
this.width * 0.2,
|
|
||||||
this.height * 0.1,
|
|
||||||
0,
|
|
||||||
this.width * 0.5,
|
|
||||||
this.height * 0.5,
|
|
||||||
Math.max(this.width, this.height)
|
|
||||||
);
|
|
||||||
gradient.addColorStop(0, 'rgba(88, 166, 255, 0.16)');
|
|
||||||
gradient.addColorStop(0.4, 'rgba(88, 166, 255, 0.2)');
|
|
||||||
gradient.addColorStop(0.7, 'rgba(147, 102, 255, 0.12)');
|
|
||||||
gradient.addColorStop(1, 'rgba(13, 17, 23, 0)');
|
|
||||||
|
|
||||||
this.ctx.fillStyle = gradient;
|
|
||||||
this.ctx.fillRect(0, 0, this.width, this.height);
|
|
||||||
|
|
||||||
if (this.localDensity.length !== this.particles.length) {
|
|
||||||
this.localDensity.length = this.particles.length;
|
|
||||||
}
|
|
||||||
if (this.lifeAlpha.length !== this.particles.length) {
|
|
||||||
this.lifeAlpha.length = this.particles.length;
|
|
||||||
}
|
|
||||||
this.localDensity.fill(0);
|
|
||||||
|
|
||||||
for (let i = 0; i < this.particles.length; i += 1) {
|
|
||||||
this.lifeAlpha[i] = this.getLifeAlpha(this.particles[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ctx.lineWidth = 1;
|
|
||||||
|
|
||||||
for (let i = 0; i < this.particles.length; i += 1) {
|
|
||||||
const a = this.particles[i];
|
|
||||||
const lifeA = this.lifeAlpha[i];
|
|
||||||
if (lifeA <= 0.01) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let j = i + 1; j < this.particles.length; j += 1) {
|
|
||||||
const b = this.particles[j];
|
|
||||||
const lifeB = this.lifeAlpha[j];
|
|
||||||
if (lifeB <= 0.01) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ax = a.x * this.width;
|
|
||||||
const ay = a.y * this.height;
|
|
||||||
const bx = b.x * this.width;
|
|
||||||
const by = b.y * this.height;
|
|
||||||
const dx = ax - bx;
|
|
||||||
const dy = ay - by;
|
|
||||||
const distSq = dx * dx + dy * dy;
|
|
||||||
|
|
||||||
if (distSq < this.densityRadiusSq) {
|
|
||||||
this.localDensity[i] += 1;
|
|
||||||
this.localDensity[j] += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (distSq < this.connectionDistanceSq) {
|
|
||||||
const alpha = 1 - distSq / this.connectionDistanceSq;
|
|
||||||
this.ctx.strokeStyle = `rgba(106, 194, 255, ${alpha * 0.38 * Math.min(lifeA, lifeB)})`;
|
|
||||||
this.ctx.beginPath();
|
|
||||||
this.ctx.moveTo(ax, ay);
|
|
||||||
this.ctx.lineTo(bx, by);
|
|
||||||
this.ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < this.particles.length; i += 1) {
|
|
||||||
const particle = this.particles[i];
|
|
||||||
const x = particle.x * this.width;
|
|
||||||
const y = particle.y * this.height;
|
|
||||||
const crowding = Math.min(
|
|
||||||
Math.max((this.localDensity[i] - this.densityFadeStart) / this.densityFadeRange, 0),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
const effectiveAlpha = Math.max(
|
|
||||||
particle.alpha * this.lifeAlpha[i] * (1 - crowding * this.maxDensityFade),
|
|
||||||
0.02
|
|
||||||
);
|
|
||||||
|
|
||||||
if (effectiveAlpha <= 0.021) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ctx.beginPath();
|
|
||||||
this.ctx.arc(x, y, particle.size, 0, Math.PI * 2);
|
|
||||||
this.ctx.fillStyle = `rgba(222, 244, 255, ${effectiveAlpha})`;
|
|
||||||
this.ctx.fill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private randomLifetimeMs(): number {
|
|
||||||
return this.minLifetimeMs + Math.random() * (this.maxLifetimeMs - this.minLifetimeMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private randomRespawnDelayMs(): number {
|
|
||||||
return this.minRespawnDelayMs + Math.random() * (this.maxRespawnDelayMs - this.minRespawnDelayMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetParticle(particle: Particle, withDelay: boolean): void {
|
|
||||||
particle.x = Math.random();
|
|
||||||
particle.y = Math.random();
|
|
||||||
particle.vx = (Math.random() - 0.5) * 0.24;
|
|
||||||
particle.vy = (Math.random() - 0.5) * 0.24;
|
|
||||||
particle.size = 1.2 + Math.random() * 2.8;
|
|
||||||
particle.alpha = 0.5 + Math.random() * 0.45;
|
|
||||||
particle.ageMs = 0;
|
|
||||||
particle.lifetimeMs = this.randomLifetimeMs();
|
|
||||||
particle.respawnDelayMs = withDelay ? this.randomRespawnDelayMs() : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getLifeAlpha(particle: Particle): number {
|
|
||||||
if (particle.respawnDelayMs > 0 || particle.lifetimeMs <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = Math.min(Math.max(particle.ageMs / particle.lifetimeMs, 0), 1);
|
|
||||||
const fadeIn = Math.min(progress / 0.25, 1);
|
|
||||||
const fadeOut = Math.min((1 - progress) / 0.32, 1);
|
|
||||||
|
|
||||||
return Math.min(fadeIn, fadeOut, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-interactive-background',
|
selector: 'app-interactive-background',
|
||||||
@@ -274,425 +6,56 @@ class ParticleEngine {
|
|||||||
templateUrl: './interactive-background.html',
|
templateUrl: './interactive-background.html',
|
||||||
styleUrl: './interactive-background.css'
|
styleUrl: './interactive-background.css'
|
||||||
})
|
})
|
||||||
export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
export class InteractiveBackground implements AfterViewInit {
|
||||||
@ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
private ctx: CanvasRenderingContext2D | null = null;
|
||||||
isPointerDown = false;
|
|
||||||
|
|
||||||
private particleEngine: ParticleEngine | null = null;
|
|
||||||
private destroyCallbacks: Array<() => void> = [];
|
|
||||||
private frameId: number | null = null;
|
|
||||||
private lastFrameTime = 0;
|
|
||||||
private pointerX = 0;
|
|
||||||
private pointerY = 0;
|
|
||||||
private pointerActive = false;
|
|
||||||
private activeTouchId: number | null = null;
|
|
||||||
private reducedMotion = false;
|
|
||||||
private lowMotionMobile = false;
|
|
||||||
private mediaQuery!: MediaQueryList;
|
|
||||||
private resizeFrameId: number | null = null;
|
|
||||||
private resizeSettleTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private lastCanvasWidth = 0;
|
|
||||||
private lastCanvasHeight = 0;
|
|
||||||
private lastCanvasDpr = 0;
|
|
||||||
private readonly pointerExcludeSelector = '[data-disable-bg-pointer]';
|
|
||||||
private readonly resizeSettleDelayMs = 140;
|
|
||||||
private readonly resizeNoiseThresholdPx = 2;
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
const canvas = this.canvasRef.nativeElement;
|
this.ctx = this.canvasRef.nativeElement.getContext('2d');
|
||||||
const context = canvas.getContext('2d');
|
this.resizeAndDraw();
|
||||||
|
|
||||||
this.mediaQuery = this.getMediaQuery('(prefers-reduced-motion: reduce)');
|
|
||||||
this.lowMotionMobile = this.getMediaQuery('(max-width: 760px), (pointer: coarse) and (hover: none)').matches;
|
|
||||||
this.reducedMotion = this.mediaQuery.matches;
|
|
||||||
|
|
||||||
if (context) {
|
|
||||||
const area = window.innerWidth * window.innerHeight;
|
|
||||||
const dynamicCount = Math.round(Math.min(170, Math.max(80, area / 14500)));
|
|
||||||
const mobileCount = Math.round(Math.min(54, Math.max(28, dynamicCount * 0.4)));
|
|
||||||
const particleCount = this.reducedMotion ? 42 : (this.lowMotionMobile ? mobileCount : dynamicCount);
|
|
||||||
this.particleEngine = new ParticleEngine(context, particleCount);
|
|
||||||
this.resizeCanvas(true);
|
|
||||||
this.renderInitialFrame();
|
|
||||||
if (!this.reducedMotion) {
|
|
||||||
this.startAnimation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const visualViewport = window.visualViewport;
|
|
||||||
const onResize = () => this.scheduleCanvasResize();
|
|
||||||
const supportsPointerEvents = 'PointerEvent' in window;
|
|
||||||
const onPointerMove = (event: PointerEvent) => this.handlePointerMove(event);
|
|
||||||
const onPointerLeave = () => this.handlePointerLeave();
|
|
||||||
const onPointerDown = (event: PointerEvent) => this.handlePointerDown(event);
|
|
||||||
const onPointerUp = (event: PointerEvent) => this.handlePointerUp(event);
|
|
||||||
const onPointerCancel = () => this.handlePointerLeave();
|
|
||||||
|
|
||||||
const onMouseMove = (event: MouseEvent) => this.handleMouseMove(event);
|
|
||||||
const onMouseLeave = () => this.handlePointerLeave();
|
|
||||||
const onMouseDown = (event: MouseEvent) => this.handleMouseDown(event);
|
|
||||||
const onMouseUp = () => this.handleMouseUp();
|
|
||||||
|
|
||||||
const onTouchStart = (event: TouchEvent) => this.handleTouchStart(event);
|
|
||||||
const onTouchMove = (event: TouchEvent) => this.handleTouchMove(event);
|
|
||||||
const onTouchEnd = (event: TouchEvent) => this.handleTouchEnd(event);
|
|
||||||
const onTouchCancel = () => this.handlePointerLeave();
|
|
||||||
|
|
||||||
const onMotionChange = (event: MediaQueryListEvent) => this.handleMotionPreferenceChange(event.matches);
|
|
||||||
|
|
||||||
window.addEventListener('resize', onResize, { passive: true });
|
|
||||||
visualViewport?.addEventListener('resize', onResize, { passive: true });
|
|
||||||
visualViewport?.addEventListener('scroll', onResize, { passive: true });
|
|
||||||
|
|
||||||
if (supportsPointerEvents) {
|
|
||||||
window.addEventListener('pointermove', onPointerMove, { passive: true });
|
|
||||||
window.addEventListener('pointerleave', onPointerLeave);
|
|
||||||
window.addEventListener('pointerdown', onPointerDown, { passive: true });
|
|
||||||
window.addEventListener('pointerup', onPointerUp, { passive: true });
|
|
||||||
window.addEventListener('pointercancel', onPointerCancel, { passive: true });
|
|
||||||
} else {
|
|
||||||
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
|
||||||
window.addEventListener('mouseleave', onMouseLeave);
|
|
||||||
window.addEventListener('mousedown', onMouseDown, { passive: true });
|
|
||||||
window.addEventListener('mouseup', onMouseUp, { passive: true });
|
|
||||||
window.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
||||||
window.addEventListener('touchmove', onTouchMove, { passive: true });
|
|
||||||
window.addEventListener('touchend', onTouchEnd, { passive: true });
|
|
||||||
window.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mediaQuery.addEventListener('change', onMotionChange);
|
|
||||||
|
|
||||||
this.destroyCallbacks = [
|
|
||||||
() => window.removeEventListener('resize', onResize),
|
|
||||||
() => visualViewport?.removeEventListener('resize', onResize),
|
|
||||||
() => visualViewport?.removeEventListener('scroll', onResize),
|
|
||||||
() => window.removeEventListener('pointermove', onPointerMove),
|
|
||||||
() => window.removeEventListener('pointerleave', onPointerLeave),
|
|
||||||
() => window.removeEventListener('pointerdown', onPointerDown),
|
|
||||||
() => window.removeEventListener('pointerup', onPointerUp),
|
|
||||||
() => window.removeEventListener('pointercancel', onPointerCancel),
|
|
||||||
() => window.removeEventListener('mousemove', onMouseMove),
|
|
||||||
() => window.removeEventListener('mouseleave', onMouseLeave),
|
|
||||||
() => window.removeEventListener('mousedown', onMouseDown),
|
|
||||||
() => window.removeEventListener('mouseup', onMouseUp),
|
|
||||||
() => window.removeEventListener('touchstart', onTouchStart),
|
|
||||||
() => window.removeEventListener('touchmove', onTouchMove),
|
|
||||||
() => window.removeEventListener('touchend', onTouchEnd),
|
|
||||||
() => window.removeEventListener('touchcancel', onTouchCancel),
|
|
||||||
() => this.mediaQuery.removeEventListener('change', onMotionChange),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
@HostListener('window:resize')
|
||||||
this.stopAnimation();
|
onResize(): void {
|
||||||
if (this.resizeFrameId !== null) {
|
this.resizeAndDraw();
|
||||||
cancelAnimationFrame(this.resizeFrameId);
|
|
||||||
this.resizeFrameId = null;
|
|
||||||
}
|
|
||||||
if (this.resizeSettleTimeout !== null) {
|
|
||||||
clearTimeout(this.resizeSettleTimeout);
|
|
||||||
this.resizeSettleTimeout = null;
|
|
||||||
}
|
|
||||||
for (const callback of this.destroyCallbacks) {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
this.destroyCallbacks = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleCanvasResize(): void {
|
private resizeAndDraw(): void {
|
||||||
if (this.resizeFrameId === null) {
|
const ctx = this.ctx;
|
||||||
this.resizeFrameId = requestAnimationFrame(() => {
|
if (!ctx) {
|
||||||
this.resizeFrameId = null;
|
|
||||||
this.resizeCanvas(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.resizeSettleTimeout !== null) {
|
|
||||||
clearTimeout(this.resizeSettleTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resizeSettleTimeout = setTimeout(() => {
|
|
||||||
this.resizeSettleTimeout = null;
|
|
||||||
this.resizeCanvas(true);
|
|
||||||
}, this.resizeSettleDelayMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private resizeCanvas(force: boolean): void {
|
|
||||||
if (!this.particleEngine) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const width = Math.max(1, window.innerWidth);
|
||||||
|
const height = Math.max(1, window.innerHeight);
|
||||||
const canvas = this.canvasRef.nativeElement;
|
const canvas = this.canvasRef.nativeElement;
|
||||||
const viewport = this.getViewportSize();
|
|
||||||
const width = Math.max(1, Math.round(viewport.width));
|
|
||||||
const height = Math.max(1, Math.round(viewport.height));
|
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
|
|
||||||
const widthChanged = Math.abs(width - this.lastCanvasWidth) > this.resizeNoiseThresholdPx;
|
if (canvas.width === width && canvas.height === height) {
|
||||||
const heightChanged = Math.abs(height - this.lastCanvasHeight) > this.resizeNoiseThresholdPx;
|
|
||||||
const dprChanged = Math.abs(dpr - this.lastCanvasDpr) > 0.01;
|
|
||||||
|
|
||||||
if (!force && !widthChanged && !heightChanged && !dprChanged) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastCanvasWidth = width;
|
canvas.width = width;
|
||||||
this.lastCanvasHeight = height;
|
canvas.height = height;
|
||||||
this.lastCanvasDpr = dpr;
|
|
||||||
|
|
||||||
canvas.width = Math.floor(width * dpr);
|
|
||||||
canvas.height = Math.floor(height * dpr);
|
|
||||||
canvas.style.width = `${width}px`;
|
canvas.style.width = `${width}px`;
|
||||||
canvas.style.height = `${height}px`;
|
canvas.style.height = `${height}px`;
|
||||||
|
|
||||||
this.particleEngine.resize(width, height, dpr);
|
const gradient = ctx.createRadialGradient(
|
||||||
this.renderInitialFrame();
|
width * 0.24,
|
||||||
|
height * 0.12,
|
||||||
|
0,
|
||||||
|
width * 0.5,
|
||||||
|
height * 0.5,
|
||||||
|
Math.max(width, height)
|
||||||
|
);
|
||||||
|
gradient.addColorStop(0, 'rgba(88, 166, 255, 0.14)');
|
||||||
|
gradient.addColorStop(0.42, 'rgba(88, 166, 255, 0.18)');
|
||||||
|
gradient.addColorStop(0.72, 'rgba(147, 102, 255, 0.1)');
|
||||||
|
gradient.addColorStop(1, 'rgba(13, 17, 23, 0)');
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getViewportSize(): { width: number; height: number } {
|
|
||||||
const visualViewport = window.visualViewport;
|
|
||||||
if (visualViewport) {
|
|
||||||
return {
|
|
||||||
width: visualViewport.width,
|
|
||||||
height: visualViewport.height,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: window.innerWidth,
|
|
||||||
height: window.innerHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private startAnimation(): void {
|
|
||||||
if (this.frameId !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastFrameTime = performance.now();
|
|
||||||
const step = (now: number) => {
|
|
||||||
if (!this.particleEngine) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = now - this.lastFrameTime;
|
|
||||||
this.lastFrameTime = now;
|
|
||||||
this.syncPointer();
|
|
||||||
this.particleEngine.renderFrame(delta);
|
|
||||||
this.frameId = requestAnimationFrame(step);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.frameId = requestAnimationFrame(step);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopAnimation(): void {
|
|
||||||
if (this.frameId !== null) {
|
|
||||||
cancelAnimationFrame(this.frameId);
|
|
||||||
this.frameId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handlePointerMove(event: PointerEvent): void {
|
|
||||||
if (this.isPointerExcluded(event.target)) {
|
|
||||||
this.handlePointerLeave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.pointerType === 'touch' && this.activeTouchId !== null && event.pointerId !== this.activeTouchId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updatePointer(event.clientX, event.clientY, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handlePointerDown(event: PointerEvent): void {
|
|
||||||
if (this.isPointerExcluded(event.target)) {
|
|
||||||
this.handlePointerLeave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.pointerType === 'touch') {
|
|
||||||
this.activeTouchId = event.pointerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isPointerDown = true;
|
|
||||||
this.updatePointer(event.clientX, event.clientY, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handlePointerUp(event: PointerEvent): void {
|
|
||||||
if (this.isPointerExcluded(event.target)) {
|
|
||||||
this.handlePointerLeave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isPointerDown = false;
|
|
||||||
|
|
||||||
if (event.pointerType === 'touch') {
|
|
||||||
if (this.activeTouchId !== null && event.pointerId !== this.activeTouchId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activeTouchId = null;
|
|
||||||
this.pointerActive = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updatePointer(event.clientX, event.clientY, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMouseMove(event: MouseEvent): void {
|
|
||||||
if (this.isPointerExcluded(event.target)) {
|
|
||||||
this.handlePointerLeave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updatePointer(event.clientX, event.clientY, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMouseDown(event: MouseEvent): void {
|
|
||||||
if (this.isPointerExcluded(event.target)) {
|
|
||||||
this.handlePointerLeave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isPointerDown = true;
|
|
||||||
this.updatePointer(event.clientX, event.clientY, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMouseUp(): void {
|
|
||||||
this.isPointerDown = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTouchStart(event: TouchEvent): void {
|
|
||||||
if (this.isPointerExcluded(event.target)) {
|
|
||||||
this.handlePointerLeave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const touch = event.changedTouches.item(0);
|
|
||||||
if (!touch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activeTouchId = touch.identifier;
|
|
||||||
this.isPointerDown = true;
|
|
||||||
this.updatePointer(touch.clientX, touch.clientY, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTouchMove(event: TouchEvent): void {
|
|
||||||
if (this.isPointerExcluded(event.target)) {
|
|
||||||
this.handlePointerLeave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.activeTouchId === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const touch = this.findTouch(event.touches, this.activeTouchId);
|
|
||||||
if (!touch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updatePointer(touch.clientX, touch.clientY, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTouchEnd(event: TouchEvent): void {
|
|
||||||
if (this.isPointerExcluded(event.target)) {
|
|
||||||
this.handlePointerLeave();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.activeTouchId === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const touch = this.findTouch(event.changedTouches, this.activeTouchId);
|
|
||||||
if (!touch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isPointerDown = false;
|
|
||||||
this.activeTouchId = null;
|
|
||||||
this.pointerActive = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handlePointerLeave(): void {
|
|
||||||
this.pointerActive = false;
|
|
||||||
this.isPointerDown = false;
|
|
||||||
this.activeTouchId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updatePointer(x: number, y: number, active: boolean): void {
|
|
||||||
this.pointerX = x;
|
|
||||||
this.pointerY = y;
|
|
||||||
this.pointerActive = active;
|
|
||||||
}
|
|
||||||
|
|
||||||
private findTouch(touches: TouchList, identifier: number): Touch | null {
|
|
||||||
for (let index = 0; index < touches.length; index += 1) {
|
|
||||||
const touch = touches.item(index);
|
|
||||||
if (touch && touch.identifier === identifier) {
|
|
||||||
return touch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isPointerExcluded(target: EventTarget | null): boolean {
|
|
||||||
if (!(target instanceof Element)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return target.closest(this.pointerExcludeSelector) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncPointer(): void {
|
|
||||||
this.particleEngine?.setPointer({
|
|
||||||
x: this.pointerX,
|
|
||||||
y: this.pointerY,
|
|
||||||
active: this.pointerActive,
|
|
||||||
pressed: this.isPointerDown,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMotionPreferenceChange(reducedMotion: boolean): void {
|
|
||||||
this.reducedMotion = reducedMotion;
|
|
||||||
|
|
||||||
if (this.reducedMotion) {
|
|
||||||
this.stopAnimation();
|
|
||||||
this.syncPointer();
|
|
||||||
this.particleEngine?.renderStatic();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.startAnimation();
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderInitialFrame(): void {
|
|
||||||
this.syncPointer();
|
|
||||||
this.particleEngine?.renderStatic();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMediaQuery(query: string): MediaQueryList {
|
|
||||||
if (typeof window.matchMedia === 'function') {
|
|
||||||
return window.matchMedia(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
matches: false,
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: () => undefined,
|
|
||||||
removeListener: () => undefined,
|
|
||||||
addEventListener: () => undefined,
|
|
||||||
removeEventListener: () => undefined,
|
|
||||||
dispatchEvent: () => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
data-disable-bg-pointer
|
data-disable-bg-pointer
|
||||||
(mouseenter)="onHoverStart()"
|
(mouseenter)="onHoverStart()"
|
||||||
(mouseleave)="onHoverEnd()"
|
(mouseleave)="onHoverEnd()"
|
||||||
(focusin)="onHoverStart()"
|
(focusin)="onFocusIn()"
|
||||||
(focusout)="onCarouselFocusOut($event)"
|
(focusout)="onCarouselFocusOut($event)"
|
||||||
(click)="onInteractionClick()"
|
(click)="onInteractionClick()"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
|||||||
private autoScrollFrameId: number | null = null;
|
private autoScrollFrameId: number | null = null;
|
||||||
private startAutoScrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
private startAutoScrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private delayedResumeTimeout: ReturnType<typeof setTimeout> | null = null;
|
private delayedResumeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private delayedHoverPauseTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private delayedResumeAt = 0;
|
private delayedResumeAt = 0;
|
||||||
|
private isHoveringCarousel = false;
|
||||||
private lastAutoScrollTime = 0;
|
private lastAutoScrollTime = 0;
|
||||||
private virtualScrollLeft = 0;
|
private virtualScrollLeft = 0;
|
||||||
private currentAutoScrollSpeedPxPerSecond = 0;
|
private currentAutoScrollSpeedPxPerSecond = 0;
|
||||||
@@ -32,6 +34,7 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
|||||||
private readonly speedRampDurationMs = 420;
|
private readonly speedRampDurationMs = 420;
|
||||||
private readonly speedStopThresholdPxPerSecond = 0.5;
|
private readonly speedStopThresholdPxPerSecond = 0.5;
|
||||||
private readonly touchScrollResumeDelayMs = 1200;
|
private readonly touchScrollResumeDelayMs = 1200;
|
||||||
|
private readonly hoverPauseDelayMs = 300;
|
||||||
isAutoScrolling = false;
|
isAutoScrolling = false;
|
||||||
showLeftFade = false;
|
showLeftFade = false;
|
||||||
showRightFade = false;
|
showRightFade = false;
|
||||||
@@ -77,6 +80,7 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.clearScheduledResume();
|
this.clearScheduledResume();
|
||||||
|
this.clearScheduledHoverPause();
|
||||||
this.pauseAutoScroll();
|
this.pauseAutoScroll();
|
||||||
this.isAutoScrolling = false;
|
this.isAutoScrolling = false;
|
||||||
}
|
}
|
||||||
@@ -121,14 +125,32 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onHoverStart(): void {
|
onHoverStart(): void {
|
||||||
this.pauseAutoScroll();
|
this.isHoveringCarousel = true;
|
||||||
this.clearScheduledResume();
|
this.clearScheduledResume();
|
||||||
|
this.clearScheduledHoverPause();
|
||||||
|
|
||||||
|
this.delayedHoverPauseTimeout = setTimeout(() => {
|
||||||
|
this.delayedHoverPauseTimeout = null;
|
||||||
|
|
||||||
|
if (!this.isHoveringCarousel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pauseAutoScroll();
|
||||||
|
}, this.hoverPauseDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
onHoverEnd(): void {
|
onHoverEnd(): void {
|
||||||
|
this.isHoveringCarousel = false;
|
||||||
|
this.clearScheduledHoverPause();
|
||||||
this.scheduleResume(1000);
|
this.scheduleResume(1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onFocusIn(): void {
|
||||||
|
this.pauseAutoScroll();
|
||||||
|
this.clearScheduledResume();
|
||||||
|
}
|
||||||
|
|
||||||
onInteractionClick(): void {
|
onInteractionClick(): void {
|
||||||
this.pauseAutoScroll();
|
this.pauseAutoScroll();
|
||||||
this.scheduleResume(2000);
|
this.scheduleResume(2000);
|
||||||
@@ -248,6 +270,13 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
|||||||
this.delayedResumeAt = 0;
|
this.delayedResumeAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearScheduledHoverPause(): void {
|
||||||
|
if (this.delayedHoverPauseTimeout !== null) {
|
||||||
|
clearTimeout(this.delayedHoverPauseTimeout);
|
||||||
|
this.delayedHoverPauseTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private updateAutoScrollSpeed(deltaMs: number): void {
|
private updateAutoScrollSpeed(deltaMs: number): void {
|
||||||
if (deltaMs <= 0) {
|
if (deltaMs <= 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user