297 lines
8.6 KiB
TypeScript
297 lines
8.6 KiB
TypeScript
import {
|
|
afterNextRender,
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
effect,
|
|
ElementRef,
|
|
inject,
|
|
input,
|
|
NgZone,
|
|
OnDestroy,
|
|
viewChild,
|
|
} from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { SettingsService } from '../../services/settings.service';
|
|
|
|
@Component({
|
|
selector: 'brand-accent',
|
|
standalone: true,
|
|
imports: [CommonModule],
|
|
templateUrl: './brand-accent.html',
|
|
styleUrl: './brand-accent.scss',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
export class BrandAccent implements OnDestroy {
|
|
canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('canvasElement');
|
|
private ctx!: CanvasRenderingContext2D;
|
|
private settingsService = inject(SettingsService);
|
|
|
|
drawGrid = input<boolean>(true);
|
|
|
|
private canvasNativeSize: [number, number] = [0, 0];
|
|
private bgGradient!: CanvasGradient;
|
|
private gridGradient!: CanvasGradient;
|
|
|
|
private readonly offscreenCanvas!: OffscreenCanvas;
|
|
private readonly offscreenCtx!: OffscreenCanvasRenderingContext2D;
|
|
|
|
private resizeObserver?: ResizeObserver;
|
|
private intersectionObserver?: IntersectionObserver;
|
|
|
|
private rendererScale!: number;
|
|
|
|
private animation: {
|
|
id?: number;
|
|
startTime: number;
|
|
progress: number;
|
|
gridDimension: [number, number];
|
|
|
|
duration: number;
|
|
gridSquareSize: number;
|
|
} = {
|
|
startTime: 0,
|
|
progress: 0,
|
|
gridDimension: [0, 0],
|
|
|
|
duration: 30,
|
|
gridSquareSize: 100,
|
|
};
|
|
|
|
constructor(private ngZone: NgZone) {
|
|
if (typeof document !== 'undefined') {
|
|
this.offscreenCanvas = new OffscreenCanvas(0, 0);
|
|
this.offscreenCtx = this.offscreenCanvas.getContext('2d', { colorSpace: 'srgb' })!;
|
|
}
|
|
|
|
effect(() => {
|
|
// Trigger cache refresh when dark mode changes
|
|
const isDark = this.settingsService.darkMode();
|
|
if (this.ctx) {
|
|
this.cacheGradients(isDark);
|
|
this.cacheBackground(isDark);
|
|
}
|
|
});
|
|
|
|
afterNextRender(() => {
|
|
const nativeElement = this.canvasRef()?.nativeElement;
|
|
|
|
if (nativeElement) {
|
|
this.ctx = nativeElement.getContext('2d', { colorSpace: 'srgb' })!;
|
|
|
|
this.ngZone.runOutsideAngular(() => {
|
|
this.resizeObserver = new ResizeObserver((entries) => {
|
|
for (let entry of entries) {
|
|
const { inlineSize: width, blockSize: height } = entry.borderBoxSize[0];
|
|
this.resize(width, height);
|
|
}
|
|
});
|
|
this.resizeObserver.observe(nativeElement);
|
|
|
|
this.intersectionObserver = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0].isIntersecting) {
|
|
if (!this.animation.id) this.animate(performance.now());
|
|
} else if (this.animation.id) {
|
|
cancelAnimationFrame(this.animation.id);
|
|
this.animation.id = undefined;
|
|
}
|
|
},
|
|
{ rootMargin: '200px' },
|
|
);
|
|
this.intersectionObserver.observe(nativeElement);
|
|
|
|
// Safe Initialization
|
|
const rect = nativeElement.getBoundingClientRect();
|
|
this.resize(rect.width, rect.height);
|
|
|
|
this.animation.startTime = performance.now();
|
|
this.draw();
|
|
// this.animate(performance.now());
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
private resize(width: number, height: number) {
|
|
const canvas = this.canvasRef()?.nativeElement;
|
|
if (canvas) {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
|
|
this.rendererScale = dpr;
|
|
|
|
canvas.width = width * dpr;
|
|
canvas.height = height * dpr;
|
|
|
|
if (this.offscreenCanvas) {
|
|
this.offscreenCanvas.width = width;
|
|
this.offscreenCanvas.height = height;
|
|
}
|
|
|
|
this.canvasNativeSize = [width, height];
|
|
|
|
this.ctx.scale(dpr, dpr);
|
|
this.updateAnimationInfo();
|
|
this.animate(performance.now());
|
|
}
|
|
}
|
|
|
|
private updateAnimationInfo() {
|
|
this.animation.gridDimension = [
|
|
Math.trunc(this.canvasNativeSize[0] / this.animation.gridSquareSize) + 3,
|
|
Math.trunc(this.canvasNativeSize[1] / this.animation.gridSquareSize) + 3,
|
|
];
|
|
|
|
const isDark = this.settingsService.darkMode();
|
|
|
|
this.cacheGradients(isDark);
|
|
this.cacheBackground(isDark);
|
|
}
|
|
|
|
private cacheGradients(isDark: boolean) {
|
|
let w = this.canvasNativeSize[0];
|
|
let h = this.canvasNativeSize[1];
|
|
if (w === 0 || h === 0) return;
|
|
|
|
if (!isDark) {
|
|
this.bgGradient = this.ctx.createLinearGradient(0, 0, w, h);
|
|
this.bgGradient.addColorStop(0, 'oklch(1 0 0 / 0.6)');
|
|
this.bgGradient.addColorStop(1, 'oklch(0.7938 0.1292 47.77 / 0.6)');
|
|
}
|
|
|
|
this.gridGradient = this.ctx.createLinearGradient(0, 0, w, h);
|
|
if (isDark) {
|
|
// Dark mode: subtle light grid
|
|
this.gridGradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
|
|
this.gridGradient.addColorStop(1, 'rgba(255, 255, 255, 0.4)');
|
|
} else {
|
|
// Light mode: dark grid
|
|
this.gridGradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
|
|
this.gridGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)');
|
|
}
|
|
}
|
|
|
|
private cacheBackground(isDark: boolean) {
|
|
if (!this.offscreenCtx) return;
|
|
|
|
const w = this.canvasNativeSize[0];
|
|
const h = this.canvasNativeSize[1];
|
|
if (w === 0 || h === 0) return;
|
|
|
|
this.offscreenCtx.clearRect(0, 0, w, h);
|
|
this.offscreenCtx.globalCompositeOperation = 'source-over';
|
|
|
|
if (!isDark) {
|
|
this.offscreenCtx.fillStyle = this.bgGradient;
|
|
this.offscreenCtx.fillRect(0, 0, w, h);
|
|
} else {
|
|
this.offscreenCtx.fillStyle = 'oklch(0.1776 0 0)';
|
|
this.offscreenCtx.fillRect(0, 0, w, h);
|
|
|
|
const ellipseDimension = [Math.max(100, w * 0.9421875), Math.max(200 / 3, h * (203 / 120))];
|
|
|
|
const ellipseX = w / 2;
|
|
const ellipseY = h * 0.5763888888 + ellipseDimension[1] / 2;
|
|
|
|
const blurScale =
|
|
Math.min(780, Math.min(ellipseDimension[0], ellipseDimension[1]) * 0.6) /
|
|
this.rendererScale;
|
|
|
|
this.offscreenCtx.filter = `blur(${blurScale}px)`;
|
|
|
|
this.offscreenCtx.beginPath();
|
|
this.offscreenCtx.ellipse(
|
|
ellipseX, ellipseY,
|
|
ellipseDimension[0] / 2, ellipseDimension[1] / 2,
|
|
0, 0, 2 * Math.PI,
|
|
);
|
|
this.offscreenCtx.fillStyle = 'oklch(0.2547 0.0913 29.02)';
|
|
this.offscreenCtx.fill();
|
|
|
|
this.offscreenCtx.filter = 'none';
|
|
}
|
|
}
|
|
|
|
private animate = (currentTime: number) => {
|
|
this.animation.id = requestAnimationFrame(this.animate);
|
|
|
|
this.update(currentTime);
|
|
this.draw();
|
|
};
|
|
|
|
private update(currentTime: number) {
|
|
let elapsedTime: number;
|
|
elapsedTime = (currentTime - this.animation.startTime) / 1000;
|
|
|
|
const animationTime = elapsedTime % this.animation.duration;
|
|
this.animation.progress = animationTime / this.animation.duration;
|
|
}
|
|
|
|
private draw() {
|
|
this.ctx.clearRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
|
const isDark = this.settingsService.darkMode();
|
|
|
|
if (this.offscreenCanvas) {
|
|
this.ctx.globalCompositeOperation = 'source-over';
|
|
|
|
this.ctx.imageSmoothingEnabled = false;
|
|
this.ctx.drawImage(this.offscreenCanvas, 0, 0);
|
|
|
|
// this.ctx.drawImage(this.offscreenCanvas, 0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
|
}
|
|
|
|
if (!this.drawGrid()) return;
|
|
|
|
if (!isDark) {
|
|
this.ctx.globalCompositeOperation = 'multiply';
|
|
} else {
|
|
this.ctx.globalCompositeOperation = 'screen';
|
|
}
|
|
|
|
this.ctx.beginPath();
|
|
this.ctx.lineWidth = 2;
|
|
this.ctx.strokeStyle = this.gridGradient;
|
|
this.ctx.lineCap = 'round';
|
|
|
|
const lineRenderMargin = this.animation.gridSquareSize * 2;
|
|
|
|
const xOffset =
|
|
(this.canvasNativeSize[0] - this.animation.gridDimension[0] * this.animation.gridSquareSize) /
|
|
2;
|
|
const yOffset =
|
|
(this.canvasNativeSize[1] - this.animation.gridDimension[1] * this.animation.gridSquareSize) /
|
|
2;
|
|
|
|
const movingOffset = -(this.animation.gridSquareSize * this.animation.progress);
|
|
|
|
for (let y = 0; y < this.animation.gridDimension[1]; y++) {
|
|
this.ctx.moveTo(
|
|
-lineRenderMargin,
|
|
y * this.animation.gridSquareSize + yOffset + movingOffset,
|
|
);
|
|
this.ctx.lineTo(
|
|
this.canvasNativeSize[0] + lineRenderMargin,
|
|
y * this.animation.gridSquareSize + yOffset + movingOffset,
|
|
);
|
|
}
|
|
|
|
for (let x = 0; x < this.animation.gridDimension[0]; x++) {
|
|
this.ctx.moveTo(
|
|
x * this.animation.gridSquareSize + xOffset + movingOffset,
|
|
-lineRenderMargin,
|
|
);
|
|
this.ctx.lineTo(
|
|
x * this.animation.gridSquareSize + xOffset + movingOffset,
|
|
this.canvasNativeSize[1] + lineRenderMargin,
|
|
);
|
|
}
|
|
|
|
this.ctx.stroke();
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
if (this.animation.id) cancelAnimationFrame(this.animation.id);
|
|
this.resizeObserver?.disconnect();
|
|
this.intersectionObserver?.disconnect();
|
|
}
|
|
}
|