This commit is contained in:
@@ -1,9 +1,12 @@
|
|||||||
.shell {
|
.shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
max-width: 1500px;
|
max-width: 1500px;
|
||||||
min-height: 100svh;
|
min-height: 100svh;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -26,7 +29,7 @@
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: rgba(22, 27, 34, 0.92);
|
background: rgba(22, 27, 34, 0.92);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
z-index: 20;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
|
<app-interactive-background></app-interactive-background>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe('App', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('app-interactive-background')).toBeTruthy();
|
||||||
expect(compiled.querySelector('.sidebar')).toBeTruthy();
|
expect(compiled.querySelector('.sidebar')).toBeTruthy();
|
||||||
expect(compiled.querySelectorAll('.nav a').length).toBeGreaterThanOrEqual(4);
|
expect(compiled.querySelectorAll('.nav a').length).toBeGreaterThanOrEqual(4);
|
||||||
expect(compiled.querySelector('.site-footer')).toBeTruthy();
|
expect(compiled.querySelector('.site-footer')).toBeTruthy();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
import { InteractiveBackground } from '../components/interactive-background/interactive-background';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterLink, RouterLinkActive, RouterOutlet],
|
imports: [RouterLink, RouterLinkActive, RouterOutlet, InteractiveBackground],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.css'
|
styleUrl: './app.css'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
:host {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-cursor {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 1px solid rgba(147, 197, 253, 0.9);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate3d(-9999px, -9999px, 0);
|
||||||
|
margin-left: -13px;
|
||||||
|
margin-top: -13px;
|
||||||
|
box-shadow: 0 0 24px rgba(56, 189, 248, 0.28);
|
||||||
|
background: radial-gradient(circle, rgba(96, 165, 250, 0.25) 0%, rgba(96, 165, 250, 0) 70%);
|
||||||
|
transition: width 130ms ease, height 130ms ease, margin 130ms ease, border-color 130ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-cursor.pointer-down {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
margin-left: -19px;
|
||||||
|
margin-top: -19px;
|
||||||
|
border-color: rgba(196, 181, 253, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.custom-cursor {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<div class="background-layer" aria-hidden="true">
|
||||||
|
<canvas #canvas class="particle-canvas"></canvas>
|
||||||
|
@if (isCursorVisible) {
|
||||||
|
<div
|
||||||
|
class="custom-cursor"
|
||||||
|
[class.pointer-down]="isPointerDown"
|
||||||
|
[style.transform]="cursorTransform"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
344
src/components/interactive-background/interactive-background.ts
Normal file
344
src/components/interactive-background/interactive-background.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { AfterViewInit, Component, ElementRef, OnDestroy, 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ParticleEngine {
|
||||||
|
private readonly particles: Particle[] = [];
|
||||||
|
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) {
|
||||||
|
this.particles.push({
|
||||||
|
x: Math.random(),
|
||||||
|
y: Math.random(),
|
||||||
|
vx: (Math.random() - 0.5) * 0.18,
|
||||||
|
vy: (Math.random() - 0.5) * 0.18,
|
||||||
|
size: 1 + Math.random() * 2.2,
|
||||||
|
alpha: 0.35 + Math.random() * 0.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 step = Math.min(deltaMs, 40) / 16.6667;
|
||||||
|
this.update(step);
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStatic(): void {
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private update(step: number): void {
|
||||||
|
if (this.width === 0 || this.height === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const influenceRadius = Math.min(this.width, this.height) * 0.22;
|
||||||
|
const influenceRadiusSq = influenceRadius * influenceRadius;
|
||||||
|
|
||||||
|
for (const particle of this.particles) {
|
||||||
|
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 direction = this.pointer.pressed ? -1 : 1;
|
||||||
|
const force = normalized * 0.02 * direction;
|
||||||
|
|
||||||
|
particle.vx += (dx / distance) * force * step;
|
||||||
|
particle.vy += (dy / distance) * force * step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
particle.vx *= 0.985;
|
||||||
|
particle.vy *= 0.985;
|
||||||
|
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.5, 'rgba(147, 102, 255, 0.08)');
|
||||||
|
gradient.addColorStop(1, 'rgba(13, 17, 23, 0)');
|
||||||
|
|
||||||
|
this.ctx.fillStyle = gradient;
|
||||||
|
this.ctx.fillRect(0, 0, this.width, this.height);
|
||||||
|
|
||||||
|
for (const particle of this.particles) {
|
||||||
|
const x = particle.x * this.width;
|
||||||
|
const y = particle.y * this.height;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(x, y, particle.size, 0, Math.PI * 2);
|
||||||
|
this.ctx.fillStyle = `rgba(230, 237, 243, ${particle.alpha})`;
|
||||||
|
this.ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.particles.length; i += 1) {
|
||||||
|
const a = this.particles[i];
|
||||||
|
for (let j = i + 1; j < this.particles.length; j += 1) {
|
||||||
|
const b = this.particles[j];
|
||||||
|
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 < 10000) {
|
||||||
|
const alpha = 1 - distSq / 10000;
|
||||||
|
this.ctx.strokeStyle = `rgba(88, 166, 255, ${alpha * 0.2})`;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(ax, ay);
|
||||||
|
this.ctx.lineTo(bx, by);
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-interactive-background',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './interactive-background.html',
|
||||||
|
styleUrl: './interactive-background.css'
|
||||||
|
})
|
||||||
|
export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
isCursorVisible = false;
|
||||||
|
isPointerDown = false;
|
||||||
|
cursorTransform = 'translate3d(-9999px, -9999px, 0)';
|
||||||
|
|
||||||
|
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 reducedMotion = false;
|
||||||
|
private mediaQuery!: MediaQueryList;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
this.mediaQuery = this.getMediaQuery('(prefers-reduced-motion: reduce)');
|
||||||
|
this.reducedMotion = this.mediaQuery.matches;
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
const particleCount = this.reducedMotion ? 32 : 88;
|
||||||
|
this.particleEngine = new ParticleEngine(context, particleCount);
|
||||||
|
this.resizeCanvas();
|
||||||
|
this.renderInitialFrame();
|
||||||
|
if (!this.reducedMotion) {
|
||||||
|
this.startAnimation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isCursorVisible = this.getMediaQuery('(pointer: fine)').matches;
|
||||||
|
|
||||||
|
const onResize = () => this.resizeCanvas();
|
||||||
|
const onMove = (event: PointerEvent) => this.handlePointerMove(event);
|
||||||
|
const onLeave = () => this.handlePointerLeave();
|
||||||
|
const onDown = () => (this.isPointerDown = true);
|
||||||
|
const onUp = () => (this.isPointerDown = false);
|
||||||
|
const onMotionChange = (event: MediaQueryListEvent) => this.handleMotionPreferenceChange(event.matches);
|
||||||
|
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
window.addEventListener('pointermove', onMove, { passive: true });
|
||||||
|
window.addEventListener('pointerleave', onLeave);
|
||||||
|
window.addEventListener('pointerdown', onDown, { passive: true });
|
||||||
|
window.addEventListener('pointerup', onUp, { passive: true });
|
||||||
|
this.mediaQuery.addEventListener('change', onMotionChange);
|
||||||
|
|
||||||
|
this.destroyCallbacks = [
|
||||||
|
() => window.removeEventListener('resize', onResize),
|
||||||
|
() => window.removeEventListener('pointermove', onMove),
|
||||||
|
() => window.removeEventListener('pointerleave', onLeave),
|
||||||
|
() => window.removeEventListener('pointerdown', onDown),
|
||||||
|
() => window.removeEventListener('pointerup', onUp),
|
||||||
|
() => this.mediaQuery.removeEventListener('change', onMotionChange),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stopAnimation();
|
||||||
|
for (const callback of this.destroyCallbacks) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
this.destroyCallbacks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private resizeCanvas(): void {
|
||||||
|
if (!this.particleEngine) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = window.innerHeight;
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
|
||||||
|
canvas.width = Math.floor(width * dpr);
|
||||||
|
canvas.height = Math.floor(height * dpr);
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
|
||||||
|
this.particleEngine.resize(width, height, dpr);
|
||||||
|
this.renderInitialFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
this.pointerX = event.clientX;
|
||||||
|
this.pointerY = event.clientY;
|
||||||
|
this.pointerActive = true;
|
||||||
|
this.cursorTransform = `translate3d(${event.clientX}px, ${event.clientY}px, 0)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePointerLeave(): void {
|
||||||
|
this.pointerActive = false;
|
||||||
|
this.isPointerDown = false;
|
||||||
|
this.cursorTransform = 'translate3d(-9999px, -9999px, 0)';
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user