Added threejs video holo component visualiser, and text scramble on hero page

This commit is contained in:
AdamBtech
2025-05-22 23:53:03 +02:00
parent 11fd702957
commit 6a34aee9a8
9 changed files with 1009 additions and 6 deletions

73
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"prettier": "^3.5.3",
"rxjs": "~7.8.0",
"tailwindcss-primeui": "^0.6.1",
"three": "^0.176.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
@@ -29,6 +30,7 @@
"@tailwindcss/postcss": "^4.1.7",
"@types/jasmine": "~5.1.0",
"@types/jest": "^29.5.14",
"@types/three": "^0.176.0",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"autoprefixer": "^10.4.21",
@@ -2653,6 +2655,13 @@
"node": ">=0.1.90"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@discoveryjs/json-ext": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz",
@@ -6376,6 +6385,13 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -6718,6 +6734,36 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.176.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.176.0.tgz",
"integrity": "sha512-FwfPXxCqOtP7EdYMagCFePNKoG1AGBDUEVKtluv2BTVRpSt7b+X27xNsirPCTCqY1pGYsPUzaM3jgWP7dXSxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "^0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.22",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.22.tgz",
"integrity": "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -7112,6 +7158,13 @@
"@xtuc/long": "4.2.2"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.60",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.60.tgz",
"integrity": "sha512-8B/tdfRFKdrnejqmvq95ogp8tf52oZ51p3f4QD5m5Paey/qlX4Rhhy5Y8tgFMi7Ms70HzcMMw3EQjH/jdhTwlA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -10011,6 +10064,13 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -13503,6 +13563,13 @@
"node": ">= 8"
}
},
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"dev": true,
"license": "MIT"
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -17171,6 +17238,12 @@
"tslib": "^2"
}
},
"node_modules/three": {
"version": "0.176.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.176.0.tgz",
"integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==",
"license": "MIT"
},
"node_modules/thunky": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",

View File

@@ -21,6 +21,7 @@
"prettier": "^3.5.3",
"rxjs": "~7.8.0",
"tailwindcss-primeui": "^0.6.1",
"three": "^0.176.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
@@ -31,6 +32,7 @@
"@tailwindcss/postcss": "^4.1.7",
"@types/jasmine": "~5.1.0",
"@types/jest": "^29.5.14",
"@types/three": "^0.176.0",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"autoprefixer": "^10.4.21",

BIN
public/cyber_hands.mp4 Normal file

Binary file not shown.

View File

@@ -0,0 +1,17 @@
/* hero-display.component.css */
.scramble-text-glow {
text-shadow:
0 0 5px rgba(255, 255, 255, 0.3),
0 0 10px rgba(255, 255, 255, 0.2),
0 0 15px rgba(255, 255, 255, 0.1);
transition: text-shadow 0.3s ease;
}
/* Enhanced glow for binary characters */
.binary-glow {
text-shadow:
0 0 3px rgba(255, 255, 255, 0.4),
0 0 6px rgba(255, 255, 255, 0.3),
0 0 9px rgba(255, 255, 255, 0.2);
}

View File

@@ -1,4 +1,15 @@
<section class="">
<article class="border-b-1 h-100 flex items-center justify-center"><p class="text-9xl font-terminal-retro">HERO SECTION</p></article>
<article class="border-b-1 h-100 flex items-center justify-center checkered-bg-dark"><p class="font-terminal-nier text-9xl">CREATIVE DEVELOPER</p></article>
<!-- Video Section -->
<article class="border-b h-[40vh] sm:h-[60vh] md:h-[50vh] flex items-center justify-center">
<app-holo-video-container containerClasses="w-full aspect-video" videoSrc="cyber_hands.mp4" />
</article>
<!-- Display Text Section -->
<article class="flex-1 flex items-center justify-center checkered-bg-dark py-4 sm:py-8 md:py-12">
<p class="font-terminal-nier scramble-text-glow
text-6xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl 2xl:text-9xl
text-center px-4 leading-tight">
{{ displayText }}
</p>
</article>
</section>

View File

@@ -1,11 +1,198 @@
import { Component } from '@angular/core';
import { Component, OnInit, OnDestroy } from '@angular/core';
import { HoloVideoContainerComponent } from '../../shared/ui/holo-video-container/holo-video-container.component';
@Component({
selector: 'app-hero-display',
imports: [],
imports: [HoloVideoContainerComponent],
templateUrl: './hero-display.component.html',
styleUrl: './hero-display.component.css'
styleUrl: './hero-display.component.css',
})
export class HeroDisplayComponent {
export class HeroDisplayComponent implements OnInit, OnDestroy {
displayText: string = '';
private messageQueue: string[] = [
'Web Developer',
'Coding Enjoyer',
'Software Artisan',
];
private currentMessage: string = '';
private isGlitching: boolean = false;
private frameRequest: number | null = null;
private processTimeout: any = null;
private isInitialMount: boolean = true;
ngOnInit(): void {
this.initialMountAnimation();
}
ngOnDestroy(): void {
this.cleanup();
}
private cleanup(): void {
if (this.frameRequest) {
cancelAnimationFrame(this.frameRequest);
}
if (this.processTimeout) {
clearTimeout(this.processTimeout);
}
}
private initialMountAnimation(): void {
const firstMessage = this.messageQueue[0];
let currentIndex = 0;
const animateNextLetter = (): void => {
if (currentIndex <= firstMessage.length) {
let output = '';
// Build the confirmed part
for (let i = 0; i < currentIndex; i++) {
output += firstMessage[i];
}
// Add intense scrambling for upcoming letters (very fast scramble)
const scrambleLength = Math.min(6, firstMessage.length - currentIndex);
for (let i = 0; i < scrambleLength; i++) {
output += Math.random() > 0.5 ? '0' : '1';
}
this.displayText = output;
if (currentIndex < firstMessage.length) {
currentIndex++;
setTimeout(animateNextLetter, 35 + Math.random() * 25); // Much faster: 35-60ms
} else {
// Mount animation complete, start normal cycle
this.displayText = firstMessage;
this.currentMessage = firstMessage;
this.messageQueue.shift(); // Remove the first message since we used it
this.isInitialMount = false;
// Start the regular cycle after a short pause
setTimeout(() => {
this.processQueue();
}, 2000);
}
}
};
animateNextLetter();
}
private processQueue(): void {
if (this.messageQueue.length === 0) {
this.messageQueue = [
'Web Developer',
'Coding Enjoyer',
'Software Artisan',
];
}
const nextMessage = this.messageQueue.shift()!;
this.startScrambleAnimation(nextMessage);
this.processTimeout = setTimeout(() => {
this.processQueue();
}, 6000); // Reduced from 10000 to 6000 (faster rotation)
}
private startScrambleAnimation(nextMessage: string): void {
const length = Math.max(this.displayText.length, nextMessage.length);
let complete = 0;
const update = (): void => {
let output = '';
const scrambleChars = 3 + Math.random() * 5;
for (let i = 0; i < length; i++) {
const scramble = i < scrambleChars + complete && Math.random() > 0.8;
if (i < nextMessage.length) {
if (scramble) {
output += Math.random() > 0.5 ? '0' : '1';
} else if (i < complete) {
output += nextMessage[i];
} else {
output += this.displayText[i] || (Math.random() > 0.5 ? '0' : '1');
}
}
}
this.displayText = output;
if (complete < nextMessage.length) {
complete += 0.5 + Math.floor(Math.random() * 2);
setTimeout(update, 40 + Math.random() * 60);
} else {
this.displayText = nextMessage;
this.currentMessage = nextMessage;
this.isGlitching = true;
this.glitchText();
}
};
this.isGlitching = false;
if (this.frameRequest) {
cancelAnimationFrame(this.frameRequest);
}
update();
}
private glitchText(): void {
if (!this.isGlitching) return;
const probability = Math.random();
if (probability < 0.05) {
// Reduced from 0.2 to 0.05
const scrambledText = this.currentMessage
.split('')
.map(() => (Math.random() > 0.5 ? '0' : '1'))
.join('');
this.displayText = scrambledText;
setTimeout(() => {
this.displayText = this.currentMessage;
}, 25);
} else if (probability < 0.15) {
// Reduced from 0.5 to 0.15
const textArray = this.currentMessage.split('');
for (let i = 0; i < Math.floor(textArray.length * 0.2); i++) {
// Reduced from 0.4 to 0.2
const idx = Math.floor(Math.random() * textArray.length);
textArray[idx] = Math.random() > 0.5 ? '0' : '1';
}
this.displayText = textArray.join('');
setTimeout(() => {
this.displayText = this.currentMessage;
}, 20);
}
const jitterProbability = Math.random();
if (jitterProbability < 0.1) {
// Reduced from 0.5 to 0.1
setTimeout(() => {
const textArray = this.displayText.split('');
for (let i = 0; i < 2; i++) {
// Reduced from 4 to 2 characters
const idx = Math.floor(Math.random() * textArray.length);
if (textArray[idx] === '0' || textArray[idx] === '1') {
textArray[idx] = textArray[idx] === '0' ? '1' : '0';
}
}
this.displayText = textArray.join('');
setTimeout(() => {
this.displayText = this.currentMessage;
}, 15);
}, 15);
}
this.frameRequest = requestAnimationFrame(() => {
setTimeout(() => this.glitchText(), Math.random() * 500); // Increased from 150 to 500
});
}
}

View File

@@ -0,0 +1,45 @@
:host {
display: block;
width: 100%;
height: 100%;
}
/* Custom spinner border width for Tailwind */
.border-3 {
border-width: 3px;
}
/* Backdrop blur fallback for older browsers */
.backdrop-blur-sm {
backdrop-filter: blur(4px);
}
/* Custom shadow glow effect */
.shadow-cyan-500\/30 {
box-shadow: 0 0 20px rgba(6, 182, 212, 0.3);
}
/* Responsive container for smaller screens */
@media (max-width: 840px) {
.w-\[800px\] {
width: 100%;
max-width: 800px;
}
.h-\[600px\] {
height: 60vw;
max-height: 600px;
}
}
@media (max-width: 768px) {
.absolute.top-5.right-5 {
top: 0.625rem;
right: 0.625rem;
left: 0.625rem;
}
.absolute.top-5.right-5 button {
width: 100%;
justify-content: center;
}
}

View File

@@ -0,0 +1,29 @@
<div class="block w-full h-full" [ngClass]="containerClasses">
<div class="bg-slate-900 rounded-lg overflow-hidden w-full h-full">
<div class="relative w-full h-full">
<canvas #canvas class="block w-full h-full cursor-grab active:cursor-grabbing"></canvas>
@if (isLoadingValue) {
<div class="absolute inset-0 bg-black/80 flex items-center justify-center flex-col text-green-400 text-lg font-sans z-10">
<div class="w-10 h-10 border-3 border-gray-600 border-t-green-400 rounded-full animate-spin mb-4"></div>
<span>Loading...</span>
</div>
}
<div class="absolute top-2 right-2 bg-black/80 border border-gray-600 rounded p-2 backdrop-blur-sm z-10">
<input
type="file"
accept="video/*"
(change)="onVideoFileSelect($event)"
#videoInput
class="hidden">
<button
class="bg-gray-700 text-white border border-gray-500 px-3 py-1 rounded text-xs transition-all duration-300 hover:bg-gray-600 hover:border-gray-400 active:translate-y-px disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:bg-gray-700 disabled:hover:border-gray-500"
(click)="videoInput.click()"
[disabled]="isLoadingValue">
📁
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,639 @@
import {
Component,
ElementRef,
OnInit,
OnDestroy,
ViewChild,
Input,
signal,
effect,
HostListener,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import * as THREE from 'three';
@Component({
selector: 'app-holo-video-container',
imports: [CommonModule],
templateUrl: './holo-video-container.component.html',
styleUrl: './holo-video-container.component.css',
})
export class HoloVideoContainerComponent implements OnInit, OnDestroy {
@ViewChild('canvas', { static: true })
canvasRef!: ElementRef<HTMLCanvasElement>;
@Input() videoSrc?: string;
@Input() containerClasses: string = 'w-full h-96'; // Default with height
isLoading = signal(false);
// Three.js objects
private scene!: THREE.Scene;
private camera!: THREE.PerspectiveCamera;
private renderer!: THREE.WebGLRenderer;
private points!: THREE.Points;
private geometry!: THREE.BufferGeometry;
private material!: THREE.PointsMaterial;
private glowLayers: Array<{
points: THREE.Points;
geometry: THREE.BufferGeometry;
}> = [];
// Side decorations
private sideElements: THREE.Group[] = [];
private animationId: number = 0;
private video!: HTMLVideoElement;
private canvas2D!: HTMLCanvasElement;
private ctx2D!: CanvasRenderingContext2D;
readonly GRID_WIDTH = 320; // Increased from 240 for wider coverage
readonly GRID_HEIGHT = 180;
readonly POINTS_COUNT = this.GRID_WIDTH * this.GRID_HEIGHT;
// Mouse and effects
private mouseX = 0;
private mouseY = 0;
private isMouseOverCanvas = false;
private glitchTime = 0;
private glitchIntensity = 0;
private resizeObserver!: ResizeObserver;
constructor() {
effect(() => {
if (this.material) {
this.material.size = 8.0; // Increased from 5.0 to 8.0
}
});
}
ngOnInit(): void {
this.initThreeJS();
this.createPointCloud();
this.createSideDecorations();
this.setupVideo();
this.setupEventListeners();
this.setupResizeObserver();
this.animate();
this.onWindowResize(); // Initial resize
if (this.videoSrc) {
this.loadVideoFromSrc(this.videoSrc);
}
}
ngOnDestroy(): void {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
this.renderer?.dispose();
}
private initThreeJS(): void {
const canvas = this.canvasRef.nativeElement;
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x0a0d14);
// Initialize camera with even wider FOV for closer view
this.camera = new THREE.PerspectiveCamera(40, 1, 1, 10000);
this.camera.position.set(0, 0, 800);
this.camera.lookAt(0, 0, 0);
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
this.renderer.setPixelRatio(window.devicePixelRatio);
}
private setupResizeObserver(): void {
const canvas = this.canvasRef.nativeElement;
this.resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
this.handleResize(width, height);
}
}
});
// Observe the canvas element
this.resizeObserver.observe(canvas);
}
private handleResize(width: number, height: number): void {
// Update camera aspect ratio
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
// Update renderer size
this.renderer.setSize(width, height, false);
// Calculate optimal camera distance
const fov = this.camera.fov * (Math.PI / 180);
const gridHeight = this.GRID_HEIGHT * 12; // Updated to match new scale
const gridWidth = this.GRID_WIDTH * 12; // Updated to match new scale
// Calculate visible dimensions at camera distance
const vFov = fov;
const hFov = 2 * Math.atan(Math.tan(fov / 2) * this.camera.aspect);
// Calculate distance to fit width or height
const distanceHeight = gridHeight / 2 / Math.tan(vFov / 2);
const distanceWidth = gridWidth / 2 / Math.tan(hFov / 2);
// Use the distance that fits both dimensions with minimal margin
this.camera.position.z = Math.max(distanceHeight, distanceWidth) * 0.95;
// Update side decorations position based on aspect ratio
if (this.sideElements.length >= 2) {
const sideOffset = this.camera.aspect * 1000;
this.sideElements[0].position.x = -sideOffset;
this.sideElements[1].position.x = sideOffset;
}
}
@HostListener('window:resize')
onWindowResize(): void {
const canvas = this.canvasRef.nativeElement;
const rect = canvas.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
this.handleResize(rect.width, rect.height);
}
}
private createPointCloud(): void {
this.geometry = new THREE.BufferGeometry();
const positions = new Float32Array(this.POINTS_COUNT * 3);
const colors = new Float32Array(this.POINTS_COUNT * 3);
const originalPositions = new Float32Array(this.POINTS_COUNT * 3);
let index = 0;
for (let y = 0; y < this.GRID_HEIGHT; y++) {
for (let x = 0; x < this.GRID_WIDTH; x++) {
const posX = (x - this.GRID_WIDTH / 2) * 12; // Reduced from 16 to compensate for wider grid
const posY = -(y - this.GRID_HEIGHT / 2) * 12; // Reduced from 16 to maintain proportion
positions[index * 3] = posX;
positions[index * 3 + 1] = posY;
positions[index * 3 + 2] = 0;
originalPositions[index * 3] = posX;
originalPositions[index * 3 + 1] = posY;
originalPositions[index * 3 + 2] = 0;
colors[index * 3] = colors[index * 3 + 1] = colors[index * 3 + 2] = 1;
index++;
}
}
this.geometry.setAttribute(
'position',
new THREE.BufferAttribute(positions, 3),
);
this.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
this.geometry.setAttribute(
'originalPosition',
new THREE.BufferAttribute(originalPositions, 3),
);
this.material = new THREE.PointsMaterial({
size: 8.0, // Increased from 5.0 to 8.0
vertexColors: true,
transparent: true,
opacity: 1.0,
alphaTest: 0.5,
depthWrite: false,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
});
this.points = new THREE.Points(this.geometry, this.material);
this.scene.add(this.points);
this.createGlowLayers();
}
private createGlowLayers(): void {
for (let layer = 1; layer <= 3; layer++) {
const glowGeometry = this.geometry.clone();
const glowMaterial = new THREE.PointsMaterial({
size: 8.0 * (1 + layer * 0.5), // Increased base size from 5.0 to 8.0
vertexColors: true,
transparent: true,
opacity: 0.3 / layer,
alphaTest: 0.1,
depthWrite: false,
blending: THREE.AdditiveBlending,
sizeAttenuation: true,
});
const glowPoints = new THREE.Points(glowGeometry, glowMaterial);
this.scene.add(glowPoints);
this.glowLayers.push({ points: glowPoints, geometry: glowGeometry });
}
}
private createSideDecorations(): void {
// Create vertical scan lines on both sides
const createScanLines = (xPosition: number) => {
const group = new THREE.Group();
// Main vertical lines
for (let i = 0; i < 5; i++) {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array([
xPosition + (i - 2) * 30,
-1000,
0,
xPosition + (i - 2) * 30,
1000,
0,
]);
geometry.setAttribute(
'position',
new THREE.BufferAttribute(positions, 3),
);
const material = new THREE.LineBasicMaterial({
color: new THREE.Color(0.3, 0.6, 0.8),
transparent: true,
opacity: 0.3,
linewidth: 1,
});
const line = new THREE.Line(geometry, material);
group.add(line);
}
// Floating particles
const particleGeometry = new THREE.BufferGeometry();
const particleCount = 50;
const positions = new Float32Array(particleCount * 3);
const opacities = new Float32Array(particleCount);
for (let i = 0; i < particleCount; i++) {
positions[i * 3] = xPosition + (Math.random() - 0.5) * 150;
positions[i * 3 + 1] = (Math.random() - 0.5) * 1500;
positions[i * 3 + 2] = (Math.random() - 0.5) * 100;
opacities[i] = Math.random();
}
particleGeometry.setAttribute(
'position',
new THREE.BufferAttribute(positions, 3),
);
particleGeometry.setAttribute(
'opacity',
new THREE.BufferAttribute(opacities, 1),
);
const particleMaterial = new THREE.PointsMaterial({
size: 3,
color: new THREE.Color(0.5, 0.8, 1.0),
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending,
vertexColors: false,
sizeAttenuation: true,
});
const particles = new THREE.Points(particleGeometry, particleMaterial);
group.add(particles);
// Add grid pattern
const gridGroup = new THREE.Group();
const gridSize = 100;
const gridDivisions = 10;
for (let j = -5; j <= 5; j++) {
const lineGeometry = new THREE.BufferGeometry();
const linePositions = new Float32Array([
xPosition - gridSize,
(j * gridSize) / 5,
-50,
xPosition + gridSize,
(j * gridSize) / 5,
-50,
]);
lineGeometry.setAttribute(
'position',
new THREE.BufferAttribute(linePositions, 3),
);
const lineMaterial = new THREE.LineBasicMaterial({
color: new THREE.Color(0.2, 0.4, 0.6),
transparent: true,
opacity: 0.2,
});
const gridLine = new THREE.Line(lineGeometry, lineMaterial);
gridGroup.add(gridLine);
}
group.add(gridGroup);
return group;
};
// Create decorations for left and right sides
const leftDecoration = createScanLines(-2000);
const rightDecoration = createScanLines(2000);
this.sideElements.push(leftDecoration, rightDecoration);
this.scene.add(leftDecoration);
this.scene.add(rightDecoration);
}
private animateSideElements(time: number): void {
this.sideElements.forEach((group, index) => {
// Animate vertical lines
group.children.forEach((child, childIndex) => {
if (child instanceof THREE.Line && childIndex < 5) {
child.material.opacity =
0.2 + Math.sin(time * 0.001 + childIndex) * 0.1;
}
// Animate particles
if (child instanceof THREE.Points) {
child.rotation.y = time * 0.0001;
const positions = child.geometry.attributes.position
.array as Float32Array;
for (let i = 0; i < positions.length; i += 3) {
positions[i + 1] += Math.sin(time * 0.001 + i) * 0.5;
if (positions[i + 1] > 750) positions[i + 1] = -750;
}
child.geometry.attributes.position.needsUpdate = true;
}
// Animate grid
if (child instanceof THREE.Group) {
child.rotation.z = Math.sin(time * 0.0005) * 0.05;
}
});
});
}
private setupVideo(): void {
this.video = document.createElement('video');
this.video.width = this.GRID_WIDTH;
this.video.height = this.GRID_HEIGHT;
this.video.autoplay = true;
this.video.muted = true;
this.video.loop = true;
this.canvas2D = document.createElement('canvas');
this.canvas2D.width = this.GRID_WIDTH;
this.canvas2D.height = this.GRID_HEIGHT;
this.ctx2D = this.canvas2D.getContext('2d')!;
this.createAnimatedPattern();
}
private createAnimatedPattern(): void {
const canvas = document.createElement('canvas');
canvas.width = this.GRID_WIDTH;
canvas.height = this.GRID_HEIGHT;
const ctx = canvas.getContext('2d')!;
let time = 0;
const animate = () => {
const imageData = ctx.createImageData(this.GRID_WIDTH, this.GRID_HEIGHT);
const data = imageData.data;
for (let y = 0; y < this.GRID_HEIGHT; y++) {
for (let x = 0; x < this.GRID_WIDTH; x++) {
const index = (y * this.GRID_WIDTH + x) * 4;
const centerX = this.GRID_WIDTH / 2;
const centerY = this.GRID_HEIGHT / 2;
const distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
const wave = Math.sin(distance * 0.1 + time * 0.05) * 0.5 + 0.5;
const noise =
Math.sin(x * 0.1 + time * 0.02) * Math.cos(y * 0.1 + time * 0.03);
const intensity = Math.max(
0,
Math.min(255, (wave + noise * 0.3) * 255),
);
data[index] = intensity;
data[index + 1] = intensity * 0.8;
data[index + 2] = intensity * 0.6;
data[index + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
time++;
requestAnimationFrame(animate);
};
animate();
const stream = canvas.captureStream(30);
this.video.srcObject = stream;
this.video.play();
}
private updatePointCloud(): void {
if (!this.video.videoWidth || !this.video.videoHeight) return;
this.glitchTime++;
if (Math.random() < 0.003) {
this.glitchIntensity = Math.random() * 0.7 + 0.3;
setTimeout(() => (this.glitchIntensity = 0), Math.random() * 1000 + 500);
}
this.ctx2D.drawImage(this.video, 0, 0, this.GRID_WIDTH, this.GRID_HEIGHT);
const imageData = this.ctx2D.getImageData(
0,
0,
this.GRID_WIDTH,
this.GRID_HEIGHT,
);
const data = imageData.data;
const positions = this.geometry.attributes['position']
.array as Float32Array;
const colors = this.geometry.attributes['color'].array as Float32Array;
const originalPositions = this.geometry.attributes['originalPosition']
.array as Float32Array;
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
const mouseWorldX =
((this.mouseX / rect.width) * 2 - 1) * (this.GRID_WIDTH * 6); // Adjusted for new scale
const mouseWorldY =
-((this.mouseY / rect.height) * 2 - 1) * (this.GRID_HEIGHT * 6); // Adjusted for new scale
let index = 0;
for (let y = 0; y < this.GRID_HEIGHT; y++) {
for (let x = 0; x < this.GRID_WIDTH; x++) {
const pixelIndex = (y * this.GRID_WIDTH + x) * 4;
const r = data[pixelIndex];
const g = data[pixelIndex + 1];
const b = data[pixelIndex + 2];
const brightness = (r + g + b) / 3;
if (brightness <= 40) {
positions[index * 3] = positions[index * 3 + 1] = 0;
positions[index * 3 + 2] = -10000;
colors[index * 3] = colors[index * 3 + 1] = colors[index * 3 + 2] = 0;
index++;
continue;
}
let depth = brightness / 255;
depth = Math.pow(depth, 2.2);
const baseZ = depth * 400 - 200;
const originalX = originalPositions[index * 3];
const originalY = originalPositions[index * 3 + 1];
const distanceToMouse = Math.sqrt(
Math.pow(originalX - mouseWorldX, 2) +
Math.pow(originalY - mouseWorldY, 2),
);
let scatterX = 0,
scatterY = 0,
scatterZ = 0;
if (this.isMouseOverCanvas && distanceToMouse < 50) {
const scatterFactor = 1 - distanceToMouse / 50;
const scatterIntensity = scatterFactor * 20;
const deltaX = originalX - mouseWorldX;
const deltaY = originalY - mouseWorldY;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance > 0) {
scatterX = (deltaX / distance) * scatterIntensity;
scatterY = (deltaY / distance) * scatterIntensity;
scatterZ = scatterIntensity * 0.2;
}
}
// Glitch effect
let glitchX = 0,
glitchY = 0;
if (
this.glitchIntensity > 0 &&
Math.random() < this.glitchIntensity * 0.1
) {
glitchX = (Math.random() - 0.5) * this.glitchIntensity * 25;
glitchY = (Math.random() - 0.5) * this.glitchIntensity * 25;
}
positions[index * 3] = originalX + scatterX + glitchX;
positions[index * 3 + 1] = originalY + scatterY + glitchY;
positions[index * 3 + 2] = baseZ + scatterZ;
// Apply holographic colors
this.applyHolographicColors(colors, index, brightness, distanceToMouse);
index++;
}
}
this.geometry.attributes['position'].needsUpdate = true;
this.geometry.attributes['color'].needsUpdate = true;
this.updateGlowLayers();
}
private applyHolographicColors(
colors: Float32Array,
index: number,
brightness: number,
distanceToMouse: number,
): void {
let finalColor = [0.5, 0.5, 0.5]; // Default grey
if (brightness > 220) finalColor = [1.0, 1.0, 1.0];
else if (brightness > 180) finalColor = [0.9, 0.9, 0.9];
else if (brightness > 140) finalColor = [0.7, 0.7, 0.7];
else if (brightness > 100) finalColor = [0.5, 0.5, 0.5];
else if (brightness > 60) finalColor = [0.3, 0.3, 0.3];
else finalColor = [0.1, 0.1, 0.1];
// Add subtle color variations
const variation = (index % 7) / 20;
finalColor[0] += variation * 0.1;
finalColor[1] += variation * 0.05;
finalColor[2] += variation * 0.15;
// Mouse glow effect
if (this.isMouseOverCanvas && distanceToMouse < 50) {
const glowFactor = (1 - distanceToMouse / 50) * 0.6;
finalColor[0] = Math.min(1.0, finalColor[0] + glowFactor * 0.7);
finalColor[1] = Math.min(1.0, finalColor[1] + glowFactor * 0.8);
finalColor[2] = Math.min(1.0, finalColor[2] + glowFactor * 1.0);
}
colors[index * 3] = finalColor[0];
colors[index * 3 + 1] = finalColor[1];
colors[index * 3 + 2] = finalColor[2];
}
private updateGlowLayers(): void {
this.glowLayers.forEach((layer, layerIndex) => {
const layerPositions = layer.geometry.attributes['position']
.array as Float32Array;
const layerColors = layer.geometry.attributes['color']
.array as Float32Array;
const mainPositions = this.geometry.attributes['position']
.array as Float32Array;
const mainColors = this.geometry.attributes['color']
.array as Float32Array;
for (let i = 0; i < mainPositions.length; i += 3) {
layerPositions[i] = mainPositions[i];
layerPositions[i + 1] = mainPositions[i + 1];
layerPositions[i + 2] = mainPositions[i + 2] - (layerIndex + 1) * 2;
}
for (let i = 0; i < mainColors.length; i += 3) {
const glowIntensity = 0.8 / (layerIndex + 1);
layerColors[i] = mainColors[i] * glowIntensity;
layerColors[i + 1] = mainColors[i + 1] * glowIntensity;
layerColors[i + 2] = mainColors[i + 2] * glowIntensity;
}
layer.geometry.attributes['position'].needsUpdate = true;
layer.geometry.attributes['color'].needsUpdate = true;
});
}
private setupEventListeners(): void {
const canvas = this.canvasRef.nativeElement;
canvas.addEventListener('mousemove', (event) => {
const rect = canvas.getBoundingClientRect();
this.mouseX = event.clientX - rect.left;
this.mouseY = event.clientY - rect.top;
});
canvas.addEventListener(
'mouseenter',
() => (this.isMouseOverCanvas = true),
);
canvas.addEventListener(
'mouseleave',
() => (this.isMouseOverCanvas = false),
);
// Removed wheel event listener - no more zooming
}
private animate(): void {
this.animationId = requestAnimationFrame(() => this.animate());
const time = Date.now();
this.updatePointCloud();
this.animateSideElements(time);
this.renderer.render(this.scene, this.camera);
}
private async loadVideoFromSrc(src: string): Promise<void> {
this.video.srcObject = null;
this.video.src = src;
this.video.load();
await this.video.play();
}
get isLoadingValue(): boolean {
return this.isLoading();
}
}